v0.2.0 toml-only, harden concurrency and env export, dep updates
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
This commit is contained in:
1 parent
3c35601179
commit
e08467bf38
38 files changed
+2529
-4270
No files matched your search
@@ -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 .
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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,8 +23,10 @@ type configItem struct {
|
||||
type structCache struct {
|
||||
target any // User-provided struct pointer
|
||||
targetType reflect.Type // Cached type for validation
|
||||
version int64 // Version for invalidation
|
||||
populated bool // Whether cache is valid
|
||||
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,20 +38,22 @@ 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
|
||||
securityOpts *SecurityOptions
|
||||
mutex sync.RWMutex
|
||||
options LoadOptions // Current load options
|
||||
fileData map[string]any // Cached file data
|
||||
envData map[string]any // Cached env data
|
||||
cliData map[string]any // Cached CLI data
|
||||
version atomic.Int64
|
||||
structCache *structCache
|
||||
items map[string]configItem
|
||||
tagName string
|
||||
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
|
||||
structCache *structCache
|
||||
|
||||
// CLI paths that matched no registered path (reset on each CLI load)
|
||||
unknownCLIKeys []string
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
item.values[source] = value
|
||||
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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -31,8 +31,6 @@ const (
|
||||
// Format identifiers
|
||||
const (
|
||||
FormatTOML = "toml"
|
||||
FormatJSON = "json"
|
||||
FormatYAML = "yaml"
|
||||
FormatAuto = "auto"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
for path, item := range c.items {
|
||||
setNestedValue(nestedMap, path, item.currentValue)
|
||||
func (c *Config) nestedLocked(source Source) map[string]any {
|
||||
nested := make(map[string]any)
|
||||
for path, item := range c.items {
|
||||
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"))
|
||||
}
|
||||
|
||||
// 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))
|
||||
next := reflect.New(rv.Elem().Type()).Elem()
|
||||
next.Set(rv.Elem())
|
||||
if err := decodeInto(data, next, 0); err != nil {
|
||||
return wrapError(ErrDecode, 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 decodeInto(data any, dst reflect.Value, depth int) error {
|
||||
if depth > maxValueDepth {
|
||||
return fmt.Errorf("decode nesting exceeds %d", maxValueDepth)
|
||||
}
|
||||
if data == 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
|
||||
}
|
||||
|
||||
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 make(map[string]any), nil
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
// If it's already the correct type, return it.
|
||||
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()))
|
||||
}
|
||||
|
||||
// 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
|
||||
if v.Kind() != reflect.Map || v.Type().Key().Kind() != reflect.String {
|
||||
return nil, fmt.Errorf("expected string-keyed map, got %T", data)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
m := make(map[string]any, v.Len())
|
||||
it := v.MapRange()
|
||||
for it.Next() {
|
||||
m[it.Key().String()] = it.Value().Interface()
|
||||
}
|
||||
return m, 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 = value
|
||||
current = m[key]
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
+25
-84
@@ -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) {
|
||||
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")
|
||||
})
|
||||
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()
|
||||
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
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
+27
-327
@@ -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"
|
||||
}
|
||||
}`
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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`,
|
||||
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))
|
||||
}
|
||||
|
||||
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 }{
|
||||
{".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))
|
||||
}
|
||||
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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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 err := c.loadCLI(args); err != nil {
|
||||
loadErrors = append(loadErrors, wrapError(ErrCLIParse, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(loadErrors...)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
c.invalidateCache()
|
||||
return nil
|
||||
return c.replaceSourceLocked(SourceEnv, values)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
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.currentValue = c.computeValue(item)
|
||||
c.items[path] = item
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+131
-36
@@ -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,21 +282,18 @@ 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()
|
||||
}
|
||||
nestedValue = fieldValue.Elem()
|
||||
}
|
||||
|
||||
nestedPrefix := currentPath + "."
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+77
-138
@@ -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"))
|
||||
}
|
||||
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 needsInvalidation {
|
||||
c.invalidateCache() // Batch invalidation after all flags
|
||||
}
|
||||
|
||||
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 {
|
||||
return result, 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
|
||||
}
|
||||
// Type conversion failed, fall through to set default
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
if err := validatePath(path); err != nil {
|
||||
c.mutex.Unlock()
|
||||
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))
|
||||
owned, err := copyValue(defaultValue)
|
||||
if err == nil {
|
||||
err = c.registerLocked(path, owned)
|
||||
}
|
||||
|
||||
return defaultValue, nil
|
||||
c.mutex.Unlock()
|
||||
if err != nil {
|
||||
return defaultValue, err
|
||||
}
|
||||
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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
MaxWatchers int
|
||||
|
||||
// ReloadTimeout for file reload operations
|
||||
ReloadTimeout time.Duration
|
||||
|
||||
// VerifyPermissions checks file hasn't been replaced with different permissions
|
||||
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 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
|
||||
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
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
opts WatchOptions
|
||||
filePath string
|
||||
lastMode os.FileMode
|
||||
watching atomic.Bool
|
||||
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
|
||||
}
|
||||
|
||||
// Stop existing watcher if path changed
|
||||
if c.watcher != nil && c.watcher.filePath != filePath {
|
||||
if c.watcher != nil {
|
||||
if c.watcher.filePath == c.configFilePath && c.watcher.watching.Load() {
|
||||
return
|
||||
}
|
||||
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),
|
||||
}
|
||||
|
||||
// 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)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
return c.watcher.subscribe()
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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))
|
||||
return
|
||||
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()
|
||||
}
|
||||
|
||||
// Compare and notify changes
|
||||
newValues := c.snapshot()
|
||||
for path, newVal := range newValues {
|
||||
if oldVal, existed := oldValues[path]; !existed || !reflect.DeepEqual(oldVal, newVal) {
|
||||
w.notifyWatchers(path)
|
||||
}
|
||||
} 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
|
||||
}
|
||||
|
||||
// Check for deletions
|
||||
for path := range oldValues {
|
||||
if _, exists := newValues[path]; !exists {
|
||||
w.notifyWatchers(path)
|
||||
}
|
||||
}
|
||||
if key != w.observed {
|
||||
w.observed, w.observedAt = key, time.Now()
|
||||
}
|
||||
if key == w.handled || time.Since(w.observedAt) < w.opts.Debounce {
|
||||
return
|
||||
}
|
||||
if w.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if event != "" {
|
||||
if event == EventReloadTimeout {
|
||||
w.notifyTimeout(key)
|
||||
} else {
|
||||
w.notifyWatchers(event)
|
||||
w.handled = key
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
// Reload timeout
|
||||
w.notifyWatchers(EventReloadTimeout)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// subscribe creates a new watcher channel
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user