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
+2496
-4237
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.
|
Thread-safe configuration from TOML files, environment variables, command-line
|
||||||
|
arguments and registered defaults. Requires Go 1.27.1. The only runtime dependency
|
||||||
## Features
|
is [lixenwraith/toml](https://github.com/lixenwraith/toml), pinned to merged commit
|
||||||
|
`b59d068ff6a4`. Testify and its dependencies are used only by tests.
|
||||||
- **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
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
"github.com/lixenwraith/config"
|
"github.com/lixenwraith/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AppConfig struct {
|
type Settings struct {
|
||||||
Server struct {
|
Port uint16 `toml:"port"`
|
||||||
Host string `toml:"host"`
|
Timeout time.Duration `toml:"timeout"`
|
||||||
Port int `toml:"port"`
|
|
||||||
} `toml:"server"`
|
|
||||||
Debug bool `toml:"debug"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
defaults := &AppConfig{}
|
initial := Settings{Port: 8080, Timeout: 5 * time.Second}
|
||||||
defaults.Server.Host = "localhost"
|
cfg, err := config.NewBuilder().
|
||||||
defaults.Server.Port = 8080
|
WithTarget(&initial).
|
||||||
|
WithFile("config.toml").
|
||||||
cfg, err := config.Quick(defaults, "MYAPP_", "config.toml")
|
WithEnvPrefix("APP_").
|
||||||
if err != nil {
|
WithArgs([]string{"--port=9090"}).
|
||||||
log.Fatal(err)
|
Build()
|
||||||
}
|
if err != nil { log.Fatal(err) }
|
||||||
|
latest, err := cfg.AsStruct()
|
||||||
port, _ := cfg.Get("server.port")
|
if err != nil { log.Fatal(err) }
|
||||||
log.Printf("Server port: %d", port.(int64))
|
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
|
## Documentation
|
||||||
|
|
||||||
- [Quick Start](doc/quick-start.md) - Get started
|
- [Quick start](doc/quick-start.md), [builder](doc/builder.md), [access](doc/access.md)
|
||||||
- [Builder Pattern](doc/builder.md) - Advanced configuration with the builder
|
- [Files and saving](doc/file.md), [environment](doc/env.md), [CLI](doc/cli.md)
|
||||||
- [Access Patterns](doc/access.md) - Getting and setting values
|
- [Live reconfiguration](doc/reconfiguration.md), [validation](doc/validator.md)
|
||||||
- [Command Line](doc/cli.md) - CLI argument handling
|
- [Architecture](doc/architecture.md), [audit evidence](doc/audit.md)
|
||||||
- [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
|
|
||||||
|
|
||||||
## 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 WithDefaults() was called, it takes precedence
|
||||||
// If not, but WithTarget() was called, use the target struct for defaults
|
// If not, but WithTarget() was called, use the target struct for defaults
|
||||||
if b.defaults != nil {
|
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.
|
// WithDefaults() was called explicitly.
|
||||||
if err := b.cfg.RegisterStructWithTags(b.prefix, b.defaults, tagName); err != nil {
|
if err := b.cfg.RegisterStructWithTags(b.prefix, b.defaults, tagName); err != nil {
|
||||||
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("failed to register defaults: %w", err))
|
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,
|
// 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)
|
// even if the initial load fails with a non-fatal error (file not found)
|
||||||
b.cfg.configFilePath = b.file
|
b.cfg.configFilePath = b.file
|
||||||
|
if b.cfg.structCache != nil {
|
||||||
|
b.cfg.structCache.prefix = strings.TrimSuffix(b.prefix, ".")
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Load configuration
|
// 2. Load configuration
|
||||||
loadErr := b.cfg.loadWithOptions(b.file, b.args, b.opts)
|
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
|
// 3. Run non-typed validators
|
||||||
|
if err := b.cfg.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
for _, validator := range b.validators {
|
for _, validator := range b.validators {
|
||||||
if err := validator(b.cfg); err != nil {
|
if err := validator(b.cfg); err != nil {
|
||||||
return nil, wrapError(ErrValidation, fmt.Errorf("configuration validation failed: %w", err))
|
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
|
// 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)
|
// Populate the target struct first, unifying all types (e.g., string "8888" -> int64 8888)
|
||||||
populatedTarget, err := b.cfg.AsStruct()
|
populatedTarget, err := b.cfg.AsStruct()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, wrapError(ErrValidation, fmt.Errorf("failed to populate target struct for validation: %w", err))
|
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
|
// Run the typed validators against the populated, type-safe struct
|
||||||
for _, validator := range b.typedValidators {
|
for _, validator := range b.typedValidators {
|
||||||
validatorFunc := reflect.ValueOf(validator)
|
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
|
// 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 {
|
func (b *Builder) WithTagName(tagName string) *Builder {
|
||||||
switch tagName {
|
switch tagName {
|
||||||
case FormatTOML, FormatJSON, FormatYAML:
|
case FormatTOML:
|
||||||
b.tagName = tagName
|
b.tagName = tagName
|
||||||
if b.cfg != nil { // Ensure cfg exists
|
if b.cfg != nil { // Ensure cfg exists
|
||||||
b.cfg.tagName = tagName
|
b.cfg.tagName = tagName
|
||||||
}
|
}
|
||||||
default:
|
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
|
return b
|
||||||
}
|
}
|
||||||
@@ -162,7 +179,7 @@ func (b *Builder) WithTagName(tagName string) *Builder {
|
|||||||
// WithFileFormat sets the expected file format
|
// WithFileFormat sets the expected file format
|
||||||
func (b *Builder) WithFileFormat(format string) *Builder {
|
func (b *Builder) WithFileFormat(format string) *Builder {
|
||||||
switch format {
|
switch format {
|
||||||
case FormatTOML, FormatJSON, FormatYAML, FormatAuto:
|
case FormatTOML, FormatAuto:
|
||||||
b.fileFormat = format
|
b.fileFormat = format
|
||||||
default:
|
default:
|
||||||
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("unsupported file format %q", format))
|
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
|
// WithArgs sets the command-line arguments
|
||||||
func (b *Builder) WithArgs(args []string) *Builder {
|
func (b *Builder) WithArgs(args []string) *Builder {
|
||||||
b.args = args
|
b.args = append([]string(nil), args...)
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithSources sets the precedent order for configuration sources
|
// WithSources sets the precedent order for configuration sources
|
||||||
func (b *Builder) WithSources(sources ...Source) *Builder {
|
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
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,6 +354,9 @@ func (b *Builder) WithTypedValidator(fn any) *Builder {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if reflect.ValueOf(fn).IsNil() {
|
||||||
|
return b
|
||||||
|
}
|
||||||
b.typedValidators = append(b.typedValidators, fn)
|
b.typedValidators = append(b.typedValidators, fn)
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
+3
-3
@@ -43,8 +43,8 @@ func TestBuilder(t *testing.T) {
|
|||||||
os.WriteFile(configFile, []byte(`host = "filehost"`), 0644)
|
os.WriteFile(configFile, []byte(`host = "filehost"`), 0644)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Host string `json:"hostname"`
|
Host string `toml:"hostname"`
|
||||||
Port int `json:"port"`
|
Port int `toml:"port"`
|
||||||
}
|
}
|
||||||
|
|
||||||
defaults := &Config{
|
defaults := &Config{
|
||||||
@@ -59,7 +59,7 @@ func TestBuilder(t *testing.T) {
|
|||||||
|
|
||||||
cfg, err := NewBuilder().
|
cfg, err := NewBuilder().
|
||||||
WithDefaults(defaults).
|
WithDefaults(defaults).
|
||||||
WithTagName("json").
|
WithTagName("toml").
|
||||||
WithPrefix("server").
|
WithPrefix("server").
|
||||||
WithEnvPrefix("APP_").
|
WithEnvPrefix("APP_").
|
||||||
WithFile(configFile).
|
WithFile(configFile).
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
// configItem holds configuration values from different sources
|
// configItem holds configuration values from different sources
|
||||||
type configItem struct {
|
type configItem struct {
|
||||||
|
required bool
|
||||||
defaultValue any
|
defaultValue any
|
||||||
values map[Source]any // Values from each source
|
values map[Source]any // Values from each source
|
||||||
currentValue any // Computed value based on precedence
|
currentValue any // Computed value based on precedence
|
||||||
@@ -22,6 +23,8 @@ type configItem struct {
|
|||||||
type structCache struct {
|
type structCache struct {
|
||||||
target any // User-provided struct pointer
|
target any // User-provided struct pointer
|
||||||
targetType reflect.Type // Cached type for validation
|
targetType reflect.Type // Cached type for validation
|
||||||
|
snapshot any // Immutable cached result; callers receive copies
|
||||||
|
prefix string
|
||||||
version int64 // Version for invalidation
|
version int64 // Version for invalidation
|
||||||
populated bool // Whether cache is valid
|
populated bool // Whether cache is valid
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -35,16 +38,18 @@ type SecurityOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Config manages application configuration. It can be used in two primary ways:
|
// 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()
|
// 1. As a dynamic key-value store, accessed via Get() and GetTyped[T]()
|
||||||
// 2. As a source for a type-safe struct, populated via BuildAndScan() or AsStruct()
|
// 2. As a source for typed structs, populated via Scan() or AsStruct()
|
||||||
type Config struct {
|
type Config struct {
|
||||||
items map[string]configItem
|
items map[string]configItem
|
||||||
tagName string
|
tagName string
|
||||||
fileFormat string // Separate from tagName: toml, json, yaml, or auto
|
fileFormat string // Separate from tagName: toml or auto
|
||||||
securityOpts *SecurityOptions
|
securityOpts *SecurityOptions
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
options LoadOptions // Current load options
|
options LoadOptions // Current load options
|
||||||
fileData map[string]any // Cached file data
|
fileData map[string]any // Cached file data
|
||||||
|
fileComments []string
|
||||||
|
fileGeneration uint64 // Invalidates staged watcher reloads
|
||||||
envData map[string]any // Cached env data
|
envData map[string]any // Cached env data
|
||||||
cliData map[string]any // Cached CLI data
|
cliData map[string]any // Cached CLI data
|
||||||
version atomic.Int64
|
version atomic.Int64
|
||||||
@@ -74,7 +79,7 @@ func New() *Config {
|
|||||||
// NewWithOptions creates a new Config instance with custom load options
|
// NewWithOptions creates a new Config instance with custom load options
|
||||||
func NewWithOptions(opts LoadOptions) *Config {
|
func NewWithOptions(opts LoadOptions) *Config {
|
||||||
c := New()
|
c := New()
|
||||||
c.options = opts
|
c.options = cloneLoadOptions(opts)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,17 +88,19 @@ func (c *Config) SetLoadOptions(opts LoadOptions) {
|
|||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
c.options = opts
|
c.options = cloneLoadOptions(opts)
|
||||||
|
|
||||||
// Recompute all current values based on new precedence
|
// Recompute all current values based on new precedence
|
||||||
for path, item := range c.items {
|
for path, item := range c.items {
|
||||||
item.currentValue = c.computeValue(item)
|
item.currentValue = c.computeValue(item)
|
||||||
c.items[path] = item
|
c.items[path] = item
|
||||||
}
|
}
|
||||||
|
c.invalidateCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPrecedence updates source precedence with validation
|
// SetPrecedence updates source precedence with validation
|
||||||
func (c *Config) SetPrecedence(sources ...Source) error {
|
func (c *Config) SetPrecedence(sources ...Source) error {
|
||||||
|
sources = slices.Clone(sources)
|
||||||
// Validate all required sources present
|
// Validate all required sources present
|
||||||
required := map[Source]bool{
|
required := map[Source]bool{
|
||||||
SourceDefault: false,
|
SourceDefault: false,
|
||||||
@@ -167,10 +174,10 @@ func (c *Config) GetPrecedence() []Source {
|
|||||||
// Use "auto" to detect based on file extension
|
// Use "auto" to detect based on file extension
|
||||||
func (c *Config) SetFileFormat(format string) error {
|
func (c *Config) SetFileFormat(format string) error {
|
||||||
switch format {
|
switch format {
|
||||||
case FormatTOML, FormatJSON, FormatYAML, FormatAuto:
|
case FormatTOML, FormatAuto:
|
||||||
// Valid formats
|
// Valid formats
|
||||||
default:
|
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()
|
c.mutex.Lock()
|
||||||
@@ -185,6 +192,7 @@ func (c *Config) SetSecurityOptions(opts SecurityOptions) {
|
|||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
c.securityOpts = &opts
|
c.securityOpts = &opts
|
||||||
|
c.fileGeneration++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get retrieves a configuration value using the path and indicator if the path was registered
|
// 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 nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return item.currentValue, true
|
return cloneOwned(item.currentValue), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSource retrieves a value from a specific source
|
// 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]
|
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
|
// 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
|
// 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
|
// To set a value in a specific source, use SetSource instead
|
||||||
func (c *Config) Set(path string, value any) error {
|
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
|
// SetSource sets a value for a specific source
|
||||||
func (c *Config) SetSource(source Source, path string, value any) error {
|
func (c *Config) SetSource(source Source, path string, value any) error {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
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]
|
item, registered := c.items[path]
|
||||||
if !registered {
|
if !registered {
|
||||||
return wrapError(ErrPathNotRegistered, fmt.Errorf("path %s is not registered", path))
|
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 = make(map[Source]any)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !validSource(source) {
|
||||||
|
return wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source))
|
||||||
|
}
|
||||||
|
if err := validateValue(item, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
owned, err := copyValue(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
value = owned
|
||||||
|
if source == SourceDefault {
|
||||||
|
if item.defaultValue != nil {
|
||||||
|
target := reflect.New(reflect.TypeOf(item.defaultValue))
|
||||||
|
if err := decodeConfig(value, target.Interface()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
value = target.Elem().Interface()
|
||||||
|
}
|
||||||
|
item.defaultValue = value
|
||||||
|
} else {
|
||||||
item.values[source] = value
|
item.values[source] = value
|
||||||
|
}
|
||||||
item.currentValue = c.computeValue(item)
|
item.currentValue = c.computeValue(item)
|
||||||
c.items[path] = item
|
c.items[path] = item
|
||||||
|
|
||||||
@@ -271,7 +309,7 @@ func (c *Config) GetSources(path string) map[Source]any {
|
|||||||
|
|
||||||
result := make(map[Source]any)
|
result := make(map[Source]any)
|
||||||
for source, value := range item.values {
|
for source, value := range item.values {
|
||||||
result[source] = value
|
result[source] = cloneOwned(value)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -285,6 +323,8 @@ func (c *Config) Reset() {
|
|||||||
c.fileData = make(map[string]any)
|
c.fileData = make(map[string]any)
|
||||||
c.envData = make(map[string]any)
|
c.envData = make(map[string]any)
|
||||||
c.cliData = make(map[string]any)
|
c.cliData = make(map[string]any)
|
||||||
|
c.unknownCLIKeys = nil
|
||||||
|
c.fileGeneration++
|
||||||
|
|
||||||
// Reset all items to default values
|
// Reset all items to default values
|
||||||
for path, item := range c.items {
|
for path, item := range c.items {
|
||||||
@@ -305,10 +345,12 @@ func (c *Config) ResetSource(source Source) {
|
|||||||
switch source {
|
switch source {
|
||||||
case SourceFile:
|
case SourceFile:
|
||||||
c.fileData = make(map[string]any)
|
c.fileData = make(map[string]any)
|
||||||
|
c.fileGeneration++
|
||||||
case SourceEnv:
|
case SourceEnv:
|
||||||
c.envData = make(map[string]any)
|
c.envData = make(map[string]any)
|
||||||
case SourceCLI:
|
case SourceCLI:
|
||||||
c.cliData = make(map[string]any)
|
c.cliData = make(map[string]any)
|
||||||
|
c.unknownCLIKeys = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove source values from all items
|
// 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
|
// AsStruct returns the populated struct if in type-aware mode
|
||||||
func (c *Config) AsStruct() (any, error) {
|
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"))
|
return nil, wrapError(ErrNotConfigured, fmt.Errorf("no target struct configured"))
|
||||||
}
|
}
|
||||||
|
cache := c.structCache
|
||||||
c.structCache.mu.RLock()
|
cache.mu.Lock()
|
||||||
currentVersion := c.version.Load()
|
defer cache.mu.Unlock()
|
||||||
needsUpdate := !c.structCache.populated || c.structCache.version != currentVersion
|
version := c.version.Load()
|
||||||
c.structCache.mu.RUnlock()
|
if !cache.populated || cache.version != version {
|
||||||
|
next := reflect.New(cache.targetType)
|
||||||
if needsUpdate {
|
if err := decodeConfig(navigateToPath(c.nestedLocked(""), cache.prefix), next.Interface()); err != nil {
|
||||||
if err := c.populateStruct(); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
cache.snapshot = next.Interface()
|
||||||
|
cache.version, cache.populated = version, true
|
||||||
}
|
}
|
||||||
|
return copyValue(cache.snapshot)
|
||||||
return c.structCache.target, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnknownCLIKeys returns CLI paths that matched no registered config path
|
// 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 {
|
func (c *Config) computeValue(item configItem) any {
|
||||||
// Check sources in precedence order
|
// Check sources in precedence order
|
||||||
for _, source := range c.options.Sources {
|
for _, source := range c.options.Sources {
|
||||||
|
if source == SourceDefault {
|
||||||
|
return item.defaultValue
|
||||||
|
}
|
||||||
if val, exists := item.values[source]; exists && val != nil {
|
if val, exists := item.values[source]; exists && val != nil {
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
@@ -361,25 +408,6 @@ func (c *Config) computeValue(item configItem) any {
|
|||||||
return item.defaultValue
|
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
|
// invalidateCache override Set methods to invalidate cache
|
||||||
func (c *Config) invalidateCache() {
|
func (c *Config) invalidateCache() {
|
||||||
c.version.Add(1)
|
c.version.Add(1)
|
||||||
|
|||||||
+7
-12
@@ -124,15 +124,10 @@ func TestComplexStructRegistration(t *testing.T) {
|
|||||||
assert.Equal(t, 30*time.Second, val)
|
assert.Equal(t, 30*time.Second, val)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("JSONTags", func(t *testing.T) {
|
t.Run("RejectJSONTags", func(t *testing.T) {
|
||||||
cfg := New()
|
cfg := New()
|
||||||
err := cfg.RegisterStructWithTags("", defaultConfig, "json")
|
require.Error(t, cfg.RegisterStructWithTags("", defaultConfig, "json"))
|
||||||
require.NoError(t, err)
|
require.Empty(t, cfg.GetRegisteredPaths())
|
||||||
|
|
||||||
// JSON tags should create different paths
|
|
||||||
paths := cfg.GetRegisteredPaths("")
|
|
||||||
assert.True(t, paths["db.db_host"])
|
|
||||||
assert.True(t, paths["db.db_port"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("UnsupportedTag", func(t *testing.T) {
|
t.Run("UnsupportedTag", func(t *testing.T) {
|
||||||
@@ -446,7 +441,7 @@ func TestPrecedenceWithAutoUpdate(t *testing.T) {
|
|||||||
assert.Equal(t, "cli-server", val)
|
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) {
|
func TestTypeConversion(t *testing.T) {
|
||||||
type TestConfig struct {
|
type TestConfig struct {
|
||||||
IntValue int64 `toml:"int"`
|
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, "ipnet", "10.0.0.0/8")
|
||||||
cfg.SetSource(SourceEnv, "url", "https://example.com:8080/path")
|
cfg.SetSource(SourceEnv, "url", "https://example.com:8080/path")
|
||||||
cfg.SetSource(SourceEnv, "strings", "x,y,z")
|
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
|
// Scan into struct
|
||||||
var result TestConfig
|
var result TestConfig
|
||||||
@@ -538,7 +533,7 @@ func TestConcurrentAccess(t *testing.T) {
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
for j := 0; j < 100; j++ {
|
for j := 0; j < 100; j++ {
|
||||||
path := fmt.Sprintf("path%d", 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 {
|
if err := cfg.Set(path, value); err != nil {
|
||||||
errors <- fmt.Errorf("writer %d: %v", id, err)
|
errors <- fmt.Errorf("writer %d: %v", id, err)
|
||||||
}
|
}
|
||||||
@@ -555,7 +550,7 @@ func TestConcurrentAccess(t *testing.T) {
|
|||||||
for j := 0; j < 50; j++ {
|
for j := 0; j < 50; j++ {
|
||||||
path := fmt.Sprintf("path%d", j)
|
path := fmt.Sprintf("path%d", j)
|
||||||
source := sources[j%len(sources)]
|
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 {
|
if err := cfg.SetSource(source, path, value); err != nil {
|
||||||
errors <- fmt.Errorf("source writer %d: %v", id, err)
|
errors <- fmt.Errorf("source writer %d: %v", id, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ const (
|
|||||||
// Format identifiers
|
// Format identifiers
|
||||||
const (
|
const (
|
||||||
FormatTOML = "toml"
|
FormatTOML = "toml"
|
||||||
FormatJSON = "json"
|
|
||||||
FormatYAML = "yaml"
|
|
||||||
FormatAuto = "auto"
|
FormatAuto = "auto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,309 +1,342 @@
|
|||||||
// FILE: lixenwraith/config/decode.go
|
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"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 {
|
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 := ""
|
path := ""
|
||||||
switch len(basePath) {
|
if len(basePath) == 1 {
|
||||||
case 0:
|
path = strings.TrimSuffix(basePath[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)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()
|
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
|
func (c *Config) nestedLocked(source Source) map[string]any {
|
||||||
nestedMap := make(map[string]any)
|
nested := make(map[string]any)
|
||||||
|
|
||||||
if source == "" {
|
|
||||||
// Use current merged state
|
|
||||||
for path, item := range c.items {
|
for path, item := range c.items {
|
||||||
setNestedValue(nestedMap, path, item.currentValue)
|
var value any
|
||||||
}
|
var ok bool
|
||||||
} else {
|
switch source {
|
||||||
// Use specific source
|
case "":
|
||||||
for path, item := range c.items {
|
value, ok = item.currentValue, true
|
||||||
if val, exists := item.values[source]; exists {
|
case SourceDefault:
|
||||||
setNestedValue(nestedMap, path, val)
|
value, ok = item.defaultValue, true
|
||||||
|
default:
|
||||||
|
value, ok = item.values[source]
|
||||||
}
|
}
|
||||||
|
if ok {
|
||||||
|
setNestedValue(nested, path, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nested
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate to basePath section
|
// decodeConfig stages writes and retains missing struct fields. It never mutates
|
||||||
sectionData := navigateToPath(nestedMap, path)
|
// stored configuration or existing destination containers on a failed conversion.
|
||||||
|
func decodeConfig(data any, target any) error {
|
||||||
// Ensure we have a map to decode, normalizing if necessary
|
rv := reflect.ValueOf(target)
|
||||||
sectionMap, err := normalizeMap(sectionData)
|
if rv.Kind() != reflect.Pointer || rv.IsNil() {
|
||||||
if err != nil {
|
return wrapError(ErrTypeMismatch, fmt.Errorf("target must be non-nil pointer"))
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
next := reflect.New(rv.Elem().Type()).Elem()
|
||||||
|
next.Set(rv.Elem())
|
||||||
|
if err := decodeInto(data, next, 0); err != nil {
|
||||||
|
return wrapError(ErrDecode, err)
|
||||||
}
|
}
|
||||||
|
rv.Elem().Set(next)
|
||||||
// Create decoder with comprehensive hooks
|
|
||||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
|
||||||
Result: target,
|
|
||||||
TagName: c.tagName,
|
|
||||||
WeaklyTypedInput: true,
|
|
||||||
DecodeHook: c.getDecodeHook(),
|
|
||||||
ZeroFields: true,
|
|
||||||
Metadata: nil,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := decoder.Decode(sectionMap); err != nil {
|
|
||||||
return wrapError(ErrDecode, fmt.Errorf("decode failed for path %q: %w", path, err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
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) {
|
func normalizeMap(data any) (map[string]any, error) {
|
||||||
if data == nil {
|
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 {
|
if m, ok := data.(map[string]any); ok {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use reflection to handle other map types (e.g., map[string]bool)
|
|
||||||
v := reflect.ValueOf(data)
|
v := reflect.ValueOf(data)
|
||||||
if v.Kind() == reflect.Map {
|
if v.Kind() != reflect.Map || v.Type().Key().Kind() != reflect.String {
|
||||||
if v.Type().Key().Kind() != reflect.String {
|
return nil, fmt.Errorf("expected string-keyed map, got %T", data)
|
||||||
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("map keys must be strings, but got %v", v.Type().Key()))
|
|
||||||
}
|
}
|
||||||
|
m := make(map[string]any, v.Len())
|
||||||
// Create a new map[string]any and copy the values
|
it := v.MapRange()
|
||||||
normalized := make(map[string]any, v.Len())
|
for it.Next() {
|
||||||
iter := v.MapRange()
|
m[it.Key().String()] = it.Value().Interface()
|
||||||
for iter.Next() {
|
|
||||||
normalized[iter.Key().String()] = iter.Value().Interface()
|
|
||||||
}
|
}
|
||||||
return normalized, nil
|
return m, nil
|
||||||
}
|
|
||||||
|
|
||||||
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("expected a map but got %T", data))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDecodeHook returns the composite decode hook for all type conversions
|
|
||||||
func (c *Config) getDecodeHook() mapstructure.DecodeHookFunc {
|
|
||||||
return mapstructure.ComposeDecodeHookFunc(
|
|
||||||
// JSON Number handling
|
|
||||||
jsonNumberHookFunc(),
|
|
||||||
|
|
||||||
// Network types
|
|
||||||
stringToNetIPHookFunc(),
|
|
||||||
stringToNetIPNetHookFunc(),
|
|
||||||
stringToURLHookFunc(),
|
|
||||||
|
|
||||||
// Standard hooks
|
|
||||||
mapstructure.StringToTimeDurationHookFunc(),
|
|
||||||
mapstructure.StringToTimeHookFunc(time.RFC3339),
|
|
||||||
mapstructure.StringToSliceHookFunc(","),
|
|
||||||
|
|
||||||
// Custom application hooks
|
|
||||||
c.customDecodeHook(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsonNumberHookFunc handles json.Number conversion to appropriate numeric types
|
|
||||||
func jsonNumberHookFunc() mapstructure.DecodeHookFunc {
|
|
||||||
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
|
|
||||||
// Check if source is json.Number
|
|
||||||
if f != reflect.TypeOf(json.Number("")) {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
num := data.(json.Number)
|
|
||||||
|
|
||||||
// Convert based on target type
|
|
||||||
switch t.Kind() {
|
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
||||||
val, err := num.Int64()
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapError(ErrDecode, err)
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
||||||
// Parse as int64 first, then convert
|
|
||||||
i, err := num.Int64()
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapError(ErrDecode, err)
|
|
||||||
}
|
|
||||||
if i < 0 {
|
|
||||||
return nil, wrapError(ErrDecode, fmt.Errorf("cannot convert negative number to unsigned type"))
|
|
||||||
}
|
|
||||||
return uint64(i), nil
|
|
||||||
case reflect.Float32, reflect.Float64:
|
|
||||||
val, err := num.Float64()
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapError(ErrDecode, err)
|
|
||||||
}
|
|
||||||
return val, nil
|
|
||||||
case reflect.String:
|
|
||||||
return num.String(), nil
|
|
||||||
default:
|
|
||||||
// Return as-is for other types
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stringToNetIPHookFunc handles net.IP conversion
|
|
||||||
func stringToNetIPHookFunc() mapstructure.DecodeHookFunc {
|
|
||||||
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
|
|
||||||
if f.Kind() != reflect.String {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if t != reflect.TypeOf(net.IP{}) {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SECURITY: Validate IP string format to prevent injection
|
|
||||||
str := data.(string)
|
|
||||||
if len(str) > MaxIPv6Length {
|
|
||||||
return nil, fmt.Errorf("invalid IP length: %d", len(str))
|
|
||||||
}
|
|
||||||
|
|
||||||
ip := net.ParseIP(str)
|
|
||||||
if ip == nil {
|
|
||||||
return nil, fmt.Errorf("invalid IP address: %s", str)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ip, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stringToNetIPNetHookFunc handles net.IPNet conversion
|
|
||||||
func stringToNetIPNetHookFunc() mapstructure.DecodeHookFunc {
|
|
||||||
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
|
|
||||||
if f.Kind() != reflect.String {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
isPtr := t.Kind() == reflect.Ptr
|
|
||||||
targetType := t
|
|
||||||
if isPtr {
|
|
||||||
targetType = t.Elem()
|
|
||||||
}
|
|
||||||
if targetType != reflect.TypeOf(net.IPNet{}) {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
str := data.(string)
|
|
||||||
if len(str) > MaxCIDRLength {
|
|
||||||
return nil, wrapError(ErrDecode, fmt.Errorf("invalid CIDR length: %d", len(str)))
|
|
||||||
}
|
|
||||||
_, ipnet, err := net.ParseCIDR(str)
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapError(ErrDecode, fmt.Errorf("invalid CIDR: %w", err))
|
|
||||||
}
|
|
||||||
if isPtr {
|
|
||||||
return ipnet, nil
|
|
||||||
}
|
|
||||||
return *ipnet, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// stringToURLHookFunc handles url.URL conversion
|
|
||||||
func stringToURLHookFunc() mapstructure.DecodeHookFunc {
|
|
||||||
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
|
|
||||||
if f.Kind() != reflect.String {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
isPtr := t.Kind() == reflect.Ptr
|
|
||||||
targetType := t
|
|
||||||
if isPtr {
|
|
||||||
targetType = t.Elem()
|
|
||||||
}
|
|
||||||
if targetType != reflect.TypeOf(url.URL{}) {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
str := data.(string)
|
|
||||||
if len(str) > MaxURLLength {
|
|
||||||
return nil, wrapError(ErrDecode, fmt.Errorf("URL too long: %d bytes", len(str)))
|
|
||||||
}
|
|
||||||
u, err := url.Parse(str)
|
|
||||||
if err != nil {
|
|
||||||
return nil, wrapError(ErrDecode, fmt.Errorf("invalid URL: %w", err))
|
|
||||||
}
|
|
||||||
if isPtr {
|
|
||||||
return u, nil
|
|
||||||
}
|
|
||||||
return *u, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// customDecodeHook allows for application-specific type conversions
|
|
||||||
func (c *Config) customDecodeHook() mapstructure.DecodeHookFunc {
|
|
||||||
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
|
|
||||||
// TODO: Add support of custom validation for application types here
|
|
||||||
// Example: Rate limit parsing, permission validation, etc.
|
|
||||||
|
|
||||||
// Pass through by default
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// navigateToPath traverses nested map to reach the specified path
|
|
||||||
func navigateToPath(nested map[string]any, path string) any {
|
func navigateToPath(nested map[string]any, path string) any {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nested
|
return nested
|
||||||
}
|
}
|
||||||
|
var current any = nested
|
||||||
path = strings.TrimSuffix(path, ".")
|
for _, key := range strings.Split(strings.TrimSuffix(path, "."), ".") {
|
||||||
if path == "" {
|
m, err := normalizeMap(current)
|
||||||
return nested
|
if err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
segments := strings.Split(path, ".")
|
|
||||||
current := any(nested)
|
|
||||||
|
|
||||||
for _, segment := range segments {
|
|
||||||
currentMap, ok := current.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
current = m[key]
|
||||||
value, exists := currentMap[segment]
|
|
||||||
if !exists {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
current = value
|
|
||||||
}
|
|
||||||
|
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
+22
-81
@@ -4,6 +4,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -135,7 +136,7 @@ func TestScanFromSource(t *testing.T) {
|
|||||||
{SourceFile, "fromfile"},
|
{SourceFile, "fromfile"},
|
||||||
{SourceEnv, "fromenv"},
|
{SourceEnv, "fromenv"},
|
||||||
{SourceCLI, "fromcli"},
|
{SourceCLI, "fromcli"},
|
||||||
{SourceDefault, ""}, // No value in default source
|
{SourceDefault, "default"}, // Registered defaults are a readable source
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -174,90 +175,30 @@ func TestInvalidScanTargets(t *testing.T) {
|
|||||||
|
|
||||||
// TestCustomTypeConversion tests edge cases in type conversion
|
// TestCustomTypeConversion tests edge cases in type conversion
|
||||||
func TestCustomTypeConversion(t *testing.T) {
|
func TestCustomTypeConversion(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
initial any
|
||||||
|
input string
|
||||||
|
fragment string
|
||||||
|
}{
|
||||||
|
{"IP", net.IP{}, "not-an-ip", "invalid IP address"},
|
||||||
|
{"CIDR", (*net.IPNet)(nil), "invalid-cidr", "invalid CIDR"},
|
||||||
|
{"URL", (*url.URL)(nil), "://invalid-url", "invalid URL"},
|
||||||
|
{"LongIP", net.IP{}, strings.Repeat("x", 50), "invalid IP length"},
|
||||||
|
{"LongURL", (*url.URL)(nil), strings.Repeat("x", MaxURLLength+1), "URL too long"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
cfg := New()
|
cfg := New()
|
||||||
|
require.NoError(t, cfg.Register("value", tc.initial))
|
||||||
t.Run("InvalidIPAddress", func(t *testing.T) {
|
err := cfg.Set("value", tc.input)
|
||||||
type Config struct {
|
require.Error(t, err)
|
||||||
IP net.IP `toml:"ip"`
|
assert.Contains(t, err.Error(), tc.fragment)
|
||||||
}
|
got, _ := cfg.Get("value")
|
||||||
|
assert.Equal(t, tc.initial, got)
|
||||||
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")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestZeroFields tests that ZeroFields option works correctly
|
|
||||||
func TestZeroFields(t *testing.T) {
|
func TestZeroFields(t *testing.T) {
|
||||||
type Config struct {
|
type Config struct {
|
||||||
KeepValue string `toml:"keep"`
|
KeepValue string `toml:"keep"`
|
||||||
@@ -324,5 +265,5 @@ func TestWeaklyTypedInput(t *testing.T) {
|
|||||||
assert.Equal(t, 3.14159, result.FloatFromString)
|
assert.Equal(t, 3.14159, result.FloatFromString)
|
||||||
assert.Equal(t, true, result.BoolFromString)
|
assert.Equal(t, true, result.BoolFromString)
|
||||||
assert.Equal(t, "12345", result.StringFromInt)
|
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
|
# Access and conversion
|
||||||
|
|
||||||
This guide covers all methods for getting and setting configuration values, type conversions, and working with structured data.
|
| API | Behavior |
|
||||||
|
| --- | --- |
|
||||||
**Always Register First**: Register paths before setting values
|
| `Get(path)` | Detached raw effective value and registration flag |
|
||||||
**Use Type Assertions**: After struct registration, types are guaranteed
|
| `GetSource(path, source)` | Detached source value; Default returns the registered default |
|
||||||
|
| `GetSources(path)` | Detached map of explicit source overrides |
|
||||||
## Getting Values
|
| `GetTyped[T](cfg, path)` | Checked conversion to T |
|
||||||
|
| `GetTypedWithDefault[T]` | Registers a default only when the path is absent; existing conversion errors are returned |
|
||||||
### Basic Get
|
| `Scan(&target, optionalPath)` | Transactional decoding of current values |
|
||||||
|
| `ScanSource(source, &target, optionalPath)` | Decodes only the selected source |
|
||||||
```go
|
| `ScanTyped[T]` | Allocates and populates a typed destination |
|
||||||
// Get returns (value, exists)
|
| `ScanMap(values, &target)` | Uses the same decoder without registration |
|
||||||
value, exists := cfg.Get("server.port")
|
| `AsStruct()` | Independent snapshot of the configured target type |
|
||||||
if !exists {
|
|
||||||
log.Fatal("server.port not configured")
|
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
|
||||||
// Type assertion (safe after registration)
|
with an empty name use the field name; `-` skips a field. Unknown input fields and
|
||||||
port := value.(int64)
|
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.
|
||||||
### Type-Safe Access
|
|
||||||
|
Conversions support signed/unsigned numbers, floats, booleans, strings, slices,
|
||||||
When using struct registration, types are guaranteed:
|
arrays, string-keyed maps, structs and pointers. Durations, time.Time, net.IP,
|
||||||
|
net.IPNet and url.URL are atomic configuration values, including pointer forms.
|
||||||
```go
|
They accept their native types or documented text representations. Comma-separated
|
||||||
type Config struct {
|
strings can populate typed numeric slices as well as string slices.
|
||||||
Server struct {
|
|
||||||
Port int64 `toml:"port"`
|
No truncating integer conversions or integer-to-float precision loss is allowed.
|
||||||
Host string `toml:"host"`
|
Float narrowing may round within range. NaN/Inf and unsupported object graphs are
|
||||||
} `toml:"server"`
|
rejected. All decoding failures leave the destination unchanged. There are no
|
||||||
}
|
custom decode hooks or automatic text-unmarshal interfaces.
|
||||||
|
|
||||||
cfg.RegisterStruct("", &Config{})
|
A Config owns accepted values. Modifying Get results, defaults after registration,
|
||||||
|
clones or AsStruct snapshots cannot modify stored state. Do not mutate arguments
|
||||||
// After registration, type assertions are safe
|
concurrently while a call is reading them. Builder setup itself is not concurrent.
|
||||||
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
|
|
||||||
```
|
|
||||||
+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.
|
||||||
|
|
||||||
```
|
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.
|
||||||
│ User API Layer │
|
The watcher additionally verifies cancellation, timeout and generation at commit.
|
||||||
├────────────────┬────────────────┬────────────────────────┤
|
One worker owns fingerprint/debounce state. Subscriber send/close operations share a
|
||||||
│ Builder │ Type-Safe │ Dynamic Access │
|
mutex; stop does not wait for that worker while holding the Config mutex.
|
||||||
│ 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 │
|
|
||||||
└──────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Interactions
|
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
|
||||||
Builder Flow:
|
source precedence, snapshot isolation and live reload publication.
|
||||||
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
|
|
||||||
@@ -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
|
# Builder
|
||||||
|
|
||||||
The builder pattern provides fine-grained control over configuration initialization and loading behavior.
|
`NewBuilder` collects defaults, an optional typed target, file, environment and CLI
|
||||||
|
settings. Build registers defaults, loads each configured source, checks required
|
||||||
## Basic Builder Usage
|
paths, runs Config validators and then typed validators. Missing files are nonfatal
|
||||||
|
only when other loading and validation succeeds.
|
||||||
```go
|
|
||||||
cfg, err := config.NewBuilder().
|
| Option | Purpose |
|
||||||
WithDefaults(defaultStruct).
|
| --- | --- |
|
||||||
WithEnvPrefix("MYAPP_").
|
| `WithDefaults(value)` | Explicit struct defaults |
|
||||||
WithFile("config.toml").
|
| `WithTarget(&value)` | Target type and fallback defaults; filled once by Build |
|
||||||
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 |
|
||||||
## Builder Methods
|
| `WithEnvPrefix`, `WithEnvTransform`, `WithEnvWhitelist` | Environment mapping |
|
||||||
|
| `WithArgs(args)` | Explicit CLI input; nil disables it |
|
||||||
### WithDefaults
|
| `WithSources(...)` | Source precedence; first wins |
|
||||||
|
| `WithSecurityOptions(options)` | File traversal, owner and size checks |
|
||||||
Register a struct containing default values:
|
| `WithValidator(fn)` | Build-time validation using Config accessors |
|
||||||
|
| `WithTypedValidator(func(*T) error)` | Build-time validation of the target snapshot |
|
||||||
```go
|
|
||||||
type Config struct {
|
Only TOML tags and file format are supported. WithTagName accepts `toml`;
|
||||||
Host string `toml:"host"`
|
WithFileFormat accepts `toml` or `auto`. JSON/YAML modes are removed.
|
||||||
Port int `toml:"port"`
|
|
||||||
}
|
Use a builder from one goroutine during initialization. Runtime updates go through
|
||||||
|
Config methods. `AsStruct` returns independent snapshots and never mutates the
|
||||||
defaults := &Config{
|
original target after Build. Validators run during Build, not on every later Set
|
||||||
Host: "localhost",
|
or reload; later changes are always checked against registered value types.
|
||||||
Port: 8080,
|
|
||||||
}
|
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;
|
||||||
cfg, _ := config.NewBuilder().
|
signatures must match the target pointer and return error.
|
||||||
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()`
|
|
||||||
+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
|
The Builder reads os.Args by default. Use `WithArgs(nil)` to disable CLI loading for
|
||||||
# Space-separated
|
that build or supply an explicit argument slice for deterministic initialization.
|
||||||
./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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
+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
|
`DiscoverEnv(prefix)` returns registered paths with present variable names.
|
||||||
cfg, _ := config.Quick(defaults, "MYAPP_", "config.toml")
|
`ExportEnv(prefix)` returns formatted effective values that differ from defaults;
|
||||||
|
it safely handles containers. It is diagnostic output, not a general lossless
|
||||||
// These environment variables are automatically loaded:
|
serialization format for maps, slices or strings containing delimiters. Use Save
|
||||||
// MYAPP_SERVER_PORT → server.port
|
for round-trip persistence.
|
||||||
// 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()
|
|
||||||
```
|
|
||||||
+34
-304
@@ -1,304 +1,34 @@
|
|||||||
# Configuration Files
|
# TOML files and saves
|
||||||
|
|
||||||
The config package supports TOML configuration files with automatic loading, discovery, and atomic saving.
|
`LoadFile(path)` parses TOML regardless of the usual TOML/conf/config extension;
|
||||||
|
JSON/YAML extensions are rejected. `SetFileFormat` and `WithFileFormat` accept
|
||||||
## TOML Format
|
`toml` and the compatibility alias `auto`; neither enables format detection.
|
||||||
|
Only registered paths are applied. Registered map fields accept whole tables.
|
||||||
TOML (Tom's Obvious, Minimal Language) is the supported configuration format:
|
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.
|
||||||
```toml
|
|
||||||
# Basic values
|
Loads parse and validate before publication. Syntax errors, incompatible values,
|
||||||
host = "localhost"
|
overflow and read failures leave prior values, comments and the loaded path intact.
|
||||||
port = 8080
|
Readers must be regular files. Security options check ownership of the opened file
|
||||||
debug = false
|
on Unix and enforce size limits both before and during reading. Ownership checks
|
||||||
|
fail explicitly on unsupported platforms. PreventPathTraversal rejects relative
|
||||||
# Nested sections
|
paths escaping through `..`; it is not a symlink sandbox. Absolute paths remain
|
||||||
[server]
|
allowed. A zero MaxFileSize means no configured byte limit.
|
||||||
host = "0.0.0.0"
|
|
||||||
port = 9090
|
`Save(path)` writes effective values; `SaveSource(path, source)` writes one source.
|
||||||
timeout = "30s"
|
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
|
||||||
[database]
|
file's permission bits and use 0600 for a new file. They do not promise portable
|
||||||
url = "postgres://localhost/mydb"
|
power-loss durability of the directory entry or platform-independent replacement
|
||||||
max_conns = 25
|
of open files. Encoding failures occur before the destination is changed.
|
||||||
timeout = "5s"
|
|
||||||
|
Loaded comment text, including inline comments, is preserved as a preamble. Hashes
|
||||||
# Arrays
|
inside strings are not comments. Formatting and comment positions are standardized,
|
||||||
[features]
|
and unknown/unregistered keys are omitted. Saves do not merge concurrent edits made
|
||||||
enabled = ["auth", "api", "metrics"]
|
outside Config after the last successful load. Save does not switch the watched path.
|
||||||
|
|
||||||
# Inline tables
|
Durations and atomic time/network values are saved as strings and decoded back to
|
||||||
tls = { enabled = true, cert = "/path/to/cert", key = "/path/to/key" }
|
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
|
||||||
## Loading Configuration Files
|
numeric key restrictions follow the pinned [toml package](https://github.com/lixenwraith/toml).
|
||||||
|
|
||||||
### 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
|
|
||||||
@@ -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.
|
Use Go 1.27.1 and TOML tags. Register defaults before loading sources:
|
||||||
|
|
||||||
## Basic Usage
|
|
||||||
|
|
||||||
The simplest way to use the config package is with the `Quick` function:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
cfg := config.New()
|
||||||
|
if err := cfg.Register("server.port", uint16(8080)); err != nil { return err }
|
||||||
import (
|
if err := cfg.LoadFile("config.toml"); err != nil { return err }
|
||||||
"log"
|
if err := cfg.LoadEnv("APP_"); err != nil { return err }
|
||||||
"github.com/lixenwraith/config"
|
if err := cfg.LoadCLI(os.Args[1:]); err != nil { return err }
|
||||||
)
|
port, err := config.GetTyped[uint16](cfg, "server.port")
|
||||||
|
|
||||||
// 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))
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Sources
|
`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:
|
||||||
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)
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[server]
|
[server]
|
||||||
host = "0.0.0.0"
|
|
||||||
port = 8080
|
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:
|
A missing file returns `ErrConfigNotFound` along with a usable Config when all
|
||||||
|
other initialization succeeds. Check that category explicitly if a file is
|
||||||
```go
|
optional. Invalid environment or CLI values remain fatal even when the file is
|
||||||
// This struct defines the expected types
|
missing. `MustBuild` and `MustQuick` ignore only a missing file and panic on other
|
||||||
type Config struct {
|
initialization errors. All source loads replace the previous values of that source.
|
||||||
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
|
|
||||||
+36
-349
@@ -1,349 +1,36 @@
|
|||||||
# Live Reconfiguration
|
# Live reconfiguration
|
||||||
|
|
||||||
The config package supports automatic configuration reloading when files change, enabling zero-downtime reconfiguration.
|
`AutoUpdate` starts polling the loaded file. `Watch` starts it if necessary and
|
||||||
|
subscribes to notifications. Option variants configure the polling interval
|
||||||
## Basic File Watching
|
(minimum 100ms), debounce, subscriber limit, reload timeout and permission checks.
|
||||||
|
Options for an already-running watcher stay unchanged; stop/restart to change them.
|
||||||
### Enable Auto-Update
|
|
||||||
|
One worker reads and fingerprints the regular file per poll, including same-size,
|
||||||
```go
|
same-timestamp edits and atomic replacements. Both successful and erroneous reads
|
||||||
cfg, _ := config.NewBuilder().
|
must settle for the debounce interval. Stable errors are reported once per observed
|
||||||
WithDefaults(&Config{}).
|
state. Deletion preserves the last valid state, and recreation can reload normally.
|
||||||
WithFile("config.toml").
|
This trades one file read per poll for reliable detection of metadata-preserving edits.
|
||||||
Build()
|
|
||||||
|
Before publishing, the worker checks its context, current watcher identity, watched
|
||||||
// Enable automatic reloading
|
path and source generation under the Config lock. Type checks and allocation finish
|
||||||
cfg.AutoUpdate()
|
before the final timeout check. Expired, stopped or superseded operations cannot
|
||||||
|
publish. ReloadTimeout limits publication eligibility; it cannot forcibly interrupt
|
||||||
// Your application continues running
|
a kernel filesystem operation. Reads never hold the Config lock.
|
||||||
// Config reloads automatically when file changes
|
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.
|
||||||
// Stop watching when done
|
|
||||||
defer cfg.StopAutoUpdate()
|
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
|
||||||
### Watch for Changes
|
permission changes are blocked when VerifyPermissions is enabled; correcting the
|
||||||
|
permissions allows loading again. Default options enable that check.
|
||||||
```go
|
|
||||||
// Get notified of configuration changes
|
Notifications are buffered (10 entries) and best effort; a slow subscriber may miss
|
||||||
changes := cfg.Watch()
|
events and should read the latest full state. MaxWatchers bounds retained channels.
|
||||||
|
StopAutoUpdate cancels publication and closes channels synchronously without waiting
|
||||||
go func() {
|
under the Config lock. There is no goroutine per subscriber or per timed-out reload.
|
||||||
for path := range changes {
|
A new Watch call after stopping creates a new channel and worker.
|
||||||
log.Printf("Configuration changed: %s", path)
|
|
||||||
|
`WatchFile` loads a replacement path before replacing the watcher and preserves its
|
||||||
// React to specific changes
|
options. Failure leaves the prior watcher and values active. Loading a different
|
||||||
switch path {
|
file directly stops the previous watcher; call Watch to watch the new 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
```
|
|
||||||
+18
-170
@@ -1,175 +1,23 @@
|
|||||||
# Validation
|
# 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`)
|
Helpers include Port, Positive, NonNegative, IPAddress, IPv4Address, IPv6Address,
|
||||||
|
URLPath, OneOf, Range, Pattern and NonEmpty. Pattern compiles its expression when the
|
||||||
Runs on raw configuration values before struct population:
|
validator is created and panics on an invalid expression. Network validators allow
|
||||||
|
the documented empty/unspecified defaults. Use errors.Is with ErrValidation,
|
||||||
```go
|
ErrTypeMismatch and ErrDecode to handle failures.
|
||||||
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).
|
|
||||||
+26
-326
@@ -1,234 +1,43 @@
|
|||||||
// FILE: lixenwraith/config/dynamic_test.go
|
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestMultiFormatLoading tests loading different config formats
|
func TestTOMLOnly(t *testing.T) {
|
||||||
func TestMultiFormatLoading(t *testing.T) {
|
for _, format := range []string{"json", "yaml", "other"} {
|
||||||
tmpDir := t.TempDir()
|
c := New()
|
||||||
|
require.Error(t, c.SetFileFormat(format))
|
||||||
// Create test config in different formats
|
_, err := NewBuilder().WithTagName(format).Build()
|
||||||
tomlConfig := `
|
require.Error(t, err)
|
||||||
[server]
|
var dst struct{ Value string }
|
||||||
host = "toml-host"
|
require.Error(t, ScanMap(nil, &dst, format))
|
||||||
port = 8080
|
|
||||||
|
|
||||||
[database]
|
|
||||||
url = "postgres://localhost/toml"
|
|
||||||
`
|
|
||||||
|
|
||||||
jsonConfig := `{
|
|
||||||
"server": {
|
|
||||||
"host": "json-host",
|
|
||||||
"port": 9090
|
|
||||||
},
|
|
||||||
"database": {
|
|
||||||
"url": "postgres://localhost/json"
|
|
||||||
}
|
}
|
||||||
}`
|
for _, tc := range []struct{ ext, content string }{
|
||||||
|
{".toml", `value = "toml"`}, {".conf", `value = "toml"`}, {".config", `value = "toml"`},
|
||||||
yamlConfig := `
|
} {
|
||||||
server:
|
c := New()
|
||||||
host: yaml-host
|
require.NoError(t, c.Register("value", ""))
|
||||||
port: 7070
|
path := filepath.Join(t.TempDir(), "config"+tc.ext)
|
||||||
database:
|
require.NoError(t, os.WriteFile(path, []byte(tc.content), 0600))
|
||||||
url: postgres://localhost/yaml
|
require.NoError(t, c.LoadFile(path))
|
||||||
`
|
|
||||||
|
|
||||||
// Write config files
|
|
||||||
tomlPath := filepath.Join(tmpDir, "config.toml")
|
|
||||||
jsonPath := filepath.Join(tmpDir, "config.json")
|
|
||||||
yamlPath := filepath.Join(tmpDir, "config.yaml")
|
|
||||||
|
|
||||||
require.NoError(t, os.WriteFile(tomlPath, []byte(tomlConfig), 0644))
|
|
||||||
require.NoError(t, os.WriteFile(jsonPath, []byte(jsonConfig), 0644))
|
|
||||||
require.NoError(t, os.WriteFile(yamlPath, []byte(yamlConfig), 0644))
|
|
||||||
|
|
||||||
t.Run("AutoDetectFormats", func(t *testing.T) {
|
|
||||||
cfg := New()
|
|
||||||
cfg.Register("server.host", "")
|
|
||||||
cfg.Register("server.port", 0)
|
|
||||||
cfg.Register("database.url", "")
|
|
||||||
|
|
||||||
// Test TOML
|
|
||||||
cfg.SetFileFormat("auto")
|
|
||||||
require.NoError(t, cfg.LoadFile(tomlPath))
|
|
||||||
host, _ := cfg.Get("server.host")
|
|
||||||
assert.Equal(t, "toml-host", host)
|
|
||||||
|
|
||||||
// Test JSON
|
|
||||||
require.NoError(t, cfg.LoadFile(jsonPath))
|
|
||||||
host, _ = cfg.Get("server.host")
|
|
||||||
assert.Equal(t, "json-host", host)
|
|
||||||
port, _ := cfg.Get("server.port")
|
|
||||||
// JSON number should be preserved as json.Number but convertible
|
|
||||||
switch v := port.(type) {
|
|
||||||
case json.Number:
|
|
||||||
// Expected for raw value
|
|
||||||
assert.Equal(t, json.Number("9090"), v)
|
|
||||||
case int64:
|
|
||||||
// Expected after decode hook conversion
|
|
||||||
assert.Equal(t, int64(9090), v)
|
|
||||||
case float64:
|
|
||||||
// Alternative conversion
|
|
||||||
assert.Equal(t, float64(9090), v)
|
|
||||||
default:
|
|
||||||
t.Errorf("Unexpected type for port: %T", port)
|
|
||||||
}
|
}
|
||||||
|
for _, tc := range []struct{ ext, content string }{
|
||||||
// Test YAML
|
{".json", `{"value":"json"}`}, {".yaml", "value: yaml"}, {".conf", `{"value":"json"}`},
|
||||||
require.NoError(t, cfg.LoadFile(yamlPath))
|
} {
|
||||||
host, _ = cfg.Get("server.host")
|
c := New()
|
||||||
assert.Equal(t, "yaml-host", host)
|
require.NoError(t, c.Register("value", ""))
|
||||||
})
|
path := filepath.Join(t.TempDir(), "config"+tc.ext)
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(tc.content), 0600))
|
||||||
t.Run("ExplicitFormat", func(t *testing.T) {
|
require.Error(t, c.LoadFile(path))
|
||||||
cfg := New()
|
|
||||||
cfg.Register("server.host", "")
|
|
||||||
|
|
||||||
// Force JSON parsing on .conf file
|
|
||||||
confPath := filepath.Join(tmpDir, "config.conf")
|
|
||||||
require.NoError(t, os.WriteFile(confPath, []byte(jsonConfig), 0644))
|
|
||||||
|
|
||||||
cfg.SetFileFormat("json")
|
|
||||||
require.NoError(t, cfg.LoadFile(confPath))
|
|
||||||
|
|
||||||
host, _ := cfg.Get("server.host")
|
|
||||||
assert.Equal(t, "json-host", host)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ContentDetection", func(t *testing.T) {
|
|
||||||
cfg := New()
|
|
||||||
cfg.Register("server.host", "")
|
|
||||||
|
|
||||||
// Ambiguous extension
|
|
||||||
ambigPath := filepath.Join(tmpDir, "config.conf")
|
|
||||||
require.NoError(t, os.WriteFile(ambigPath, []byte(yamlConfig), 0644))
|
|
||||||
|
|
||||||
cfg.SetFileFormat("auto")
|
|
||||||
require.NoError(t, cfg.LoadFile(ambigPath))
|
|
||||||
|
|
||||||
host, _ := cfg.Get("server.host")
|
|
||||||
assert.Equal(t, "yaml-host", host)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDynamicFormatSwitching tests runtime format changes
|
|
||||||
func TestDynamicFormatSwitching(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
|
|
||||||
// Create configs in different formats with same structure
|
|
||||||
configs := map[string]string{
|
|
||||||
"toml": `value = "from-toml"`,
|
|
||||||
"json": `{"value": "from-json"}`,
|
|
||||||
"yaml": `value: from-yaml`,
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := New()
|
|
||||||
cfg.Register("value", "default")
|
|
||||||
|
|
||||||
for format, content := range configs {
|
|
||||||
t.Run(format, func(t *testing.T) {
|
|
||||||
filePath := filepath.Join(tmpDir, "config."+format)
|
|
||||||
require.NoError(t, os.WriteFile(filePath, []byte(content), 0644))
|
|
||||||
|
|
||||||
// Set format and load
|
|
||||||
require.NoError(t, cfg.SetFileFormat(format))
|
|
||||||
require.NoError(t, cfg.LoadFile(filePath))
|
|
||||||
|
|
||||||
val, _ := cfg.Get("value")
|
|
||||||
assert.Equal(t, "from-"+format, val)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
func TestSecurityOptions(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
@@ -307,112 +116,3 @@ func waitForWatchingState(t *testing.T, cfg *Config, expected bool, msgAndArgs .
|
|||||||
return cfg.IsWatching() == expected
|
return cfg.IsWatching() == expected
|
||||||
}, testEventuallyTimeout, 2*SpinWaitInterval, msgAndArgs...)
|
}, 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)")
|
log.Println(" (Set environment variable APP_SERVER_PORT=8888)")
|
||||||
|
|
||||||
// Create a "target" struct. The builder will automatically populate this
|
// 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{}
|
target := &AppConfig{}
|
||||||
|
|
||||||
// Use the builder to chain multiple configuration options
|
// 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
|
module github.com/lixenwraith/config
|
||||||
|
|
||||||
go 1.26.0
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0
|
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4 h1:9qZU0iBIsqintDh39vieio0ygLiRrPmCEo6Ra45EQyY=
|
||||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/lixenwraith/toml v0.0.0-20260928180512-b59d068ff6a4/go.mod h1:itcT/Vt5mwmX9KVGCMsWZr6vbU/JgZ24wV/I0J5cMt0=
|
||||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
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
|
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 {
|
for _, r := range s {
|
||||||
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||||||
isDigit := r >= '0' && r <= '9'
|
isDigit := r >= '0' && r <= '9'
|
||||||
|
|||||||
@@ -2,19 +2,13 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"reflect"
|
||||||
"runtime"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Source represents a configuration source, used to define load precedence
|
// 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 limits which paths are checked for env vars (nil = all)
|
||||||
EnvWhitelist map[string]bool
|
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
|
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
|
// loadWithOptions loads configuration from multiple sources with custom options
|
||||||
func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOptions) error {
|
func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOptions) error {
|
||||||
c.mutex.Lock()
|
opts = cloneLoadOptions(opts)
|
||||||
c.options = opts
|
c.SetLoadOptions(opts)
|
||||||
c.mutex.Unlock()
|
|
||||||
|
|
||||||
var loadErrors []error
|
var loadErrors []error
|
||||||
|
var missingFile error
|
||||||
|
|
||||||
// Process each source according to precedence (in reverse order for proper layering)
|
// Process each source according to precedence (in reverse order for proper layering)
|
||||||
for i := len(opts.Sources) - 1; i >= 0; i-- {
|
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 filePath != "" {
|
||||||
if err := c.loadFile(filePath); err != nil {
|
if err := c.loadFile(filePath); err != nil {
|
||||||
if errors.Is(err, ErrConfigNotFound) {
|
if errors.Is(err, ErrConfigNotFound) {
|
||||||
loadErrors = append(loadErrors, err)
|
missingFile = err
|
||||||
} else {
|
} else {
|
||||||
return wrapError(ErrFileAccess, err) // Fatal error
|
return wrapError(ErrFileAccess, err) // Fatal error
|
||||||
}
|
}
|
||||||
@@ -96,20 +100,23 @@ func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOption
|
|||||||
}
|
}
|
||||||
|
|
||||||
case SourceCLI:
|
case SourceCLI:
|
||||||
if len(args) > 0 {
|
|
||||||
if err := c.loadCLI(args); err != nil {
|
if err := c.loadCLI(args); err != nil {
|
||||||
loadErrors = append(loadErrors, wrapError(ErrCLIParse, err))
|
loadErrors = append(loadErrors, wrapError(ErrCLIParse, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
if len(loadErrors) != 0 {
|
||||||
return errors.Join(loadErrors...)
|
return errors.Join(loadErrors...)
|
||||||
|
}
|
||||||
|
return missingFile
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadEnv loads configuration values from environment variables
|
// LoadEnv loads configuration values from environment variables
|
||||||
func (c *Config) LoadEnv(prefix string) error {
|
func (c *Config) LoadEnv(prefix string) error {
|
||||||
opts := c.options
|
c.mutex.RLock()
|
||||||
|
opts := cloneLoadOptions(c.options)
|
||||||
|
c.mutex.RUnlock()
|
||||||
opts.EnvPrefix = prefix
|
opts.EnvPrefix = prefix
|
||||||
return c.loadEnv(opts)
|
return c.loadEnv(opts)
|
||||||
}
|
}
|
||||||
@@ -130,115 +137,23 @@ func (c *Config) LoadFile(filePath string) error {
|
|||||||
return nil
|
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
|
// DiscoverEnv finds all environment variables matching registered paths
|
||||||
// and returns a map of path -> env var name for found variables
|
// and returns a map of path -> env var name for found variables
|
||||||
func (c *Config) DiscoverEnv(prefix string) map[string]string {
|
func (c *Config) DiscoverEnv(prefix string) map[string]string {
|
||||||
|
c.mutex.RLock()
|
||||||
transform := c.options.EnvTransform
|
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 {
|
if transform == nil {
|
||||||
transform = defaultEnvTransform(prefix)
|
transform = defaultEnvTransform(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
discovered := make(map[string]string)
|
discovered := make(map[string]string)
|
||||||
|
|
||||||
for path := range c.items {
|
for _, path := range paths {
|
||||||
envVar := transform(path)
|
envVar := transform(path)
|
||||||
if _, exists := os.LookupEnv(envVar); exists {
|
if _, exists := os.LookupEnv(envVar); exists {
|
||||||
discovered[path] = envVar
|
discovered[path] = envVar
|
||||||
@@ -251,278 +166,109 @@ func (c *Config) DiscoverEnv(prefix string) map[string]string {
|
|||||||
// ExportEnv exports the current configuration as environment variables
|
// ExportEnv exports the current configuration as environment variables
|
||||||
// Only exports paths that have non-default values
|
// Only exports paths that have non-default values
|
||||||
func (c *Config) ExportEnv(prefix string) map[string]string {
|
func (c *Config) ExportEnv(prefix string) map[string]string {
|
||||||
|
c.mutex.RLock()
|
||||||
transform := c.options.EnvTransform
|
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 {
|
if transform == nil {
|
||||||
transform = defaultEnvTransform(prefix)
|
transform = defaultEnvTransform(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mutex.RLock()
|
|
||||||
defer c.mutex.RUnlock()
|
|
||||||
|
|
||||||
exports := make(map[string]string)
|
exports := make(map[string]string)
|
||||||
|
|
||||||
for path, item := range c.items {
|
for path, value := range changed {
|
||||||
// Only export if value differs from default
|
exports[transform(path)] = fmt.Sprintf("%v", value)
|
||||||
if item.currentValue != item.defaultValue {
|
|
||||||
envVar := transform(path)
|
|
||||||
exports[envVar] = fmt.Sprintf("%v", item.currentValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return exports
|
return exports
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadFile reads and parses a TOML configuration file
|
// loadEnv replaces the complete environment source, including removed variables.
|
||||||
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
|
|
||||||
func (c *Config) loadEnv(opts LoadOptions) error {
|
func (c *Config) loadEnv(opts LoadOptions) error {
|
||||||
transform := opts.EnvTransform
|
transform := opts.EnvTransform
|
||||||
if transform == nil {
|
if transform == nil {
|
||||||
transform = defaultEnvTransform(opts.EnvPrefix)
|
transform = defaultEnvTransform(opts.EnvPrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Prepare data (Read-Lock to get paths)
|
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
paths := make([]string, 0, len(c.items))
|
paths := make([]string, 0, len(c.items))
|
||||||
for p := range c.items {
|
for path := range c.items {
|
||||||
paths = append(paths, p)
|
paths = append(paths, path)
|
||||||
}
|
}
|
||||||
c.mutex.RUnlock()
|
c.mutex.RUnlock()
|
||||||
|
values := make(map[string]any)
|
||||||
// 2. Process env vars (No Lock)
|
|
||||||
foundEnvVars := make(map[string]string)
|
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] {
|
if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if value, ok := os.LookupEnv(transform(path)); ok {
|
||||||
envVar := transform(path)
|
values[path] = value
|
||||||
if value, exists := os.LookupEnv(envVar); exists {
|
|
||||||
if len(value) > MaxValueSize {
|
|
||||||
return ErrValueSize
|
|
||||||
}
|
|
||||||
foundEnvVars[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()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
return c.replaceSourceLocked(SourceEnv, values)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCLI loads configuration from command-line arguments
|
|
||||||
func (c *Config) loadCLI(args []string) error {
|
func (c *Config) loadCLI(args []string) error {
|
||||||
// -- 1. Prepare data (No Lock)
|
parsed, err := parseArgs(args)
|
||||||
parsedCLI, err := parseArgs(args)
|
|
||||||
if err != nil {
|
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()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
return c.replaceSourceLocked(SourceCLI, flattenMap(parsed, ""))
|
||||||
|
}
|
||||||
|
|
||||||
c.cliData = flattenedCLI
|
// 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 {
|
||||||
// Reset per load; reloads must not accumulate duplicates
|
accepted := make(map[string]any, len(values))
|
||||||
c.unknownCLIKeys = c.unknownCLIKeys[:0]
|
var unknown []string
|
||||||
|
for path, value := range values {
|
||||||
for path, value := range flattenedCLI {
|
item, ok := c.items[path]
|
||||||
if item, exists := c.items[path]; exists {
|
if !ok {
|
||||||
if item.values == nil {
|
if source == SourceCLI {
|
||||||
item.values = make(map[Source]any)
|
unknown = append(unknown, path)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := validateValue(item, value); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
owned, err := copyValue(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
accepted[path] = owned
|
||||||
|
}
|
||||||
|
if len(guard) > 0 {
|
||||||
|
if err := guard[0](); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path, item := range c.items {
|
||||||
|
if value, ok := accepted[path]; ok {
|
||||||
|
item.values[source] = value
|
||||||
|
} else {
|
||||||
|
delete(item.values, source)
|
||||||
}
|
}
|
||||||
item.values[SourceCLI] = value
|
|
||||||
item.currentValue = c.computeValue(item)
|
item.currentValue = c.computeValue(item)
|
||||||
c.items[path] = item
|
c.items[path] = item
|
||||||
} else {
|
|
||||||
// Record CLI keys that match no registered path
|
|
||||||
c.unknownCLIKeys = append(c.unknownCLIKeys, path)
|
|
||||||
}
|
}
|
||||||
|
switch source {
|
||||||
|
case SourceFile:
|
||||||
|
c.fileData = accepted
|
||||||
|
case SourceEnv:
|
||||||
|
c.envData = accepted
|
||||||
|
case SourceCLI:
|
||||||
|
c.cliData = accepted
|
||||||
|
slices.Sort(unknown)
|
||||||
|
c.unknownCLIKeys = unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
c.invalidateCache()
|
c.invalidateCache()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -540,7 +286,7 @@ func defaultEnvTransform(prefix string) EnvTransformFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// parseValue attempts to parse a string into appropriate types
|
// 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 {
|
func parseValue(s string) any {
|
||||||
if s == "true" {
|
if s == "true" {
|
||||||
return true
|
return true
|
||||||
@@ -554,50 +300,10 @@ func parseValue(s string) any {
|
|||||||
return s[1 : len(s)-1]
|
return s[1 : len(s)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return as string - mapstructure will convert as needed
|
// Return as string - typed decoding converts as needed
|
||||||
return s
|
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.
|
// parseArgs processes command-line arguments into a nested map structure.
|
||||||
func parseArgs(args []string) (map[string]any, error) {
|
func parseArgs(args []string) (map[string]any, error) {
|
||||||
result := make(map[string]any)
|
result := make(map[string]any)
|
||||||
@@ -612,9 +318,8 @@ func parseArgs(args []string) (map[string]any, error) {
|
|||||||
|
|
||||||
argContent := strings.TrimPrefix(arg, "--")
|
argContent := strings.TrimPrefix(arg, "--")
|
||||||
if argContent == "" {
|
if argContent == "" {
|
||||||
// Skip "--" argument if used as a separator
|
// Arguments after the terminator are positional.
|
||||||
i++
|
break
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var keyPath string
|
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.
|
// Always store as a string. Let Scan handle final type conversion.
|
||||||
|
if len(valueStr) > MaxValueSize {
|
||||||
|
return nil, ErrValueSize
|
||||||
|
}
|
||||||
setNestedValue(result, keyPath, valueStr)
|
setNestedValue(result, keyPath, valueStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
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
|
||||||
|
}
|
||||||
+130
-35
@@ -13,30 +13,71 @@ import (
|
|||||||
// Each segment of the path must be a valid TOML key identifier
|
// 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
|
// defaultValue is the value returned by Get if no specific value has been set
|
||||||
func (c *Config) Register(path string, defaultValue any) error {
|
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 == "" {
|
if path == "" {
|
||||||
return wrapError(ErrInvalidPath, fmt.Errorf("registration path cannot be empty"))
|
return wrapError(ErrInvalidPath, fmt.Errorf("registration path cannot be empty"))
|
||||||
}
|
}
|
||||||
|
parts := strings.Split(path, ".")
|
||||||
// Validate path segments
|
if len(parts) > maxValueDepth {
|
||||||
segments := strings.Split(path, ".")
|
return wrapError(ErrInvalidPath, fmt.Errorf("path nesting exceeds %d", maxValueDepth))
|
||||||
for _, segment := range segments {
|
}
|
||||||
if !isValidKeySegment(segment) {
|
for _, part := range parts {
|
||||||
return wrapError(ErrInvalidPath, fmt.Errorf("invalid path segment %q in path %q", segment, path))
|
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
|
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
|
// RegisterWithEnv registers a path with an explicit environment variable mapping
|
||||||
func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) error {
|
func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) error {
|
||||||
if err := c.Register(path, defaultValue); err != nil {
|
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
|
// Check if the environment variable exists and load it
|
||||||
if value, exists := os.LookupEnv(envVar); exists {
|
if value, exists := os.LookupEnv(envVar); exists {
|
||||||
parsed := parseValue(value)
|
return c.SetSource(SourceEnv, path, value) // Already wrapped with error category in SetSource
|
||||||
return c.SetSource(SourceEnv, path, parsed) // Already wrapped with error category in SetSource
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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
|
// RegisterRequired registers a path and marks it as required
|
||||||
// The configuration will fail validation if this value is not provided
|
// The configuration will fail validation if this value is not provided
|
||||||
func (c *Config) RegisterRequired(path string, defaultValue any) error {
|
func (c *Config) RegisterRequired(path string, defaultValue any) error {
|
||||||
// For now, just register normally
|
if err := validatePath(path); err != nil {
|
||||||
// The required paths will be tracked separately in a future enhancement
|
return err
|
||||||
return c.Register(path, defaultValue)
|
}
|
||||||
|
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
|
// 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
|
// Remove the path itself if it exists
|
||||||
delete(c.items, path)
|
delete(c.items, path)
|
||||||
|
delete(c.fileData, path)
|
||||||
|
delete(c.envData, path)
|
||||||
|
delete(c.cliData, path)
|
||||||
|
|
||||||
|
c.invalidateCache()
|
||||||
|
|
||||||
// Remove any child paths
|
// Remove any child paths
|
||||||
prefix := path + "."
|
prefix := path + "."
|
||||||
for childPath := range c.items {
|
for childPath := range c.items {
|
||||||
if strings.HasPrefix(childPath, prefix) {
|
if strings.HasPrefix(childPath, prefix) {
|
||||||
delete(c.items, childPath)
|
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
|
// Validate tag name
|
||||||
switch tagName {
|
switch tagName {
|
||||||
case FormatTOML, FormatJSON, FormatYAML:
|
case FormatTOML:
|
||||||
// Supported tags
|
// Supported tags
|
||||||
default:
|
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
|
var errors []string
|
||||||
|
|
||||||
// Use helper function for recursive registration with specified tag
|
// 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 {
|
if len(errors) > 0 {
|
||||||
return wrapError(ErrTypeMismatch, fmt.Errorf("failed to register %d field(s): %s", len(errors), strings.Join(errors, "; ")))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// registerFields is a helper function that handles the recursive field registration
|
// 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) {
|
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()
|
t := v.Type()
|
||||||
|
|
||||||
for i := 0; i < v.NumField(); i++ {
|
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
|
// Get tag value based on tagName parameter
|
||||||
tag := field.Tag.Get(tagName)
|
tag := field.Tag.Get(tagName)
|
||||||
if tag == "-" {
|
if strings.Split(tag, ",")[0] == "-" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,22 +282,19 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
|
|||||||
if isStruct || isPtrToStruct {
|
if isStruct || isPtrToStruct {
|
||||||
// Check if the field's TYPE is one that should be treated as a single value,
|
// 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
|
// even though it's a struct. These types have custom decode hooks
|
||||||
fieldType := fieldValue.Type()
|
isAtomicStruct := atomicType(fieldValue.Type())
|
||||||
isAtomicStruct := false
|
|
||||||
switch fieldType.String() {
|
|
||||||
case "time.Time", "*net.IPNet", "*url.URL", "net.IP": // Match the exact type names
|
|
||||||
isAtomicStruct = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only recurse if it's a "normal" struct, not an atomic one
|
// Only recurse if it's a "normal" struct, not an atomic one
|
||||||
if !isAtomicStruct {
|
if !isAtomicStruct {
|
||||||
nestedValue := fieldValue
|
nestedValue := fieldValue
|
||||||
if isPtrToStruct {
|
if isPtrToStruct {
|
||||||
if fieldValue.IsNil() {
|
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 + "."
|
nestedPrefix := currentPath + "."
|
||||||
c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors, tagName)
|
c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors, tagName)
|
||||||
@@ -212,6 +304,10 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Register non-struct fields
|
// Register non-struct fields
|
||||||
|
if _, exists := c.items[currentPath]; exists {
|
||||||
|
*errors = append(*errors, fmt.Sprintf("duplicate TOML path %q", currentPath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
defaultValue := fieldValue.Interface()
|
defaultValue := fieldValue.Interface()
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
@@ -228,8 +324,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
|
|||||||
// Handle explicit env tag
|
// Handle explicit env tag
|
||||||
if envTag != "" && err == nil {
|
if envTag != "" && err == nil {
|
||||||
if value, exists := os.LookupEnv(envTag); exists {
|
if value, exists := os.LookupEnv(envTag); exists {
|
||||||
parsed := parseValue(value)
|
if setErr := c.SetSource(SourceEnv, currentPath, value); setErr != nil {
|
||||||
if setErr := c.SetSource(SourceEnv, currentPath, parsed); setErr != nil {
|
|
||||||
*errors = append(*errors, fmt.Sprintf("field %s%s env %s: %v", fieldPath, field.Name, envTag, setErr))
|
*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)
|
result := make(map[string]any)
|
||||||
for path, item := range c.items {
|
for path, item := range c.items {
|
||||||
if strings.HasPrefix(path, p) {
|
if strings.HasPrefix(path, p) {
|
||||||
result[path] = item.defaultValue
|
result[path] = cloneOwned(item.defaultValue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+76
-137
@@ -8,10 +8,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
|
||||||
"github.com/go-viper/mapstructure/v2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Quick creates a fully configured Config instance with a single call
|
// 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
|
// BindFlags updates configuration from parsed flag.FlagSet
|
||||||
func (c *Config) BindFlags(fs *flag.FlagSet) error {
|
func (c *Config) BindFlags(fs *flag.FlagSet) error {
|
||||||
var errors []error
|
if fs == nil {
|
||||||
needsInvalidation := false
|
return wrapError(ErrCLIParse, fmt.Errorf("nil flag set"))
|
||||||
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
})
|
values := make(map[string]any)
|
||||||
|
fs.Visit(func(f *flag.Flag) { values[f.Name] = f.Value.String() })
|
||||||
if needsInvalidation {
|
c.mutex.Lock()
|
||||||
c.invalidateCache() // Batch invalidation after all flags
|
defer c.mutex.Unlock()
|
||||||
|
for path := range values {
|
||||||
|
if _, ok := c.items[path]; !ok {
|
||||||
|
return wrapError(ErrCLIParse, fmt.Errorf("failed to bind unregistered flag %q: %w", path, ErrPathNotRegistered))
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(errors) > 0 {
|
|
||||||
return wrapError(ErrCLIParse, fmt.Errorf("failed to bind %d flags: %w", len(errors), errors[0]))
|
|
||||||
}
|
}
|
||||||
|
if err := c.replaceSourceLocked(SourceCLI, values); err != nil {
|
||||||
|
return wrapError(ErrCLIParse, err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +110,13 @@ func (c *Config) Validate(required ...string) error {
|
|||||||
defer c.mutex.RUnlock()
|
defer c.mutex.RUnlock()
|
||||||
|
|
||||||
var missing []string
|
var missing []string
|
||||||
|
if len(required) == 0 {
|
||||||
|
for path, item := range c.items {
|
||||||
|
if item.required {
|
||||||
|
required = append(required, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, path := range required {
|
for _, path := range required {
|
||||||
item, exists := c.items[path]
|
item, exists := c.items[path]
|
||||||
@@ -132,7 +129,11 @@ func (c *Config) Validate(required ...string) error {
|
|||||||
if reflect.DeepEqual(item.currentValue, item.defaultValue) {
|
if reflect.DeepEqual(item.currentValue, item.defaultValue) {
|
||||||
// Check if any source provided a value
|
// Check if any source provided a value
|
||||||
hasValue := false
|
hasValue := false
|
||||||
for _, val := range item.values {
|
for _, source := range c.options.Sources {
|
||||||
|
if source == SourceDefault {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val := item.values[source]
|
||||||
if val != nil {
|
if val != nil {
|
||||||
hasValue = true
|
hasValue = true
|
||||||
break
|
break
|
||||||
@@ -177,59 +178,50 @@ func (c *Config) Debug() string {
|
|||||||
// Dump writes the current configuration to stdout in TOML format
|
// Dump writes the current configuration to stdout in TOML format
|
||||||
func (c *Config) Dump() error {
|
func (c *Config) Dump() error {
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
defer c.mutex.RUnlock()
|
nestedData := c.nestedLocked("")
|
||||||
|
c.mutex.RUnlock()
|
||||||
|
|
||||||
nestedData := make(map[string]any)
|
data, err := marshalConfig(nestedData)
|
||||||
for path, item := range c.items {
|
if err != nil {
|
||||||
setNestedValue(nestedData, path, item.currentValue)
|
return err
|
||||||
}
|
}
|
||||||
|
_, err = os.Stdout.Write(data)
|
||||||
encoder := toml.NewEncoder(os.Stdout)
|
return err
|
||||||
if err := encoder.Encode(nestedData); err != nil {
|
|
||||||
return wrapError(ErrDecode, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clone creates a deep copy of the configuration
|
// Clone creates a deep copy of the configuration
|
||||||
func (c *Config) Clone() *Config {
|
func (c *Config) Clone() *Config {
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
defer c.mutex.RUnlock()
|
defer c.mutex.RUnlock()
|
||||||
|
clone := NewWithOptions(c.options)
|
||||||
clone := &Config{
|
clone.tagName, clone.fileFormat = c.tagName, c.fileFormat
|
||||||
items: make(map[string]configItem),
|
clone.configFilePath = c.configFilePath
|
||||||
options: c.options,
|
clone.fileComments = append([]string(nil), c.fileComments...)
|
||||||
fileData: make(map[string]any),
|
clone.unknownCLIKeys = append([]string(nil), c.unknownCLIKeys...)
|
||||||
envData: make(map[string]any),
|
if c.securityOpts != nil {
|
||||||
cliData: make(map[string]any),
|
opts := *c.securityOpts
|
||||||
|
clone.securityOpts = &opts
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deep copy items
|
|
||||||
for path, item := range c.items {
|
for path, item := range c.items {
|
||||||
newItem := configItem{
|
next := configItem{defaultValue: cloneOwned(item.defaultValue), currentValue: cloneOwned(item.currentValue), values: make(map[Source]any)}
|
||||||
defaultValue: item.defaultValue,
|
|
||||||
currentValue: item.currentValue,
|
|
||||||
values: make(map[Source]any),
|
|
||||||
}
|
|
||||||
|
|
||||||
for source, value := range item.values {
|
for source, value := range item.values {
|
||||||
newItem.values[source] = value
|
next.values[source] = cloneOwned(value)
|
||||||
}
|
}
|
||||||
|
next.required = item.required
|
||||||
clone.items[path] = newItem
|
clone.items[path] = next
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy cache data
|
|
||||||
for k, v := range c.fileData {
|
for k, v := range c.fileData {
|
||||||
clone.fileData[k] = v
|
clone.fileData[k] = cloneOwned(v)
|
||||||
}
|
}
|
||||||
for k, v := range c.envData {
|
for k, v := range c.envData {
|
||||||
clone.envData[k] = v
|
clone.envData[k] = cloneOwned(v)
|
||||||
}
|
}
|
||||||
for k, v := range c.cliData {
|
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
|
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))
|
return zero, wrapError(ErrPathNotFound, fmt.Errorf("path %q not found", path))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the input map and target struct for the decoder
|
var target T
|
||||||
inputMap := map[string]any{"value": rawValue}
|
if err := decodeConfig(rawValue, &target); err != nil {
|
||||||
var target struct {
|
return zero, err
|
||||||
Value T `mapstructure:"value"`
|
|
||||||
}
|
}
|
||||||
|
return target, nil
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTypedWithDefault retrieves a configuration value with a default fallback
|
// 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
|
// 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
|
// For simple cases where explicit defaults aren't pre-registered
|
||||||
func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) {
|
func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) {
|
||||||
// Check if path exists and has a value
|
c.mutex.Lock()
|
||||||
if _, exists := c.Get(path); exists {
|
if item, exists := c.items[path]; exists {
|
||||||
// Path exists, try to decode the current value
|
raw := item.currentValue
|
||||||
result, err := GetTyped[T](c, path)
|
c.mutex.Unlock()
|
||||||
if err == nil {
|
var result T
|
||||||
|
if err := decodeConfig(raw, &result); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
// Type conversion failed, fall through to set default
|
if err := validatePath(path); err != nil {
|
||||||
|
c.mutex.Unlock()
|
||||||
|
return defaultValue, err
|
||||||
}
|
}
|
||||||
|
owned, err := copyValue(defaultValue)
|
||||||
// Path doesn't exist or value not set - register and set default
|
if err == nil {
|
||||||
// This handles the case where the path wasn't pre-registered
|
err = c.registerLocked(path, owned)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
c.mutex.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue, err
|
||||||
}
|
}
|
||||||
|
var result T
|
||||||
// Set the default value
|
err = decodeConfig(owned, &result)
|
||||||
if err := c.Set(path, defaultValue); err != nil {
|
return result, err
|
||||||
return defaultValue, wrapError(ErrTypeMismatch, fmt.Errorf("%w : failed to set default value for path %q", ErrTypeMismatch, path))
|
|
||||||
}
|
|
||||||
|
|
||||||
return defaultValue, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScanTyped is a generic wrapper around Scan. It allocates a new instance of type T,
|
// 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
|
// without requiring a full Config instance. This is useful for plugin
|
||||||
// initialization where config data arrives as a map[string]any.
|
// initialization where config data arrives as a map[string]any.
|
||||||
func ScanMap(configMap map[string]any, target any, tagName ...string) error {
|
func ScanMap(configMap map[string]any, target any, tagName ...string) error {
|
||||||
// Handle nil map
|
if len(tagName) > 1 || len(tagName) == 1 && tagName[0] != "" && tagName[0] != FormatTOML {
|
||||||
if configMap == nil {
|
return wrapError(ErrTypeMismatch, fmt.Errorf("only toml tags are supported"))
|
||||||
configMap = make(map[string]any)
|
|
||||||
}
|
}
|
||||||
|
return decodeConfig(configMap, target)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
+5
-20
@@ -162,7 +162,7 @@ func TestFlagGeneration(t *testing.T) {
|
|||||||
|
|
||||||
err := cfg.BindFlags(fs)
|
err := cfg.BindFlags(fs)
|
||||||
assert.Error(t, err)
|
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)
|
assert.Equal(t, "info", target.LogLevel)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ScanWithJSONTags", func(t *testing.T) {
|
t.Run("RejectJSONTags", func(t *testing.T) {
|
||||||
configMap := map[string]any{
|
|
||||||
"server": map[string]any{
|
|
||||||
"hostname": "json-host",
|
|
||||||
"port": 9090,
|
|
||||||
"timeout": "1m",
|
|
||||||
},
|
|
||||||
"logLevel": "debug",
|
|
||||||
}
|
|
||||||
|
|
||||||
var target Config
|
var target Config
|
||||||
err := ScanMap(configMap, &target, "json")
|
require.Error(t, ScanMap(map[string]any{}, &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)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("NilMapInput", func(t *testing.T) {
|
t.Run("NilMapInput", func(t *testing.T) {
|
||||||
@@ -463,7 +448,7 @@ func TestScanMap(t *testing.T) {
|
|||||||
|
|
||||||
err := ScanMap(configMap, target)
|
err := ScanMap(configMap, target)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
// The underlying mapstructure error is "result must be a pointer"
|
// Invalid targets return a categorized type error.
|
||||||
assert.Contains(t, err.Error(), "must be a pointer")
|
assert.Contains(t, err.Error(), "must be non-nil pointer")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+7
-6
@@ -3,6 +3,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"net"
|
"net"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,16 +20,16 @@ func Port(p int64) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Positive validates positive numbers
|
// Positive validates positive numbers
|
||||||
func Positive[T int64 | float64](n T) error {
|
func Positive[T ~int64 | ~float64](n T) error {
|
||||||
if n <= 0 {
|
if n <= 0 || math.IsNaN(float64(n)) || math.IsInf(float64(n), 0) {
|
||||||
return wrapError(ErrValidation, fmt.Errorf("must be positive, got %v", n))
|
return wrapError(ErrValidation, fmt.Errorf("must be positive, got %v", n))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NonNegative validates non-negative numbers
|
// NonNegative validates non-negative numbers
|
||||||
func NonNegative[T int64 | float64](n T) error {
|
func NonNegative[T ~int64 | ~float64](n T) error {
|
||||||
if n < 0 {
|
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 wrapError(ErrValidation, fmt.Errorf("must be non-negative, got %v", n))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -94,9 +95,9 @@ func OneOf[T comparable](allowed ...T) func(T) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Range creates a min/max validator
|
// 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 {
|
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 wrapError(ErrValidation, fmt.Errorf("must be %v-%v, got %v", min, max, val))
|
||||||
}
|
}
|
||||||
return nil
|
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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WatchOptions configures file watching behavior
|
|
||||||
type WatchOptions struct {
|
type WatchOptions struct {
|
||||||
// PollInterval for file stat checks (minimum 100ms)
|
PollInterval time.Duration // Minimum 100ms; each poll reads and fingerprints the file.
|
||||||
PollInterval time.Duration
|
Debounce time.Duration // Quiet interval before publishing a value or error.
|
||||||
|
|
||||||
// Debounce duration to avoid rapid reloads
|
|
||||||
Debounce time.Duration
|
|
||||||
|
|
||||||
// MaxWatchers limits concurrent watch channels
|
|
||||||
MaxWatchers int
|
MaxWatchers int
|
||||||
|
ReloadTimeout time.Duration // Expired reads/parses cannot publish.
|
||||||
// ReloadTimeout for file reload operations
|
|
||||||
ReloadTimeout time.Duration
|
|
||||||
|
|
||||||
// VerifyPermissions checks file hasn't been replaced with different permissions
|
|
||||||
VerifyPermissions bool
|
VerifyPermissions bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultWatchOptions returns sensible defaults for file watching
|
|
||||||
func DefaultWatchOptions() WatchOptions {
|
func DefaultWatchOptions() WatchOptions {
|
||||||
return WatchOptions{
|
return WatchOptions{DefaultPollInterval, DefaultDebounce, DefaultMaxWatchers, DefaultReloadTimeout, true}
|
||||||
PollInterval: DefaultPollInterval,
|
|
||||||
Debounce: DefaultDebounce,
|
|
||||||
MaxWatchers: DefaultMaxWatchers,
|
|
||||||
ReloadTimeout: DefaultReloadTimeout,
|
|
||||||
VerifyPermissions: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// watcher manages file watching state
|
|
||||||
type watcher struct {
|
type watcher struct {
|
||||||
mu sync.RWMutex
|
mu sync.Mutex
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
opts WatchOptions
|
opts WatchOptions
|
||||||
filePath string
|
filePath string
|
||||||
lastModTime time.Time
|
|
||||||
lastSize int64
|
|
||||||
lastMode os.FileMode
|
lastMode os.FileMode
|
||||||
watching atomic.Bool
|
watching atomic.Bool
|
||||||
reloadInProgress atomic.Bool
|
watchers map[int64]chan string
|
||||||
watchers map[int64]chan string // subscriber channels
|
watcherID int64
|
||||||
watcherID atomic.Int64
|
observed string
|
||||||
debounceTimer *time.Timer
|
handled string
|
||||||
|
timeoutKey string
|
||||||
|
observedAt time.Time
|
||||||
|
read func(context.Context, string, fileSettings) (*fileSnapshot, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// configWatcher extends Config with watching capabilities
|
func normalizeWatchOptions(opts WatchOptions) WatchOptions {
|
||||||
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
|
|
||||||
if opts.PollInterval < MinPollInterval {
|
if opts.PollInterval < MinPollInterval {
|
||||||
opts.PollInterval = MinPollInterval
|
opts.PollInterval = MinPollInterval
|
||||||
}
|
}
|
||||||
|
if opts.Debounce < 0 {
|
||||||
|
opts.Debounce = 0
|
||||||
|
}
|
||||||
if opts.MaxWatchers <= 0 {
|
if opts.MaxWatchers <= 0 {
|
||||||
opts.MaxWatchers = 100
|
opts.MaxWatchers = DefaultMaxWatchers
|
||||||
}
|
}
|
||||||
if opts.ReloadTimeout <= 0 {
|
if opts.ReloadTimeout <= 0 {
|
||||||
opts.ReloadTimeout = DefaultReloadTimeout
|
opts.ReloadTimeout = DefaultReloadTimeout
|
||||||
}
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) AutoUpdate() { c.AutoUpdateWithOptions(DefaultWatchOptions()) }
|
||||||
|
func (c *Config) AutoUpdateWithOptions(opts WatchOptions) {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
c.startWatcherLocked(opts)
|
||||||
// Get path of current file to watch
|
}
|
||||||
filePath := c.getConfigFilePath()
|
func (c *Config) startWatcherLocked(opts WatchOptions) {
|
||||||
if filePath == "" {
|
if c.configFilePath == "" {
|
||||||
// No file configured, nothing to watch
|
return
|
||||||
|
}
|
||||||
|
if c.watcher != nil {
|
||||||
|
if c.watcher.filePath == c.configFilePath && c.watcher.watching.Load() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop existing watcher if path changed
|
|
||||||
if c.watcher != nil && c.watcher.filePath != filePath {
|
|
||||||
c.watcher.stop()
|
c.watcher.stop()
|
||||||
c.watcher = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize watcher if needed
|
|
||||||
if c.watcher == nil {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
c.watcher = &watcher{
|
w := &watcher{ctx: ctx, cancel: cancel, opts: normalizeWatchOptions(opts), filePath: c.configFilePath, watchers: make(map[int64]chan string), read: readConfigFile}
|
||||||
ctx: ctx,
|
if info, err := os.Stat(w.filePath); err == nil {
|
||||||
cancel: cancel,
|
w.lastMode = info.Mode()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
w.watching.Store(true)
|
||||||
|
c.watcher = w
|
||||||
|
go w.watchLoop(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopAutoUpdate stops automatic configuration reloading
|
|
||||||
func (c *Config) StopAutoUpdate() {
|
func (c *Config) StopAutoUpdate() {
|
||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
if c.watcher != nil {
|
if c.watcher != nil {
|
||||||
c.watcher.stop()
|
c.watcher.stop()
|
||||||
c.watcher = nil
|
c.watcher = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func (c *Config) Watch() <-chan string { return c.WatchWithOptions(DefaultWatchOptions()) }
|
||||||
// Watch returns a channel that receives paths of changed configuration values
|
func (c *Config) WatchWithOptions(opts WatchOptions) <-chan string {
|
||||||
func (c *Config) Watch() <-chan string {
|
c.mutex.Lock()
|
||||||
return c.WatchWithOptions(DefaultWatchOptions())
|
defer c.mutex.Unlock()
|
||||||
|
c.startWatcherLocked(opts)
|
||||||
|
if c.watcher == nil {
|
||||||
|
return closedWatchChannel()
|
||||||
|
}
|
||||||
|
return c.watcher.subscribe()
|
||||||
}
|
}
|
||||||
|
func (c *Config) WatchFile(path string, formatHint ...string) error {
|
||||||
// WatchFile stops any existing file watcher, loads a new configuration file,
|
if len(formatHint) > 1 {
|
||||||
// and starts a new watcher on that file path. Optionally accepts format hint
|
return ErrFileFormat
|
||||||
func (c *Config) WatchFile(filePath string, formatHint ...string) error {
|
}
|
||||||
// Stop any currently running watcher
|
if len(formatHint) == 1 {
|
||||||
c.StopAutoUpdate()
|
|
||||||
|
|
||||||
// Set format hint if provided
|
|
||||||
if len(formatHint) > 0 {
|
|
||||||
if err := c.SetFileFormat(formatHint[0]); err != nil {
|
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()
|
c.mutex.RLock()
|
||||||
opts := DefaultWatchOptions()
|
opts := DefaultWatchOptions()
|
||||||
if c.watcher != nil {
|
if c.watcher != nil {
|
||||||
opts = c.watcher.opts
|
opts = c.watcher.opts
|
||||||
}
|
}
|
||||||
c.mutex.RUnlock()
|
c.mutex.RUnlock()
|
||||||
|
// A failed load leaves the existing watcher and valid state intact.
|
||||||
// Create and start a new watcher with the new file path
|
if err := c.LoadFile(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
c.AutoUpdateWithOptions(opts)
|
c.AutoUpdateWithOptions(opts)
|
||||||
return nil
|
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 {
|
func (c *Config) IsWatching() bool {
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
defer c.mutex.RUnlock()
|
defer c.mutex.RUnlock()
|
||||||
return c.watcher != nil && c.watcher.watching.Load()
|
return c.watcher != nil && c.watcher.watching.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
// WatcherCount returns the number of active watch channels
|
|
||||||
func (c *Config) WatcherCount() int {
|
func (c *Config) WatcherCount() int {
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
defer c.mutex.RUnlock()
|
defer c.mutex.RUnlock()
|
||||||
|
|
||||||
if c.watcher == nil {
|
if c.watcher == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
c.watcher.mu.Lock()
|
||||||
c.watcher.mu.RLock()
|
defer c.watcher.mu.Unlock()
|
||||||
defer c.watcher.mu.RUnlock()
|
|
||||||
return len(c.watcher.watchers)
|
return len(c.watcher.watchers)
|
||||||
}
|
}
|
||||||
|
|
||||||
// watchLoop is the main file watching loop
|
|
||||||
func (w *watcher) watchLoop(c *Config) {
|
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)
|
ticker := time.NewTicker(w.opts.PollInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-w.ctx.Done():
|
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) {
|
func (w *watcher) checkAndReload(c *Config) {
|
||||||
info, err := os.Stat(w.filePath)
|
if w.ctx.Err() != nil {
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
// File was deleted, notify watchers
|
|
||||||
w.notifyWatchers(EventFileDeleted)
|
|
||||||
}
|
|
||||||
return
|
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)
|
ctx, cancel := context.WithTimeout(w.ctx, w.opts.ReloadTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
c.mutex.RLock()
|
||||||
// Track what changed
|
opts := c.fileSettingsLocked()
|
||||||
oldValues := c.snapshot()
|
generation := c.fileGeneration
|
||||||
|
c.mutex.RUnlock()
|
||||||
// Reload file in a goroutine with timeout
|
snapshot, readErr := w.read(ctx, w.filePath, opts)
|
||||||
done := make(chan error, 1)
|
key := ""
|
||||||
go func() {
|
event := ""
|
||||||
done <- c.loadFile(w.filePath)
|
if readErr != nil {
|
||||||
}()
|
key = "error:" + readErr.Error()
|
||||||
|
switch {
|
||||||
select {
|
case errors.Is(readErr, context.DeadlineExceeded):
|
||||||
case err := <-done:
|
event = EventReloadTimeout
|
||||||
if err != nil {
|
case errors.Is(readErr, ErrConfigNotFound):
|
||||||
// Reload failed, notify error
|
event = EventFileDeleted
|
||||||
w.notifyWatchers(fmt.Sprintf("%s:%v", EventReloadError, err))
|
default:
|
||||||
|
event = EventReloadError + ":" + readErr.Error()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
key = fmt.Sprintf("%x:%o", snapshot.digest, snapshot.info.Mode())
|
||||||
|
if w.opts.VerifyPermissions && w.lastMode != 0 && (snapshot.info.Mode()&0077) != (w.lastMode&0077) {
|
||||||
|
event = EventPermissionsChanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if key != w.observed {
|
||||||
|
w.observed, w.observedAt = key, time.Now()
|
||||||
|
}
|
||||||
|
if key == w.handled || time.Since(w.observedAt) < w.opts.Debounce {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if w.ctx.Err() != nil {
|
||||||
// Compare and notify changes
|
return
|
||||||
newValues := c.snapshot()
|
}
|
||||||
for path, newVal := range newValues {
|
if event != "" {
|
||||||
if oldVal, existed := oldValues[path]; !existed || !reflect.DeepEqual(oldVal, newVal) {
|
if event == EventReloadTimeout {
|
||||||
|
w.notifyTimeout(key)
|
||||||
|
} else {
|
||||||
|
w.notifyWatchers(event)
|
||||||
|
w.handled = key
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
root, comments, err := parseFile(snapshot)
|
||||||
|
if err != nil {
|
||||||
|
w.notifyWatchers(EventReloadError + ":" + err.Error())
|
||||||
|
w.handled = key
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.mutex.Lock()
|
||||||
|
defer c.mutex.Unlock()
|
||||||
|
if c.watcher != w || c.configFilePath != w.filePath || c.fileGeneration != generation || w.ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
w.notifyTimeout(key)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
old := make(map[string]any, len(c.items))
|
||||||
|
for path, item := range c.items {
|
||||||
|
old[path] = item.currentValue
|
||||||
|
}
|
||||||
|
if err := c.applyFileLocked(root, comments, w.filePath, ctx.Err); err != nil {
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
w.notifyTimeout(key)
|
||||||
|
} else {
|
||||||
|
w.notifyWatchers(EventReloadError + ":" + err.Error())
|
||||||
|
w.handled = key
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.lastMode, w.handled = snapshot.info.Mode(), key
|
||||||
|
w.timeoutKey = ""
|
||||||
|
var changed []string
|
||||||
|
for path, item := range c.items {
|
||||||
|
if !reflect.DeepEqual(old[path], item.currentValue) {
|
||||||
|
changed = append(changed, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(changed)
|
||||||
|
for _, path := range changed {
|
||||||
w.notifyWatchers(path)
|
w.notifyWatchers(path)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check for deletions
|
|
||||||
for path := range oldValues {
|
|
||||||
if _, exists := newValues[path]; !exists {
|
|
||||||
w.notifyWatchers(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Reload timeout
|
|
||||||
w.notifyWatchers(EventReloadTimeout)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func (w *watcher) subscribe() <-chan string {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
if w.ctx.Err() != nil || len(w.watchers) >= w.opts.MaxWatchers {
|
||||||
// Check watcher limit
|
return closedWatchChannel()
|
||||||
if len(w.watchers) >= w.opts.MaxWatchers {
|
|
||||||
// Return closed channel to prevent resource exhaustion
|
|
||||||
ch := make(chan string)
|
|
||||||
close(ch)
|
|
||||||
return ch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create buffered channel to prevent blocking
|
|
||||||
ch := make(chan string, WatchChannelBuffer)
|
ch := make(chan string, WatchChannelBuffer)
|
||||||
id := w.watcherID.Add(1)
|
w.watcherID++
|
||||||
w.watchers[id] = ch
|
w.watchers[w.watcherID] = ch
|
||||||
|
|
||||||
// Cleanup goroutine
|
|
||||||
go func() {
|
|
||||||
<-w.ctx.Done()
|
|
||||||
w.mu.Lock()
|
|
||||||
delete(w.watchers, id)
|
|
||||||
close(ch)
|
|
||||||
w.mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
return ch
|
return ch
|
||||||
}
|
}
|
||||||
|
|
||||||
// notifyWatchers sends change notification to all subscribers
|
|
||||||
func (w *watcher) notifyWatchers(path string) {
|
func (w *watcher) notifyWatchers(path string) {
|
||||||
w.mu.RLock()
|
w.mu.Lock()
|
||||||
defer w.mu.RUnlock()
|
defer w.mu.Unlock()
|
||||||
|
if w.ctx.Err() != nil {
|
||||||
for id, ch := range w.watchers {
|
return
|
||||||
|
}
|
||||||
|
for _, ch := range w.watchers {
|
||||||
select {
|
select {
|
||||||
case ch <- path:
|
case ch <- path:
|
||||||
// Sent successfully
|
|
||||||
default:
|
default:
|
||||||
// Channel full or closed, skip
|
|
||||||
// Could implement removal of dead watchers here
|
|
||||||
_ = id
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// stop terminates the watcher
|
|
||||||
func (w *watcher) stop() {
|
func (w *watcher) stop() {
|
||||||
if w.cancel != nil {
|
|
||||||
w.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop debounce timer
|
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
if w.debounceTimer != nil {
|
defer w.mu.Unlock()
|
||||||
w.debounceTimer.Stop()
|
w.cancel()
|
||||||
w.debounceTimer = nil
|
w.watching.Store(false)
|
||||||
}
|
for id, ch := range w.watchers {
|
||||||
w.mu.Unlock()
|
close(ch)
|
||||||
|
delete(w.watchers, id)
|
||||||
// Wait for watch loop to exit with timeout
|
|
||||||
deadline := time.Now().Add(ShutdownTimeout)
|
|
||||||
for w.watching.Load() && time.Now().Before(deadline) {
|
|
||||||
time.Sleep(SpinWaitInterval)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
func (c *Config) snapshot() map[string]any {
|
||||||
c.mutex.RLock()
|
c.mutex.RLock()
|
||||||
defer c.mutex.RUnlock()
|
defer c.mutex.RUnlock()
|
||||||
|
out := make(map[string]any, len(c.items))
|
||||||
snapshot := make(map[string]any, len(c.items))
|
|
||||||
for path, item := range 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