v0.2.0 toml-only, harden concurrency and env export, dep updates
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-28 16:55:58 -04:00
1 parent 3c35601179
commit e08467bf38
38 files changed
+2495 -4236

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
name: Test
on: [push, pull_request]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go test -timeout 60s -race ./...
- run: GOARCH=386 go test -timeout 60s ./...
- run: go vet ./...
- run: go test -run '^$' -fuzz '^FuzzConfigNumericConversions$' -fuzztime=10s -parallel=2
- run: GOOS=windows GOARCH=amd64 go test -c -o /tmp/config-windows.test.exe .
+93 -48
View File
@@ -1,68 +1,113 @@
# Config
# config
Thread-safe configuration management for Go applications with support for multiple sources (files, environment variables, command-line arguments, defaults) and configurable precedence.
## Features
- **Multiple Sources**: Load configuration from defaults, files, environment variables, and CLI arguments
- **Configurable Precedence**: Control which sources override others
- **Type Safety**: Struct-based configuration with automatic validation
- **Thread-Safe**: Concurrent access with read-write locking
- **File Watching**: Automatic reloading on configuration changes
- **Source Tracking**: Know exactly where each value came from
- **Tag Support**: Use `toml`, `json`, or `yaml` struct tags
## Installation
```bash
go get github.com/lixenwraith/config
```
## Quick Start
Thread-safe configuration from TOML files, environment variables, command-line
arguments and registered defaults. Requires Go 1.27.1. The only runtime dependency
is [lixenwraith/toml](https://github.com/lixenwraith/toml), pinned to merged commit
`b59d068ff6a4`. Testify and its dependencies are used only by tests.
```go
package main
import (
"log"
"time"
"github.com/lixenwraith/config"
)
type AppConfig struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
Debug bool `toml:"debug"`
type Settings struct {
Port uint16 `toml:"port"`
Timeout time.Duration `toml:"timeout"`
}
func main() {
defaults := &AppConfig{}
defaults.Server.Host = "localhost"
defaults.Server.Port = 8080
cfg, err := config.Quick(defaults, "MYAPP_", "config.toml")
if err != nil {
log.Fatal(err)
}
port, _ := cfg.Get("server.port")
log.Printf("Server port: %d", port.(int64))
initial := Settings{Port: 8080, Timeout: 5 * time.Second}
cfg, err := config.NewBuilder().
WithTarget(&initial).
WithFile("config.toml").
WithEnvPrefix("APP_").
WithArgs([]string{"--port=9090"}).
Build()
if err != nil { log.Fatal(err) }
latest, err := cfg.AsStruct()
if err != nil { log.Fatal(err) }
log.Printf("%+v", latest.(*Settings))
}
```
```toml
# Service settings
timeout = "10s"
port = 8080
```
Precedence is CLI > environment > file > default. Register paths with `Register`
or structs with `RegisterStruct`; defaults define the types used to validate new
values. `LoadFile`, `LoadEnv` and `LoadCLI` replace their complete source. A failed
source load keeps its previous values. Removing an environment variable, CLI flag
or file key removes the old override on the next load.
`Get` returns a detached raw source value: environment and CLI values remain
strings, TOML integers are int64. `GetTyped[T]`, `Scan`, `ScanSource`, `ScanTyped[T]`
and `ScanMap` provide checked conversions. Errors leave destinations unchanged;
missing struct fields retain their existing values. `AsStruct` returns a detached
snapshot on every call. Build fills `WithTarget` once; subsequent reloads never
mutate that caller-owned object.
## Migration
- Only TOML files and `toml` tags are supported. JSON/YAML parsers, format constants
and alternate tag modes are removed. Existing format/tag option methods accept
only `toml` (`auto` remains an alias for TOML for file options).
- Numeric conversion rejects overflow, negative unsigned inputs, fractional
integer conversion, non-finite floats and integer-to-float precision loss.
Float64-to-float32 rounding within range remains supported. Full uint64 is
supported in memory; saving a value above MaxInt64 fails without changing the
destination file. Use an application-level string representation if needed.
- Strings can represent decimal numbers, booleans, comma-separated slices/arrays,
durations, RFC3339 timestamps, IP addresses, CIDR networks and URLs. Boolean to
string conversion uses `true`/`false`. There are no user decode hooks or automatic
`encoding.TextUnmarshaler` calls.
- Setters and source loads reject values incompatible with registered defaults
immediately. Check their errors. Untyped nil defaults leave the value type open;
typed nil pointers retain their declared type. Unsupported values, non-finite
floats and cyclic/deep graphs are rejected. Configuration value depth is 128.
- All public values, defaults, options and clones own their mutable containers.
Existing references returned from `AsStruct` are snapshots, not live objects.
- Saves standardize formatting and retain loaded comments as a preamble. Comment
positions are not preserved. Existing file permissions are retained; new files
use 0600. Only registered paths are saved, so unknown file keys are omitted.
- Paths use dot-separated ASCII key segments. Numeric-only segments and overlapping
registrations such as `server` plus `server.port` are rejected. Embedded fields
are not promoted. Recursive struct schemas are unsupported.
## Watching
```go
changes := cfg.WatchWithOptions(config.DefaultWatchOptions())
defer cfg.StopAutoUpdate()
for event := range changes {
// Events are hints; always read the latest complete state.
log.Print(event)
latest, err := cfg.AsStruct()
if err != nil { log.Print(err); continue }
_ = latest.(*Settings)
}
```
The watcher fingerprints file contents, waits for a quiet debounce interval, then
validates and publishes. Invalid edits, timeouts, deletion and blocked permission
changes retain the last valid state. Cancelled, expired or superseded reloads
cannot publish. Stop closes subscriptions synchronously; a new Watch call can
restart watching. Notifications are buffered and best effort.
## Documentation
- [Quick Start](doc/quick-start.md) - Get started
- [Builder Pattern](doc/builder.md) - Advanced configuration with the builder
- [Access Patterns](doc/access.md) - Getting and setting values
- [Command Line](doc/cli.md) - CLI argument handling
- [Environment Variables](doc/env.md) - Environment variable handling
- [File Configuration](doc/file.md) - File formats and loading
- [Validation](doc/validator.md) - Validation functions and integration
- [Live Reconfiguration](doc/reconfiguration.md) - File watching and auto-update on change
- [Quick Guide](doc/quick-guide_lixenwraith_config.md) - Quick reference guide
- [Quick start](doc/quick-start.md), [builder](doc/builder.md), [access](doc/access.md)
- [Files and saving](doc/file.md), [environment](doc/env.md), [CLI](doc/cli.md)
- [Live reconfiguration](doc/reconfiguration.md), [validation](doc/validator.md)
- [Architecture](doc/architecture.md), [audit evidence](doc/audit.md)
## License
Run `go test -race ./...` and `go vet ./...`. CI also executes the 386 suite,
cross-compiles for Windows and runs bounded numeric conversion fuzzing.
BSD-3-Clause
BSD-3-Clause; see LICENSE.
+33 -7
View File
@@ -66,6 +66,15 @@ func (b *Builder) Build() (*Config, error) {
// If WithDefaults() was called, it takes precedence
// If not, but WithTarget() was called, use the target struct for defaults
if b.defaults != nil {
if b.cfg.structCache != nil {
t := reflect.TypeOf(b.defaults)
if t.Kind() == reflect.Pointer {
t = t.Elem()
}
if t != b.cfg.structCache.targetType {
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("explicit defaults must have the target struct type"))
}
}
// WithDefaults() was called explicitly.
if err := b.cfg.RegisterStructWithTags(b.prefix, b.defaults, tagName); err != nil {
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("failed to register defaults: %w", err))
@@ -80,6 +89,9 @@ func (b *Builder) Build() (*Config, error) {
// Explicitly set the file path on the config object so the watcher can find it,
// even if the initial load fails with a non-fatal error (file not found)
b.cfg.configFilePath = b.file
if b.cfg.structCache != nil {
b.cfg.structCache.prefix = strings.TrimSuffix(b.prefix, ".")
}
// 2. Load configuration
loadErr := b.cfg.loadWithOptions(b.file, b.args, b.opts)
@@ -89,6 +101,9 @@ func (b *Builder) Build() (*Config, error) {
}
// 3. Run non-typed validators
if err := b.cfg.Validate(); err != nil {
return nil, err
}
for _, validator := range b.validators {
if err := validator(b.cfg); err != nil {
return nil, wrapError(ErrValidation, fmt.Errorf("configuration validation failed: %w", err))
@@ -96,13 +111,15 @@ func (b *Builder) Build() (*Config, error) {
}
// 4. Populate target and run typed validators
if b.cfg.structCache != nil && b.cfg.structCache.target != nil && len(b.typedValidators) > 0 {
if b.cfg.structCache != nil && b.cfg.structCache.target != nil {
// Populate the target struct first, unifying all types (e.g., string "8888" -> int64 8888)
populatedTarget, err := b.cfg.AsStruct()
if err != nil {
return nil, wrapError(ErrValidation, fmt.Errorf("failed to populate target struct for validation: %w", err))
}
reflect.ValueOf(b.cfg.structCache.target).Elem().Set(reflect.ValueOf(populatedTarget).Elem())
// Run the typed validators against the populated, type-safe struct
for _, validator := range b.typedValidators {
validatorFunc := reflect.ValueOf(validator)
@@ -145,16 +162,16 @@ func (b *Builder) WithDefaults(defaults any) *Builder {
}
// WithTagName sets the struct tag name to use for field mapping
// Supported values: toml (default), json, yaml
// Supported values: toml only
func (b *Builder) WithTagName(tagName string) *Builder {
switch tagName {
case FormatTOML, FormatJSON, FormatYAML:
case FormatTOML:
b.tagName = tagName
if b.cfg != nil { // Ensure cfg exists
b.cfg.tagName = tagName
}
default:
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName))
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml", tagName))
}
return b
}
@@ -162,7 +179,7 @@ func (b *Builder) WithTagName(tagName string) *Builder {
// WithFileFormat sets the expected file format
func (b *Builder) WithFileFormat(format string) *Builder {
switch format {
case FormatTOML, FormatJSON, FormatYAML, FormatAuto:
case FormatTOML, FormatAuto:
b.fileFormat = format
default:
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("unsupported file format %q", format))
@@ -196,13 +213,19 @@ func (b *Builder) WithFile(path string) *Builder {
// WithArgs sets the command-line arguments
func (b *Builder) WithArgs(args []string) *Builder {
b.args = args
b.args = append([]string(nil), args...)
return b
}
// WithSources sets the precedent order for configuration sources
func (b *Builder) WithSources(sources ...Source) *Builder {
b.opts.Sources = sources
for _, source := range sources {
if !validSource(source) {
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source))
return b
}
}
b.opts.Sources = append([]Source(nil), sources...)
return b
}
@@ -331,6 +354,9 @@ func (b *Builder) WithTypedValidator(fn any) *Builder {
return b
}
if reflect.ValueOf(fn).IsNil() {
return b
}
b.typedValidators = append(b.typedValidators, fn)
return b
}
+3 -3
View File
@@ -43,8 +43,8 @@ func TestBuilder(t *testing.T) {
os.WriteFile(configFile, []byte(`host = "filehost"`), 0644)
type Config struct {
Host string `json:"hostname"`
Port int `json:"port"`
Host string `toml:"hostname"`
Port int `toml:"port"`
}
defaults := &Config{
@@ -59,7 +59,7 @@ func TestBuilder(t *testing.T) {
cfg, err := NewBuilder().
WithDefaults(defaults).
WithTagName("json").
WithTagName("toml").
WithPrefix("server").
WithEnvPrefix("APP_").
WithFile(configFile).
+69 -41
View File
@@ -13,6 +13,7 @@ import (
// configItem holds configuration values from different sources
type configItem struct {
required bool
defaultValue any
values map[Source]any // Values from each source
currentValue any // Computed value based on precedence
@@ -22,6 +23,8 @@ type configItem struct {
type structCache struct {
target any // User-provided struct pointer
targetType reflect.Type // Cached type for validation
snapshot any // Immutable cached result; callers receive copies
prefix string
version int64 // Version for invalidation
populated bool // Whether cache is valid
mu sync.RWMutex
@@ -35,16 +38,18 @@ type SecurityOptions struct {
}
// Config manages application configuration. It can be used in two primary ways:
// 1. As a dynamic key-value store, accessed via methods like Get(), String(), and Int64()
// 2. As a source for a type-safe struct, populated via BuildAndScan() or AsStruct()
// 1. As a dynamic key-value store, accessed via Get() and GetTyped[T]()
// 2. As a source for typed structs, populated via Scan() or AsStruct()
type Config struct {
items map[string]configItem
tagName string
fileFormat string // Separate from tagName: toml, json, yaml, or auto
fileFormat string // Separate from tagName: toml or auto
securityOpts *SecurityOptions
mutex sync.RWMutex
options LoadOptions // Current load options
fileData map[string]any // Cached file data
fileComments []string
fileGeneration uint64 // Invalidates staged watcher reloads
envData map[string]any // Cached env data
cliData map[string]any // Cached CLI data
version atomic.Int64
@@ -74,7 +79,7 @@ func New() *Config {
// NewWithOptions creates a new Config instance with custom load options
func NewWithOptions(opts LoadOptions) *Config {
c := New()
c.options = opts
c.options = cloneLoadOptions(opts)
return c
}
@@ -83,17 +88,19 @@ func (c *Config) SetLoadOptions(opts LoadOptions) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.options = opts
c.options = cloneLoadOptions(opts)
// Recompute all current values based on new precedence
for path, item := range c.items {
item.currentValue = c.computeValue(item)
c.items[path] = item
}
c.invalidateCache()
}
// SetPrecedence updates source precedence with validation
func (c *Config) SetPrecedence(sources ...Source) error {
sources = slices.Clone(sources)
// Validate all required sources present
required := map[Source]bool{
SourceDefault: false,
@@ -167,10 +174,10 @@ func (c *Config) GetPrecedence() []Source {
// Use "auto" to detect based on file extension
func (c *Config) SetFileFormat(format string) error {
switch format {
case FormatTOML, FormatJSON, FormatYAML, FormatAuto:
case FormatTOML, FormatAuto:
// Valid formats
default:
return wrapError(ErrFileFormat, fmt.Errorf("unsupported file format %q, must be one of: toml, json, yaml, auto", format))
return wrapError(ErrFileFormat, fmt.Errorf("unsupported file format %q, must be one of: toml, auto", format))
}
c.mutex.Lock()
@@ -185,6 +192,7 @@ func (c *Config) SetSecurityOptions(opts SecurityOptions) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.securityOpts = &opts
c.fileGeneration++
}
// Get retrieves a configuration value using the path and indicator if the path was registered
@@ -197,7 +205,7 @@ func (c *Config) Get(path string) (any, bool) {
return nil, false
}
return item.currentValue, true
return cloneOwned(item.currentValue), true
}
// GetSource retrieves a value from a specific source
@@ -211,7 +219,10 @@ func (c *Config) GetSource(path string, source Source) (any, bool) {
}
val, exists := item.values[source]
return val, exists
if source == SourceDefault {
return cloneOwned(item.defaultValue), true
}
return cloneOwned(val), exists
}
// Set updates a configuration value for the given path
@@ -219,14 +230,19 @@ func (c *Config) GetSource(path string, source Source) (any, bool) {
// By default, this is SourceCLI. Returns an error if the path is not registered
// To set a value in a specific source, use SetSource instead
func (c *Config) Set(path string, value any) error {
return c.SetSource(c.options.Sources[0], path, value)
c.mutex.Lock()
defer c.mutex.Unlock()
return c.setSourceLocked(c.options.Sources[0], path, value)
}
// SetSource sets a value for a specific source
func (c *Config) SetSource(source Source, path string, value any) error {
c.mutex.Lock()
defer c.mutex.Unlock()
return c.setSourceLocked(source, path, value)
}
func (c *Config) setSourceLocked(source Source, path string, value any) error {
item, registered := c.items[path]
if !registered {
return wrapError(ErrPathNotRegistered, fmt.Errorf("path %s is not registered", path))
@@ -240,7 +256,29 @@ func (c *Config) SetSource(source Source, path string, value any) error {
item.values = make(map[Source]any)
}
if !validSource(source) {
return wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source))
}
if err := validateValue(item, value); err != nil {
return err
}
owned, err := copyValue(value)
if err != nil {
return err
}
value = owned
if source == SourceDefault {
if item.defaultValue != nil {
target := reflect.New(reflect.TypeOf(item.defaultValue))
if err := decodeConfig(value, target.Interface()); err != nil {
return err
}
value = target.Elem().Interface()
}
item.defaultValue = value
} else {
item.values[source] = value
}
item.currentValue = c.computeValue(item)
c.items[path] = item
@@ -271,7 +309,7 @@ func (c *Config) GetSources(path string) map[Source]any {
result := make(map[Source]any)
for source, value := range item.values {
result[source] = value
result[source] = cloneOwned(value)
}
return result
}
@@ -285,6 +323,8 @@ func (c *Config) Reset() {
c.fileData = make(map[string]any)
c.envData = make(map[string]any)
c.cliData = make(map[string]any)
c.unknownCLIKeys = nil
c.fileGeneration++
// Reset all items to default values
for path, item := range c.items {
@@ -305,10 +345,12 @@ func (c *Config) ResetSource(source Source) {
switch source {
case SourceFile:
c.fileData = make(map[string]any)
c.fileGeneration++
case SourceEnv:
c.envData = make(map[string]any)
case SourceCLI:
c.cliData = make(map[string]any)
c.unknownCLIKeys = nil
}
// Remove source values from all items
@@ -323,22 +365,24 @@ func (c *Config) ResetSource(source Source) {
// AsStruct returns the populated struct if in type-aware mode
func (c *Config) AsStruct() (any, error) {
if c.structCache == nil || c.structCache.target == nil {
c.mutex.RLock()
defer c.mutex.RUnlock()
if c.structCache == nil {
return nil, wrapError(ErrNotConfigured, fmt.Errorf("no target struct configured"))
}
c.structCache.mu.RLock()
currentVersion := c.version.Load()
needsUpdate := !c.structCache.populated || c.structCache.version != currentVersion
c.structCache.mu.RUnlock()
if needsUpdate {
if err := c.populateStruct(); err != nil {
cache := c.structCache
cache.mu.Lock()
defer cache.mu.Unlock()
version := c.version.Load()
if !cache.populated || cache.version != version {
next := reflect.New(cache.targetType)
if err := decodeConfig(navigateToPath(c.nestedLocked(""), cache.prefix), next.Interface()); err != nil {
return nil, err
}
cache.snapshot = next.Interface()
cache.version, cache.populated = version, true
}
return c.structCache.target, nil
return copyValue(cache.snapshot)
}
// UnknownCLIKeys returns CLI paths that matched no registered config path
@@ -352,6 +396,9 @@ func (c *Config) UnknownCLIKeys() []string {
func (c *Config) computeValue(item configItem) any {
// Check sources in precedence order
for _, source := range c.options.Sources {
if source == SourceDefault {
return item.defaultValue
}
if val, exists := item.values[source]; exists && val != nil {
return val
}
@@ -361,25 +408,6 @@ func (c *Config) computeValue(item configItem) any {
return item.defaultValue
}
// populateStruct updates the cached struct representation using unified unmarshal
func (c *Config) populateStruct() error {
c.structCache.mu.Lock()
defer c.structCache.mu.Unlock()
currentVersion := c.version.Load()
if c.structCache.populated && c.structCache.version == currentVersion {
return nil
}
if err := c.unmarshal("", c.structCache.target); err != nil {
return wrapError(ErrDecode, fmt.Errorf("failed to populate struct cache: %w", err))
}
c.structCache.version = currentVersion
c.structCache.populated = true
return nil
}
// invalidateCache override Set methods to invalidate cache
func (c *Config) invalidateCache() {
c.version.Add(1)
+7 -12
View File
@@ -124,15 +124,10 @@ func TestComplexStructRegistration(t *testing.T) {
assert.Equal(t, 30*time.Second, val)
})
t.Run("JSONTags", func(t *testing.T) {
t.Run("RejectJSONTags", func(t *testing.T) {
cfg := New()
err := cfg.RegisterStructWithTags("", defaultConfig, "json")
require.NoError(t, err)
// JSON tags should create different paths
paths := cfg.GetRegisteredPaths("")
assert.True(t, paths["db.db_host"])
assert.True(t, paths["db.db_port"])
require.Error(t, cfg.RegisterStructWithTags("", defaultConfig, "json"))
require.Empty(t, cfg.GetRegisteredPaths())
})
t.Run("UnsupportedTag", func(t *testing.T) {
@@ -446,7 +441,7 @@ func TestPrecedenceWithAutoUpdate(t *testing.T) {
assert.Equal(t, "cli-server", val)
}
// TestTypeConversion tests automatic type conversion through mapstructure
// TestTypeConversion tests automatic type conversion through checked decoding
func TestTypeConversion(t *testing.T) {
type TestConfig struct {
IntValue int64 `toml:"int"`
@@ -486,7 +481,7 @@ func TestTypeConversion(t *testing.T) {
cfg.SetSource(SourceEnv, "ipnet", "10.0.0.0/8")
cfg.SetSource(SourceEnv, "url", "https://example.com:8080/path")
cfg.SetSource(SourceEnv, "strings", "x,y,z")
// cfg.SetSource("ints", SourceEnv, "7,8,9") // failure due to mapstructure limitation
require.NoError(t, cfg.SetSource(SourceEnv, "ints", "7,8,9"))
// Scan into struct
var result TestConfig
@@ -538,7 +533,7 @@ func TestConcurrentAccess(t *testing.T) {
defer wg.Done()
for j := 0; j < 100; j++ {
path := fmt.Sprintf("path%d", j)
value := fmt.Sprintf("writer%d-value%d", id, j)
value := id*100 + j
if err := cfg.Set(path, value); err != nil {
errors <- fmt.Errorf("writer %d: %v", id, err)
}
@@ -555,7 +550,7 @@ func TestConcurrentAccess(t *testing.T) {
for j := 0; j < 50; j++ {
path := fmt.Sprintf("path%d", j)
source := sources[j%len(sources)]
value := fmt.Sprintf("source%d-value%d", id, j)
value := id*100 + j
if err := cfg.SetSource(source, path, value); err != nil {
errors <- fmt.Errorf("source writer %d: %v", id, err)
}
-2
View File
@@ -31,8 +31,6 @@ const (
// Format identifiers
const (
FormatTOML = "toml"
FormatJSON = "json"
FormatYAML = "yaml"
FormatAuto = "auto"
)
+296 -263
View File
@@ -1,309 +1,342 @@
// FILE: lixenwraith/config/decode.go
package config
import (
"encoding/json"
"fmt"
"net"
"net/url"
"reflect"
"strconv"
"strings"
"time"
"github.com/go-viper/mapstructure/v2"
"github.com/lixenwraith/toml"
)
// unmarshal is the single authoritative function for decoding configuration
// into target structures. All public decoding methods delegate to this
func (c *Config) unmarshal(source Source, target any, basePath ...string) error {
// Parse variadic basePath
if len(basePath) > 1 {
return wrapError(ErrInvalidPath, fmt.Errorf("expected at most one basePath"))
}
path := ""
switch len(basePath) {
case 0:
// Use default empty path
case 1:
path = basePath[0]
default:
return wrapError(ErrInvalidPath, fmt.Errorf("too many basePath arguments: expected 0 or 1, got %d", len(basePath)))
if len(basePath) == 1 {
path = strings.TrimSuffix(basePath[0], ".")
}
// Validate target
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return wrapError(ErrTypeMismatch, fmt.Errorf("unmarshal target must be non-nil pointer, got %T", target))
}
c.mutex.RLock()
defer c.mutex.RUnlock()
nested := c.nestedLocked(source)
c.mutex.RUnlock()
section := navigateToPath(nested, path)
if section == nil {
section = map[string]any{}
}
if reflect.TypeOf(section).Kind() != reflect.Map {
return wrapError(ErrTypeMismatch, fmt.Errorf("path %q refers to non-map value", path))
}
return decodeConfig(section, target)
}
// Build nested map based on source selection
nestedMap := make(map[string]any)
if source == "" {
// Use current merged state
func (c *Config) nestedLocked(source Source) map[string]any {
nested := make(map[string]any)
for path, item := range c.items {
setNestedValue(nestedMap, path, item.currentValue)
var value any
var ok bool
switch source {
case "":
value, ok = item.currentValue, true
case SourceDefault:
value, ok = item.defaultValue, true
default:
value, ok = item.values[source]
}
} else {
// Use specific source
for path, item := range c.items {
if val, exists := item.values[source]; exists {
setNestedValue(nestedMap, path, val)
if ok {
setNestedValue(nested, path, value)
}
}
return nested
}
// Navigate to basePath section
sectionData := navigateToPath(nestedMap, path)
// Ensure we have a map to decode, normalizing if necessary
sectionMap, err := normalizeMap(sectionData)
if err != nil {
if sectionData == nil {
sectionMap = make(map[string]any) // Empty section is valid
} else {
// Path points to a non-map value, which is an error for Scan
return wrapError(ErrTypeMismatch, fmt.Errorf("path %q refers to non-map value (type %T)", path, sectionData))
// decodeConfig stages writes and retains missing struct fields. It never mutates
// stored configuration or existing destination containers on a failed conversion.
func decodeConfig(data any, target any) error {
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Pointer || rv.IsNil() {
return wrapError(ErrTypeMismatch, fmt.Errorf("target must be non-nil pointer"))
}
next := reflect.New(rv.Elem().Type()).Elem()
next.Set(rv.Elem())
if err := decodeInto(data, next, 0); err != nil {
return wrapError(ErrDecode, err)
}
// Create decoder with comprehensive hooks
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: target,
TagName: c.tagName,
WeaklyTypedInput: true,
DecodeHook: c.getDecodeHook(),
ZeroFields: true,
Metadata: nil,
})
if err != nil {
return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err))
}
if err := decoder.Decode(sectionMap); err != nil {
return wrapError(ErrDecode, fmt.Errorf("decode failed for path %q: %w", path, err))
}
rv.Elem().Set(next)
return nil
}
// normalizeMap ensures that the input data is a map[string]any for the decoder
func normalizeMap(data any) (map[string]any, error) {
func decodeInto(data any, dst reflect.Value, depth int) error {
if depth > maxValueDepth {
return fmt.Errorf("decode nesting exceeds %d", maxValueDepth)
}
if data == nil {
return make(map[string]any), nil
dst.SetZero()
return nil
}
src := reflect.ValueOf(data)
for src.Kind() == reflect.Pointer || src.Kind() == reflect.Interface {
if src.IsNil() {
dst.SetZero()
return nil
}
src = src.Elem()
depth++
if depth > maxValueDepth {
return fmt.Errorf("cyclic pointer input")
}
}
data = src.Interface()
if dst.Kind() == reflect.Pointer {
next := reflect.New(dst.Type().Elem())
if !dst.IsNil() {
next.Elem().Set(dst.Elem())
}
if err := decodeInto(data, next.Elem(), depth+1); err != nil {
return err
}
dst.Set(next)
return nil
}
if atomicType(dst.Type()) && src.Type() == dst.Type() {
v, err := copyReflect(src, depth+1)
if err != nil {
return err
}
dst.Set(v)
return nil
}
if src.Kind() == reflect.String {
s := src.String()
if len(s) > MaxValueSize {
return ErrValueSize
}
switch dst.Type() {
case durationType:
v, err := time.ParseDuration(s)
if err != nil {
return err
}
dst.SetInt(int64(v))
return nil
case timeType:
v, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return err
}
dst.Set(reflect.ValueOf(v))
return nil
case ipType:
if len(s) > MaxIPv6Length {
return fmt.Errorf("invalid IP length: %d", len(s))
}
v := net.ParseIP(s)
if v == nil {
return fmt.Errorf("invalid IP address %q", s)
}
dst.Set(reflect.ValueOf(v))
return nil
case ipNetType:
if len(s) > MaxCIDRLength {
return fmt.Errorf("invalid CIDR length: %d", len(s))
}
_, v, err := net.ParseCIDR(s)
if err != nil {
return fmt.Errorf("invalid CIDR: %w", err)
}
dst.Set(reflect.ValueOf(*v))
return nil
case urlType:
if len(s) > MaxURLLength {
return fmt.Errorf("URL too long: %d bytes", len(s))
}
v, err := url.Parse(s)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
dst.Set(reflect.ValueOf(*v))
return nil
}
switch dst.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
v, err := strconv.ParseInt(s, 10, dst.Type().Bits())
if err != nil {
return err
}
dst.SetInt(v)
return nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
v, err := strconv.ParseUint(s, 10, dst.Type().Bits())
if err != nil {
return err
}
dst.SetUint(v)
return nil
case reflect.Float32, reflect.Float64:
v, err := strconv.ParseFloat(s, dst.Type().Bits())
if err != nil {
return err
}
return toml.Decode(v, dst.Addr().Interface())
case reflect.Bool:
v, err := strconv.ParseBool(s)
if err != nil {
return err
}
dst.SetBool(v)
return nil
case reflect.Slice, reflect.Array:
parts := []string{}
if s != "" {
parts = strings.Split(s, ",")
}
data, src = parts, reflect.ValueOf(parts)
}
}
switch dst.Kind() {
case reflect.Interface:
v, err := copyReflect(src, depth+1)
if err != nil {
return err
}
if !v.Type().AssignableTo(dst.Type()) {
return fmt.Errorf("cannot assign %s to %s", v.Type(), dst.Type())
}
dst.Set(v)
case reflect.Struct:
if atomicType(dst.Type()) {
return fmt.Errorf("cannot decode %T as %s", data, dst.Type())
}
m, err := normalizeMap(data)
if err != nil {
return err
}
seen := make(map[string]bool)
for i := 0; i < dst.NumField(); i++ {
f := dst.Type().Field(i)
if !f.IsExported() {
continue
}
key := fieldKey(f)
if key == "-" {
continue
}
if seen[key] {
return fmt.Errorf("duplicate TOML field %q", key)
}
seen[key] = true
if v, ok := m[key]; ok {
if err := decodeInto(v, dst.Field(i), depth+1); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
}
}
case reflect.Map:
if dst.Type().Key().Kind() != reflect.String {
return fmt.Errorf("map keys must be strings")
}
m, err := normalizeMap(data)
if err != nil {
return err
}
next := reflect.MakeMapWithSize(dst.Type(), len(m))
for key, value := range m {
v := reflect.New(dst.Type().Elem()).Elem()
if err := decodeInto(value, v, depth+1); err != nil {
return fmt.Errorf("%s: %w", key, err)
}
next.SetMapIndex(reflect.ValueOf(key).Convert(dst.Type().Key()), v)
}
dst.Set(next)
case reflect.Slice, reflect.Array:
if src.Kind() != reflect.Slice && src.Kind() != reflect.Array {
return fmt.Errorf("expected array, got %T", data)
}
var next reflect.Value
if dst.Kind() == reflect.Array {
if dst.Len() != src.Len() {
return fmt.Errorf("array length %d does not match %d", src.Len(), dst.Len())
}
next = reflect.New(dst.Type()).Elem()
} else {
next = reflect.MakeSlice(dst.Type(), src.Len(), src.Len())
}
for i := 0; i < src.Len(); i++ {
if err := decodeInto(src.Index(i).Interface(), next.Index(i), depth+1); err != nil {
return fmt.Errorf("index %d: %w", i, err)
}
}
dst.Set(next)
case reflect.String:
switch src.Kind() {
case reflect.String:
dst.SetString(src.String())
case reflect.Bool:
dst.SetString(strconv.FormatBool(src.Bool()))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
dst.SetString(strconv.FormatInt(src.Int(), 10))
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
dst.SetString(strconv.FormatUint(src.Uint(), 10))
case reflect.Float32, reflect.Float64:
if _, err := copyReflect(src, depth+1); err != nil {
return err
}
dst.SetString(strconv.FormatFloat(src.Float(), 'g', -1, src.Type().Bits()))
default:
return fmt.Errorf("cannot convert %T to string", data)
}
case reflect.Bool:
if src.Kind() != reflect.Bool {
return fmt.Errorf("cannot convert %T to bool", data)
}
dst.SetBool(src.Bool())
default:
return toml.Decode(data, dst.Addr().Interface())
}
return nil
}
// If it's already the correct type, return it.
func fieldKey(f reflect.StructField) string {
key, _, _ := strings.Cut(f.Tag.Get(FormatTOML), ",")
if key == "" {
key = f.Name
}
return key
}
func normalizeMap(data any) (map[string]any, error) {
if data == nil {
return map[string]any{}, nil
}
if m, ok := data.(map[string]any); ok {
return m, nil
}
// Use reflection to handle other map types (e.g., map[string]bool)
v := reflect.ValueOf(data)
if v.Kind() == reflect.Map {
if v.Type().Key().Kind() != reflect.String {
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("map keys must be strings, but got %v", v.Type().Key()))
if v.Kind() != reflect.Map || v.Type().Key().Kind() != reflect.String {
return nil, fmt.Errorf("expected string-keyed map, got %T", data)
}
m := make(map[string]any, v.Len())
it := v.MapRange()
for it.Next() {
m[it.Key().String()] = it.Value().Interface()
}
return m, nil
}
// Create a new map[string]any and copy the values
normalized := make(map[string]any, v.Len())
iter := v.MapRange()
for iter.Next() {
normalized[iter.Key().String()] = iter.Value().Interface()
}
return normalized, nil
}
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("expected a map but got %T", data))
}
// getDecodeHook returns the composite decode hook for all type conversions
func (c *Config) getDecodeHook() mapstructure.DecodeHookFunc {
return mapstructure.ComposeDecodeHookFunc(
// JSON Number handling
jsonNumberHookFunc(),
// Network types
stringToNetIPHookFunc(),
stringToNetIPNetHookFunc(),
stringToURLHookFunc(),
// Standard hooks
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
// Custom application hooks
c.customDecodeHook(),
)
}
// jsonNumberHookFunc handles json.Number conversion to appropriate numeric types
func jsonNumberHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
// Check if source is json.Number
if f != reflect.TypeOf(json.Number("")) {
return data, nil
}
num := data.(json.Number)
// Convert based on target type
switch t.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
val, err := num.Int64()
if err != nil {
return nil, wrapError(ErrDecode, err)
}
return val, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
// Parse as int64 first, then convert
i, err := num.Int64()
if err != nil {
return nil, wrapError(ErrDecode, err)
}
if i < 0 {
return nil, wrapError(ErrDecode, fmt.Errorf("cannot convert negative number to unsigned type"))
}
return uint64(i), nil
case reflect.Float32, reflect.Float64:
val, err := num.Float64()
if err != nil {
return nil, wrapError(ErrDecode, err)
}
return val, nil
case reflect.String:
return num.String(), nil
default:
// Return as-is for other types
return data, nil
}
}
}
// stringToNetIPHookFunc handles net.IP conversion
func stringToNetIPHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
if f.Kind() != reflect.String {
return data, nil
}
if t != reflect.TypeOf(net.IP{}) {
return data, nil
}
// SECURITY: Validate IP string format to prevent injection
str := data.(string)
if len(str) > MaxIPv6Length {
return nil, fmt.Errorf("invalid IP length: %d", len(str))
}
ip := net.ParseIP(str)
if ip == nil {
return nil, fmt.Errorf("invalid IP address: %s", str)
}
return ip, nil
}
}
// stringToNetIPNetHookFunc handles net.IPNet conversion
func stringToNetIPNetHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
if f.Kind() != reflect.String {
return data, nil
}
isPtr := t.Kind() == reflect.Ptr
targetType := t
if isPtr {
targetType = t.Elem()
}
if targetType != reflect.TypeOf(net.IPNet{}) {
return data, nil
}
str := data.(string)
if len(str) > MaxCIDRLength {
return nil, wrapError(ErrDecode, fmt.Errorf("invalid CIDR length: %d", len(str)))
}
_, ipnet, err := net.ParseCIDR(str)
if err != nil {
return nil, wrapError(ErrDecode, fmt.Errorf("invalid CIDR: %w", err))
}
if isPtr {
return ipnet, nil
}
return *ipnet, nil
}
}
// stringToURLHookFunc handles url.URL conversion
func stringToURLHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
if f.Kind() != reflect.String {
return data, nil
}
isPtr := t.Kind() == reflect.Ptr
targetType := t
if isPtr {
targetType = t.Elem()
}
if targetType != reflect.TypeOf(url.URL{}) {
return data, nil
}
str := data.(string)
if len(str) > MaxURLLength {
return nil, wrapError(ErrDecode, fmt.Errorf("URL too long: %d bytes", len(str)))
}
u, err := url.Parse(str)
if err != nil {
return nil, wrapError(ErrDecode, fmt.Errorf("invalid URL: %w", err))
}
if isPtr {
return u, nil
}
return *u, nil
}
}
// customDecodeHook allows for application-specific type conversions
func (c *Config) customDecodeHook() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
// TODO: Add support of custom validation for application types here
// Example: Rate limit parsing, permission validation, etc.
// Pass through by default
return data, nil
}
}
// navigateToPath traverses nested map to reach the specified path
func navigateToPath(nested map[string]any, path string) any {
if path == "" {
return nested
}
path = strings.TrimSuffix(path, ".")
if path == "" {
return nested
}
segments := strings.Split(path, ".")
current := any(nested)
for _, segment := range segments {
currentMap, ok := current.(map[string]any)
if !ok {
var current any = nested
for _, key := range strings.Split(strings.TrimSuffix(path, "."), ".") {
m, err := normalizeMap(current)
if err != nil {
return nil
}
value, exists := currentMap[segment]
if !exists {
return nil
current = m[key]
}
current = value
}
return current
}
+23 -82
View File
@@ -4,6 +4,7 @@ package config
import (
"net"
"net/url"
"strings"
"testing"
"time"
@@ -135,7 +136,7 @@ func TestScanFromSource(t *testing.T) {
{SourceFile, "fromfile"},
{SourceEnv, "fromenv"},
{SourceCLI, "fromcli"},
{SourceDefault, ""}, // No value in default source
{SourceDefault, "default"}, // Registered defaults are a readable source
}
for _, tt := range tests {
@@ -174,90 +175,30 @@ func TestInvalidScanTargets(t *testing.T) {
// TestCustomTypeConversion tests edge cases in type conversion
func TestCustomTypeConversion(t *testing.T) {
for _, tc := range []struct {
name string
initial any
input string
fragment string
}{
{"IP", net.IP{}, "not-an-ip", "invalid IP address"},
{"CIDR", (*net.IPNet)(nil), "invalid-cidr", "invalid CIDR"},
{"URL", (*url.URL)(nil), "://invalid-url", "invalid URL"},
{"LongIP", net.IP{}, strings.Repeat("x", 50), "invalid IP length"},
{"LongURL", (*url.URL)(nil), strings.Repeat("x", MaxURLLength+1), "URL too long"},
} {
t.Run(tc.name, func(t *testing.T) {
cfg := New()
t.Run("InvalidIPAddress", func(t *testing.T) {
type Config struct {
IP net.IP `toml:"ip"`
}
cfg.Register("ip", net.IP{})
cfg.Set("ip", "not-an-ip")
var result Config
err := cfg.Scan(&result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid IP address")
})
t.Run("InvalidCIDR", func(t *testing.T) {
type Config struct {
Network *net.IPNet `toml:"network"`
}
cfg.Register("network", (*net.IPNet)(nil))
cfg.Set("network", "invalid-cidr")
var result Config
err := cfg.Scan(&result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid CIDR")
})
t.Run("InvalidURL", func(t *testing.T) {
type Config struct {
Endpoint *url.URL `toml:"endpoint"`
}
cfg.Register("endpoint", (*url.URL)(nil))
cfg.Set("endpoint", "://invalid-url")
var result Config
err := cfg.Scan(&result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid URL")
})
t.Run("LongIPString", func(t *testing.T) {
type Config struct {
IP net.IP `toml:"ip"`
}
cfg.Register("ip", net.IP{})
// String longer than max IPv6 length
longIP := make([]byte, 50)
for i := range longIP {
longIP[i] = 'x'
}
cfg.Set("ip", string(longIP))
var result Config
err := cfg.Scan(&result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid IP length")
})
t.Run("LongURL", func(t *testing.T) {
type Config struct {
URL *url.URL `toml:"url"`
}
cfg.Register("url", (*url.URL)(nil))
// URL longer than 2048 bytes
longURL := "https://example.com/"
for i := 0; i < 2048; i++ {
longURL += "x"
}
cfg.Set("url", longURL)
var result Config
err := cfg.Scan(&result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "URL too long")
require.NoError(t, cfg.Register("value", tc.initial))
err := cfg.Set("value", tc.input)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.fragment)
got, _ := cfg.Get("value")
assert.Equal(t, tc.initial, got)
})
}
}
// TestZeroFields tests that ZeroFields option works correctly
func TestZeroFields(t *testing.T) {
type Config struct {
KeepValue string `toml:"keep"`
@@ -324,5 +265,5 @@ func TestWeaklyTypedInput(t *testing.T) {
assert.Equal(t, 3.14159, result.FloatFromString)
assert.Equal(t, true, result.BoolFromString)
assert.Equal(t, "12345", result.StringFromInt)
assert.Equal(t, "1", result.StringFromBool) // mapstructure converts bool(true) to "1" in weak conversion
assert.Equal(t, "true", result.StringFromBool) // Canonical boolean text
}
+37 -401
View File
@@ -1,401 +1,37 @@
# Access Patterns
This guide covers all methods for getting and setting configuration values, type conversions, and working with structured data.
**Always Register First**: Register paths before setting values
**Use Type Assertions**: After struct registration, types are guaranteed
## Getting Values
### Basic Get
```go
// Get returns (value, exists)
value, exists := cfg.Get("server.port")
if !exists {
log.Fatal("server.port not configured")
}
// Type assertion (safe after registration)
port := value.(int64)
```
### Type-Safe Access
When using struct registration, types are guaranteed:
```go
type Config struct {
Server struct {
Port int64 `toml:"port"`
Host string `toml:"host"`
} `toml:"server"`
}
cfg.RegisterStruct("", &Config{})
// After registration, type assertions are safe
port, _ := cfg.Get("server.port")
portNum := port.(int64) // Won't panic - type is enforced
```
### Get from Specific Source
```go
// Get value from specific source
envPort, exists := cfg.GetSource(config.SourceEnv, "server.port")
if exists {
log.Printf("Port from environment: %v", envPort)
}
// Check all sources
sources := cfg.GetSources("server.port")
for source, value := range sources {
log.Printf("%s: %v", source, value)
}
```
### Struct Scanning
```go
// Scan into struct
var serverConfig struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
TLS struct {
Enabled bool `toml:"enabled"`
Cert string `toml:"cert"`
} `toml:"tls"`
}
if err := cfg.Scan(&serverConfig, "server"); err != nil {
log.Fatal(err)
}
// Use structured data
log.Printf("Server: %s:%d", serverConfig.Host, serverConfig.Port)
```
### Target Population
```go
// Populate entire config struct
var config AppConfig
if err := cfg.Target(&config); err != nil {
log.Fatal(err)
}
// Or with builder pattern
var config AppConfig
cfg, _ := config.NewBuilder().
WithTarget(&config).
Build()
// Access directly
fmt.Println(config.Server.Port)
```
### GetTyped
Retrieves a single configuration value and decodes it to the specified type.
```go
import "time"
// Returns an int, converting from string "9090" if necessary.
port, err := config.GetTyped[int](cfg, "server.port")
// Returns a time.Duration, converting from string "5m30s".
timeout, err := config.GetTyped[time.Duration](cfg, "server.timeout")
```
### ScanTyped
A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of the specified type.
```go
// Instead of:
// var dbConf DBConfig
// if err := cfg.Scan("database", &dbConf); err != nil { ... }
// You can write:
dbConf, err := config.ScanTyped[DBConfig](cfg, "database")
if err != nil {
// ...
}
// dbConf is a *DBConfig```
```
### Type-Aware Mode
```go
var conf AppConfig
cfg, _ := config.NewBuilder().
WithTarget(&conf).
Build()
// Get updated struct anytime
latest, err := cfg.AsStruct()
if err != nil {
log.Fatal(err)
}
appConfig := latest.(*AppConfig)
```
## Setting Values
### Basic Set
```go
// Set updates the highest priority source (default: CLI)
if err := cfg.Set("server.port", int64(9090)); err != nil {
log.Fatal(err) // Error if path not registered
}
```
### Set in Specific Source
```go
// Set value in specific source
cfg.SetSource(config.SourceEnv, "server.port", "8080")
cfg.SetSource(config.SourceCLI, "debug", true)
// File source typically set via LoadFile, but can be manual
cfg.SetSource(config.SourceFile, "feature.enabled", true)
```
### Batch Updates
```go
// Multiple updates
updates := map[string]any{
"server.port": int64(9090),
"server.host": "0.0.0.0",
"database.maxconns": int64(50),
}
for path, value := range updates {
if err := cfg.Set(path, value); err != nil {
log.Printf("Failed to set %s: %v", path, err)
}
}
```
## Type Conversions
The package uses mapstructure for flexible type conversion:
```go
// These all work for a string field
cfg.Set("name", "value") // Direct string
cfg.Set("name", 123) // Number → "123"
cfg.Set("name", true) // Boolean → "true"
// For int64 fields
cfg.Set("port", int64(8080)) // Direct
cfg.Set("port", "8080") // String → int64
cfg.Set("port", 8080.0) // Float → int64
cfg.Set("port", int(8080)) // int → int64
```
### Duration Handling
```go
type Config struct {
Timeout time.Duration `toml:"timeout"`
}
// All these work
cfg.Set("timeout", 30*time.Second) // Direct duration
cfg.Set("timeout", "30s") // String parsing
cfg.Set("timeout", "5m30s") // Complex duration
```
### Network Types
```go
type Config struct {
IP net.IP `toml:"ip"`
CIDR net.IPNet `toml:"cidr"`
URL url.URL `toml:"url"`
}
// Automatic parsing
cfg.Set("ip", "192.168.1.1")
cfg.Set("cidr", "10.0.0.0/8")
cfg.Set("url", "https://example.com:8080/path")
```
### Slice Handling
```go
type Config struct {
Tags []string `toml:"tags"`
Ports []int `toml:"ports"`
}
// Direct slice
cfg.Set("tags", []string{"prod", "stable"})
// Comma-separated string (from env/CLI)
cfg.Set("tags", "prod,stable,v2")
// Number arrays
cfg.Set("ports", []int{8080, 8081, 8082})
```
## Checking Configuration
### Path Registration
```go
// Check if path is registered
if _, exists := cfg.Get("server.port"); !exists {
log.Fatal("server.port not registered")
}
// Get all registered paths
paths := cfg.GetRegisteredPaths("server.")
for path := range paths {
log.Printf("Registered: %s", path)
}
// With default values
defaults := cfg.GetRegisteredPathsWithDefaults("")
for path, defaultVal := range defaults {
log.Printf("%s = %v (default)", path, defaultVal)
}
```
### Validation
```go
// Check required fields
if err := cfg.Validate("api.key", "database.url"); err != nil {
log.Fatal("Missing required config:", err)
}
// Custom validation
requiredPorts := []string{"server.port", "metrics.port"}
for _, path := range requiredPorts {
if val, exists := cfg.Get(path); exists {
if port := val.(int64); port < 1024 {
log.Fatalf("%s must be >= 1024", path)
}
}
}
```
### Source Inspection
```go
// Debug specific value
path := "server.port"
log.Printf("=== %s ===", path)
log.Printf("Current: %v", cfg.Get(path))
sources := cfg.GetSources(path)
for source, value := range sources {
log.Printf(" %s: %v", source, value)
}
```
## Advanced Patterns
### Dynamic Configuration
```go
// Change configuration at runtime
func updatePort(cfg *config.Config, port int64) error {
if port < 1 || port > 65535 {
return fmt.Errorf("invalid port: %d", port)
}
return cfg.Set("server.port", port)
}
```
### Configuration Facade
```go
type ConfigFacade struct {
cfg *config.Config
}
func (f *ConfigFacade) ServerPort() int64 {
val, _ := f.cfg.Get("server.port")
return val.(int64)
}
func (f *ConfigFacade) SetServerPort(port int64) error {
return f.cfg.Set("server.port", port)
}
func (f *ConfigFacade) DatabaseURL() string {
val, _ := f.cfg.Get("database.url")
return val.(string)
}
```
### Default Fallbacks
```go
// Helper for optional configuration
func getOrDefault(cfg *config.Config, path string, defaultVal any) any {
if val, exists := cfg.Get(path); exists {
return val
}
return defaultVal
}
// Usage
timeout := getOrDefault(cfg, "timeout", 30*time.Second).(time.Duration)
```
## Thread Safety
All access methods are thread-safe:
```go
// Safe concurrent access
var wg sync.WaitGroup
// Multiple readers
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
port, _ := cfg.Get("server.port")
log.Printf("Port: %v", port)
}()
}
// Concurrent writes are safe too
wg.Add(1)
go func() {
defer wg.Done()
cfg.Set("counter", atomic.AddInt64(&counter, 1))
}()
wg.Wait()
```
## Debugging
### View All Configuration
```go
// Debug output
fmt.Println(cfg.Debug())
// Dump as TOML
cfg.Dump() // Writes to stdout
```
### Clone for Testing
```go
// Create isolated copy for testing
testCfg := cfg.Clone()
testCfg.Set("server.port", int64(0)) // Random port for tests
```
# Access and conversion
| API | Behavior |
| --- | --- |
| `Get(path)` | Detached raw effective value and registration flag |
| `GetSource(path, source)` | Detached source value; Default returns the registered default |
| `GetSources(path)` | Detached map of explicit source overrides |
| `GetTyped[T](cfg, path)` | Checked conversion to T |
| `GetTypedWithDefault[T]` | Registers a default only when the path is absent; existing conversion errors are returned |
| `Scan(&target, optionalPath)` | Transactional decoding of current values |
| `ScanSource(source, &target, optionalPath)` | Decodes only the selected source |
| `ScanTyped[T]` | Allocates and populates a typed destination |
| `ScanMap(values, &target)` | Uses the same decoder without registration |
| `AsStruct()` | Independent snapshot of the configured target type |
Raw environment/CLI values remain strings. TOML integer literals are int64.
Defaults retain their declared types. Use typed access instead of assuming a raw
source type. Matching uses `toml` tags, then case-sensitive Go field names. Tags
with an empty name use the field name; `-` skips a field. Unknown input fields and
unexported destination fields are ignored. Maps and slices replace existing
containers; fixed arrays require exact lengths. Missing struct fields remain as
provided by the caller. A present nil value clears its destination.
Conversions support signed/unsigned numbers, floats, booleans, strings, slices,
arrays, string-keyed maps, structs and pointers. Durations, time.Time, net.IP,
net.IPNet and url.URL are atomic configuration values, including pointer forms.
They accept their native types or documented text representations. Comma-separated
strings can populate typed numeric slices as well as string slices.
No truncating integer conversions or integer-to-float precision loss is allowed.
Float narrowing may round within range. NaN/Inf and unsupported object graphs are
rejected. All decoding failures leave the destination unchanged. There are no
custom decode hooks or automatic text-unmarshal interfaces.
A Config owns accepted values. Modifying Get results, defaults after registration,
clones or AsStruct snapshots cannot modify stored state. Do not mutate arguments
concurrently while a call is reading them. Builder setup itself is not concurrent.
+24 -169
View File
@@ -1,174 +1,29 @@
# Config Package Architecture
# Architecture
## Overview
Registered items hold a default, source overrides and an effective value selected
by precedence. A Config RWMutex protects these maps and runtime options. Values
are copied on admission and never mutated internally afterward; replacement removes
or swaps references. Public reads return copies. Nested snapshots can therefore be
assembled under a read lock and decoded after releasing it.
The `lixenwraith/config` package provides thread-safe configuration management with support for multiple sources, type-safe struct population, and live reconfiguration.
The decoder handles the limited configuration object model: TOML tags, standard
containers and atomic duration/time/network values. It delegates numeric conversion
to lixenwraith/toml and uses explicit decimal parsing for environment/CLI strings.
No mapstructure or external file parser is used in the runtime dependency graph.
## Logical Architecture
Typed cache metadata uses a second mutex, always acquired after the Config read
lock. Version changes invalidate its immutable decoded snapshot. Every AsStruct call
returns a detached copy, so publication cannot race with existing readers. Build
writes the supplied target once. Clone copies configuration state and options but
does not copy active watchers or subscriptions.
```
┌──────────────────────────────────────────────────────────┐
│ User API Layer │
├────────────────┬────────────────┬────────────────────────┤
│ Builder │ Type-Safe │ Dynamic Access │
│ Pattern │ Struct API │ Key-Value API │
├────────────────┴────────────────┴────────────────────────┤
│ Core Config Engine │
│ • Path Registration • Source Merging • Thread Safety │
├──────────────────────────────────────────────────────────┤
│ Source Loaders │
│ File │ Environment │ CLI Arguments │ Defaults │
├──────────────────────────────────────────────────────────┤
│ Supporting Systems │
│ Validation │ Type Decode │ File Watch │ Error Handling │
└──────────────────────────────────────────────────────────┘
```
File reads and parsing precede source publication. All candidate values are checked
against the current schema, then the complete source is replaced under one lock.
The watcher additionally verifies cancellation, timeout and generation at commit.
One worker owns fingerprint/debounce state. Subscriber send/close operations share a
mutex; stop does not wait for that worker while holding the Config mutex.
## Component Interactions
```
Builder Flow:
NewBuilder()
↓
Configure (WithTarget, WithFile, WithEnvPrefix)
↓
Build() → Register Paths → Load Sources → Merge Values
↓
Config Instance
↓
AsStruct() / Get() / Watch()
Value Resolution:
Path Request
↓
Check Registration
↓
Search Sources (CLI → Env → File → Default)
↓
Type Conversion
↓
Return Value
Live Reload:
File Change Detected
↓
Debounce Timer
↓
Reload File
↓
Merge with Sources
↓
Notify Watchers
```
## File Organization
### Core (`config.go`)
- **Config struct**: Thread-safe state management with atomic versioning
- **Initialization**: `New()`, `NewWithOptions()`
- **State Management**: `Get()`, `Set()`, `GetSource()`, `SetSource()`
- **Precedence Control**: `SetPrecedence()`, `GetPrecedence()`, `computeValue()`
- **Cache Management**: `AsStruct()`, `populateStruct()`, `invalidateCache()`
- **Source Operations**: `Reset()`, `ResetSource()`, `GetSources()`
### Registration (`register.go`)
- **Path Registration**: `Register()`, `RegisterWithEnv()`, `RegisterRequired()`, `Unregister()`
- **Struct Registration**: `RegisterStruct()`, `RegisterStructWithTags()`, `registerFields()`
- **Discovery**: `GetRegisteredPaths()`, `GetRegisteredPathsWithDefaults()`
- **Decoding Bridge**: `Scan()`, `ScanSource()` - delegates to decode.go
### Builder Pattern (`builder.go`)
- **Fluent API**: `NewBuilder()`, `Build()`, `MustBuild()`
- **Configuration**: `WithTarget()`, `WithDefaults()`, `WithFile()`, `WithEnvPrefix()`
- **Discovery Integration**: `WithFileDiscovery()`
- **Validation**: `WithValidator()`, `WithTypedValidator()`
- **Security**: `WithSecurityOptions()`
### Source Loading (`loader.go`)
- **Multi-Source Loading**: `loadWithOptions()`
- **Individual Sources**: `LoadFile()`, `LoadEnv()`, `LoadCLI()`
- **Persistence**: `Save()`, `SaveSource()`, `atomicWriteFile()`
- **Environment Mapping**: `DiscoverEnv()`, `ExportEnv()`, `defaultEnvTransform()`
- **Parsing**: `parseArgs()`, `parseValue()`, `detectFileFormat()`
- **Security Checks**: Path traversal, file ownership, size limits
### Type System (`decode.go`)
- **Unified Decoding**: `unmarshal()` - single authoritative decoder
- **Hook Composition**: `getDecodeHook()`, `customDecodeHook()`
- **Type Converters**: `jsonNumberHookFunc()`, `stringToNetIPHookFunc()`, `stringToURLHookFunc()`
- **Navigation**: `navigateToPath()`, `normalizeMap()`
### File Watching (`watch.go`)
- **Auto-Reload**: `AutoUpdate()`, `AutoUpdateWithOptions()`, `StopAutoUpdate()`
- **Subscriptions**: `Watch()`, `WatchWithOptions()`, `WatchFile()`
- **Watcher State**: `watcher` struct with debouncing, polling, version tracking
- **Change Detection**: `checkAndReload()`, `performReload()`, `notifyWatchers()`
- **Resource Management**: Max watcher limits, graceful shutdown
### Convenience API (`utility.go`)
- **Quick Setup**: `Quick()`, `QuickCustom()`, `MustQuick()`, `QuickTyped()`
- **Flag Integration**: `GenerateFlags()`, `BindFlags()`
- **Type-Safe Access**: `GetTyped()`, `GetTypedWithDefault()`, `ScanTyped()`
- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()`, `ScanMap()`
### File Discovery (`discovery.go`)
- **Options**: `FileDiscoveryOptions` struct with search strategies
- **XDG Compliance**: `getXDGConfigPaths()` for standard config locations
- **Defaults**: `DefaultDiscoveryOptions()` with sensible patterns
### Validation Library (`validator.go`)
- **Network**: `Port()`, `IPAddress()`, `IPv4Address()`, `IPv6Address()`
- **Numeric**: `Positive()`, `NonNegative()`, `Range()`
- **String**: `NonEmpty()`, `Pattern()`, `OneOf()`, `URLPath()`
### Error System (`error.go`)
- **Categories**: Sentinel errors for `errors.Is()` checking
- **Wrapping**: `wrapError()` maintains dual error chains
### Internal Utilities (`helper.go`)
- **Map Operations**: `flattenMap()`, `setNestedValue()`
- **Validation**: `isValidKeySegment()` for path validation
### Constants (`constant.go`)
- **Shared Constants**: Defines shared constants for timing, formats, limits, file watcher and discovery.
## Data Flow Patterns
### Configuration Loading
1. **Registration Phase**: Paths registered with types/defaults
2. **Source Collection**: Each source populates its layer
3. **Merge Phase**: Precedence determines final values
4. **Population Phase**: Struct populated via reflection
### Thread Safety Model
- **Read Operations**: Multiple concurrent readers via RLock
- **Write Operations**: Exclusive access via Lock
- **Atomic Updates**: Prepare → Lock → Swap → Unlock pattern
- **Version Tracking**: Atomic counter for cache invalidation
### Error Propagation
- **Wrapped Errors**: Category + specific error detail
- **Early Return**: Builder accumulates first error
- **Panic Mode**: MustBuild for fail-fast scenarios
## Extension Points
### Custom Types
Implement decode hooks in `customDecodeHook()`:
```go
func(f reflect.Type, t reflect.Type, data any) (any, error)
```
### Source Transformation
Environment variable mapping via `WithEnvTransform()`:
```go
func(path string) string // Return env var name
```
### Validation Layers
- Pre-decode: `WithValidator(func(*Config) error)`
- Post-decode: `WithTypedValidator(func(*YourType) error)`
### File Discovery
Search strategy via `WithFileDiscovery()`:
- CLI flag check → Env var → XDG paths → Current dir
Save normalizes atomic application values to TOML strings, marshals before touching
the target file, then writes/syncs/renames a temporary file. Comment text is captured
by lexer tokens and emitted as a preamble. The codec owns syntax, while Config owns
source precedence, snapshot isolation and live reload publication.
+66
View File
@@ -0,0 +1,66 @@
# Config audit and TOML migration
This branch updates only lixenwraith/config. It uses Go 1.27.1 and the user's merged
TOML main commit `b59d068ff6a48b86365a65e1121343f672f77a67`, pinned as
`v0.0.0-20260928180512-b59d068ff6a4` because no version tag existed at integration.
Logwisp adoption and other TOML consumers are separate follow-up work.
## Confirmed defects and fixes
| Finding | Result | Regression evidence |
| --- | --- | --- |
| Fractions/overflow could pass through weak numeric conversion | Checked TOML conversion and explicit destination-width parsing | `TestCheckedConversionsAndRollback`, `FuzzConfigNumericConversions` |
| Options, input containers and returned values could alias caller state | Owned options/values and detached reads/clones | `TestOptionsAreOwned`, `TestOwnedValuesAndSnapshots` |
| Updating the cached target changed old AsStruct results | Immutable cached result and a fresh snapshot per call | `TestOwnedValuesAndSnapshots` |
| ExportEnv compared uncomparable slices/maps directly | Deep equality, with callbacks outside Config locks | `TestExportEnvContainerValues` |
| Invalid sources could be published before decoding failed | Whole-source validation before mutation | `TestSourceReplacementIsTransactional` |
| Removed env/CLI values survived subsequent loads | Successful loads replace their complete source | `TestSourceReplacementIsTransactional` |
| A timed-out reload kept running and could publish after stop | Context, watcher identity, path and generation checked at commit | `TestReloadCannotPublishAfterStopTimeoutOrNewLoad` |
| A timeout could suppress retries of unchanged content | Retry timeouts while deduplicating their notifications | `TestWatcherRetriesTimedOutUnchangedContent` |
| Debounce did not cover errors from partial in-place writes | Errors and successful updates wait for a quiet interval | `TestWatcherDebouncesErrorsAndRecovers` |
| Metadata-only polling missed some replacements/edits | Content fingerprints on every poll; deletion/recreation recovery | `TestWatcherSameMetadataReplacementAndRecreation` |
| Source saving lost comments and widened permissions | Comment preamble, existing modes retained, new files 0600 | `TestSaveCommentsPermissionsAndFailure` |
| Atomic types were inconsistently registered/serialized | Exact type matching for duration/time/IP/CIDR/URL and pointer forms | `TestAtomicValuesAndFileRoundTrip` |
| A missing file could hide a joined invalid CLI/environment error | Missing-file fallback only when other sources succeed | `TestBuilderDoesNotHideInvalidSourceBehindMissingFile` |
| Required registration was a no-op; overlapping/duplicate fields were ambiguous | Required paths validated at Build; staged registration rejects conflicts | `TestRegistrationAndRequiredContracts` |
| Default source selection and narrow target schemas could be inconsistent | Default precedence honored; explicit defaults must match the typed target | `TestRegistrationAndRequiredContracts` |
Reproduction against original main `ccd280b`: fractional-to-integer conversion
silently produced 1 from 1.5; modifying an input slice changed its registered
default; an out-of-range CLI batch returned success; and requesting a refreshed
AsStruct changed an earlier result under its reader. All four were reproduced with
public-API probes; committed regressions cover the corrected behavior.
## Dependency and scope decision
Mapstructure is replaceable for the package's actual object model. The replacement
supports TOML field names, ordinary containers, pointers and the existing standard
atomic types. It does not reproduce mapstructure's broad weak coercions or add a
custom hook API. Numeric correctness is shared with lixenwraith/toml.
`go list -deps ./...` reports only the standard library, lixenwraith/toml and this
module. BurntSushi TOML, mapstructure and YAML are removed from runtime code.
Testify remains test-only, so its transitive YAML dependency still appears in go.mod
and go.sum. JSON/YAML files and tags are unsupported, with explicit rejection tests.
No global reflection cache or lock was introduced. Known container sizes are used
for allocation. Stored values are immutable after admission. One watcher worker
owns observation/debounce state, and subscription cleanup has no per-subscriber
goroutine. File I/O and environment callbacks execute outside Config locks.
## Verification and limits
The full unit and race suites and go vet pass with Go 1.27.1. Numeric fuzzing ran
for 10 seconds and passed 234,155 executions, covering full uint64 preservation,
signed boundaries and narrowing. The 386 and Windows test binaries cross-compile
locally. This host cannot execute 386 binaries; CI executes that suite and also
runs race tests, vet, fuzzing and Windows compilation. Final checks are on the PR.
TOML's supported subset is unchanged. Native datetime syntax is not added; time.Time
is represented by a quoted RFC3339Nano string. Full uint64 works in memory but values
above MaxInt64 cannot be saved as TOML integers. Comment text survives as a preamble;
locations and formatting do not. Unknown file keys are not saved. Depth is bounded
at 128 for configuration graphs. Live application validators remain an application
responsibility; Builder validators run only during initialization. Notifications
are best effort, and file timeouts prevent late publication without forcibly
interrupting kernel I/O. See the linked topic guides for migration details.
+32 -373
View File
@@ -1,373 +1,32 @@
# Builder Pattern
The builder pattern provides fine-grained control over configuration initialization and loading behavior.
## Basic Builder Usage
```go
cfg, err := config.NewBuilder().
WithDefaults(defaultStruct).
WithEnvPrefix("MYAPP_").
WithFile("config.toml").
Build()
```
## Builder Methods
### WithDefaults
Register a struct containing default values:
```go
type Config struct {
Host string `toml:"host"`
Port int `toml:"port"`
}
defaults := &Config{
Host: "localhost",
Port: 8080,
}
cfg, _ := config.NewBuilder().
WithDefaults(defaults).
Build()
```
### WithTarget
Enable type-aware mode with automatic struct population:
```go
var appConfig Config
cfg, _ := config.NewBuilder().
WithTarget(&appConfig). // Registers struct and enables AsStruct()
WithFile("config.toml").
Build()
// Access populated struct
populated, _ := cfg.AsStruct()
config := populated.(*Config)
```
### WithTagName
Use different struct tags for field mapping:
```go
type Config struct {
Server struct {
Host string `json:"host"` // Using JSON tags
Port int `json:"port"`
} `json:"server"`
}
cfg, _ := config.NewBuilder().
WithDefaults(&Config{}).
WithTagName("json"). // Use json tags instead of toml
Build()
```
Supported tag names: `toml` (default), `json`, `yaml`
### WithPrefix
Add a prefix to all registered paths:
```go
cfg, _ := config.NewBuilder().
WithDefaults(serverConfig).
WithPrefix("server"). // All paths prefixed with "server."
Build()
// Access as "server.host" instead of just "host"
host, _ := cfg.Get("server.host")
```
### WithEnvPrefix
Set environment variable prefix:
```go
cfg, err := config.NewBuilder().
WithEnvPrefix("MYAPP_").
Build()
// Reads from MYAPP_SERVER_PORT for "server.port"
```
### WithSources
Configure source precedence order:
```go
// Environment variables take highest priority
cfg, _ := config.NewBuilder().
WithSources(
config.SourceEnv,
config.SourceFile,
config.SourceCLI,
config.SourceDefault,
).
Build()
```
### WithEnvTransform
Custom environment variable name mapping:
```go
cfg, _ := config.NewBuilder().
WithEnvTransform(func(path string) string {
// Custom mapping logic
switch path {
case "server.port":
return "PORT" // Use $PORT instead of $MYAPP_SERVER_PORT
case "database.url":
return "DATABASE_URL"
default:
// Default transformation
return "MYAPP_" + strings.ToUpper(
strings.ReplaceAll(path, ".", "_"),
)
}
}).
Build()
```
### WithEnvWhitelist
Limit which configuration paths check environment variables:
```go
cfg, _ := config.NewBuilder().
WithEnvWhitelist(
"server.port",
"database.url",
"api.key",
). // Only these paths read from env
Build()
```
### WithValidator
Add validation functions that run *before* the target struct is populated. These validators operate on the raw `*config.Config` object and are suitable for checking required paths or formats before type conversion.
```go
// Validator runs on raw, pre-decoded values.
cfg, _ := config.NewBuilder().
WithDefaults(defaults).
WithValidator(func(c *config.Config) error {
// Validate port range
port, _ := c.Get("server.port")
if p := port.(int64); p < 1024 || p > 65535 {
return fmt.Errorf("port must be between 1024-65535")
}
return nil
}).
WithValidator(func(c *config.Config) error {
// Validate required fields
return c.Validate("api.key", "database.url")
}).
Build()
```
For type-safe validation, see `WithTypedValidator`.
### WithTypedValidator
Add a type-safe validation function that runs *after* the configuration has been fully loaded and decoded into the target struct (set by `WithTarget`). This is the recommended approach for most validation logic.
The validation function must accept a single argument: a pointer to the same struct type that was passed to `WithTarget`.
```go
type AppConfig struct {
Server struct {
Port int64 `toml:"port"`
} `toml:"server"`
}
var target AppConfig
cfg, err := config.NewBuilder().
WithTarget(&target).
WithFile("config.toml").
WithTypedValidator(func(conf *AppConfig) error {
if conf.Server.Port < 1024 || conf.Server.Port > 65535 {
return fmt.Errorf("port %d is outside the valid range", conf.Server.Port)
}
return nil
}).
Build()
```
### WithFile
Set configuration file path:
```go
cfg, _ := config.NewBuilder().
WithFile("/etc/myapp/config.toml").
Build()
```
### WithArgs
Override command-line arguments (default is os.Args[1:]):
```go
cfg, _ := config.NewBuilder().
WithArgs([]string{"--debug", "--server.port=9090"}).
Build()
```
### WithFileDiscovery
Enable automatic configuration file discovery:
```go
cfg, _ := config.NewBuilder().
WithFileDiscovery(config.FileDiscoveryOptions{
Name: "myapp",
Extensions: []string{".toml", ".conf"},
EnvVar: "MYAPP_CONFIG",
CLIFlag: "--config",
UseXDG: true,
}).
Build()
```
This searches for configuration files in:
1. Path specified by `--config` flag
2. Path in `$MYAPP_CONFIG` environment variable
3. Current directory
4. XDG config directories (`~/.config/myapp/`, `/etc/myapp/`)
## Method Interaction and Precedence
While most builder methods can be chained in any order, it's important to understand how `WithDefaults` and `WithTarget` interact to define the default configuration values.
### `WithDefaults` Has Precedence
**Rule:** If `WithDefaults()` is used anywhere in the chain, it will **always** be the definitive source for default values.
This is the recommended approach for clarity and explicitness. It cleanly separates the struct that defines the defaults from the struct that will be populated.
**Example (Recommended Pattern):**
```go
// initialData contains the fallback values.
initialData := &AppConfig{
Server: ServerConfig{Port: 8080},
}
// target is an empty shell for population.
var target AppConfig
// WithDefaults explicitly sets the defaults.
// WithTarget sets up the config for type-safe decoding.
cfg, err := config.NewBuilder().
WithTarget(&target).
WithDefaults(initialData).
WithFile("config.toml").
Build()
```
In this scenario, the `target` struct is *only* used for type information and `AsStruct()` functionality; its initial (zero) values are not used as defaults as per below.
### Using `WithTarget` for Defaults
**Rule:** If `WithDefaults()` is **not** used, the struct passed to `WithTarget()` will serve as the source of default values.
This provides a convenient shorthand for simpler cases where the initial state of your application's config struct *is* the desired default state. The unit tests for the package rely on this behavior.
**Example (Convenience Pattern):**
```go
// The initial state of this struct will be used as the defaults.
target := &AppConfig{
Server: ServerConfig{Port: 8080},
}
// Since WithDefaults() is absent, the builder uses `target`
// for both defaults and for type-safe decoding.
cfg, err := config.NewBuilder().
WithTarget(&target).
WithFile("config.toml").
Build()
```
## Usage Patterns
### Type-Safe Configuration Access
```go
type AppConfig struct {
Server ServerConfig `toml:"server"`
DB DBConfig `toml:"database"`
}
var conf AppConfig
cfg, _ := config.NewBuilder().
WithTarget(&conf).
WithFile("config.toml").
Build()
// Direct struct access after building
fmt.Printf("Port: %d\n", conf.Server.Port)
// Or get updated struct anytime
latest, _ := cfg.AsStruct()
appConf := latest.(*AppConfig)
```
### Multi-Stage Validation
```go
cfg, err := config.NewBuilder().
WithDefaults(defaults).
// Stage 1: Validate structure
WithValidator(validateStructure).
// Stage 2: Validate values
WithValidator(validateRanges).
// Stage 3: Validate relationships
WithValidator(validateRelationships).
Build()
func validateStructure(c *config.Config) error {
required := []string{"server.host", "server.port", "database.url"}
return c.Validate(required...)
}
func validateRanges(c *config.Config) error {
port, _ := c.Get("server.port")
if p := port.(int64); p < 1 || p > 65535 {
return fmt.Errorf("invalid port: %d", p)
}
return nil
}
func validateRelationships(c *config.Config) error {
// Validate that related values make sense together
// e.g., if SSL is enabled, ensure cert paths are set
return nil
}
```
### Error Handling
The builder accumulates errors and returns them on `Build()`:
```go
cfg, err := config.NewBuilder().
WithTarget(nil). // Error: nil target
WithTagName("invalid"). // Error: unsupported tag
Build()
if err != nil {
// err contains first error encountered
}
```
For panic on error use `MustBuild()`
# Builder
`NewBuilder` collects defaults, an optional typed target, file, environment and CLI
settings. Build registers defaults, loads each configured source, checks required
paths, runs Config validators and then typed validators. Missing files are nonfatal
only when other loading and validation succeeds.
| Option | Purpose |
| --- | --- |
| `WithDefaults(value)` | Explicit struct defaults |
| `WithTarget(&value)` | Target type and fallback defaults; filled once by Build |
| `WithPrefix("section")` | Prefix registered paths and the typed snapshot section |
| `WithFile(path)` | TOML file; missing path can be watched for later creation |
| `WithFileDiscovery(options)` | Search CLI, environment and configured directories |
| `WithEnvPrefix`, `WithEnvTransform`, `WithEnvWhitelist` | Environment mapping |
| `WithArgs(args)` | Explicit CLI input; nil disables it |
| `WithSources(...)` | Source precedence; first wins |
| `WithSecurityOptions(options)` | File traversal, owner and size checks |
| `WithValidator(fn)` | Build-time validation using Config accessors |
| `WithTypedValidator(func(*T) error)` | Build-time validation of the target snapshot |
Only TOML tags and file format are supported. WithTagName accepts `toml`;
WithFileFormat accepts `toml` or `auto`. JSON/YAML modes are removed.
Use a builder from one goroutine during initialization. Runtime updates go through
Config methods. `AsStruct` returns independent snapshots and never mutates the
original target after Build. Validators run during Build, not on every later Set
or reload; later changes are always checked against registered value types.
Explicit defaults must use the same struct type as the target. `WithDefaults`
takes precedence over the initial target values. A typed nil validator is ignored;
signatures must match the target pointer and return error.
+15 -184
View File
@@ -1,188 +1,19 @@
# Command Line Arguments
# Command-line arguments
The config package supports command-line argument parsing with flexible formats and automatic type conversion.
`LoadCLI` accepts `--server.port=9000`, `--server.port 9000`, and bare boolean flags
such as `--debug`. Arguments after `--` are positional and are not parsed. Other
positional arguments are ignored. There is no short-flag parser; use Go's flag package
when that interface is needed. Paths must obey registration's dot-separated key syntax.
## Argument Formats
Values remain strings and are validated against registered types before publication.
Each successful call replaces the complete CLI source, including an empty argument
list. Omitted flags lose their previous overrides. Unknown paths are recorded in
`UnknownCLIKeys` in sorted order and are not registered. The returned list is a copy.
### Key-Value Pairs
`GenerateFlags` creates a flag.FlagSet from defaults. `BindFlags` atomically replaces
the CLI source with explicitly visited flags, rejects unregistered flags, and uses
the same checked conversions. Repeated flags resolve to the last value. Invalid
values leave the prior CLI source and unknown-key list intact.
```bash
# Space-separated
./myapp --server.port 8080 --database.url "postgres://localhost/db"
# Equals-separated
./myapp --server.port=8080 --database.url=postgres://localhost/db
# Mixed formats
./myapp --server.port 8080 --debug=true
```
### Boolean Flags
```bash
# Boolean flags don't require a value (assumed true)
./myapp --debug --verbose
# Explicit boolean values
./myapp --debug=true --verbose=false
```
### Nested Paths
Use dot notation for nested configuration:
```bash
./myapp --server.host=0.0.0.0 --server.port=9090 --server.tls.enabled=true
```
## Type Conversion
Command-line values are automatically converted to match registered types:
```go
type Config struct {
Port int64 `toml:"port"`
Timeout time.Duration `toml:"timeout"`
Ratio float64 `toml:"ratio"`
Enabled bool `toml:"enabled"`
Tags []string `toml:"tags"`
}
// All these are parsed correctly:
// --port=8080 → int64(8080)
// --timeout=30s → time.Duration(30 * time.Second)
// --ratio=0.95 → float64(0.95)
// --enabled=true → bool(true)
// --tags=prod,stable → []string{"prod", "stable"}
```
## Integration with flag Package
### Generate flag.FlagSet
```go
// Generate flags from registered configuration
fs := cfg.GenerateFlags()
// Parse command line
if err := fs.Parse(os.Args[1:]); err != nil {
log.Fatal(err)
}
// Apply parsed flags to configuration
if err := cfg.BindFlags(fs); err != nil {
log.Fatal(err)
}
```
### Custom Flag Registration
```go
fs := flag.NewFlagSet("myapp", flag.ContinueOnError)
// Add custom flags
verbose := fs.Bool("v", false, "verbose output")
configFile := fs.String("config", "config.toml", "config file path")
// Parse
fs.Parse(os.Args[1:])
// Use custom flags
if *verbose {
log.SetLevel(log.DebugLevel)
}
// Load config with custom file path
cfg, _ := config.NewBuilder().
WithFile(*configFile).
Build()
// Bind remaining flags
cfg.BindFlags(fs)
```
## Precedence and Overrides
Command-line arguments have the highest precedence by default:
```go
// Default precedence: CLI > Env > File > Default
cfg, _ := config.Quick(defaults, "APP_", "config.toml")
// Even if config.toml sets port=8080 and APP_PORT=9090,
// --port=7070 will win
```
Change precedence if needed:
```go
cfg, _ := config.NewBuilder().
WithSources(
config.SourceEnv, // Env highest
config.SourceCLI, // Then CLI
config.SourceFile, // Then file
config.SourceDefault, // Finally defaults
).
Build()
```
## Argument Parsing Details
### Validation
- Paths must use valid identifiers (letters, numbers, underscore, dash)
- No leading/trailing dots in paths
- Empty segments not allowed (no `..` in paths)
### Special Cases
```bash
# Double dash stops flag parsing
./myapp --port=8080 -- --not-a-flag
# Single dash flags are ignored (not GNU-style)
./myapp -p 8080 # Ignored, use --port
# Quoted values preserve spaces
./myapp --message="Hello World" --name='John Doe'
# Escape quotes in values
./myapp --json="{\"key\": \"value\"}"
```
### Value Parsing Rules
1. **Booleans**: `true`, `false` (case-sensitive)
2. **Numbers**: Standard decimal notation
3. **Strings**: Quoted or unquoted (quotes removed if present)
4. **Lists**: Comma-separated (when target type is slice)
## Override Arguments
```go
// Parse custom arguments instead of os.Args
customArgs := []string{"--debug", "--port=9090"}
cfg, _ := config.NewBuilder().
WithArgs(customArgs).
Build()
```
## Error Handling
CLI parsing errors are returned from `Build()` or `LoadCLI()`:
```go
cfg, err := config.NewBuilder().
WithDefaults(&Config{}).
Build()
if err != nil {
switch {
case errors.Is(err, config.ErrCLIParse):
log.Fatal("Invalid command line arguments:", err)
default:
log.Fatal("Configuration error:", err)
}
}
```
The Builder reads os.Args by default. Use `WithArgs(nil)` to disable CLI loading for
that build or supply an explicit argument slice for deterministic initialization.
+18 -198
View File
@@ -1,202 +1,22 @@
# Environment Variables
# Environment variables
The config package provides flexible environment variable support with automatic name transformation, custom mappings, and whitelist capabilities.
`LoadEnv("APP_")` maps `server.port` to `APP_SERVER_PORT`. It replaces the complete
environment source: variables removed since the previous load lose their overrides.
Set `LoadOptions.EnvTransform` for custom names and `EnvWhitelist` to limit paths.
Options are copied. Custom transforms run outside Config locks and may inspect Config.
## Basic Usage
Environment values are stored as strings and validated against registered types
before any part of the replacement is published. Numbers use decimal text; durations
use Go duration syntax; timestamps use RFC3339Nano-compatible text. Slices and arrays
use comma-separated elements without an escaping convention. Empty text gives an
empty slice. Values larger than MaxValueSize are rejected.
Environment variables are automatically mapped from configuration paths:
`RegisterWithEnv` and a struct field's `env` tag load an explicitly named variable
during registration. Later `LoadEnv` calls use the configured transform/whitelist,
so explicit registration-time mappings are not automatically repeated on reload.
```go
cfg, _ := config.Quick(defaults, "MYAPP_", "config.toml")
// These environment variables are automatically loaded:
// MYAPP_SERVER_PORT → server.port
// MYAPP_DATABASE_URL → database.url
// MYAPP_LOG_LEVEL → log.level
// MYAPP_FEATURES_ENABLED → features.enabled
```
## Name Transformation
### Default Transformation
By default, paths are transformed as follows:
1. Dots (`.`) become underscores (`_`)
2. Converted to uppercase
3. Prefix is prepended
```go
// Path transformations:
// server.port → MYAPP_SERVER_PORT
// database.url → MYAPP_DATABASE_URL
// tls.cert.path → MYAPP_TLS_CERT_PATH
// maxRetries → MYAPP_MAXRETRIES
```
### Custom Transformation
Define custom environment variable mappings:
```go
cfg, _ := config.NewBuilder().
WithEnvTransform(func(path string) string {
switch path {
case "server.port":
return "PORT" // Use $PORT directly
case "database.url":
return "DATABASE_URL"
case "api.key":
return "API_KEY"
default:
// Fallback to default transformation
return "MYAPP_" + strings.ToUpper(
strings.ReplaceAll(path, ".", "_"),
)
}
}).
Build()
```
### No Transformation
Return empty string to skip environment lookup:
```go
cfg, _ := config.NewBuilder().
WithEnvTransform(func(path string) string {
// Only allow specific env vars
allowed := map[string]string{
"port": "PORT",
"database": "DATABASE_URL",
}
return allowed[path] // Empty string if not in map
}).
Build()
```
## Explicit Environment Variable Mapping
Use the `env` struct tag for explicit mappings:
```go
type Config struct {
Port int `toml:"port" env:"PORT"`
Database string `toml:"database" env:"DATABASE_URL"`
APIKey string `toml:"api_key" env:"API_KEY"`
}
// These use the explicit env tag names, ignoring prefix
cfg, _ := config.NewBuilder().
WithDefaults(&Config{}).
WithEnvPrefix("MYAPP_"). // Not used for tagged fields
Build()
```
Or register with explicit environment variable:
```go
cfg.RegisterWithEnv("server.port", 8080, "PORT")
cfg.RegisterWithEnv("database.url", "localhost", "DATABASE_URL")
```
## Using the `env` Tag
Structs can specify explicit environment variable names using the `env` tag:
```go
type Config struct {
Server struct {
Port int `toml:"port" env:"PORT"` // Uses $PORT
Host string `toml:"host" env:"SERVER_HOST"` // Uses $SERVER_HOST
} `toml:"server"`
}
```
## Environment Variable Whitelist
Limit which paths can be set via environment:
```go
cfg, _ := config.NewBuilder().
WithEnvWhitelist(
"server.port",
"database.url",
"api.key",
"log.level",
). // Only these paths read from environment
Build()
```
## Type Conversion
Environment variables (strings) are automatically converted to the registered type:
```bash
# Booleans
export MYAPP_DEBUG=true
export MYAPP_VERBOSE=false
# Numbers
export MYAPP_PORT=8080
export MYAPP_TIMEOUT=30
export MYAPP_RATIO=0.95
# Durations
export MYAPP_TIMEOUT=30s
export MYAPP_INTERVAL=5m
# Lists (comma-separated)
export MYAPP_TAGS=prod,stable,v2
```
## Manual Environment Loading
Load environment variables at any time:
```go
cfg := config.New()
cfg.RegisterStruct("", &Config{})
// Load with prefix
if err := cfg.LoadEnv("MYAPP_"); err != nil {
log.Fatal(err)
}
// Or use existing options
if err := cfg.LoadWithOptions("", nil, config.LoadOptions{
EnvPrefix: "MYAPP_",
Sources: []config.Source{config.SourceEnv},
}); err != nil {
log.Fatal(err)
}
```
## Discovering Environment Variables
Find which environment variables are set:
```go
// Discover all env vars matching registered paths
discovered := cfg.DiscoverEnv("MYAPP_")
for path, envVar := range discovered {
log.Printf("%s is set via %s", path, envVar)
}
```
## Precedence Examples
Default precedence: CLI > Env > File > Default
Custom precedence (Env > File > CLI > Default):
```go
cfg, _ := config.NewBuilder().
WithSources(
config.SourceEnv,
config.SourceFile,
config.SourceCLI,
config.SourceDefault,
).
Build()
```
`DiscoverEnv(prefix)` returns registered paths with present variable names.
`ExportEnv(prefix)` returns formatted effective values that differ from defaults;
it safely handles containers. It is diagnostic output, not a general lossless
serialization format for maps, slices or strings containing delimiters. Use Save
for round-trip persistence.
+34 -304
View File
@@ -1,304 +1,34 @@
# Configuration Files
The config package supports TOML configuration files with automatic loading, discovery, and atomic saving.
## TOML Format
TOML (Tom's Obvious, Minimal Language) is the supported configuration format:
```toml
# Basic values
host = "localhost"
port = 8080
debug = false
# Nested sections
[server]
host = "0.0.0.0"
port = 9090
timeout = "30s"
[database]
url = "postgres://localhost/mydb"
max_conns = 25
timeout = "5s"
# Arrays
[features]
enabled = ["auth", "api", "metrics"]
# Inline tables
tls = { enabled = true, cert = "/path/to/cert", key = "/path/to/key" }
```
## Loading Configuration Files
### Basic Loading
```go
cfg := config.New()
cfg.RegisterStruct("", &Config{})
if err := cfg.LoadFile("config.toml"); err != nil {
if errors.Is(err, config.ErrConfigNotFound) {
log.Println("Config file not found, using defaults")
} else {
log.Fatal("Failed to load config:", err)
}
}
```
### With Builder
```go
cfg, err := config.NewBuilder().
WithDefaults(&Config{}).
WithFile("/etc/myapp/config.toml").
Build()
```
### Multiple File Attempts
```go
// Try multiple locations
locations := []string{
"./config.toml",
"~/.config/myapp/config.toml",
"/etc/myapp/config.toml",
}
var cfg *config.Config
var err error
for _, path := range locations {
cfg, err = config.NewBuilder().
WithDefaults(&Config{}).
WithFile(path).
Build()
if err == nil || !errors.Is(err, config.ErrConfigNotFound) {
break
}
}
```
## Automatic File Discovery
Use file discovery to find configuration automatically:
```go
cfg, _ := config.NewBuilder().
WithDefaults(&Config{}).
WithFileDiscovery(config.FileDiscoveryOptions{
Name: "myapp",
Extensions: []string{".toml", ".conf"},
EnvVar: "MYAPP_CONFIG",
CLIFlag: "--config",
UseXDG: true,
UseCurrentDir: true,
Paths: []string{"/opt/myapp"},
}).
Build()
```
Search order:
1. CLI flag: `--config=/path/to/config.toml`
2. Environment variable: `$MYAPP_CONFIG`
3. Current directory: `./myapp.toml`, `./myapp.conf`
4. XDG config: `~/.config/myapp/myapp.toml`
5. System paths: `/etc/myapp/myapp.toml`
6. Custom paths: `/opt/myapp/myapp.toml`
## Saving Configuration
### Save Current State
```go
// Save all current values atomically
if err := cfg.Save("config.toml"); err != nil {
log.Fatal("Failed to save config:", err)
}
```
The save operation is atomic - it writes to a temporary file then renames it.
### Save Specific Source
```go
// Save only values from environment variables
if err := cfg.SaveSource("env-config.toml", config.SourceEnv); err != nil {
log.Fatal(err)
}
// Save only file-loaded values
if err := cfg.SaveSource("file-only.toml", config.SourceFile); err != nil {
log.Fatal(err)
}
```
### Generate Default Configuration
```go
// Create a default config file
defaults := &Config{}
// ... set default values ...
cfg, _ := config.NewBuilder().
WithDefaults(defaults).
Build()
// Save defaults as config template
if err := cfg.SaveSource("config.toml.example", config.SourceDefault); err != nil {
log.Fatal(err)
}
```
## File Structure Mapping
TOML structure maps directly to dot-notation paths:
```toml
# Maps to "debug"
debug = true
[server]
# Maps to "server.host"
host = "localhost"
# Maps to "server.port"
port = 8080
[server.tls]
# Maps to "server.tls.enabled"
enabled = true
# Maps to "server.tls.cert"
cert = "/path/to/cert"
[[users]]
# Array elements: "users.0.name", "users.0.role"
name = "admin"
role = "administrator"
[[users]]
# Array elements: "users.1.name", "users.1.role"
name = "user"
role = "standard"
```
## Type Handling
TOML types map to Go types:
```toml
# Strings
name = "myapp"
multiline = """
Line one
Line two
"""
# Numbers
port = 8080 # int64
timeout = 30 # int64
ratio = 0.95 # float64
max_size = 1_000_000 # int64 (underscores allowed)
# Booleans
enabled = true
debug = false
# Dates/Times (RFC 3339)
created_at = 2024-01-15T09:30:00Z
expires = 2024-12-31
# Arrays
ports = [8080, 8081, 8082]
tags = ["production", "stable"]
# Tables (objects)
[database]
host = "localhost"
port = 5432
# Array of tables
[[servers]]
name = "web1"
host = "10.0.0.1"
[[servers]]
name = "web2"
host = "10.0.0.2"
```
## Error Handling
File loading can produce several error types:
```go
err := cfg.LoadFile("config.toml")
if err != nil {
switch {
case errors.Is(err, config.ErrConfigNotFound):
// File doesn't exist - often not fatal
log.Println("No config file, using defaults")
case strings.Contains(err.Error(), "failed to parse TOML"):
// TOML syntax error
log.Fatal("Invalid TOML syntax:", err)
case strings.Contains(err.Error(), "failed to read"):
// Permission or I/O error
log.Fatal("Cannot read config file:", err)
default:
log.Fatal("Config error:", err)
}
}
```
## Security Considerations
### File Permissions
```go
// After saving, verify permissions
info, err := os.Stat("config.toml")
if err == nil {
mode := info.Mode()
if mode&0077 != 0 {
log.Warn("Config file is world/group readable")
// Fix permissions
os.Chmod("config.toml", 0600)
}
}
```
### Size Limits
Files and values have size limits:
- Maximum file size: ~10MB (10 * MaxValueSize)
- Maximum value size: 1MB
## Partial Loading
Load only specific sections:
```go
var serverCfg ServerConfig
if err := cfg.Scan("server", &serverCfg); err != nil {
log.Fatal(err)
}
var dbCfg DatabaseConfig
if err := cfg.Scan("database", &dbCfg); err != nil {
log.Fatal(err)
}
```
## Best Practices
1. **Use Example Files**: Generate `.example` files with defaults
2. **Check Permissions**: Ensure config files aren't world-readable
3. **Validate After Load**: Add validators to check loaded values
4. **Handle Missing Files**: Missing config files often aren't fatal
5. **Use Atomic Saves**: The built-in Save method is atomic
6. **Document Structure**: Comment your TOML files thoroughly
# TOML files and saves
`LoadFile(path)` parses TOML regardless of the usual TOML/conf/config extension;
JSON/YAML extensions are rejected. `SetFileFormat` and `WithFileFormat` accept
`toml` and the compatibility alias `auto`; neither enables format detection.
Only registered paths are applied. Registered map fields accept whole tables.
Replacing a registered parent table with a scalar is an error. A missing key
removes that source override, exposing the next source or registered default.
Loads parse and validate before publication. Syntax errors, incompatible values,
overflow and read failures leave prior values, comments and the loaded path intact.
Readers must be regular files. Security options check ownership of the opened file
on Unix and enforce size limits both before and during reading. Ownership checks
fail explicitly on unsupported platforms. PreventPathTraversal rejects relative
paths escaping through `..`; it is not a symlink sandbox. Absolute paths remain
allowed. A zero MaxFileSize means no configured byte limit.
`Save(path)` writes effective values; `SaveSource(path, source)` writes one source.
Default selects registered defaults. Both use a temporary file in the destination
directory, sync it, and rename it over the destination. They preserve an existing
file's permission bits and use 0600 for a new file. They do not promise portable
power-loss durability of the directory entry or platform-independent replacement
of open files. Encoding failures occur before the destination is changed.
Loaded comment text, including inline comments, is preserved as a preamble. Hashes
inside strings are not comments. Formatting and comment positions are standardized,
and unknown/unregistered keys are omitted. Saves do not merge concurrent edits made
outside Config after the last successful load. Save does not switch the watched path.
Durations and atomic time/network values are saved as strings and decoded back to
their registered types. time.Time uses RFC3339Nano; native TOML datetime syntax is
unsupported. Integers in files have signed 64-bit range. Full uint64 values can be
held in memory but cannot be saved above MaxInt64. Unsupported TOML syntax and
numeric key restrictions follow the pinned [toml package](https://github.com/lixenwraith/toml).
-159
View File
@@ -1,159 +0,0 @@
# lixenwraith/config Quick Reference Guide
This guide details the `lixenwraith/config` package for thread-safe Go configuration.
It supports multiple sources (files, environment, CLI), type-safe struct population, and live reconfiguration.
## Quick Start: Recommended Usage
The recommended pattern uses the **Builder** with a **target struct** providing compile-time type safety and eliminating the need for runtime type assertions
```go
package main
import (
"fmt"
"log"
"os"
"github.com/lixenwraith/config"
)
// 1. Define your application's configuration struct
type AppConfig struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
Debug bool `toml:"debug"`
}
func main() {
// 2. Create a target instance with defaults
// Method A: Direct initialization (cleaner for simple defaults)
target := &AppConfig{}
target.Server.Host = "localhost"
target.Server.Port = 8080
// Method B: Use WithDefaults() for explicit separation (shown below)
// 3. Use the builder to configure and load all sources
cfg, err := config.NewBuilder().
WithTarget(target). // Enable type-safe mode and register struct fields
// WithDefaults(&AppConfig{...}), // Optional: Override defaults from WithTarget
WithFile("config.toml"). // Load from file (supports .toml, .json, .yaml)
WithEnvPrefix("APP_"). // Load from environment (e.g., APP_SERVER_PORT)
WithArgs(os.Args[1:]). // Load from command-line flags (e.g., --server.port=9090)
Build() // Build the final config object
if err != nil {
log.Fatalf("Config build failed: %v", err)
}
// 4. Access the fully populated, type-safe struct
// The `target` variable is now populated with the final merged values
fmt.Printf("Running on %s:%d\n", target.Server.Host, target.Server.Port)
// Or, retrieve the updated struct at any time (e.g., after a live reload)
latest, _ := cfg.AsStruct()
latestConfig := latest.(*AppConfig)
fmt.Printf("Debug mode: %v\n", latestConfig.Debug)
}
```
## Supported Formats: TOML, JSON, YAML
The package supports multiple file formats. The format is auto-detected from the file extension (`.toml`, `.json`, `.yaml`, `.yml`) or file content
* To specify a format explicitly, use `Builder.WithFileFormat("json")`
* The default struct tag for field mapping is `toml`, but can be changed with `Builder.WithTagName("json")`
## Builder Pattern
The `Builder` is the primary way to construct a `Config` instance
```go
// NewBuilder creates a new configuration builder
func NewBuilder() *Builder
// Build finalizes configuration; returns the first of any accumulated errors
func (b *Builder) Build() (*Config, error)
// MustBuild is like Build but panics on fatal errors
func (b *Builder) MustBuild() *Config
```
### Builder Methods
* `WithTarget(target any)`: **(Recommended)** Registers fields from the target struct and allows access via `AsStruct()`
* `WithDefaults(defaults any)`: Explicitly sets a struct containing default values, overriding any default values already in struct from `WithTarget`
* `WithFile(path string)`: Sets the configuration file path
* `WithFileDiscovery(opts FileDiscoveryOptions)`: Enables automatic config file discovery (searches CLI flags, env vars, XDG paths, and current directory)
* `WithArgs(args []string)`: Sets the command-line arguments to parse (e.g., `--server.port=9090`)
* `WithEnvPrefix(prefix string)`: Sets the global environment variable prefix (e.g., `MYAPP_`)
* `WithSources(sources ...Source)`: Overrides the default source precedence order
* `WithTypedValidator(fn any)`: **(Recommended for validation)** Adds a type-safe validation function with signature `func(c *YourConfigType) error` that runs *after* the target struct is populated
* `WithValidator(fn ValidatorFunc)`: Adds a validation function that runs *before* type-safe population, operating on the raw `*Config` object
* `WithTagName(tagName string)`: Sets the primary struct tag for field mapping (`"toml"`, `"json"`, `"yaml"`)
* `WithPrefix(prefix string)`: Adds a prefix to all registered paths from a struct
* `WithFileFormat(format string)`: Explicitly sets the file format (`"toml"`, `"json"`, `"yaml"`, `"auto"`)
* `WithSecurityOptions(opts SecurityOptions)`: Sets security options for file loading (path traversal, file size limits)
* `WithEnvTransform(fn EnvTransformFunc)`: Sets a custom environment variable mapping function
* `WithEnvWhitelist(paths ...string)`: Limits environment variable loading to a specific set of paths
## Type-Safe Access & Population
These are the **preferred methods** for accessing configuration data
* `AsStruct() (any, error)`: After using `Builder.WithTarget()`, this method returns the populated, type-safe target struct - This is the primary way to access config after initialization or live reload
* `Scan(basePath string, target any)`: Populates a struct with values from a specific config path (e.g., `cfg.Scan("server", &serverConf)`)
* `ScanMap(configMap map[string]any, target any, tagName ...string)`: Decodes a map[string]any directly into a target struct, useful for plugins or custom config sources
* `GetTyped[T](c *Config, path string) (T, error)`: Retrieves a single value and decodes it to type `T`, handling type conversion automatically
* `ScanTyped[T](c *Config, basePath ...string) (*T, error)`: A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of type `T`
## Live Reconfiguration
Enable automatic reloading of configuration when the source file changes
* `AutoUpdate()`: Enables file watching and automatic reloading with default options
* `AutoUpdateWithOptions(opts WatchOptions)`: Enables reloading with custom options (e.g., poll interval, debounce)
* `StopAutoUpdate()`: Stops the file watcher
* `Watch() <-chan string`: Returns a channel that receives the paths of changed values
* `WatchFile(filePath string, formatHint ...string)`: Switches the watcher to a new file at runtime
* `IsWatching() bool`: Returns `true` if the file watcher is active
The watch channel also receives special notifications: `"file_deleted"`, `"permissions_changed"`, `"reload_error:..."`
## Source Precedence
The default order of precedence (highest to lowest) is:
1. **CLI**: Command-line arguments (`--server.port=9090`)
2. **Env**: Environment variables (`MYAPP_SERVER_PORT=8888`)
3. **File**: Configuration file (`config.toml`)
4. **Default**: Values registered from a struct
This order can be changed via `Builder.WithSources()` or `Config.SetPrecedence()`
## Dynamic / Legacy Value Access
These methods are for dynamic key-value access that require type assertion and should be **avoided when a type-safe struct can be used**
* `Get(path string) (any, bool)`: Retrieves the final merged value. The `bool` indicates if the path was registered. Requires a type assertion, e.g., `port := val.(int64)`
* `Set(path string, value any)`: Updates a value in the highest priority source. The path must be registered first
* `GetSource(path string, source Source) (any, bool)`: Retrieves a value from a specific source layer
* `SetSource(path string, source Source, value any)`: Sets a value for a specific source layer
## API Reference Summary
### Core Types
* `Config`: The primary thread-safe configuration manager
* `Source`: A configuration source (`SourceCLI`, `SourceEnv`, `SourceFile`, `SourceDefault`)
* `LoadOptions`: Options for loading configuration from multiple sources
* `Builder`: Fluent API for constructing a `Config` instance
### Core Methods
* `New() *Config`: Creates a new `Config` instance.
* `Register(path string, defaultValue any)`: Registers a path with a default value
* `RegisterStruct(prefix string, structWithDefaults any)`: Recursively registers fields from a struct using `toml` tags
* `Validate(required ...string)`: Checks that all specified required paths have been set from a non-default source
* `Save(path string)`: Atomically saves the current merged configuration state to a file
* `Clone() *Config`: Creates a deep copy of the configuration state
* `Debug() string`: Returns a formatted string of all values for debugging
+18 -203
View File
@@ -1,215 +1,30 @@
# Quick Start Guide
# Quick start
This guide gets you up and running with the config package in minutes.
## Basic Usage
The simplest way to use the config package is with the `Quick` function:
Use Go 1.27.1 and TOML tags. Register defaults before loading sources:
```go
package main
import (
"log"
"github.com/lixenwraith/config"
)
// Define your configuration structure
type Config struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
Database struct {
URL string `toml:"url"`
MaxConns int `toml:"max_conns"`
} `toml:"database"`
Debug bool `toml:"debug"`
}
func main() {
// Create defaults
defaults := &Config{}
defaults.Server.Host = "localhost"
defaults.Server.Port = 8080
defaults.Database.URL = "postgres://localhost/mydb"
defaults.Database.MaxConns = 10
defaults.Debug = false
// Initialize configuration
cfg, err := config.Quick(
defaults, // Default values from struct
"MYAPP_", // Environment variable prefix
"config.toml", // Configuration file path
)
if err != nil {
log.Fatal(err)
}
// Access values
port, _ := cfg.Get("server.port")
dbURL, _ := cfg.Get("database.url")
log.Printf("Server running on port %d", port.(int64))
log.Printf("Database URL: %s", dbURL.(string))
}
cfg := config.New()
if err := cfg.Register("server.port", uint16(8080)); err != nil { return err }
if err := cfg.LoadFile("config.toml"); err != nil { return err }
if err := cfg.LoadEnv("APP_"); err != nil { return err }
if err := cfg.LoadCLI(os.Args[1:]); err != nil { return err }
port, err := config.GetTyped[uint16](cfg, "server.port")
```
## Configuration Sources
The package loads configuration from multiple sources in this default order (highest to lowest priority):
1. **Command-line arguments** - Override everything
2. **Environment variables** - Override file and defaults
3. **Configuration file** - Override defaults
4. **Default values** - Base configuration
### Command-Line Arguments
```bash
./myapp --server.port=9090 --debug
```
### Environment Variables
```bash
export MYAPP_SERVER_PORT=9090
export MYAPP_DATABASE_URL="postgres://prod/mydb"
export MYAPP_DEBUG=true
```
### Configuration File (config.toml)
`APP_SERVER_PORT=9000` and `--server.port=9001` address the same registered path.
CLI has highest default priority. TOML values can be configured in a table:
```toml
[server]
host = "0.0.0.0"
port = 8080
[database]
url = "postgres://localhost/mydb"
max_conns = 25
debug = false
```
## Type Safety
For a typed application, use `NewBuilder().WithTarget(&settings)` as shown in the
[README](../README.md). Build fills the supplied target once. Use `AsStruct` to
obtain new snapshots after updates; retaining an old snapshot is safe.
The package uses struct tags to ensure type safety. When you register a struct, the types are enforced:
```go
// This struct defines the expected types
type Config struct {
Port int64 `toml:"port"` // Must be a number
Host string `toml:"host"` // Must be a string
Debug bool `toml:"debug"` // Must be a boolean
}
// Type assertions are safe after registration
port, _ := cfg.Get("port")
portNum := port.(int64) // Type matches registration
```
## Error Handling
The package validates types during loading:
```go
cfg, err := config.Quick(defaults, "APP_", "config.toml")
if err != nil {
// Handle errors like:
// - Invalid TOML syntax
// - Type mismatches (e.g., string value for int field)
// - File permissions issues
log.Fatal(err)
}
```
### Error Categories
The package uses structured error categories for better error handling. Check errors using `errors.Is()`:
```go
if err := cfg.LoadFile("config.toml"); err != nil {
switch {
case errors.Is(err, config.ErrConfigNotFound):
// Config file doesn't exist, use defaults
case errors.Is(err, config.ErrFileAccess):
// Permission denied or file access issues
case errors.Is(err, config.ErrFileFormat):
// Invalid TOML/JSON/YAML syntax
case errors.Is(err, config.ErrTypeMismatch):
// Value type doesn't match registered type
case errors.Is(err, config.ErrValidation):
// Validation failed
default:
log.Fatal(err)
}
}
```
See [Errors](./error.go) for the complete list of error categories and description.
## Common Patterns
### Required Fields
```go
// Register required configuration
cfg.RegisterRequired("api.key", "")
cfg.RegisterRequired("database.url", "")
// Validate all required fields are set
if err := cfg.Validate("api.key", "database.url"); err != nil {
log.Fatal("Missing required configuration:", err)
}
```
### Type-Safe Validation
```go
cfg, _ := config.NewBuilder().
WithTarget(&Config{}).
WithTypedValidator(func(c *Config) error {
if c.Server.Port < 1024 {
return fmt.Errorf("port must be >= 1024")
}
return nil
}).
Build()
```
See [Validation](validator.md) for more validation options.
### Using Different Struct Tags
```go
// Use JSON tags instead of default TOML
type Config struct {
Server struct {
Host string `json:"host"`
Port int `json:"port"`
} `json:"server"`
}
cfg, _ := config.NewBuilder().
WithTarget(&Config{}).
WithTagName("json").
WithFile("config.json").
Build()
```
### Checking Value Sources
```go
// See which source provided a value
port, _ := cfg.Get("server.port")
sources := cfg.GetSources("server.port")
for source, value := range sources {
log.Printf("server.port from %s: %v", source, value)
}
```
## Next Steps
- [Builder Pattern](builder.md) - Advanced configuration options
- [Access Patterns](access.md) - All ways to get and set values
A missing file returns `ErrConfigNotFound` along with a usable Config when all
other initialization succeeds. Check that category explicitly if a file is
optional. Invalid environment or CLI values remain fatal even when the file is
missing. `MustBuild` and `MustQuick` ignore only a missing file and panic on other
initialization errors. All source loads replace the previous values of that source.
+36 -349
View File
@@ -1,349 +1,36 @@
# Live Reconfiguration
The config package supports automatic configuration reloading when files change, enabling zero-downtime reconfiguration.
## Basic File Watching
### Enable Auto-Update
```go
cfg, _ := config.NewBuilder().
WithDefaults(&Config{}).
WithFile("config.toml").
Build()
// Enable automatic reloading
cfg.AutoUpdate()
// Your application continues running
// Config reloads automatically when file changes
// Stop watching when done
defer cfg.StopAutoUpdate()
```
### Watch for Changes
```go
// Get notified of configuration changes
changes := cfg.Watch()
go func() {
for path := range changes {
log.Printf("Configuration changed: %s", path)
// React to specific changes
switch path {
case "server.port":
// Restart server with new port
restartServer()
case "log.level":
// Update log level
updateLogLevel()
}
}
}()
```
## Watch Options
### Custom Watch Configuration
```go
opts := config.WatchOptions{
PollInterval: 500 * time.Millisecond, // Check every 500ms
Debounce: 200 * time.Millisecond, // Wait 200ms after changes
MaxWatchers: 50, // Limit concurrent watchers
ReloadTimeout: 10 * time.Second, // Timeout for reload
VerifyPermissions: true, // Security check
}
cfg.AutoUpdateWithOptions(opts)
```
### Watch Without Auto-Update
```go
// Just watch, don't auto-reload
changes := cfg.WatchWithOptions(config.WatchOptions{
PollInterval: time.Second,
})
// Manually reload when desired
go func() {
for range changes {
if shouldReload() {
cfg.LoadFile("config.toml")
}
}
}()
```
## Change Detection
### Value Changes
The watcher detects and notifies about:
- New values added
- Existing values modified
- Values removed
- Type changes
```go
changes := cfg.Watch()
for path := range changes {
newVal, exists := cfg.Get(path)
if !exists {
log.Printf("Removed: %s", path)
continue
}
sources := cfg.GetSources(path)
fileVal, hasFile := sources[config.SourceFile]
log.Printf("Changed: %s = %v (from file: %v)",
path, newVal, hasFile)
}
```
### Special Notifications
```go
changes := cfg.Watch()
for notification := range changes {
switch notification {
case "file_deleted":
log.Warn("Config file was deleted")
case "permissions_changed":
log.Error("Config file permissions changed - potential security issue")
case "reload_timeout":
log.Error("Config reload timed out")
default:
if strings.HasPrefix(notification, "reload_error:") {
log.Error("Reload error:", notification)
} else {
// Normal path change
handleConfigChange(notification)
}
}
}
```
## Debouncing
Rapid file changes are automatically debounced:
```go
// Multiple rapid saves to config.toml
// Only triggers one reload after debounce period
opts := config.WatchOptions{
PollInterval: 100 * time.Millisecond,
Debounce: 500 * time.Millisecond, // Wait 500ms
}
cfg.AutoUpdateWithOptions(opts)
```
## Permission Monitoring
```go
opts := config.WatchOptions{
VerifyPermissions: true, // Enabled by default
}
cfg.AutoUpdateWithOptions(opts)
// Detects if file becomes world-writable
changes := cfg.Watch()
for change := range changes {
if change == "permissions_changed" {
// File permissions changed
// Possible security breach
alert("Config file permissions modified!")
}
}
```
## Pattern: Reconfiguration
```go
type Server struct {
cfg *config.Config
listener net.Listener
mu sync.RWMutex
}
func (s *Server) watchConfig() {
changes := s.cfg.Watch()
for path := range changes {
switch {
case strings.HasPrefix(path, "server."):
s.scheduleRestart()
case path == "log.level":
s.updateLogLevel()
case strings.HasPrefix(path, "feature."):
s.reloadFeatures()
}
}
}
func (s *Server) scheduleRestart() {
s.mu.Lock()
defer s.mu.Unlock()
// Graceful restart logic
log.Info("Scheduling server restart for config changes")
// ... drain connections, restart listener ...
}
```
## Pattern: Feature Flags
```go
type FeatureFlags struct {
cfg *config.Config
mu sync.RWMutex
}
func (ff *FeatureFlags) Watch() {
changes := ff.cfg.Watch()
for path := range changes {
if strings.HasPrefix(path, "features.") {
feature := strings.TrimPrefix(path, "features.")
enabled, _ := ff.cfg.Get(path)
log.Printf("Feature %s: %v", feature, enabled)
ff.notifyFeatureChange(feature, enabled.(bool))
}
}
}
func (ff *FeatureFlags) IsEnabled(feature string) bool {
ff.mu.RLock()
defer ff.mu.RUnlock()
val, exists := ff.cfg.Get("features." + feature)
return exists && val.(bool)
}
```
## Pattern: Multi-Stage Reload
```go
func watchConfigWithValidation(cfg *config.Config) {
changes := cfg.Watch()
for range changes {
// Stage 1: Snapshot current config
backup := cfg.Clone()
// Stage 2: Validate new configuration
if err := validateNewConfig(cfg); err != nil {
log.Error("Invalid configuration:", err)
continue
}
// Stage 3: Apply changes
if err := applyConfigChanges(cfg, backup); err != nil {
log.Error("Failed to apply changes:", err)
// Could restore from backup here
continue
}
log.Info("Configuration successfully reloaded")
}
}
```
## Monitoring
### Watch Status
```go
// Check if watching is active
if cfg.IsWatching() {
log.Printf("Auto-update is enabled")
log.Printf("Active watchers: %d", cfg.WatcherCount())
}
```
### Resource Management
```go
// Limit watchers to prevent resource exhaustion
opts := config.WatchOptions{
MaxWatchers: 10, // Max 10 concurrent watch channels
}
// Watchers beyond limit receive closed channels
cfg.AutoUpdateWithOptions(opts)
```
## Best Practices
1. **Always Stop Watching**: Use `defer cfg.StopAutoUpdate()` to clean up
2. **Handle All Notifications**: Check for special error notifications
3. **Validate After Reload**: Ensure new config is valid before applying
4. **Use Debouncing**: Prevent reload storms from rapid edits
5. **Monitor Permissions**: Enable permission verification for security
6. **Graceful Updates**: Plan how your app handles config changes
7. **Log Changes**: Audit configuration modifications
## Limitations
- File watching uses polling (not inotify/kqueue)
- No support for watching multiple files
- Changes only detected for registered paths
- Reloads entire file (no partial updates)
## Common Issues
### Changes Not Detected
```go
// Ensure path is registered before watching
cfg.Register("new.value", "default")
// Now changes to new.value will be detected
```
### Rapid Reloads
```go
// Increase debounce to prevent rapid reloads
opts := config.WatchOptions{
Debounce: 2 * time.Second, // Wait 2s after changes stop
}
```
### Memory Leaks
```go
// Always stop watching to prevent goroutine leaks
watcher := cfg.Watch()
// Use context for cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
for {
select {
case change := <-watcher:
handleChange(change)
case <-ctx.Done():
return
}
}
}()
```
# Live reconfiguration
`AutoUpdate` starts polling the loaded file. `Watch` starts it if necessary and
subscribes to notifications. Option variants configure the polling interval
(minimum 100ms), debounce, subscriber limit, reload timeout and permission checks.
Options for an already-running watcher stay unchanged; stop/restart to change them.
One worker reads and fingerprints the regular file per poll, including same-size,
same-timestamp edits and atomic replacements. Both successful and erroneous reads
must settle for the debounce interval. Stable errors are reported once per observed
state. Deletion preserves the last valid state, and recreation can reload normally.
This trades one file read per poll for reliable detection of metadata-preserving edits.
Before publishing, the worker checks its context, current watcher identity, watched
path and source generation under the Config lock. Type checks and allocation finish
before the final timeout check. Expired, stopped or superseded operations cannot
publish. ReloadTimeout limits publication eligibility; it cannot forcibly interrupt
a kernel filesystem operation. Reads never hold the Config lock.
Timed-out operations retry on later polls even if the content is unchanged; repeated
timeouts for the same observation emit only one notification until recovery.
Events are changed registered paths, or `file_deleted`, `permissions_changed`,
`reload_timeout`, `reload_error:<detail>`, and `precedence:<path>`. A path event means
the effective value changed; a file value masked by CLI need not notify. Group/world
permission changes are blocked when VerifyPermissions is enabled; correcting the
permissions allows loading again. Default options enable that check.
Notifications are buffered (10 entries) and best effort; a slow subscriber may miss
events and should read the latest full state. MaxWatchers bounds retained channels.
StopAutoUpdate cancels publication and closes channels synchronously without waiting
under the Config lock. There is no goroutine per subscriber or per timed-out reload.
A new Watch call after stopping creates a new channel and worker.
`WatchFile` loads a replacement path before replacing the watcher and preserves its
options. Failure leaves the prior watcher and values active. Loading a different
file directly stops the previous watcher; call Watch to watch the new path.
+18 -170
View File
@@ -1,175 +1,23 @@
# Validation
The config package provides flexible validation through function injection and bundled validators for common scenarios.
Registration defaults define conversion types. Set, SetSource and complete source
loads reject incompatible values before publication. Overflow, fractional integer
conversion, non-finite values and invalid textual duration/time/network values error.
Untyped nil defaults leave the value type open within the supported value graph.
## Validation Stages
`Validate(paths...)` requires a registered path to have a supplied value, even if it
equals the default. `RegisterRequired` and `required:"true"` fields are collected by
`Validate()` with no arguments and by Build. Defaults alone do not satisfy required
inputs. Registration of a struct is staged, so a duplicate/invalid field or invalid
explicit environment value cannot leave a partially registered schema.
The package supports two validation stages when using the Builder pattern:
Build supports `WithValidator(func(*Config) error)` and a matching
`WithTypedValidator(func(*T) error)`. They run at initialization. They are not rerun
on runtime changes: apply application-level constraints before adopting a new
snapshot when those constraints must hold after reconfiguration.
### Pre-Population Validation (`WithValidator`)
Runs on raw configuration values before struct population:
```go
cfg, _ := config.NewBuilder().
WithDefaults(defaults).
WithValidator(func(c *config.Config) error {
// Check required paths exist
return c.Validate("api.key", "database.url")
}).
Build()
```
### Post-Population Validation (`WithTypedValidator`)
Type-safe validation after struct is populated:
```go
type AppConfig struct {
Server struct {
Port int64 `toml:"port"`
} `toml:"server"`
}
cfg, _ := config.NewBuilder().
WithTarget(&AppConfig{}).
WithTypedValidator(func(cfg *AppConfig) error {
// No type assertion needed - cfg.Server.Port is int64
if cfg.Server.Port < 1024 || cfg.Server.Port > 65535 {
return fmt.Errorf("port %d outside valid range", cfg.Server.Port)
}
return nil
}).
Build()
```
## Bundled Validators
The package includes common validation functions in the `config` package:
### Numeric Validators
```go
// Port validates TCP/UDP port range (1-65535)
config.Port(8080) // returns nil
// Positive validates positive numbers
config.Positive(42) // returns nil
config.Positive(-1) // returns error
// NonNegative validates non-negative numbers
config.NonNegative(0) // returns nil
config.NonNegative(-5) // returns error
// Range creates min/max validators
portValidator := config.Range(1024, 65535)
err := portValidator(8080) // returns nil
```
### String Validators
```go
// NonEmpty validates non-empty strings
config.NonEmpty("hello") // returns nil
config.NonEmpty("") // returns error
// Pattern creates regex validators
emailPattern := config.Pattern(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
err := emailPattern("user@example.com") // returns nil
// URLPath validates URL path format
config.URLPath("/api/v1") // returns nil
config.URLPath("api/v1") // returns error (must start with /)
```
### Network Validators
```go
// IPAddress validates any IP address
config.IPAddress("192.168.1.1") // returns nil
config.IPAddress("::1") // returns nil
// IPv4Address validates IPv4 only
config.IPv4Address("192.168.1.1") // returns nil
config.IPv4Address("::1") // returns error
// IPv6Address validates IPv6 only
config.IPv6Address("2001:db8::1") // returns nil
config.IPv6Address("192.168.1.1") // returns error
```
### Enum Validators
```go
// OneOf creates allowed values validator
logLevel := config.OneOf("debug", "info", "warn", "error")
err := logLevel("info") // returns nil
err = logLevel("trace") // returns error
```
## Integration Example
```go
type ServerConfig struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
Mode string `toml:"mode"`
}
cfg, err := config.NewBuilder().
WithTarget(&ServerConfig{}).
WithFile("config.toml").
WithTypedValidator(func(c *ServerConfig) error {
// Use bundled validators
if err := config.IPAddress(c.Host); err != nil {
return fmt.Errorf("invalid host: %w", err)
}
if err := config.Port(c.Port); err != nil {
return fmt.Errorf("invalid port: %w", err)
}
modeValidator := config.OneOf("development", "production")
if err := modeValidator(c.Mode); err != nil {
return fmt.Errorf("invalid mode: %w", err)
}
return nil
}).
Build()
```
## Integration with go-playground/validator
The package works seamlessly with `go-playground/validator`:
```go
import "github.com/go-playground/validator/v10"
type ServerConfig struct {
Host string `toml:"host" validate:"required,hostname"`
Port int `toml:"port" validate:"required,min=1024,max=65535"`
TLS struct {
Enabled bool `toml:"enabled"`
Cert string `toml:"cert" validate:"required_if=Enabled true"`
Key string `toml:"key" validate:"required_if=Enabled true"`
} `toml:"tls"`
}
// Load configuration
cfg, _ := config.NewBuilder().
WithDefaults(&ServerConfig{}).
Build()
// Get populated struct
serverCfg, _ := cfg.AsStruct()
// Validate with go-playground/validator
validate := validator.New()
if err := validate.Struct(serverCfg); err != nil {
return err // Validation errors
}
```
Tags don't conflict since each package uses different tag names (`toml`/`json`/`yaml` for config, `validate` for validator).
Helpers include Port, Positive, NonNegative, IPAddress, IPv4Address, IPv6Address,
URLPath, OneOf, Range, Pattern and NonEmpty. Pattern compiles its expression when the
validator is created and panics on an invalid expression. Network validators allow
the documented empty/unspecified defaults. Use errors.Is with ErrValidation,
ErrTypeMismatch and ErrDecode to handle failures.
+26 -326
View File
@@ -1,234 +1,43 @@
// FILE: lixenwraith/config/dynamic_test.go
package config
import (
"encoding/json"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestMultiFormatLoading tests loading different config formats
func TestMultiFormatLoading(t *testing.T) {
tmpDir := t.TempDir()
// Create test config in different formats
tomlConfig := `
[server]
host = "toml-host"
port = 8080
[database]
url = "postgres://localhost/toml"
`
jsonConfig := `{
"server": {
"host": "json-host",
"port": 9090
},
"database": {
"url": "postgres://localhost/json"
func TestTOMLOnly(t *testing.T) {
for _, format := range []string{"json", "yaml", "other"} {
c := New()
require.Error(t, c.SetFileFormat(format))
_, err := NewBuilder().WithTagName(format).Build()
require.Error(t, err)
var dst struct{ Value string }
require.Error(t, ScanMap(nil, &dst, format))
}
}`
yamlConfig := `
server:
host: yaml-host
port: 7070
database:
url: postgres://localhost/yaml
`
// Write config files
tomlPath := filepath.Join(tmpDir, "config.toml")
jsonPath := filepath.Join(tmpDir, "config.json")
yamlPath := filepath.Join(tmpDir, "config.yaml")
require.NoError(t, os.WriteFile(tomlPath, []byte(tomlConfig), 0644))
require.NoError(t, os.WriteFile(jsonPath, []byte(jsonConfig), 0644))
require.NoError(t, os.WriteFile(yamlPath, []byte(yamlConfig), 0644))
t.Run("AutoDetectFormats", func(t *testing.T) {
cfg := New()
cfg.Register("server.host", "")
cfg.Register("server.port", 0)
cfg.Register("database.url", "")
// Test TOML
cfg.SetFileFormat("auto")
require.NoError(t, cfg.LoadFile(tomlPath))
host, _ := cfg.Get("server.host")
assert.Equal(t, "toml-host", host)
// Test JSON
require.NoError(t, cfg.LoadFile(jsonPath))
host, _ = cfg.Get("server.host")
assert.Equal(t, "json-host", host)
port, _ := cfg.Get("server.port")
// JSON number should be preserved as json.Number but convertible
switch v := port.(type) {
case json.Number:
// Expected for raw value
assert.Equal(t, json.Number("9090"), v)
case int64:
// Expected after decode hook conversion
assert.Equal(t, int64(9090), v)
case float64:
// Alternative conversion
assert.Equal(t, float64(9090), v)
default:
t.Errorf("Unexpected type for port: %T", port)
for _, tc := range []struct{ ext, content string }{
{".toml", `value = "toml"`}, {".conf", `value = "toml"`}, {".config", `value = "toml"`},
} {
c := New()
require.NoError(t, c.Register("value", ""))
path := filepath.Join(t.TempDir(), "config"+tc.ext)
require.NoError(t, os.WriteFile(path, []byte(tc.content), 0600))
require.NoError(t, c.LoadFile(path))
}
// Test YAML
require.NoError(t, cfg.LoadFile(yamlPath))
host, _ = cfg.Get("server.host")
assert.Equal(t, "yaml-host", host)
})
t.Run("ExplicitFormat", func(t *testing.T) {
cfg := New()
cfg.Register("server.host", "")
// Force JSON parsing on .conf file
confPath := filepath.Join(tmpDir, "config.conf")
require.NoError(t, os.WriteFile(confPath, []byte(jsonConfig), 0644))
cfg.SetFileFormat("json")
require.NoError(t, cfg.LoadFile(confPath))
host, _ := cfg.Get("server.host")
assert.Equal(t, "json-host", host)
})
t.Run("ContentDetection", func(t *testing.T) {
cfg := New()
cfg.Register("server.host", "")
// Ambiguous extension
ambigPath := filepath.Join(tmpDir, "config.conf")
require.NoError(t, os.WriteFile(ambigPath, []byte(yamlConfig), 0644))
cfg.SetFileFormat("auto")
require.NoError(t, cfg.LoadFile(ambigPath))
host, _ := cfg.Get("server.host")
assert.Equal(t, "yaml-host", host)
})
}
// TestDynamicFormatSwitching tests runtime format changes
func TestDynamicFormatSwitching(t *testing.T) {
tmpDir := t.TempDir()
// Create configs in different formats with same structure
configs := map[string]string{
"toml": `value = "from-toml"`,
"json": `{"value": "from-json"}`,
"yaml": `value: from-yaml`,
}
cfg := New()
cfg.Register("value", "default")
for format, content := range configs {
t.Run(format, func(t *testing.T) {
filePath := filepath.Join(tmpDir, "config."+format)
require.NoError(t, os.WriteFile(filePath, []byte(content), 0644))
// Set format and load
require.NoError(t, cfg.SetFileFormat(format))
require.NoError(t, cfg.LoadFile(filePath))
val, _ := cfg.Get("value")
assert.Equal(t, "from-"+format, val)
})
for _, tc := range []struct{ ext, content string }{
{".json", `{"value":"json"}`}, {".yaml", "value: yaml"}, {".conf", `{"value":"json"}`},
} {
c := New()
require.NoError(t, c.Register("value", ""))
path := filepath.Join(t.TempDir(), "config"+tc.ext)
require.NoError(t, os.WriteFile(path, []byte(tc.content), 0600))
require.Error(t, c.LoadFile(path))
}
}
// TestWatchFileFormatSwitch tests watching different file formats
func TestWatchFileFormatSwitch(t *testing.T) {
tmpDir := t.TempDir()
tomlPath := filepath.Join(tmpDir, "config.toml")
jsonPath := filepath.Join(tmpDir, "config.json")
require.NoError(t, os.WriteFile(tomlPath, []byte(`value = "toml-1"`), 0644))
require.NoError(t, os.WriteFile(jsonPath, []byte(`{"value": "json-1"}`), 0644))
cfg := New()
cfg.Register("value", "default")
// Configure fast polling for test
opts := WatchOptions{
PollInterval: testPollInterval, // Fast polling for tests
Debounce: testDebounce, // Short debounce
MaxWatchers: 10,
}
// Start watching TOML
cfg.SetFileFormat("auto")
require.NoError(t, cfg.LoadFile(tomlPath))
cfg.AutoUpdateWithOptions(opts)
defer cfg.StopAutoUpdate()
// Wait for watcher to start
require.Eventually(t, func() bool {
return cfg.IsWatching()
}, 4*testDebounce, 2*SpinWaitInterval)
val, _ := cfg.Get("value")
assert.Equal(t, "toml-1", val)
// Switch to JSON with format hint
require.NoError(t, cfg.WatchFile(jsonPath, "json"))
// Wait for new watcher to start
require.Eventually(t, func() bool {
return cfg.IsWatching()
}, 4*testDebounce, 2*SpinWaitInterval)
// Get watch channel AFTER switching files
changes := cfg.Watch()
val, _ = cfg.Get("value")
assert.Equal(t, "json-1", val)
// Update JSON file
require.NoError(t, os.WriteFile(jsonPath, []byte(`{"value": "json-2"}`), 0644))
// Wait for change notification
select {
case path := <-changes:
assert.Equal(t, "value", path)
// Wait a bit for value to be updated
require.Eventually(t, func() bool {
val, _ := cfg.Get("value")
return val == "json-2"
}, testEventuallyTimeout, 2*SpinWaitInterval)
case <-time.After(testWatchTimeout):
t.Error("Timeout waiting for JSON file change")
}
// Update old TOML file - should NOT trigger notification
require.NoError(t, os.WriteFile(tomlPath, []byte(`value = "toml-2"`), 0644))
// Should not receive notification from old file
select {
case <-changes:
t.Error("Should not receive changes from old TOML file")
case <-time.After(testPollWindow):
// Expected - no change notification
}
}
// TestSecurityOptions tests security features
func TestSecurityOptions(t *testing.T) {
tmpDir := t.TempDir()
@@ -307,112 +116,3 @@ func waitForWatchingState(t *testing.T, cfg *Config, expected bool, msgAndArgs .
return cfg.IsWatching() == expected
}, testEventuallyTimeout, 2*SpinWaitInterval, msgAndArgs...)
}
// TestBuilderWithFormat tests Builder integration
func TestBuilderWithFormat(t *testing.T) {
tmpDir := t.TempDir()
jsonPath := filepath.Join(tmpDir, "config.json")
jsonConfig := `{
"server": {
"host": "builder-host",
"port": 8080
}
}`
require.NoError(t, os.WriteFile(jsonPath, []byte(jsonConfig), 0644))
type Config struct {
Server struct {
Host string `json:"host" toml:"host"`
Port int `json:"port" toml:"port"`
} `json:"server" toml:"server"`
}
defaults := &Config{}
defaults.Server.Host = "default-host"
defaults.Server.Port = 3000
cfg, err := NewBuilder().
WithDefaults(defaults).
WithFile(jsonPath).
WithFileFormat("json").
WithTagName("toml"). // Use toml tags for registration
WithSecurityOptions(SecurityOptions{
PreventPathTraversal: true,
MaxFileSize: 1024 * 1024, // 1MB
}).
Build()
require.NoError(t, err)
// Check the value was loaded
host, exists := cfg.Get("server.host")
assert.True(t, exists, "server.host should exist")
assert.Equal(t, "builder-host", host)
port, exists := cfg.Get("server.port")
assert.True(t, exists, "server.port should exist")
// Handle json.Number or converted int
switch v := port.(type) {
case json.Number:
p, _ := v.Int64()
assert.Equal(t, int64(8080), p)
case int64:
assert.Equal(t, int64(8080), v)
case float64:
assert.Equal(t, float64(8080), v)
default:
t.Errorf("Unexpected type for port: %T", port)
}
}
// BenchmarkFormatParsing benchmarks different format parsing speeds
func BenchmarkFormatParsing(b *testing.B) {
tmpDir := b.TempDir()
// Create test data
configs := map[string]string{
"toml": `
[server]
host = "localhost"
port = 8080
[database]
url = "postgres://localhost/db"
[cache]
ttl = 300
`,
"json": `{
"server": {"host": "localhost", "port": 8080},
"database": {"url": "postgres://localhost/db"},
"cache": {"ttl": 300}
}`,
"yaml": `
server:
host: localhost
port: 8080
database:
url: postgres://localhost/db
cache:
ttl: 300
`,
}
for format, content := range configs {
b.Run(format, func(b *testing.B) {
path := filepath.Join(tmpDir, "bench."+format)
os.WriteFile(path, []byte(content), 0644)
cfg := New()
cfg.Register("server.host", "")
cfg.Register("server.port", 0)
cfg.Register("database.url", "")
cfg.Register("cache.ttl", 0)
cfg.SetFileFormat(format)
b.ResetTimer()
for i := 0; i < b.N; i++ {
cfg.LoadFile(path)
}
})
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ func main() {
log.Println(" (Set environment variable APP_SERVER_PORT=8888)")
// Create a "target" struct. The builder will automatically populate this
// and keep it updated when using `AsStruct()`
// once; later `AsStruct()` calls return independent updated snapshots.
target := &AppConfig{}
// Use the builder to chain multiple configuration options
+347
View File
@@ -0,0 +1,347 @@
package config
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"net"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"time"
"github.com/lixenwraith/toml"
)
type fileSettings struct{ security SecurityOptions }
type fileSnapshot struct {
data []byte
info os.FileInfo
digest [32]byte
}
func (c *Config) fileSettingsLocked() fileSettings {
var s fileSettings
if c.securityOpts != nil {
s.security = *c.securityOpts
}
return s
}
type contextReader struct {
ctx context.Context
reader io.Reader
}
func (r contextReader) Read(p []byte) (int, error) {
if err := r.ctx.Err(); err != nil {
return 0, err
}
return r.reader.Read(p)
}
// Read a regular file and validate the opened descriptor, not just the pathname.
func readConfigFile(ctx context.Context, path string, opts fileSettings) (*fileSnapshot, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if opts.security.PreventPathTraversal {
clean := filepath.Clean(path)
if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return nil, wrapError(ErrFileAccess, fmt.Errorf("path traversal in %q", path))
}
}
if ext := strings.ToLower(filepath.Ext(path)); ext == ".json" || ext == ".yaml" || ext == ".yml" {
return nil, ErrFileFormat
}
info, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, ErrConfigNotFound
}
return nil, wrapError(ErrFileAccess, err)
}
if !info.Mode().IsRegular() {
return nil, wrapError(ErrFileAccess, fmt.Errorf("config must be a regular file"))
}
file, err := os.Open(path)
if err != nil {
return nil, wrapError(ErrFileAccess, err)
}
defer file.Close()
info, err = file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, wrapError(ErrFileAccess, fmt.Errorf("config must be a regular file"))
}
if opts.security.EnforceFileOwnership {
if err := checkFileOwner(info); err != nil {
return nil, err
}
}
limit := opts.security.MaxFileSize
if limit > 0 && info.Size() > limit {
return nil, wrapError(ErrFileAccess, fmt.Errorf("config exceeds maximum size %d", limit))
}
var reader io.Reader = contextReader{ctx, file}
if limit > 0 && limit < int64(^uint64(0)>>1) {
reader = io.LimitReader(reader, limit+1)
}
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
if limit > 0 && int64(len(data)) > limit {
return nil, wrapError(ErrFileAccess, fmt.Errorf("config exceeds maximum size %d", limit))
}
after, err := file.Stat()
if err != nil {
return nil, err
}
if info.Size() != after.Size() || !info.ModTime().Equal(after.ModTime()) {
return nil, fmt.Errorf("config changed during read")
}
if err := ctx.Err(); err != nil {
return nil, err
}
return &fileSnapshot{data, after, sha256.Sum256(data)}, nil
}
func parseFile(snapshot *fileSnapshot) (map[string]any, []string, error) {
root, err := toml.NewParser(snapshot.data).Parse()
if err != nil {
return nil, nil, wrapError(ErrDecode, fmt.Errorf("failed to parse TOML: %w", err))
}
var comments []string
lexer := toml.NewLexer(snapshot.data)
for {
token := lexer.NextToken()
if token.Type == toml.TokenEOF {
break
}
if token.Type == toml.TokenError {
return nil, nil, wrapError(ErrDecode, fmt.Errorf("%s", token.Literal))
}
if token.Type == toml.TokenComment {
comments = append(comments, token.Literal)
}
}
return root, comments, nil
}
func (c *Config) loadFile(path string) error {
c.mutex.RLock()
opts := c.fileSettingsLocked()
c.mutex.RUnlock()
snapshot, err := readConfigFile(context.Background(), path, opts)
if err != nil {
return err
}
root, comments, err := parseFile(snapshot)
if err != nil {
return err
}
c.mutex.Lock()
defer c.mutex.Unlock()
if err := c.applyFileLocked(root, comments, path); err != nil {
return err
}
if c.watcher != nil && c.watcher.filePath != path {
c.watcher.stop()
c.watcher = nil
}
return nil
}
func (c *Config) applyFileLocked(root map[string]any, comments []string, path string, guard ...func() error) error {
values := make(map[string]any)
for registered := range c.items {
var current any = root
exists := true
for _, key := range strings.Split(registered, ".") {
m, ok := current.(map[string]any)
if !ok {
return wrapError(ErrTypeMismatch, fmt.Errorf("scalar parent of registered path %q", registered))
}
current, exists = m[key]
if !exists {
break
}
}
if exists {
values[registered] = current
}
}
if err := c.replaceSourceLocked(SourceFile, values, guard...); err != nil {
return err
}
c.configFilePath, c.fileComments = path, comments
c.fileGeneration++
return nil
}
// Save writes registered effective values as TOML. Comments from the loaded file
// are retained as a preamble; their original positions and whitespace are not.
func (c *Config) Save(path string) error { return c.save(path, "") }
func (c *Config) SaveSource(path string, source Source) error {
if !validSource(source) {
return wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source))
}
return c.save(path, source)
}
func (c *Config) save(path string, source Source) error {
c.mutex.RLock()
nested := c.nestedLocked(source)
comments := append([]string(nil), c.fileComments...)
c.mutex.RUnlock()
data, err := marshalConfig(nested)
if err != nil {
return err
}
var preamble strings.Builder
for _, comment := range comments {
preamble.WriteByte('#')
preamble.WriteString(comment)
preamble.WriteByte('\n')
}
return atomicWriteFile(path, append([]byte(preamble.String()), data...))
}
func marshalConfig(value any) ([]byte, error) {
normalized, err := fileValue(reflect.ValueOf(value), 0)
if err != nil {
return nil, wrapError(ErrFileFormat, err)
}
data, err := toml.Marshal(normalized)
if err != nil {
return nil, wrapError(ErrFileFormat, err)
}
return data, nil
}
func fileValue(v reflect.Value, depth int) (any, error) {
if depth > maxValueDepth {
return nil, fmt.Errorf("file value nesting exceeds %d", maxValueDepth)
}
for v.IsValid() && (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface) {
if v.IsNil() {
return nil, nil
}
v = v.Elem()
depth++
if depth > maxValueDepth {
return nil, fmt.Errorf("cyclic pointer")
}
}
if !v.IsValid() {
return nil, nil
}
switch v.Type() {
case durationType:
return time.Duration(v.Int()).String(), nil
case timeType:
return v.Interface().(time.Time).Format(time.RFC3339Nano), nil
case ipType:
ip := v.Interface().(net.IP)
if len(ip) == 0 {
return nil, nil
}
return ip.String(), nil
case ipNetType:
network := v.Interface().(net.IPNet)
return network.String(), nil
case urlType:
u := v.Interface().(url.URL)
return u.String(), nil
}
switch v.Kind() {
case reflect.Struct:
out := make(map[string]any)
for i := 0; i < v.NumField(); i++ {
f := v.Type().Field(i)
if !f.IsExported() || fieldKey(f) == "-" {
continue
}
value, err := fileValue(v.Field(i), depth+1)
if err != nil {
return nil, err
}
key := fieldKey(f)
if _, exists := out[key]; exists {
return nil, fmt.Errorf("duplicate TOML field %q", key)
}
out[key] = value
}
return out, nil
case reflect.Map:
if v.Type().Key().Kind() != reflect.String {
return nil, fmt.Errorf("map keys must be strings")
}
out := make(map[string]any, v.Len())
it := v.MapRange()
for it.Next() {
value, err := fileValue(it.Value(), depth+1)
if err != nil {
return nil, err
}
out[it.Key().String()] = value
}
return out, nil
case reflect.Array, reflect.Slice:
out := make([]any, v.Len())
for i := range out {
value, err := fileValue(v.Index(i), depth+1)
if err != nil {
return nil, err
}
out[i] = value
}
return out, nil
default:
return v.Interface(), nil
}
}
func atomicWriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, DirPermissions); err != nil {
return wrapError(ErrFileAccess, err)
}
mode := os.FileMode(0600)
if info, err := os.Stat(path); err == nil {
if !info.Mode().IsRegular() {
return wrapError(ErrFileAccess, fmt.Errorf("destination must be a regular file"))
}
mode = info.Mode().Perm()
} else if !errors.Is(err, os.ErrNotExist) {
return wrapError(ErrFileAccess, err)
}
f, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return wrapError(ErrFileAccess, err)
}
defer os.Remove(f.Name())
defer f.Close()
if _, err = f.Write(data); err == nil {
err = f.Chmod(mode)
}
if err == nil {
err = f.Sync()
}
if closeErr := f.Close(); err == nil {
err = closeErr
}
if err == nil {
err = os.Rename(f.Name(), path)
}
if err != nil {
return wrapError(ErrFileAccess, err)
}
return nil
}
+3 -4
View File
@@ -1,15 +1,14 @@
module github.com/lixenwraith/config
go 1.26.0
go 1.27.1
require (
github.com/BurntSushi/toml v1.6.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4
github.com/stretchr/testify v1.10.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+2 -8
View File
@@ -1,13 +1,7 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4 h1:9qZU0iBIsqintDh39vieio0ygLiRrPmCEo6Ra45EQyY=
github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+303
View File
@@ -0,0 +1,303 @@
package config
import (
"errors"
"math"
"net"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
)
func TestCheckedConversionsAndRollback(t *testing.T) {
type limits struct {
U uint64 `toml:"u"`
I int64 `toml:"i"`
B uint8 `toml:"b"`
F float64 `toml:"f"`
}
initial := limits{1, 2, 3, 4}
for _, input := range []map[string]any{
{"u": "18446744073709551616"}, {"u": int64(-1)}, {"i": uint64(math.MaxUint64)},
{"i": float64(0x1p63)}, {"u": float64(0x1p64)}, {"b": 256}, {"i": 1.5},
{"f": uint64(1<<53) + 1}, {"f": math.NaN()}, {"f": "Inf"},
} {
got := initial
if err := ScanMap(input, &got); err == nil || got != initial {
t.Fatalf("accepted or published invalid %#v: %+v (%v)", input, got, err)
}
}
var got limits
if err := ScanMap(map[string]any{"u": "18446744073709551615", "i": int64(math.MinInt64), "b": 255}, &got); err != nil || got.U != math.MaxUint64 || got.I != math.MinInt64 || got.B != 255 {
t.Fatalf("boundaries: %+v %v", got, err)
}
var array struct {
Values [2]uint16 `toml:"values"`
}
if err := ScanMap(map[string]any{"values": "1,65535"}, &array); err != nil || array.Values != [2]uint16{1, 65535} {
t.Fatalf("array: %+v %v", array, err)
}
}
func TestOwnedValuesAndSnapshots(t *testing.T) {
type settings struct {
Values map[string][]int `toml:"values"`
Port uint16 `toml:"port"`
}
target := settings{Values: map[string][]int{"a": {1}}, Port: 80}
c, err := NewBuilder().WithTarget(&target).WithArgs(nil).Build()
if err != nil {
t.Fatal(err)
}
target.Values["a"][0] = 99
first, err := c.AsStruct()
if err != nil {
t.Fatal(err)
}
if first.(*settings).Values["a"][0] != 1 {
t.Fatal("target defaults alias internal values")
}
first.(*settings).Values["a"][0] = 88
raw, _ := c.Get("values")
raw.(map[string][]int)["a"][0] = 77
source := map[string][]int{"a": {2}}
if err := c.Set("values", source); err != nil {
t.Fatal(err)
}
source["a"][0] = 66
second, err := c.AsStruct()
if err != nil {
t.Fatal(err)
}
if second.(*settings).Values["a"][0] != 2 || first.(*settings).Values["a"][0] != 88 {
t.Fatal("snapshot changed or input leaked")
}
clone := c.Clone()
v, _ := clone.Get("values")
v.(map[string][]int)["a"][0] = 44
if err := clone.Set("values", map[string][]int{"a": {3}}); err != nil {
t.Fatal(err)
}
third, _ := c.AsStruct()
if third.(*settings).Values["a"][0] != 2 {
t.Fatal("clone aliases original")
}
var wg sync.WaitGroup
for range 4 {
wg.Go(func() {
for range 40 {
s, err := c.AsStruct()
if err != nil {
t.Error(err)
return
}
s.(*settings).Values["a"][0]++
}
})
}
wg.Wait()
}
func TestAtomicValuesAndFileRoundTrip(t *testing.T) {
type settings struct {
IP net.IP `toml:"ip"`
Network net.IPNet `toml:"network"`
URL url.URL `toml:"url"`
Time *time.Time `toml:"time"`
Delay time.Duration `toml:"delay"`
}
_, network, _ := net.ParseCIDR("10.2.0.0/16")
endpoint, _ := url.Parse("https://user:pass@example.com/path?q=x")
stamp := time.Date(2026, 9, 28, 12, 0, 0, 123, time.UTC)
initial := settings{net.ParseIP("::1"), *network, *endpoint, &stamp, 1500 * time.Millisecond}
c := New()
if err := c.RegisterStruct("", initial); err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "config.toml")
if err := c.Save(path); err != nil {
t.Fatal(err)
}
next := New()
if err := next.RegisterStruct("", settings{}); err != nil {
t.Fatal(err)
}
if err := next.LoadFile(path); err != nil {
t.Fatal(err)
}
var got settings
if err := next.Scan(&got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, initial) {
t.Fatalf("atomic roundtrip: %+v != %+v", got, initial)
}
for _, bad := range []any{net.IP{1}, net.IPNet{IP: net.IP{1}, Mask: net.IPMask{255}}, math.NaN(), make(chan int)} {
if err := c.Register("bad", bad); err == nil {
t.Fatalf("accepted %T", bad)
}
}
cycle := map[string]any{}
cycle["self"] = cycle
if err := c.Register("cycle", cycle); err == nil {
t.Fatal("accepted cycle")
}
var p any
p = &p
if err := decodeConfig(p, new(int)); err == nil {
t.Fatal("accepted cyclic pointer")
}
}
func TestSourceReplacementIsTransactional(t *testing.T) {
c := New()
_ = c.Register("port", uint16(80))
_ = c.Register("name", "default")
if err := c.LoadCLI([]string{"--port=8080", "--name=old"}); err != nil {
t.Fatal(err)
}
if err := c.LoadCLI([]string{"--name=new", "--port=65536"}); err == nil {
t.Fatal("accepted overflow")
}
if v, _ := c.Get("name"); v != "old" {
t.Fatal("partially published CLI")
}
if err := c.LoadCLI([]string{"--port=90", "--", "--name=ignored"}); err != nil {
t.Fatal(err)
}
if v, _ := c.Get("name"); v != "default" {
t.Fatal("stale CLI value")
}
t.Setenv("AUDIT_PORT", "100")
if err := c.LoadEnv("AUDIT_"); err != nil {
t.Fatal(err)
}
if err := os.Unsetenv("AUDIT_PORT"); err != nil {
t.Fatal(err)
}
if err := c.LoadEnv("AUDIT_"); err != nil {
t.Fatal(err)
}
if _, ok := c.GetSource("port", SourceEnv); ok {
t.Fatal("removed environment variable retained")
}
path := filepath.Join(t.TempDir(), "c.toml")
_ = os.WriteFile(path, []byte("port=100\nname=\"valid\""), 0600)
if err := c.LoadFile(path); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(path, []byte("port=65536\nname=\"invalid\""), 0600)
if err := c.LoadFile(path); err == nil {
t.Fatal("accepted invalid file")
}
if v, _ := c.GetSource("name", SourceFile); v != "valid" {
t.Fatal("partially published file")
}
if err := c.SetSource(Source("invalid"), "name", "x"); err == nil {
t.Fatal("accepted invalid source")
}
if err := c.Register("name.child", "x"); err == nil {
t.Fatal("accepted overlapping path")
}
}
func TestSaveCommentsPermissionsAndFailure(t *testing.T) {
c := New()
_ = c.Register("value", uint64(0))
_ = c.Register("text", "")
path := filepath.Join(t.TempDir(), "c.toml")
original := "# heading\nvalue=7 # inline\ntext=\"hash # inside\"\n"
_ = os.WriteFile(path, []byte(original), 0600)
if err := c.LoadFile(path); err != nil {
t.Fatal(err)
}
if err := c.Save(path); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(path)
info, _ := os.Stat(path)
if strings.Count(string(data), "# heading") != 1 || strings.Count(string(data), "# inline") != 1 || !strings.Contains(string(data), `hash # inside`) || info.Mode().Perm() != 0600 {
t.Fatalf("save: %s mode=%v", data, info.Mode())
}
before := string(data)
if err := c.Set("value", uint64(math.MaxUint64)); err != nil {
t.Fatal(err)
}
if err := c.Save(path); !errors.Is(err, ErrFileFormat) {
t.Fatalf("expected range error, got %v", err)
}
after, _ := os.ReadFile(path)
if string(after) != before {
t.Fatal("failed save changed destination")
}
}
func FuzzConfigNumericConversions(f *testing.F) {
for _, v := range []uint64{0, 255, 256, 1 << 63, math.MaxUint64} {
f.Add(v)
}
f.Fuzz(func(t *testing.T, n uint64) {
var u uint64
if err := decodeConfig(n, &u); err != nil || u != n {
t.Fatalf("uint64 loss %d %v", u, err)
}
var b uint8
err := decodeConfig(n, &b)
if (err == nil) != (n <= 255) || err == nil && uint64(b) != n {
t.Fatalf("narrowing %d %v", b, err)
}
var i int64
err = decodeConfig(n, &i)
if (err == nil) != (n <= math.MaxInt64) || err == nil && uint64(i) != n {
t.Fatalf("signed %d %v", i, err)
}
})
}
func TestRegistrationAndRequiredContracts(t *testing.T) {
type settings struct {
Port uint16 `toml:"port" required:"true"`
private string
}
target := settings{Port: 80}
if _, err := NewBuilder().WithTarget(&target).WithArgs(nil).Build(); !errors.Is(err, ErrValidation) {
t.Fatalf("required field ignored: %v", err)
}
c, err := NewBuilder().WithTarget(&target).WithArgs([]string{"--port=80"}).Build()
if err != nil {
t.Fatal(err)
}
if _, err := c.AsStruct(); err != nil {
t.Fatal(err)
}
if err := c.SetPrecedence(SourceDefault, SourceCLI); err != nil {
t.Fatal(err)
}
if err := c.Validate(); err == nil {
t.Fatal("inactive override satisfied required field")
}
type wrong struct {
Port uint64 `toml:"port"`
}
if _, err := NewBuilder().WithTarget(&target).WithDefaults(wrong{}).Build(); err == nil {
t.Fatal("mismatched target schema accepted")
}
type duplicate struct {
A int `toml:"same"`
B int `toml:"same"`
}
empty := New()
if err := empty.RegisterStruct("", duplicate{}); err == nil || len(empty.GetRegisteredPaths()) != 0 {
t.Fatal("partial or duplicate schema accepted")
}
for _, n := range []float64{math.NaN(), math.Inf(1)} {
if Positive(n) == nil || NonNegative(n) == nil || Range[float64](0, 10)(n) == nil {
t.Fatal("non-finite validator input accepted")
}
}
}
+9
View File
@@ -74,6 +74,15 @@ func isValidKeySegment(s string) bool {
return false // Segments themselves cannot contain dots
}
numeric := strings.TrimLeft(s, "-") != ""
for _, r := range strings.TrimLeft(s, "-") {
if r < '0' || r > '9' {
numeric = false
}
}
if numeric {
return false
}
for _, r := range s {
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
isDigit := r >= '0' && r <= '9'
+99 -432
View File
@@ -2,19 +2,13 @@
package config
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"os"
"path/filepath"
"runtime"
"reflect"
"slices"
"strings"
"syscall"
"github.com/BurntSushi/toml"
"gopkg.in/yaml.v3"
)
// Source represents a configuration source, used to define load precedence
@@ -51,7 +45,8 @@ type LoadOptions struct {
// EnvWhitelist limits which paths are checked for env vars (nil = all)
EnvWhitelist map[string]bool
// SkipValidation skips path validation during load
// Deprecated: SkipValidation is retained for source compatibility and ignored.
// Path and value checks are always enforced.
SkipValidation bool
}
@@ -62,13 +57,22 @@ func DefaultLoadOptions() LoadOptions {
}
}
func cloneLoadOptions(opts LoadOptions) LoadOptions {
opts.Sources = slices.Clone(opts.Sources)
if len(opts.Sources) == 0 {
opts.Sources = DefaultLoadOptions().Sources
}
opts.EnvWhitelist = maps.Clone(opts.EnvWhitelist)
return opts
}
// loadWithOptions loads configuration from multiple sources with custom options
func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOptions) error {
c.mutex.Lock()
c.options = opts
c.mutex.Unlock()
opts = cloneLoadOptions(opts)
c.SetLoadOptions(opts)
var loadErrors []error
var missingFile error
// Process each source according to precedence (in reverse order for proper layering)
for i := len(opts.Sources) - 1; i >= 0; i-- {
@@ -83,7 +87,7 @@ func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOption
if filePath != "" {
if err := c.loadFile(filePath); err != nil {
if errors.Is(err, ErrConfigNotFound) {
loadErrors = append(loadErrors, err)
missingFile = err
} else {
return wrapError(ErrFileAccess, err) // Fatal error
}
@@ -96,20 +100,23 @@ func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOption
}
case SourceCLI:
if len(args) > 0 {
if err := c.loadCLI(args); err != nil {
loadErrors = append(loadErrors, wrapError(ErrCLIParse, err))
}
}
}
}
if len(loadErrors) != 0 {
return errors.Join(loadErrors...)
}
return missingFile
}
// LoadEnv loads configuration values from environment variables
func (c *Config) LoadEnv(prefix string) error {
opts := c.options
c.mutex.RLock()
opts := cloneLoadOptions(c.options)
c.mutex.RUnlock()
opts.EnvPrefix = prefix
return c.loadEnv(opts)
}
@@ -130,115 +137,23 @@ func (c *Config) LoadFile(filePath string) error {
return nil
}
// Save writes the current configuration to a TOML file atomically.
// Only registered paths are saved.
func (c *Config) Save(path string) error {
c.mutex.RLock()
nestedData := make(map[string]any)
for itemPath, item := range c.items {
setNestedValue(nestedData, itemPath, item.currentValue)
}
c.mutex.RUnlock()
// Marshal using BurntSushi/toml
var buf bytes.Buffer
encoder := toml.NewEncoder(&buf)
if err := encoder.Encode(nestedData); err != nil {
return wrapError(ErrFileFormat, fmt.Errorf("failed to marshal config data to TOML: %w", err))
}
tomlData := buf.Bytes()
// Atomic write logic
dir := filepath.Dir(path)
// Ensure the directory exists
if err := os.MkdirAll(dir, DirPermissions); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to create config directory '%s': %w", dir, err))
}
// Create a temporary file in the same directory
tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to create temporary config file in '%s': %w", dir, err))
}
tempFilePath := tempFile.Name()
removed := false
defer func() {
if !removed {
os.Remove(tempFilePath)
}
}()
// Write data to the temporary file
if _, err := tempFile.Write(tomlData); err != nil {
tempFile.Close()
return wrapError(ErrFileAccess, fmt.Errorf("failed to write temp config file '%s': %w", tempFilePath, err))
}
// Sync data to disk
if err := tempFile.Sync(); err != nil {
tempFile.Close()
return wrapError(ErrFileAccess, fmt.Errorf("failed to sync temp config file '%s': %w", tempFilePath, err))
}
// Close the temporary file
if err := tempFile.Close(); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to close temp config file '%s': %w", tempFilePath, err))
}
// Set permissions on the temporary file
if err := os.Chmod(tempFilePath, FilePermissions); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to set permissions on temporary config file '%s': %w", tempFilePath, err))
}
// Atomically replace the original file
if err := os.Rename(tempFilePath, path); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to rename temp file '%s' to '%s': %w", tempFilePath, path, err))
}
removed = true
return nil
}
// SaveSource writes values from a specific source to a TOML file
func (c *Config) SaveSource(path string, source Source) error {
c.mutex.RLock()
nestedData := make(map[string]any)
for itemPath, item := range c.items {
if val, exists := item.values[source]; exists {
setNestedValue(nestedData, itemPath, val)
}
}
c.mutex.RUnlock()
// Marshal using BurntSushi/toml
var buf bytes.Buffer
encoder := toml.NewEncoder(&buf)
if err := encoder.Encode(nestedData); err != nil {
return wrapError(ErrFileFormat, fmt.Errorf("failed to marshal %s source data to TOML: %w", source, err))
}
return atomicWriteFile(path, buf.Bytes())
}
// DiscoverEnv finds all environment variables matching registered paths
// and returns a map of path -> env var name for found variables
func (c *Config) DiscoverEnv(prefix string) map[string]string {
c.mutex.RLock()
transform := c.options.EnvTransform
paths := make([]string, 0, len(c.items))
for path := range c.items {
paths = append(paths, path)
}
c.mutex.RUnlock()
if transform == nil {
transform = defaultEnvTransform(prefix)
}
c.mutex.RLock()
defer c.mutex.RUnlock()
discovered := make(map[string]string)
for path := range c.items {
for _, path := range paths {
envVar := transform(path)
if _, exists := os.LookupEnv(envVar); exists {
discovered[path] = envVar
@@ -251,278 +166,109 @@ func (c *Config) DiscoverEnv(prefix string) map[string]string {
// ExportEnv exports the current configuration as environment variables
// Only exports paths that have non-default values
func (c *Config) ExportEnv(prefix string) map[string]string {
c.mutex.RLock()
transform := c.options.EnvTransform
changed := make(map[string]any)
for path, item := range c.items {
if !reflect.DeepEqual(item.currentValue, item.defaultValue) {
changed[path] = item.currentValue
}
}
c.mutex.RUnlock()
if transform == nil {
transform = defaultEnvTransform(prefix)
}
c.mutex.RLock()
defer c.mutex.RUnlock()
exports := make(map[string]string)
for path, item := range c.items {
// Only export if value differs from default
if item.currentValue != item.defaultValue {
envVar := transform(path)
exports[envVar] = fmt.Sprintf("%v", item.currentValue)
}
for path, value := range changed {
exports[transform(path)] = fmt.Sprintf("%v", value)
}
return exports
}
// loadFile reads and parses a TOML configuration file
func (c *Config) loadFile(path string) error {
// Security: Path traversal check
if c.securityOpts != nil && c.securityOpts.PreventPathTraversal {
// Clean the path and check for traversal attempts
cleanPath := filepath.Clean(path)
// Check if cleaned path tries to go outside current directory
if strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || cleanPath == ".." {
return wrapError(ErrFileAccess, fmt.Errorf("potential path traversal detected in config path: %s", path))
}
// Also check for absolute paths that might escape jail
if filepath.IsAbs(cleanPath) && filepath.IsAbs(path) {
// Absolute paths are OK if that's what was provided
} else if filepath.IsAbs(cleanPath) && !filepath.IsAbs(path) {
// Relative path became absolute after cleaning - suspicious
return wrapError(ErrFileAccess, fmt.Errorf("potential path traversal detected in config path: %s", path))
}
}
// Read file with size limit
fileInfo, err := os.Stat(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return ErrConfigNotFound
}
return wrapError(ErrFileAccess, fmt.Errorf("failed to stat config file '%s': %w", path, err))
}
// Security: File size check
if c.securityOpts != nil && c.securityOpts.MaxFileSize > 0 {
if fileInfo.Size() > c.securityOpts.MaxFileSize {
return wrapError(ErrFileAccess, fmt.Errorf("config file '%s' exceeds maximum size %d bytes", path, c.securityOpts.MaxFileSize))
}
}
// Security: File ownership check (Unix only)
if c.securityOpts != nil && c.securityOpts.EnforceFileOwnership && runtime.GOOS != "windows" {
if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
if stat.Uid != uint32(os.Geteuid()) {
return wrapError(ErrFileAccess, fmt.Errorf("config file '%s' is not owned by current user (file UID: %d, process UID: %d)",
path, stat.Uid, os.Geteuid()))
}
}
}
// 1. Read and parse file data
file, err := os.Open(path)
if err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to open config file '%s': %w", path, err))
}
defer file.Close()
// Use LimitedReader for additional safety
var reader io.Reader = file
if c.securityOpts != nil && c.securityOpts.MaxFileSize > 0 {
reader = io.LimitReader(file, c.securityOpts.MaxFileSize)
}
fileData, err := io.ReadAll(reader)
if err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to read config file '%s': %w", path, err))
}
// Determine format
format := c.fileFormat
if format == "" || format == "auto" {
// Try extension first
format = detectFileFormat(path)
if format == "" {
// Fall back to content detection
format = detectFormatFromContent(fileData)
if format == "" {
// Last resort: use tagName as hint
format = c.tagName
}
}
}
// Parse based on detected/specified format
fileConfig := make(map[string]any)
switch format {
case FormatTOML:
if err := toml.Unmarshal(fileData, &fileConfig); err != nil {
return wrapError(ErrDecode, fmt.Errorf("failed to parse TOML config file '%s': %w", path, err))
}
case FormatJSON:
decoder := json.NewDecoder(bytes.NewReader(fileData))
decoder.UseNumber() // Preserve number precision
if err := decoder.Decode(&fileConfig); err != nil {
return wrapError(ErrDecode, fmt.Errorf("failed to parse JSON config file '%s': %w", path, err))
}
case FormatYAML:
if err := yaml.Unmarshal(fileData, &fileConfig); err != nil {
return wrapError(ErrDecode, fmt.Errorf("failed to parse YAML config file '%s': %w", path, err))
}
default:
return wrapError(ErrFileFormat, fmt.Errorf("unable to determine config format for file '%s'", path))
}
// 2. Prepare New State (Read-Lock Only)
newFileData := make(map[string]any)
// Briefly acquire a read-lock to safely get the list of registered paths.
c.mutex.RLock()
registeredPaths := make(map[string]bool, len(c.items))
for p := range c.items {
registeredPaths[p] = true
}
c.mutex.RUnlock()
// Define a recursive function to populate newFileData. This runs without any lock.
var apply func(prefix string, data map[string]any)
apply = func(prefix string, data map[string]any) {
for key, value := range data {
fullPath := key
if prefix != "" {
fullPath = prefix + "." + key
}
if registeredPaths[fullPath] {
newFileData[fullPath] = value
} else if subMap, isMap := value.(map[string]any); isMap {
apply(fullPath, subMap)
}
}
}
apply("", fileConfig)
// 3. Atomically Update Config (Write-Lock)
c.mutex.Lock()
defer c.mutex.Unlock()
c.configFilePath = path
c.fileData = newFileData
// Apply the new state to the main config items.
for path, item := range c.items {
if value, exists := newFileData[path]; exists {
if item.values == nil {
item.values = make(map[Source]any)
}
item.values[SourceFile] = value
} else {
// Key was not in the new file, so remove its old file-sourced value.
delete(item.values, SourceFile)
}
// Recompute the current value based on new source precedence.
item.currentValue = c.computeValue(item)
c.items[path] = item
}
c.invalidateCache()
return nil
}
// loadEnv loads configuration from environment variables
// loadEnv replaces the complete environment source, including removed variables.
func (c *Config) loadEnv(opts LoadOptions) error {
transform := opts.EnvTransform
if transform == nil {
transform = defaultEnvTransform(opts.EnvPrefix)
}
// 1. Prepare data (Read-Lock to get paths)
c.mutex.RLock()
paths := make([]string, 0, len(c.items))
for p := range c.items {
paths = append(paths, p)
for path := range c.items {
paths = append(paths, path)
}
c.mutex.RUnlock()
// 2. Process env vars (No Lock)
foundEnvVars := make(map[string]string)
values := make(map[string]any)
for _, path := range paths {
if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] {
continue
}
envVar := transform(path)
if value, exists := os.LookupEnv(envVar); exists {
if len(value) > MaxValueSize {
return ErrValueSize
}
foundEnvVars[path] = value
if value, ok := os.LookupEnv(transform(path)); ok {
values[path] = value
}
}
// If no relevant env vars were found, we are done.
if len(foundEnvVars) == 0 {
return nil
}
// -- 3. Atomically update config (Write-Lock)
c.mutex.Lock()
defer c.mutex.Unlock()
c.envData = make(map[string]any, len(foundEnvVars))
for path, value := range foundEnvVars {
// Store raw string value - mapstructure will handle conversion later.
if item, exists := c.items[path]; exists {
if item.values == nil {
item.values = make(map[Source]any)
}
item.values[SourceEnv] = value // Store as string
item.currentValue = c.computeValue(item)
c.items[path] = item
c.envData[path] = value
}
return c.replaceSourceLocked(SourceEnv, values)
}
c.invalidateCache()
return nil
}
// loadCLI loads configuration from command-line arguments
func (c *Config) loadCLI(args []string) error {
// -- 1. Prepare data (No Lock)
parsedCLI, err := parseArgs(args)
parsed, err := parseArgs(args)
if err != nil {
return err // Already wrapped with error category in parseArgs
return err
}
flattenedCLI := flattenMap(parsedCLI, "")
if len(flattenedCLI) == 0 {
return nil // No CLI args to process.
}
// 2. Atomically update config (Write-Lock)
c.mutex.Lock()
defer c.mutex.Unlock()
return c.replaceSourceLocked(SourceCLI, flattenMap(parsed, ""))
}
c.cliData = flattenedCLI
// Reset per load; reloads must not accumulate duplicates
c.unknownCLIKeys = c.unknownCLIKeys[:0]
for path, value := range flattenedCLI {
if item, exists := c.items[path]; exists {
if item.values == nil {
item.values = make(map[Source]any)
// Validate the entire replacement before any mutation. Caller holds c.mutex.
func (c *Config) replaceSourceLocked(source Source, values map[string]any, guard ...func() error) error {
accepted := make(map[string]any, len(values))
var unknown []string
for path, value := range values {
item, ok := c.items[path]
if !ok {
if source == SourceCLI {
unknown = append(unknown, path)
}
continue
}
if err := validateValue(item, value); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
owned, err := copyValue(value)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
accepted[path] = owned
}
if len(guard) > 0 {
if err := guard[0](); err != nil {
return err
}
}
for path, item := range c.items {
if value, ok := accepted[path]; ok {
item.values[source] = value
} else {
delete(item.values, source)
}
item.values[SourceCLI] = value
item.currentValue = c.computeValue(item)
c.items[path] = item
} else {
// Record CLI keys that match no registered path
c.unknownCLIKeys = append(c.unknownCLIKeys, path)
}
switch source {
case SourceFile:
c.fileData = accepted
case SourceEnv:
c.envData = accepted
case SourceCLI:
c.cliData = accepted
slices.Sort(unknown)
c.unknownCLIKeys = unknown
}
c.invalidateCache()
return nil
}
@@ -540,7 +286,7 @@ func defaultEnvTransform(prefix string) EnvTransformFunc {
}
// parseValue attempts to parse a string into appropriate types
// Only basic parse, complex parsing is deferred to mapstructure's decode hooks
// Only basic parse, complex parsing is deferred to typed decoding
func parseValue(s string) any {
if s == "true" {
return true
@@ -554,50 +300,10 @@ func parseValue(s string) any {
return s[1 : len(s)-1]
}
// Return as string - mapstructure will convert as needed
// Return as string - typed decoding converts as needed
return s
}
// atomicWriteFile performs atomic file write
func atomicWriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to create directory '%s': %w", dir, err))
}
tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to create temporary file: %w", err))
}
tempPath := tempFile.Name()
defer os.Remove(tempPath) // Clean up on any error
if _, err := tempFile.Write(data); err != nil {
tempFile.Close()
return wrapError(ErrFileAccess, fmt.Errorf("failed to write temporary file: %w", err))
}
if err := tempFile.Sync(); err != nil {
tempFile.Close()
return wrapError(ErrFileAccess, fmt.Errorf("failed to sync temporary file: %w", err))
}
if err := tempFile.Close(); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to close temporary file: %w", err))
}
if err := os.Chmod(tempPath, 0644); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to set permissions: %w", err))
}
if err := os.Rename(tempPath, path); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to rename temporary file: %w", err))
}
return nil
}
// parseArgs processes command-line arguments into a nested map structure.
func parseArgs(args []string) (map[string]any, error) {
result := make(map[string]any)
@@ -612,9 +318,8 @@ func parseArgs(args []string) (map[string]any, error) {
argContent := strings.TrimPrefix(arg, "--")
if argContent == "" {
// Skip "--" argument if used as a separator
i++
continue
// Arguments after the terminator are positional.
break
}
var keyPath string
@@ -654,49 +359,11 @@ func parseArgs(args []string) (map[string]any, error) {
}
// Always store as a string. Let Scan handle final type conversion.
if len(valueStr) > MaxValueSize {
return nil, ErrValueSize
}
setNestedValue(result, keyPath, valueStr)
}
return result, nil
}
// detectFileFormat determines format from file extension
func detectFileFormat(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".toml", ".tml":
return FormatTOML
case ".json":
return FormatJSON
case ".yaml", ".yml":
return FormatYAML
case ".conf", ".config":
// Try to detect from content
return ""
default:
return ""
}
}
// detectFormatFromContent attempts to detect format by parsing
func detectFormatFromContent(data []byte) string {
// Try JSON first (strict format)
var jsonTest any
if err := json.Unmarshal(data, &jsonTest); err == nil {
return FormatJSON
}
// Try YAML (superset of JSON, so check after JSON)
var yamlTest any
if err := yaml.Unmarshal(data, &yamlTest); err == nil {
return FormatYAML
}
// Try TOML last
var tomlTest any
if err := toml.Unmarshal(data, &tomlTest); err == nil {
return FormatTOML
}
return ""
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !unix
package config
import (
"fmt"
"os"
)
func checkFileOwner(os.FileInfo) error {
return wrapError(ErrFileAccess, fmt.Errorf("file ownership checks are unsupported on this platform"))
}
+34
View File
@@ -0,0 +1,34 @@
package config
import "testing"
func TestOptionsAreOwned(t *testing.T) {
opts := DefaultLoadOptions()
opts.EnvWhitelist = map[string]bool{"value": true}
c := NewWithOptions(opts)
opts.Sources[0] = SourceFile
opts.EnvWhitelist["value"] = false
if c.GetPrecedence()[0] != SourceCLI || !c.options.EnvWhitelist["value"] {
t.Fatal("options retained caller-owned containers")
}
c.SetLoadOptions(LoadOptions{})
if err := c.Register("value", 0); err != nil {
t.Fatal(err)
}
if err := c.Set("value", 1); err != nil {
t.Fatal(err)
}
}
func TestExportEnvContainerValues(t *testing.T) {
c := New()
if err := c.Register("items", []string{"old"}); err != nil {
t.Fatal(err)
}
if err := c.Set("items", []string{"new"}); err != nil {
t.Fatal(err)
}
if got := c.ExportEnv("APP_")["APP_ITEMS"]; got == "" {
t.Fatal("changed slice was not exported")
}
}
+17
View File
@@ -0,0 +1,17 @@
//go:build unix
package config
import (
"fmt"
"os"
"syscall"
)
func checkFileOwner(info os.FileInfo) error {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Uid != uint32(os.Geteuid()) {
return wrapError(ErrFileAccess, fmt.Errorf("config file is not owned by current user"))
}
return nil
}
+130 -35
View File
@@ -13,30 +13,71 @@ import (
// Each segment of the path must be a valid TOML key identifier
// defaultValue is the value returned by Get if no specific value has been set
func (c *Config) Register(path string, defaultValue any) error {
if err := validatePath(path); err != nil {
return err
}
owned, err := copyValue(defaultValue)
if err != nil {
return err
}
c.mutex.Lock()
defer c.mutex.Unlock()
return c.registerLocked(path, owned)
}
func validatePath(path string) error {
if path == "" {
return wrapError(ErrInvalidPath, fmt.Errorf("registration path cannot be empty"))
}
// Validate path segments
segments := strings.Split(path, ".")
for _, segment := range segments {
if !isValidKeySegment(segment) {
return wrapError(ErrInvalidPath, fmt.Errorf("invalid path segment %q in path %q", segment, path))
parts := strings.Split(path, ".")
if len(parts) > maxValueDepth {
return wrapError(ErrInvalidPath, fmt.Errorf("path nesting exceeds %d", maxValueDepth))
}
for _, part := range parts {
if !isValidKeySegment(part) {
return wrapError(ErrInvalidPath, fmt.Errorf("invalid path segment %q", part))
}
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.items[path] = configItem{
defaultValue: defaultValue,
currentValue: defaultValue, // Initially set to default
values: make(map[Source]any),
}
return nil
}
func (c *Config) registerLocked(path string, value any) error {
for existing := range c.items {
if existing != path && (strings.HasPrefix(existing, path+".") || strings.HasPrefix(path, existing+".")) {
return wrapError(ErrInvalidPath, fmt.Errorf("overlapping registration %q and %q", path, existing))
}
}
item, exists := c.items[path]
if !exists {
item.values = make(map[Source]any)
}
item.defaultValue = value
for _, v := range item.values {
if err := validateValue(item, v); err != nil {
return err
}
}
item.currentValue = c.computeValue(item)
c.items[path] = item
c.invalidateCache()
return nil
}
func validateValue(item configItem, value any) error {
if item.defaultValue == nil || value == nil {
return nil
}
target := reflect.New(reflect.TypeOf(item.defaultValue)).Interface()
if err := decodeConfig(value, target); err != nil {
return wrapError(ErrTypeMismatch, err)
}
return nil
}
func validSource(source Source) bool {
return source == SourceDefault || source == SourceFile || source == SourceEnv || source == SourceCLI
}
// RegisterWithEnv registers a path with an explicit environment variable mapping
func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) error {
if err := c.Register(path, defaultValue); err != nil {
@@ -45,8 +86,7 @@ func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) e
// Check if the environment variable exists and load it
if value, exists := os.LookupEnv(envVar); exists {
parsed := parseValue(value)
return c.SetSource(SourceEnv, path, parsed) // Already wrapped with error category in SetSource
return c.SetSource(SourceEnv, path, value) // Already wrapped with error category in SetSource
}
return nil
@@ -55,9 +95,22 @@ func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) e
// RegisterRequired registers a path and marks it as required
// The configuration will fail validation if this value is not provided
func (c *Config) RegisterRequired(path string, defaultValue any) error {
// For now, just register normally
// The required paths will be tracked separately in a future enhancement
return c.Register(path, defaultValue)
if err := validatePath(path); err != nil {
return err
}
owned, err := copyValue(defaultValue)
if err != nil {
return err
}
c.mutex.Lock()
defer c.mutex.Unlock()
if err := c.registerLocked(path, owned); err != nil {
return err
}
item := c.items[path]
item.required = true
c.items[path] = item
return nil
}
// Unregister removes a configuration path and all its children
@@ -84,12 +137,20 @@ func (c *Config) Unregister(path string) error {
// Remove the path itself if it exists
delete(c.items, path)
delete(c.fileData, path)
delete(c.envData, path)
delete(c.cliData, path)
c.invalidateCache()
// Remove any child paths
prefix := path + "."
for childPath := range c.items {
if strings.HasPrefix(childPath, prefix) {
delete(c.items, childPath)
delete(c.fileData, childPath)
delete(c.envData, childPath)
delete(c.cliData, childPath)
}
}
@@ -121,26 +182,60 @@ func (c *Config) RegisterStructWithTags(prefix string, structWithDefaults any, t
// Validate tag name
switch tagName {
case FormatTOML, FormatJSON, FormatYAML:
case FormatTOML:
// Supported tags
default:
return wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName))
return wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml", tagName))
}
var errors []string
// Use helper function for recursive registration with specified tag
c.registerFields(v, prefix, "", &errors, tagName)
// Stage registrations and explicit env values so failures do not leave a partial schema.
staged := New()
staged.registerFields(v, prefix, "", &errors, tagName)
if len(errors) > 0 {
return wrapError(ErrTypeMismatch, fmt.Errorf("failed to register %d field(s): %s", len(errors), strings.Join(errors, "; ")))
}
c.mutex.Lock()
defer c.mutex.Unlock()
for path, item := range staged.items {
for existing := range c.items {
if existing != path && (strings.HasPrefix(existing, path+".") || strings.HasPrefix(path, existing+".")) {
return wrapError(ErrInvalidPath, fmt.Errorf("overlapping registration %q and %q", path, existing))
}
}
if old, ok := c.items[path]; ok {
for _, value := range old.values {
if err := validateValue(item, value); err != nil {
return err
}
}
}
}
for path, item := range staged.items {
if old, ok := c.items[path]; ok {
for source, value := range old.values {
if _, provided := item.values[source]; !provided {
item.values[source] = value
}
}
}
item.currentValue = c.computeValue(item)
c.items[path] = item
}
c.invalidateCache()
return nil
}
// registerFields is a helper function that handles the recursive field registration
func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, errors *[]string, tagName string) {
if strings.Count(pathPrefix, ".") > maxValueDepth {
*errors = append(*errors, "recursive struct exceeds maximum depth")
return
}
t := v.Type()
for i := 0; i < v.NumField(); i++ {
@@ -153,7 +248,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
// Get tag value based on tagName parameter
tag := field.Tag.Get(tagName)
if tag == "-" {
if strings.Split(tag, ",")[0] == "-" {
continue
}
@@ -187,22 +282,19 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
if isStruct || isPtrToStruct {
// Check if the field's TYPE is one that should be treated as a single value,
// even though it's a struct. These types have custom decode hooks
fieldType := fieldValue.Type()
isAtomicStruct := false
switch fieldType.String() {
case "time.Time", "*net.IPNet", "*url.URL", "net.IP": // Match the exact type names
isAtomicStruct = true
}
isAtomicStruct := atomicType(fieldValue.Type())
// Only recurse if it's a "normal" struct, not an atomic one
if !isAtomicStruct {
nestedValue := fieldValue
if isPtrToStruct {
if fieldValue.IsNil() {
continue // Skip nil pointers in the default struct
nestedValue = reflect.New(fieldType.Elem()).Elem()
}
if !fieldValue.IsNil() {
nestedValue = fieldValue.Elem()
}
}
nestedPrefix := currentPath + "."
c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors, tagName)
@@ -212,6 +304,10 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
}
// Register non-struct fields
if _, exists := c.items[currentPath]; exists {
*errors = append(*errors, fmt.Sprintf("duplicate TOML path %q", currentPath))
continue
}
defaultValue := fieldValue.Interface()
var err error
@@ -228,8 +324,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
// Handle explicit env tag
if envTag != "" && err == nil {
if value, exists := os.LookupEnv(envTag); exists {
parsed := parseValue(value)
if setErr := c.SetSource(SourceEnv, currentPath, parsed); setErr != nil {
if setErr := c.SetSource(SourceEnv, currentPath, value); setErr != nil {
*errors = append(*errors, fmt.Sprintf("field %s%s env %s: %v", fieldPath, field.Name, envTag, setErr))
}
}
@@ -270,7 +365,7 @@ func (c *Config) GetRegisteredPathsWithDefaults(prefix ...string) map[string]any
result := make(map[string]any)
for path, item := range c.items {
if strings.HasPrefix(path, p) {
result[path] = item.defaultValue
result[path] = cloneOwned(item.defaultValue)
}
}
+76 -137
View File
@@ -8,10 +8,6 @@ import (
"os"
"reflect"
"strings"
"time"
"github.com/BurntSushi/toml"
"github.com/go-viper/mapstructure/v2"
)
// Quick creates a fully configured Config instance with a single call
@@ -89,27 +85,21 @@ func (c *Config) GenerateFlags() *flag.FlagSet {
// BindFlags updates configuration from parsed flag.FlagSet
func (c *Config) BindFlags(fs *flag.FlagSet) error {
var errors []error
needsInvalidation := false
fs.Visit(func(f *flag.Flag) {
value := f.Value.String()
// Let mapstructure handle type conversion
if err := c.SetSource(SourceCLI, f.Name, value); err != nil {
errors = append(errors, fmt.Errorf("flag %s: %w", f.Name, err))
} else {
needsInvalidation = true
if fs == nil {
return wrapError(ErrCLIParse, fmt.Errorf("nil flag set"))
}
})
if needsInvalidation {
c.invalidateCache() // Batch invalidation after all flags
values := make(map[string]any)
fs.Visit(func(f *flag.Flag) { values[f.Name] = f.Value.String() })
c.mutex.Lock()
defer c.mutex.Unlock()
for path := range values {
if _, ok := c.items[path]; !ok {
return wrapError(ErrCLIParse, fmt.Errorf("failed to bind unregistered flag %q: %w", path, ErrPathNotRegistered))
}
if len(errors) > 0 {
return wrapError(ErrCLIParse, fmt.Errorf("failed to bind %d flags: %w", len(errors), errors[0]))
}
if err := c.replaceSourceLocked(SourceCLI, values); err != nil {
return wrapError(ErrCLIParse, err)
}
return nil
}
@@ -120,6 +110,13 @@ func (c *Config) Validate(required ...string) error {
defer c.mutex.RUnlock()
var missing []string
if len(required) == 0 {
for path, item := range c.items {
if item.required {
required = append(required, path)
}
}
}
for _, path := range required {
item, exists := c.items[path]
@@ -132,7 +129,11 @@ func (c *Config) Validate(required ...string) error {
if reflect.DeepEqual(item.currentValue, item.defaultValue) {
// Check if any source provided a value
hasValue := false
for _, val := range item.values {
for _, source := range c.options.Sources {
if source == SourceDefault {
break
}
val := item.values[source]
if val != nil {
hasValue = true
break
@@ -177,59 +178,50 @@ func (c *Config) Debug() string {
// Dump writes the current configuration to stdout in TOML format
func (c *Config) Dump() error {
c.mutex.RLock()
defer c.mutex.RUnlock()
nestedData := c.nestedLocked("")
c.mutex.RUnlock()
nestedData := make(map[string]any)
for path, item := range c.items {
setNestedValue(nestedData, path, item.currentValue)
data, err := marshalConfig(nestedData)
if err != nil {
return err
}
encoder := toml.NewEncoder(os.Stdout)
if err := encoder.Encode(nestedData); err != nil {
return wrapError(ErrDecode, err)
}
return nil
_, err = os.Stdout.Write(data)
return err
}
// Clone creates a deep copy of the configuration
func (c *Config) Clone() *Config {
c.mutex.RLock()
defer c.mutex.RUnlock()
clone := &Config{
items: make(map[string]configItem),
options: c.options,
fileData: make(map[string]any),
envData: make(map[string]any),
cliData: make(map[string]any),
clone := NewWithOptions(c.options)
clone.tagName, clone.fileFormat = c.tagName, c.fileFormat
clone.configFilePath = c.configFilePath
clone.fileComments = append([]string(nil), c.fileComments...)
clone.unknownCLIKeys = append([]string(nil), c.unknownCLIKeys...)
if c.securityOpts != nil {
opts := *c.securityOpts
clone.securityOpts = &opts
}
// Deep copy items
for path, item := range c.items {
newItem := configItem{
defaultValue: item.defaultValue,
currentValue: item.currentValue,
values: make(map[Source]any),
}
next := configItem{defaultValue: cloneOwned(item.defaultValue), currentValue: cloneOwned(item.currentValue), values: make(map[Source]any)}
for source, value := range item.values {
newItem.values[source] = value
next.values[source] = cloneOwned(value)
}
clone.items[path] = newItem
next.required = item.required
clone.items[path] = next
}
// Copy cache data
for k, v := range c.fileData {
clone.fileData[k] = v
clone.fileData[k] = cloneOwned(v)
}
for k, v := range c.envData {
clone.envData[k] = v
clone.envData[k] = cloneOwned(v)
}
for k, v := range c.cliData {
clone.cliData[k] = v
clone.cliData[k] = cloneOwned(v)
}
if c.structCache != nil {
clone.structCache = &structCache{targetType: c.structCache.targetType, prefix: c.structCache.prefix}
}
return clone
}
@@ -253,62 +245,42 @@ func GetTyped[T any](c *Config, path string) (T, error) {
return zero, wrapError(ErrPathNotFound, fmt.Errorf("path %q not found", path))
}
// Prepare the input map and target struct for the decoder
inputMap := map[string]any{"value": rawValue}
var target struct {
Value T `mapstructure:"value"`
var target T
if err := decodeConfig(rawValue, &target); err != nil {
return zero, err
}
// Create a new decoder configured with the same hooks as the main config
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: &target,
TagName: c.tagName,
WeaklyTypedInput: true,
DecodeHook: c.getDecodeHook(),
Metadata: nil,
})
if err != nil {
return zero, wrapError(ErrDecode, fmt.Errorf("failed to create decoder for path %q: %w", path, err))
}
// Decode the single value.
if err := decoder.Decode(inputMap); err != nil {
return zero, wrapError(ErrDecode, fmt.Errorf("failed to decode value for path %q into type %T: %w", path, zero, err))
}
return target.Value, nil
return target, nil
}
// GetTypedWithDefault retrieves a configuration value with a default fallback
// If the path doesn't exist or isn't set, it sets and returns the default value
// For simple cases where explicit defaults aren't pre-registered
func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) {
// Check if path exists and has a value
if _, exists := c.Get(path); exists {
// Path exists, try to decode the current value
result, err := GetTyped[T](c, path)
if err == nil {
c.mutex.Lock()
if item, exists := c.items[path]; exists {
raw := item.currentValue
c.mutex.Unlock()
var result T
if err := decodeConfig(raw, &result); err != nil {
return result, err
}
return result, nil
}
// Type conversion failed, fall through to set default
if err := validatePath(path); err != nil {
c.mutex.Unlock()
return defaultValue, err
}
// Path doesn't exist or value not set - register and set default
// This handles the case where the path wasn't pre-registered
if err := c.Register(path, defaultValue); err != nil {
// Path might already be registered with incompatible type
// Try to just set the value
if setErr := c.Set(path, defaultValue); setErr != nil {
return defaultValue, wrapError(ErrPathNotRegistered, fmt.Errorf("%w : failed to register or set default for path %q", ErrPathNotRegistered, path))
owned, err := copyValue(defaultValue)
if err == nil {
err = c.registerLocked(path, owned)
}
c.mutex.Unlock()
if err != nil {
return defaultValue, err
}
// Set the default value
if err := c.Set(path, defaultValue); err != nil {
return defaultValue, wrapError(ErrTypeMismatch, fmt.Errorf("%w : failed to set default value for path %q", ErrTypeMismatch, path))
}
return defaultValue, nil
var result T
err = decodeConfig(owned, &result)
return result, err
}
// ScanTyped is a generic wrapper around Scan. It allocates a new instance of type T,
@@ -325,41 +297,8 @@ func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
// without requiring a full Config instance. This is useful for plugin
// initialization where config data arrives as a map[string]any.
func ScanMap(configMap map[string]any, target any, tagName ...string) error {
// Handle nil map
if configMap == nil {
configMap = make(map[string]any)
if len(tagName) > 1 || len(tagName) == 1 && tagName[0] != "" && tagName[0] != FormatTOML {
return wrapError(ErrTypeMismatch, fmt.Errorf("only toml tags are supported"))
}
// Determine tag name
tag := "toml" // default
if len(tagName) > 0 && tagName[0] != "" {
tag = tagName[0]
}
// Create decoder with standard hooks
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: target,
TagName: tag,
WeaklyTypedInput: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
jsonNumberHookFunc(),
stringToNetIPHookFunc(),
stringToNetIPNetHookFunc(),
stringToURLHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
),
ZeroFields: true,
})
if err != nil {
return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err))
}
// Decode directly
if err := decoder.Decode(configMap); err != nil {
return wrapError(ErrDecode, fmt.Errorf("decode failed: %w", err))
}
return nil
return decodeConfig(configMap, target)
}
+5 -20
View File
@@ -162,7 +162,7 @@ func TestFlagGeneration(t *testing.T) {
err := cfg.BindFlags(fs)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to bind 1 flags")
assert.Contains(t, err.Error(), "unregistered flag")
})
}
@@ -403,24 +403,9 @@ func TestScanMap(t *testing.T) {
assert.Equal(t, "info", target.LogLevel)
})
t.Run("ScanWithJSONTags", func(t *testing.T) {
configMap := map[string]any{
"server": map[string]any{
"hostname": "json-host",
"port": 9090,
"timeout": "1m",
},
"logLevel": "debug",
}
t.Run("RejectJSONTags", func(t *testing.T) {
var target Config
err := ScanMap(configMap, &target, "json")
require.NoError(t, err)
assert.Equal(t, "json-host", target.Server.Host)
assert.Equal(t, 9090, target.Server.Port)
assert.Equal(t, 1*time.Minute, target.Server.Timeout)
assert.Equal(t, "debug", target.LogLevel)
require.Error(t, ScanMap(map[string]any{}, &target, "json"))
})
t.Run("NilMapInput", func(t *testing.T) {
@@ -463,7 +448,7 @@ func TestScanMap(t *testing.T) {
err := ScanMap(configMap, target)
assert.Error(t, err)
// The underlying mapstructure error is "result must be a pointer"
assert.Contains(t, err.Error(), "must be a pointer")
// Invalid targets return a categorized type error.
assert.Contains(t, err.Error(), "must be non-nil pointer")
})
}
+7 -6
View File
@@ -3,6 +3,7 @@ package config
import (
"fmt"
"math"
"net"
"regexp"
"strings"
@@ -19,16 +20,16 @@ func Port(p int64) error {
}
// Positive validates positive numbers
func Positive[T int64 | float64](n T) error {
if n <= 0 {
func Positive[T ~int64 | ~float64](n T) error {
if n <= 0 || math.IsNaN(float64(n)) || math.IsInf(float64(n), 0) {
return wrapError(ErrValidation, fmt.Errorf("must be positive, got %v", n))
}
return nil
}
// NonNegative validates non-negative numbers
func NonNegative[T int64 | float64](n T) error {
if n < 0 {
func NonNegative[T ~int64 | ~float64](n T) error {
if n < 0 || math.IsNaN(float64(n)) || math.IsInf(float64(n), 0) {
return wrapError(ErrValidation, fmt.Errorf("must be non-negative, got %v", n))
}
return nil
@@ -94,9 +95,9 @@ func OneOf[T comparable](allowed ...T) func(T) error {
}
// Range creates a min/max validator
func Range[T int64 | float64](min, max T) func(T) error {
func Range[T ~int64 | ~float64](min, max T) func(T) error {
return func(val T) error {
if val < min || val > max {
if min > max || math.IsNaN(float64(min)) || math.IsNaN(float64(max)) || math.IsNaN(float64(val)) || math.IsInf(float64(val), 0) || val < min || val > max {
return wrapError(ErrValidation, fmt.Errorf("must be %v-%v, got %v", min, max, val))
}
return nil
+162
View File
@@ -0,0 +1,162 @@
package config
import (
"fmt"
"math"
"net"
"net/url"
"reflect"
"time"
"unicode/utf8"
)
const maxValueDepth = 128
var (
timeType = reflect.TypeFor[time.Time]()
durationType = reflect.TypeFor[time.Duration]()
ipType = reflect.TypeFor[net.IP]()
ipNetType = reflect.TypeFor[net.IPNet]()
urlType = reflect.TypeFor[url.URL]()
userInfoType = reflect.TypeFor[url.Userinfo]()
)
func atomicType(t reflect.Type) bool {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
return t == timeType || t == ipType || t == ipNetType || t == urlType
}
// copyValue validates the supported value graph and detaches mutable containers.
// The depth bound rejects cycles without retaining a global identity cache.
func copyValue(v any) (any, error) {
if v == nil {
return nil, nil
}
out, err := copyReflect(reflect.ValueOf(v), 0)
if err != nil {
return nil, wrapError(ErrTypeMismatch, err)
}
return out.Interface(), nil
}
func copyReflect(v reflect.Value, depth int) (reflect.Value, error) {
if depth > maxValueDepth {
return reflect.Value{}, fmt.Errorf("value nesting exceeds %d (possible cycle)", maxValueDepth)
}
if v.Type() == ipType && v.Len() != 0 && v.Interface().(net.IP).To16() == nil {
return reflect.Value{}, fmt.Errorf("invalid IP value")
}
if v.Type() == ipNetType {
n := v.Interface().(net.IPNet)
_, bits := n.Mask.Size()
if (len(n.IP) != 0 || len(n.Mask) != 0) && (n.IP.To16() == nil || bits == 0 || bits == 32 && n.IP.To4() == nil) {
return reflect.Value{}, fmt.Errorf("invalid CIDR value")
}
}
if v.Type() == timeType {
if _, err := v.Interface().(time.Time).MarshalText(); err != nil {
return reflect.Value{}, err
}
}
out := reflect.New(v.Type()).Elem()
switch v.Kind() {
case reflect.Interface, reflect.Pointer:
if v.IsNil() {
return out, nil
}
elem, err := copyReflect(v.Elem(), depth+1)
if err != nil {
return reflect.Value{}, err
}
if v.Kind() == reflect.Pointer {
out.Set(reflect.New(v.Type().Elem()))
out.Elem().Set(elem)
} else {
out.Set(elem)
}
case reflect.Map:
if v.Type().Key().Kind() != reflect.String {
return reflect.Value{}, fmt.Errorf("map keys must be strings")
}
if v.IsNil() {
return out, nil
}
out.Set(reflect.MakeMapWithSize(v.Type(), v.Len()))
it := v.MapRange()
for it.Next() {
if !utf8.ValidString(it.Key().String()) {
return reflect.Value{}, fmt.Errorf("invalid UTF-8 map key")
}
x, err := copyReflect(it.Value(), depth+1)
if err != nil {
return reflect.Value{}, err
}
out.SetMapIndex(it.Key(), x)
}
case reflect.Slice, reflect.Array:
if v.Kind() == reflect.Slice {
if v.IsNil() {
return out, nil
}
out.Set(reflect.MakeSlice(v.Type(), v.Len(), v.Len()))
}
for i := 0; i < v.Len(); i++ {
x, err := copyReflect(v.Index(i), depth+1)
if err != nil {
return reflect.Value{}, err
}
out.Index(i).Set(x)
}
case reflect.Struct:
// These standard-library values contain immutable private state.
if v.Type() == timeType || v.Type() == userInfoType {
out.Set(v)
return out, nil
}
exported := false
for i := 0; i < v.NumField(); i++ {
if !v.Type().Field(i).IsExported() {
continue
}
exported = true
x, err := copyReflect(v.Field(i), depth+1)
if err != nil {
return reflect.Value{}, err
}
out.Field(i).Set(x)
}
if !exported && v.NumField() != 0 {
return reflect.Value{}, fmt.Errorf("unsupported private state in %s", v.Type())
}
case reflect.String:
if len(v.String()) > MaxValueSize {
return reflect.Value{}, ErrValueSize
}
if !utf8.ValidString(v.String()) {
return reflect.Value{}, fmt.Errorf("invalid UTF-8 string")
}
out.Set(v)
case reflect.Float32, reflect.Float64:
if math.IsNaN(v.Float()) || math.IsInf(v.Float(), 0) {
return reflect.Value{}, fmt.Errorf("non-finite float")
}
out.Set(v)
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
out.Set(v)
default:
return reflect.Value{}, fmt.Errorf("unsupported value type %s", v.Type())
}
return out, nil
}
// cloneOwned is used only on values already accepted by copyValue.
func cloneOwned(v any) any {
out, err := copyValue(v)
if err != nil {
panic("config: invalid internal value: " + err.Error())
}
return out
}
+164 -299
View File
@@ -1,240 +1,146 @@
// FILE: lixenwraith/config/watch.go
package config
import (
"context"
"errors"
"fmt"
"os"
"reflect"
"slices"
"sync"
"sync/atomic"
"time"
)
// WatchOptions configures file watching behavior
type WatchOptions struct {
// PollInterval for file stat checks (minimum 100ms)
PollInterval time.Duration
// Debounce duration to avoid rapid reloads
Debounce time.Duration
// MaxWatchers limits concurrent watch channels
PollInterval time.Duration // Minimum 100ms; each poll reads and fingerprints the file.
Debounce time.Duration // Quiet interval before publishing a value or error.
MaxWatchers int
// ReloadTimeout for file reload operations
ReloadTimeout time.Duration
// VerifyPermissions checks file hasn't been replaced with different permissions
ReloadTimeout time.Duration // Expired reads/parses cannot publish.
VerifyPermissions bool
}
// DefaultWatchOptions returns sensible defaults for file watching
func DefaultWatchOptions() WatchOptions {
return WatchOptions{
PollInterval: DefaultPollInterval,
Debounce: DefaultDebounce,
MaxWatchers: DefaultMaxWatchers,
ReloadTimeout: DefaultReloadTimeout,
VerifyPermissions: true,
}
return WatchOptions{DefaultPollInterval, DefaultDebounce, DefaultMaxWatchers, DefaultReloadTimeout, true}
}
// watcher manages file watching state
type watcher struct {
mu sync.RWMutex
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
opts WatchOptions
filePath string
lastModTime time.Time
lastSize int64
lastMode os.FileMode
watching atomic.Bool
reloadInProgress atomic.Bool
watchers map[int64]chan string // subscriber channels
watcherID atomic.Int64
debounceTimer *time.Timer
watchers map[int64]chan string
watcherID int64
observed string
handled string
timeoutKey string
observedAt time.Time
read func(context.Context, string, fileSettings) (*fileSnapshot, error)
}
// configWatcher extends Config with watching capabilities
type configWatcher struct {
*Config
watcher *watcher
}
// AutoUpdate enables automatic configuration reloading when the file changes
func (c *Config) AutoUpdate() {
c.AutoUpdateWithOptions(DefaultWatchOptions())
}
// AutoUpdateWithOptions enables automatic configuration reloading with custom options
func (c *Config) AutoUpdateWithOptions(opts WatchOptions) {
// Validate options
func normalizeWatchOptions(opts WatchOptions) WatchOptions {
if opts.PollInterval < MinPollInterval {
opts.PollInterval = MinPollInterval
}
if opts.Debounce < 0 {
opts.Debounce = 0
}
if opts.MaxWatchers <= 0 {
opts.MaxWatchers = 100
opts.MaxWatchers = DefaultMaxWatchers
}
if opts.ReloadTimeout <= 0 {
opts.ReloadTimeout = DefaultReloadTimeout
}
return opts
}
func (c *Config) AutoUpdate() { c.AutoUpdateWithOptions(DefaultWatchOptions()) }
func (c *Config) AutoUpdateWithOptions(opts WatchOptions) {
c.mutex.Lock()
defer c.mutex.Unlock()
// Get path of current file to watch
filePath := c.getConfigFilePath()
if filePath == "" {
// No file configured, nothing to watch
c.startWatcherLocked(opts)
}
func (c *Config) startWatcherLocked(opts WatchOptions) {
if c.configFilePath == "" {
return
}
if c.watcher != nil {
if c.watcher.filePath == c.configFilePath && c.watcher.watching.Load() {
return
}
// Stop existing watcher if path changed
if c.watcher != nil && c.watcher.filePath != filePath {
c.watcher.stop()
c.watcher = nil
}
// Initialize watcher if needed
if c.watcher == nil {
ctx, cancel := context.WithCancel(context.Background())
c.watcher = &watcher{
ctx: ctx,
cancel: cancel,
opts: opts,
filePath: filePath,
watchers: make(map[int64]chan string),
w := &watcher{ctx: ctx, cancel: cancel, opts: normalizeWatchOptions(opts), filePath: c.configFilePath, watchers: make(map[int64]chan string), read: readConfigFile}
if info, err := os.Stat(w.filePath); err == nil {
w.lastMode = info.Mode()
}
w.watching.Store(true)
c.watcher = w
go w.watchLoop(c)
}
// Get initial file state
if info, err := os.Stat(filePath); err == nil {
c.watcher.lastModTime = info.ModTime()
c.watcher.lastSize = info.Size()
c.watcher.lastMode = info.Mode()
}
// Start watching
go c.watcher.watchLoop(c)
}
}
// StopAutoUpdate stops automatic configuration reloading
func (c *Config) StopAutoUpdate() {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.watcher != nil {
c.watcher.stop()
c.watcher = nil
}
}
// Watch returns a channel that receives paths of changed configuration values
func (c *Config) Watch() <-chan string {
return c.WatchWithOptions(DefaultWatchOptions())
func (c *Config) Watch() <-chan string { return c.WatchWithOptions(DefaultWatchOptions()) }
func (c *Config) WatchWithOptions(opts WatchOptions) <-chan string {
c.mutex.Lock()
defer c.mutex.Unlock()
c.startWatcherLocked(opts)
if c.watcher == nil {
return closedWatchChannel()
}
// WatchFile stops any existing file watcher, loads a new configuration file,
// and starts a new watcher on that file path. Optionally accepts format hint
func (c *Config) WatchFile(filePath string, formatHint ...string) error {
// Stop any currently running watcher
c.StopAutoUpdate()
// Set format hint if provided
if len(formatHint) > 0 {
return c.watcher.subscribe()
}
func (c *Config) WatchFile(path string, formatHint ...string) error {
if len(formatHint) > 1 {
return ErrFileFormat
}
if len(formatHint) == 1 {
if err := c.SetFileFormat(formatHint[0]); err != nil {
return wrapError(ErrFileFormat, fmt.Errorf("invalid format hint: %w", err))
return err
}
}
// Load the new file
if err := c.LoadFile(filePath); err != nil {
return fmt.Errorf("failed to load new file for watching: %w", err) // Already wrapped with error category in LoadFile
}
// Get previous watcher options if available
c.mutex.RLock()
opts := DefaultWatchOptions()
if c.watcher != nil {
opts = c.watcher.opts
}
c.mutex.RUnlock()
// Create and start a new watcher with the new file path
// A failed load leaves the existing watcher and valid state intact.
if err := c.LoadFile(path); err != nil {
return err
}
c.AutoUpdateWithOptions(opts)
return nil
}
// WatchWithOptions returns a channel with custom watch options
// should not restart the watcher if it's already running with the same file
func (c *Config) WatchWithOptions(opts WatchOptions) <-chan string {
c.mutex.RLock()
watcher := c.watcher
filePath := c.configFilePath
c.mutex.RUnlock()
// If no file configured, return closed channel
if filePath == "" {
ch := make(chan string)
close(ch)
return ch
}
// If watcher exists and is watching the current file, just subscribe
if watcher != nil && watcher.filePath == filePath && watcher.watching.Load() {
return watcher.subscribe()
}
// First ensure auto-update is running
c.AutoUpdateWithOptions(opts)
c.mutex.RLock()
watcher = c.watcher
c.mutex.RUnlock()
if watcher == nil {
// No file to watch, return closed channel
ch := make(chan string)
close(ch)
return ch
}
return watcher.subscribe()
}
// IsWatching returns true if auto-update is enabled
func (c *Config) IsWatching() bool {
c.mutex.RLock()
defer c.mutex.RUnlock()
return c.watcher != nil && c.watcher.watching.Load()
}
// WatcherCount returns the number of active watch channels
func (c *Config) WatcherCount() int {
c.mutex.RLock()
defer c.mutex.RUnlock()
if c.watcher == nil {
return 0
}
c.watcher.mu.RLock()
defer c.watcher.mu.RUnlock()
c.watcher.mu.Lock()
defer c.watcher.mu.Unlock()
return len(c.watcher.watchers)
}
// watchLoop is the main file watching loop
func (w *watcher) watchLoop(c *Config) {
if !w.watching.CompareAndSwap(false, true) {
return // Already watching
}
defer w.watching.Store(false)
ticker := time.NewTicker(w.opts.PollInterval)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
@@ -245,188 +151,147 @@ func (w *watcher) watchLoop(c *Config) {
}
}
// checkAndReload checks if file changed and triggers reload
// One loop owns observations and debounce state. I/O runs outside the Config lock;
// publication checks context, watcher identity and file generation under that lock.
func (w *watcher) checkAndReload(c *Config) {
info, err := os.Stat(w.filePath)
if err != nil {
if os.IsNotExist(err) {
// File was deleted, notify watchers
w.notifyWatchers(EventFileDeleted)
}
if w.ctx.Err() != nil {
return
}
// Check for changes
changed := false
// Compare modification time and size
if !info.ModTime().Equal(w.lastModTime) || info.Size() != w.lastSize {
changed = true
}
// SECURITY: Verify permissions haven't changed suspiciously
if w.opts.VerifyPermissions && w.lastMode != 0 {
if info.Mode() != w.lastMode {
// Permission change detected
if (info.Mode() & 0077) != (w.lastMode & 0077) {
// World/group permissions changed - potential security issue
w.notifyWatchers(EventPermissionsChanged)
// Don't reload on permission change for security
return
}
}
}
if changed {
// Update tracked state
w.lastModTime = info.ModTime()
w.lastSize = info.Size()
w.lastMode = info.Mode()
// Debounce rapid changes
w.mu.Lock()
if w.debounceTimer != nil {
w.debounceTimer.Stop()
}
w.debounceTimer = time.AfterFunc(w.opts.Debounce, func() {
w.performReload(c)
})
w.mu.Unlock()
}
}
// performReload reloads the configuration file
func (w *watcher) performReload(c *Config) {
// Prevent concurrent reloads
if !w.reloadInProgress.CompareAndSwap(false, true) {
return
}
defer w.reloadInProgress.Store(false)
// Create a timeout context for reload
ctx, cancel := context.WithTimeout(w.ctx, w.opts.ReloadTimeout)
defer cancel()
// Track what changed
oldValues := c.snapshot()
// Reload file in a goroutine with timeout
done := make(chan error, 1)
go func() {
done <- c.loadFile(w.filePath)
}()
select {
case err := <-done:
if err != nil {
// Reload failed, notify error
w.notifyWatchers(fmt.Sprintf("%s:%v", EventReloadError, err))
c.mutex.RLock()
opts := c.fileSettingsLocked()
generation := c.fileGeneration
c.mutex.RUnlock()
snapshot, readErr := w.read(ctx, w.filePath, opts)
key := ""
event := ""
if readErr != nil {
key = "error:" + readErr.Error()
switch {
case errors.Is(readErr, context.DeadlineExceeded):
event = EventReloadTimeout
case errors.Is(readErr, ErrConfigNotFound):
event = EventFileDeleted
default:
event = EventReloadError + ":" + readErr.Error()
}
} else {
key = fmt.Sprintf("%x:%o", snapshot.digest, snapshot.info.Mode())
if w.opts.VerifyPermissions && w.lastMode != 0 && (snapshot.info.Mode()&0077) != (w.lastMode&0077) {
event = EventPermissionsChanged
}
}
if key != w.observed {
w.observed, w.observedAt = key, time.Now()
}
if key == w.handled || time.Since(w.observedAt) < w.opts.Debounce {
return
}
// Compare and notify changes
newValues := c.snapshot()
for path, newVal := range newValues {
if oldVal, existed := oldValues[path]; !existed || !reflect.DeepEqual(oldVal, newVal) {
if w.ctx.Err() != nil {
return
}
if event != "" {
if event == EventReloadTimeout {
w.notifyTimeout(key)
} else {
w.notifyWatchers(event)
w.handled = key
}
return
}
root, comments, err := parseFile(snapshot)
if err != nil {
w.notifyWatchers(EventReloadError + ":" + err.Error())
w.handled = key
return
}
c.mutex.Lock()
defer c.mutex.Unlock()
if c.watcher != w || c.configFilePath != w.filePath || c.fileGeneration != generation || w.ctx.Err() != nil {
return
}
if ctx.Err() != nil {
w.notifyTimeout(key)
return
}
old := make(map[string]any, len(c.items))
for path, item := range c.items {
old[path] = item.currentValue
}
if err := c.applyFileLocked(root, comments, w.filePath, ctx.Err); err != nil {
if errors.Is(err, context.DeadlineExceeded) {
w.notifyTimeout(key)
} else {
w.notifyWatchers(EventReloadError + ":" + err.Error())
w.handled = key
}
return
}
w.lastMode, w.handled = snapshot.info.Mode(), key
w.timeoutKey = ""
var changed []string
for path, item := range c.items {
if !reflect.DeepEqual(old[path], item.currentValue) {
changed = append(changed, path)
}
}
slices.Sort(changed)
for _, path := range changed {
w.notifyWatchers(path)
}
}
// Check for deletions
for path := range oldValues {
if _, exists := newValues[path]; !exists {
w.notifyWatchers(path)
}
}
case <-ctx.Done():
// Reload timeout
// A timeout is retryable even when the file contents have not changed. Suppress
// repeated notifications for the same observation without suppressing its retry.
func (w *watcher) notifyTimeout(key string) {
if w.timeoutKey != key {
w.notifyWatchers(EventReloadTimeout)
w.timeoutKey = key
}
}
// subscribe creates a new watcher channel
func closedWatchChannel() <-chan string { ch := make(chan string); close(ch); return ch }
func (w *watcher) subscribe() <-chan string {
w.mu.Lock()
defer w.mu.Unlock()
// Check watcher limit
if len(w.watchers) >= w.opts.MaxWatchers {
// Return closed channel to prevent resource exhaustion
ch := make(chan string)
close(ch)
return ch
if w.ctx.Err() != nil || len(w.watchers) >= w.opts.MaxWatchers {
return closedWatchChannel()
}
// Create buffered channel to prevent blocking
ch := make(chan string, WatchChannelBuffer)
id := w.watcherID.Add(1)
w.watchers[id] = ch
// Cleanup goroutine
go func() {
<-w.ctx.Done()
w.mu.Lock()
delete(w.watchers, id)
close(ch)
w.mu.Unlock()
}()
w.watcherID++
w.watchers[w.watcherID] = ch
return ch
}
// notifyWatchers sends change notification to all subscribers
func (w *watcher) notifyWatchers(path string) {
w.mu.RLock()
defer w.mu.RUnlock()
for id, ch := range w.watchers {
w.mu.Lock()
defer w.mu.Unlock()
if w.ctx.Err() != nil {
return
}
for _, ch := range w.watchers {
select {
case ch <- path:
// Sent successfully
default:
// Channel full or closed, skip
// Could implement removal of dead watchers here
_ = id
}
}
}
// stop terminates the watcher
func (w *watcher) stop() {
if w.cancel != nil {
w.cancel()
}
// Stop debounce timer
w.mu.Lock()
if w.debounceTimer != nil {
w.debounceTimer.Stop()
w.debounceTimer = nil
}
w.mu.Unlock()
// Wait for watch loop to exit with timeout
deadline := time.Now().Add(ShutdownTimeout)
for w.watching.Load() && time.Now().Before(deadline) {
time.Sleep(SpinWaitInterval)
defer w.mu.Unlock()
w.cancel()
w.watching.Store(false)
for id, ch := range w.watchers {
close(ch)
delete(w.watchers, id)
}
}
// getConfigFilePath returns the current config file path
func (c *Config) getConfigFilePath() string {
// Access the tracked config file path
return c.configFilePath
}
// snapshot creates a snapshot of current values
func (c *Config) snapshot() map[string]any {
c.mutex.RLock()
defer c.mutex.RUnlock()
snapshot := make(map[string]any, len(c.items))
out := make(map[string]any, len(c.items))
for path, item := range c.items {
snapshot[path] = item.currentValue
out[path] = cloneOwned(item.currentValue)
}
return snapshot
return out
}
+258
View File
@@ -0,0 +1,258 @@
package config
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func manualWatcher(t *testing.T, c *Config, path string) *watcher {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
w := &watcher{ctx: ctx, cancel: cancel, opts: WatchOptions{ReloadTimeout: time.Second, MaxWatchers: 10}, filePath: path, watchers: make(map[int64]chan string), read: readConfigFile}
w.watching.Store(true)
c.mutex.Lock()
c.watcher = w
c.mutex.Unlock()
t.Cleanup(c.StopAutoUpdate)
return w
}
func watcherFixture(t *testing.T) (*Config, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.toml")
if err := os.WriteFile(path, []byte("value=1\n"), 0600); err != nil {
t.Fatal(err)
}
c := New()
if err := c.Register("value", uint8(0)); err != nil {
t.Fatal(err)
}
if err := c.LoadFile(path); err != nil {
t.Fatal(err)
}
return c, path
}
func TestReloadCannotPublishAfterStopTimeoutOrNewLoad(t *testing.T) {
for _, mode := range []string{"stop", "timeout", "new-load"} {
t.Run(mode, func(t *testing.T) {
c, path := watcherFixture(t)
w := manualWatcher(t, c, path)
ch := w.subscribe()
if err := os.WriteFile(path, []byte("value=2\n"), 0600); err != nil {
t.Fatal(err)
}
staged, err := readConfigFile(context.Background(), path, fileSettings{})
if err != nil {
t.Fatal(err)
}
w.read = func(ctx context.Context, _ string, _ fileSettings) (*fileSnapshot, error) {
switch mode {
case "stop":
c.StopAutoUpdate()
case "timeout":
<-ctx.Done()
case "new-load":
if err := os.WriteFile(path, []byte("value=3\n"), 0600); err != nil {
t.Fatal(err)
}
if err := c.LoadFile(path); err != nil {
t.Fatal(err)
}
}
return staged, nil // Deliberately complete a stale read successfully.
}
if mode == "timeout" {
w.opts.ReloadTimeout = time.Millisecond
}
w.checkAndReload(c)
want := int64(1)
if mode == "new-load" {
want = 3
}
if value, _ := c.Get("value"); value != want {
t.Fatalf("stale publication: %v want %v", value, want)
}
if mode == "timeout" {
select {
case event := <-ch:
if event != EventReloadTimeout {
t.Fatal(event)
}
default:
t.Fatal("timeout event missing")
}
}
if mode == "stop" {
if _, ok := <-ch; ok {
t.Fatal("subscriber not closed by stop")
}
}
})
}
}
func TestWatcherDebouncesErrorsAndRecovers(t *testing.T) {
c, path := watcherFixture(t)
w := manualWatcher(t, c, path)
w.opts.Debounce = time.Hour
ch := w.subscribe()
if err := os.WriteFile(path, []byte("value="), 0600); err != nil {
t.Fatal(err)
}
w.checkAndReload(c)
select {
case event := <-ch:
t.Fatalf("transient error emitted: %s", event)
default:
}
if err := os.WriteFile(path, []byte("value=2\n"), 0600); err != nil {
t.Fatal(err)
}
w.checkAndReload(c)
w.observedAt = time.Now().Add(-2 * time.Hour)
w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(2) {
t.Fatal(value)
}
if event := <-ch; event != "value" {
t.Fatal(event)
}
// A stable invalid edit is reported once and leaves the old value intact.
_ = os.WriteFile(path, []byte("value=256\n"), 0600)
w.checkAndReload(c)
w.observedAt = time.Now().Add(-2 * time.Hour)
w.checkAndReload(c)
w.checkAndReload(c)
select {
case <-ch:
default:
t.Fatal("missing invalid edit event")
}
select {
case event := <-ch:
t.Fatalf("duplicate invalid edit: %s", event)
default:
}
if value, _ := c.Get("value"); value != int64(2) {
t.Fatal("invalid value published")
}
}
func TestWatcherRetriesTimedOutUnchangedContent(t *testing.T) {
for _, mode := range []string{"read-error", "late-result"} {
t.Run(mode, func(t *testing.T) {
c, path := watcherFixture(t)
w := manualWatcher(t, c, path)
w.opts.ReloadTimeout = 20 * time.Millisecond
ch := w.subscribe()
if err := os.WriteFile(path, []byte("value=2\n"), 0600); err != nil {
t.Fatal(err)
}
staged, err := readConfigFile(context.Background(), path, fileSettings{})
if err != nil {
t.Fatal(err)
}
attempts := 0
w.read = func(ctx context.Context, _ string, _ fileSettings) (*fileSnapshot, error) {
attempts++
if attempts <= 2 {
<-ctx.Done()
if mode == "read-error" {
return nil, ctx.Err()
}
}
return staged, nil
}
w.checkAndReload(c)
select {
case event := <-ch:
if event != EventReloadTimeout {
t.Fatal(event)
}
default:
t.Fatal("timeout event missing")
}
w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(1) {
t.Fatal("timed-out result published")
}
select {
case event := <-ch:
t.Fatalf("duplicate timeout: %s", event)
default:
}
w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(2) {
t.Fatal("unchanged content was not retried after timeout")
}
select {
case event := <-ch:
if event != "value" {
t.Fatal(event)
}
default:
t.Fatal("recovery event missing")
}
})
}
}
func TestWatcherSameMetadataReplacementAndRecreation(t *testing.T) {
c, path := watcherFixture(t)
w := manualWatcher(t, c, path)
ch := w.subscribe()
w.checkAndReload(c)
info, _ := os.Stat(path)
replacement := path + ".new"
_ = os.WriteFile(replacement, []byte("value=2\n"), 0600)
_ = os.Chtimes(replacement, info.ModTime(), info.ModTime())
if err := os.Rename(replacement, path); err != nil {
t.Fatal(err)
}
w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(2) {
t.Fatal("replacement missed")
}
if event := <-ch; event != "value" {
t.Fatal(event)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
w.checkAndReload(c)
w.checkAndReload(c)
if event := <-ch; event != EventFileDeleted {
t.Fatal(event)
}
select {
case event := <-ch:
t.Fatalf("repeated deletion: %s", event)
default:
}
_ = os.WriteFile(path, []byte("value=3\n"), 0600)
w.checkAndReload(c)
if value, _ := c.Get("value"); value != int64(3) {
t.Fatal("recreation missed")
}
c.StopAutoUpdate()
for range ch {
} // Stop closes every subscriber synchronously.
c.AutoUpdateWithOptions(WatchOptions{PollInterval: time.Hour})
if !c.IsWatching() {
t.Fatal("restart failed")
}
}
func TestBuilderDoesNotHideInvalidSourceBehindMissingFile(t *testing.T) {
var target struct {
Port uint8 `toml:"port"`
}
c, err := NewBuilder().WithTarget(&target).WithFile(filepath.Join(t.TempDir(), "missing.toml")).WithArgs([]string{"--port=256"}).Build()
if err == nil || c != nil {
t.Fatalf("missing file hid invalid CLI: %v", err)
}
}