Compare commits

2 Commits
v0.1.0 ... main

18 changed files with 339 additions and 209 deletions

View File

@ -61,7 +61,7 @@ func main() {
- [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
- [LLM Integration Guide](doc/config-llm-guide.md) - Guide for LLM usage without full codebase
- [Quick Guide](doc/quick-guide_lixenwraith_config.md) - Quick reference guide
## License

View File

@ -10,8 +10,8 @@ import (
"strings"
)
// Builder provides a fluent API for constructing a Config instance. It allows for
// chaining configuration options before final build of the config object.
// Builder provides a fluent API for constructing a Config instance
// Allows chaining configuration options before final build of the config object
type Builder struct {
cfg *Config
opts LoadOptions
@ -27,8 +27,8 @@ type Builder struct {
typedValidators []any
}
// ValidatorFunc defines the signature for a function that can validate a Config instance.
// It receives the fully loaded *Config object and should return an error if validation fails.
// ValidatorFunc defines the signature for a function that can validate a Config instance
// It receives the fully loaded *Config object and returns error if validation fails
type ValidatorFunc func(c *Config) error
// NewBuilder creates a new configuration builder
@ -63,27 +63,26 @@ func (b *Builder) Build() (*Config, error) {
}
// 1. Register defaults
// If WithDefaults() was called, it takes precedence.
// If not, but WithTarget() was called, use the target struct for defaults.
// If WithDefaults() was called, it takes precedence
// If not, but WithTarget() was called, use the target struct for defaults
if b.defaults != nil {
// WithDefaults() was called explicitly.
if err := b.cfg.RegisterStructWithTags(b.prefix, b.defaults, tagName); err != nil {
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("failed to register defaults: %w", err))
}
} else if b.cfg.structCache != nil && b.cfg.structCache.target != nil {
// No explicit defaults, so use the target struct as the source of defaults.
// This is the behavior the tests rely on.
// No explicit defaults, so use the target struct as the source of defaults
if err := b.cfg.RegisterStructWithTags(b.prefix, b.cfg.structCache.target, tagName); err != nil {
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("failed to register target struct as defaults: %w", err))
}
}
// 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
// 2. Load configuration
loadErr := b.cfg.LoadWithOptions(b.file, b.args, b.opts)
loadErr := b.cfg.loadWithOptions(b.file, b.args, b.opts)
if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) {
// Return on fatal load errors. ErrConfigNotFound is not fatal.
return nil, wrapError(ErrFileAccess, loadErr)
@ -98,23 +97,23 @@ func (b *Builder) Build() (*Config, error) {
// 4. Populate target and run typed validators
if b.cfg.structCache != nil && b.cfg.structCache.target != nil && len(b.typedValidators) > 0 {
// Populate the target struct first. This unifies 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()
if err != nil {
return nil, wrapError(ErrValidation, fmt.Errorf("failed to populate target struct for validation: %w", err))
}
// 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 {
validatorFunc := reflect.ValueOf(validator)
validatorType := validatorFunc.Type()
// Check if the validator's input type matches the target's type.
// Check if the validator's input type matches the target's type
if validatorType.In(0) != reflect.TypeOf(populatedTarget) {
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("typed validator signature %v does not match target type %T", validatorType, populatedTarget))
}
// Call the validator.
// Call the validator
results := validatorFunc.Call([]reflect.Value{reflect.ValueOf(populatedTarget)})
if !results[0].IsNil() {
err := results[0].Interface().(error)
@ -319,13 +318,13 @@ func (b *Builder) WithValidator(fn ValidatorFunc) *Builder {
// WithTypedValidator adds a type-safe validation function that runs at the end of the build process,
// after the target struct has been populated. The provided function must accept a single argument
// that is a pointer to the same type as the one provided to WithTarget, and must return an error.
// that is a pointer to the same type as the one provided to WithTarget, and must return an error
func (b *Builder) WithTypedValidator(fn any) *Builder {
if fn == nil {
return b
}
// Basic reflection check to ensure it's a function that takes one argument and returns an error.
// Basic reflection check to ensure it's a function that takes one argument and returns an error
t := reflect.TypeOf(fn)
if t.Kind() != reflect.Func || t.NumIn() != 1 || t.NumOut() != 1 || t.Out(0) != reflect.TypeOf((*error)(nil)).Elem() {
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("WithTypedValidator requires a function with signature func(*T) error"))

View File

@ -224,7 +224,6 @@ func TestFileDiscovery(t *testing.T) {
assert.Equal(t, "value", val)
})
// Rest of test cases remain the same...
t.Run("DiscoveryWithEnvVar", func(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "env.toml")
@ -361,4 +360,4 @@ func TestBuilderWithTypedValidator(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "typed validator signature")
})
}
}

View File

@ -55,7 +55,7 @@ type Config struct {
configFilePath string // Track loaded file path
}
// New creates and initializes a new Config instance.
// New creates and initializes a new Config instance
func New() *Config {
return &Config{
items: make(map[string]configItem),
@ -160,8 +160,8 @@ func (c *Config) GetPrecedence() []Source {
return result
}
// SetFileFormat sets the expected format for configuration files.
// Use "auto" to detect based on file extension.
// SetFileFormat sets the expected format for configuration files
// Use "auto" to detect based on file extension
func (c *Config) SetFileFormat(format string) error {
switch format {
case FormatTOML, FormatJSON, FormatYAML, FormatAuto:
@ -211,10 +211,10 @@ func (c *Config) GetSource(path string, source Source) (any, bool) {
return val, exists
}
// Set updates a configuration value for the given path.
// It sets the value in the highest priority source from the configured Sources.
// 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.
// Set updates a configuration value for the given path
// It sets the value in the highest priority source from the configured Sources
// By default, this is SourceCLI. Returns an error if the path is not registered
// To set a value in a specific source, use SetSource instead
func (c *Config) Set(path string, value any) error {
return c.SetSource(c.options.Sources[0], path, value)
}

View File

@ -45,9 +45,6 @@ const (
EventPrecedenceChanged = "precedence"
)
// debounceSettleMultiplier ensures sufficient time for debounce to complete
const debounceSettleMultiplier = 3 // Wait 3x debounce period for value stabilization
// Channel and resource limits
const (
DefaultMaxWatchers = 100

View File

@ -14,7 +14,7 @@ import (
)
// unmarshal is the single authoritative function for decoding configuration
// into target structures. All public decoding methods delegate to this.
// into target structures. All public decoding methods delegate to this
func (c *Config) unmarshal(source Source, target any, basePath ...string) error {
// Parse variadic basePath
path := ""
@ -56,13 +56,13 @@ func (c *Config) unmarshal(source Source, target any, basePath ...string) error
// Navigate to basePath section
sectionData := navigateToPath(nestedMap, path)
// Ensure we have a map to decode, normalizing if necessary.
// Ensure we have a map to decode, normalizing if necessary
sectionMap, err := normalizeMap(sectionData)
if err != nil {
if sectionData == nil {
sectionMap = make(map[string]any) // Empty section is valid.
sectionMap = make(map[string]any) // Empty section is valid
} else {
// Path points to a non-map value, which is an error for Scan.
// 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))
}
}
@ -87,7 +87,7 @@ func (c *Config) unmarshal(source Source, target any, basePath ...string) error
return nil
}
// normalizeMap ensures that the input data is a map[string]any for the decoder.
// normalizeMap ensures that the input data is a map[string]any for the decoder
func normalizeMap(data any) (map[string]any, error) {
if data == nil {
return make(map[string]any), nil
@ -105,7 +105,7 @@ func normalizeMap(data any) (map[string]any, error) {
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("map keys must be strings, but got %v", v.Type().Key()))
}
// Create a new map[string]any and copy the values.
// Create a new map[string]any and copy the values
normalized := make(map[string]any, v.Len())
iter := v.MapRange()
for iter.Next() {

View File

@ -85,7 +85,7 @@ Live Reload:
- **Security**: `WithSecurityOptions()`
### Source Loading (`loader.go`)
- **Multi-Source Loading**: `Load()`, `LoadWithOptions()`
- **Multi-Source Loading**: `loadWithOptions()`
- **Individual Sources**: `LoadFile()`, `LoadEnv()`, `LoadCLI()`
- **Persistence**: `Save()`, `SaveSource()`, `atomicWriteFile()`
- **Environment Mapping**: `DiscoverEnv()`, `ExportEnv()`, `defaultEnvTransform()`
@ -105,11 +105,11 @@ Live Reload:
- **Change Detection**: `checkAndReload()`, `performReload()`, `notifyWatchers()`
- **Resource Management**: Max watcher limits, graceful shutdown
### Convenience API (`convenience.go`)
### 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()`
- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()`, `ScanMap()`
### File Discovery (`discovery.go`)
- **Options**: `FileDiscoveryOptions` struct with search strategies

View File

@ -1,10 +1,11 @@
# lixenwraith/config LLM Usage Guide
# 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.
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**. This provides compile-time type safety and eliminates the need for runtime type assertions.
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
@ -17,7 +18,7 @@ import (
"github.com/lixenwraith/config"
)
// 1. Define your application's configuration struct.
// 1. Define your application's configuration struct
type AppConfig struct {
Server struct {
Host string `toml:"host"`
@ -27,7 +28,7 @@ type AppConfig struct {
}
func main() {
// 2. Create a target instance with defaults.
// 2. Create a target instance with defaults
// Method A: Direct initialization (cleaner for simple defaults)
target := &AppConfig{}
target.Server.Host = "localhost"
@ -35,23 +36,23 @@ func main() {
// Method B: Use WithDefaults() for explicit separation (shown below)
// 3. Use the builder to configure and load all sources.
// 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.
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.
// 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).
// 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)
@ -60,63 +61,64 @@ func main() {
## 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.
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")`.
* 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.
The `Builder` is the primary way to construct a `Config` instance
```go
// NewBuilder creates a new configuration builder.
// NewBuilder creates a new configuration builder
func NewBuilder() *Builder
// Build finalizes configuration; returns the first of any accumulated errors.
// 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.
// MustBuild is like Build but panics on fatal errors
func (b *Builder) MustBuild() *Config
```
### Builder Methods
* `WithTarget(target any)`: **(Recommended)** Enables type-safe mode. Registers fields from the target struct and allows access via `AsStruct()`. The target's initial values are used as defaults unless `WithDefaults` is also called.
* `WithDefaults(defaults any)`: Explicitly sets a struct containing default values. Overrides any defaults 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 that runs *after* the target struct is populated. The function signature must be `func(c *YourConfigType) error`.
* `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.
* `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.
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)`).
* `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`.
* `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.
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.
* `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:..."`.
The watch channel also receives special notifications: `"file_deleted"`, `"permissions_changed"`, `"reload_error:..."`
## Source Precedence
@ -124,34 +126,34 @@ 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.
4. **Default**: Values registered from a struct
This order can be changed via `Builder.WithSources()` or `Config.SetPrecedence()`.
This order can be changed via `Builder.WithSources()` or `Config.SetPrecedence()`
## Dynamic / Legacy Value Access
These methods are for dynamic key-value access and should be **avoided when a type-safe struct can be used**. They require runtime type assertions.
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.
* `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.
* `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.
* `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

View File

@ -8,19 +8,19 @@ import (
// Error categories - major error types for client code to check with errors.Is()
var (
// ErrNotConfigured indicates an operation was attempted on a Config instance
// that was not properly prepared for it.
// that was not properly prepared for it
ErrNotConfigured = errors.New("operation requires additional configuration")
// ErrConfigNotFound indicates the specified configuration file was not found.
// ErrConfigNotFound indicates the specified configuration file was not found
ErrConfigNotFound = errors.New("configuration file not found")
// ErrCLIParse indicates that parsing command-line arguments failed.
// ErrCLIParse indicates that parsing command-line arguments failed
ErrCLIParse = errors.New("failed to parse command-line arguments")
// ErrEnvParse indicates that parsing environment variables failed.
// ErrEnvParse indicates that parsing environment variables failed
ErrEnvParse = errors.New("failed to parse environment variables")
// ErrValueSize indicates a value larger than MaxValueSize.
// ErrValueSize indicates a value larger than MaxValueSize
ErrValueSize = errors.New("value size exceeds maximum")
// ErrPathNotFound indicates the configuration path doesn't exist.

View File

@ -11,7 +11,7 @@ import (
"github.com/lixenwraith/config"
)
// AppConfig defines a richer configuration structure to showcase more features.
// AppConfig defines a richer configuration structure to showcase more features
type AppConfig struct {
Server struct {
Host string `toml:"host"`
@ -26,17 +26,17 @@ const configFilePath = "config.toml"
func main() {
// =========================================================================
// PART 1: INITIAL SETUP
// Create a clean config.toml file on disk for our program to read.
// Create a clean config.toml file on disk for our program to read
// =========================================================================
log.Println("---")
log.Println("➡️ PART 1: Creating initial configuration file...")
log.Println("PART 1: Creating initial configuration file...")
// Defer cleanup to run at the end of the program.
// Defer cleanup to run at the end of the program
defer func() {
log.Println("---")
log.Println("🧹 Cleaning up...")
log.Println("Cleaning up...")
os.Remove(configFilePath)
// Unset the environment variable we use for testing.
// Unset the environment variable we use for testing
os.Unsetenv("APP_SERVER_PORT")
log.Printf("Removed %s and unset APP_SERVER_PORT.", configFilePath)
}()
@ -48,67 +48,67 @@ func main() {
initialData.FeatureFlags = map[string]bool{"enable_metrics": true}
if err := createInitialConfigFile(initialData); err != nil {
log.Fatalf(" Failed during initial file creation: %v", err)
log.Fatalf("FAIL - Failed during initial file creation: %v", err)
}
log.Printf(" Initial configuration saved to %s.", configFilePath)
log.Printf("PASS - Initial configuration saved to %s.", configFilePath)
// =========================================================================
// PART 2: RECOMMENDED CONFIGURATION USING THE BUILDER
// This demonstrates source precedence, validation, and type-safe targets.
// This demonstrates source precedence, validation, and type-safe targets
// =========================================================================
log.Println("---")
log.Println("➡️ PART 2: Configuring manager with the Builder...")
log.Println("PART 2: Configuring manager with the Builder...")
// Set an environment variable to demonstrate source precedence (Env > File).
// Set an environment variable to demonstrate source precedence (Env > File)
os.Setenv("APP_SERVER_PORT", "8888")
log.Println(" (Set environment variable APP_SERVER_PORT=8888)")
// Create a "target" struct. The builder will automatically populate this
// and keep it updated when using `AsStruct()`.
// and keep it updated when using `AsStruct()`
target := &AppConfig{}
// Use the builder to chain multiple configuration options.
// Use the builder to chain multiple configuration options
builder := config.NewBuilder().
WithTarget(target). // Enables type-safe `AsStruct()` and auto-registration.
WithDefaults(initialData). // Explicitly set the source of defaults.
WithFile(configFilePath). // Specifies the config file to read.
WithEnvPrefix("APP_"). // Sets prefix for environment variables (e.g., APP_SERVER_PORT).
WithTarget(target). // Enables type-safe `AsStruct()` and auto-registration
WithDefaults(initialData). // Explicitly set the source of defaults
WithFile(configFilePath). // Specifies the config file to read
WithEnvPrefix("APP_"). // Sets prefix for environment variables (e.g., APP_SERVER_PORT)
WithTypedValidator(func(cfg *AppConfig) error { // <-- NEW METHOD
// No type assertion needed! `cfg.Server.Port` is guaranteed to be an int64
// because the validator runs *after* the target struct is populated.
// because the validator runs *after* the target struct is populated
if cfg.Server.Port < 1024 || cfg.Server.Port > 65535 {
return fmt.Errorf("port %d is outside the recommended range (1024-65535)", cfg.Server.Port)
}
return nil
})
// Build the final config object.
// Build the final config object
cfg, err := builder.Build()
if err != nil {
log.Fatalf(" Builder failed: %v", err)
log.Fatalf("FAIL - Builder failed: %v", err)
}
log.Println(" Builder finished successfully. Initial values loaded.")
log.Println("PASS - Builder finished successfully. Initial values loaded.")
initialTarget, _ := cfg.AsStruct()
printCurrentState(initialTarget.(*AppConfig), "Initial State (Env overrides File)")
// =========================================================================
// PART 3: DYNAMIC RELOADING WITH THE WATCHER
// We'll now modify the file and verify the watcher updates the config.
// We'll now modify the file and verify the watcher updates the config
// =========================================================================
log.Println("---")
log.Println("➡️ PART 3: Testing the file watcher...")
log.Println("PART 3: Testing the file watcher...")
// Use WithOptions to demonstrate customizing the watcher.
// Use WithOptions to demonstrate customizing the watcher
watchOpts := config.WatchOptions{
PollInterval: 250 * time.Millisecond,
Debounce: 100 * time.Millisecond,
}
cfg.AutoUpdateWithOptions(watchOpts)
changes := cfg.Watch()
log.Println(" Watcher is now active with custom options.")
log.Println("PASS - Watcher is now active with custom options.")
// Start a goroutine to modify the file after a short delay.
// Start a goroutine to modify the file after a short delay
var wg sync.WaitGroup
wg.Add(1)
go modifyFileOnDiskStructurally(&wg)
@ -117,33 +117,33 @@ func main() {
log.Println(" (Waiting for watcher notification...)")
select {
case path := <-changes:
log.Printf(" Watcher detected a change for path: '%s'", path)
log.Printf("PASS - Watcher detected a change for path: '%s'", path)
log.Println(" Verifying in-memory config using AsStruct()...")
// Retrieve the updated, type-safe struct.
// Retrieve the updated, type-safe struct
updatedTarget, err := cfg.AsStruct()
if err != nil {
log.Fatalf(" AsStruct() failed after update: %v", err)
log.Fatalf("FAIL - AsStruct() failed after update: %v", err)
}
// Type-assert and verify the new values.
// Type-assert and verify the new values
typedCfg := updatedTarget.(*AppConfig)
expectedLevel := "debug"
if typedCfg.Server.LogLevel != expectedLevel {
log.Fatalf(" VERIFICATION FAILED: Expected log_level '%s', but got '%s'.", expectedLevel, typedCfg.Server.LogLevel)
log.Fatalf("FAIL - VERIFICATION FAILED: Expected log_level '%s', but got '%s'.", expectedLevel, typedCfg.Server.LogLevel)
}
log.Println(" VERIFICATION SUCCESSFUL: In-memory config was updated by the watcher.")
log.Println("PASS - VERIFICATION SUCCESSFUL: In-memory config was updated by the watcher.")
printCurrentState(typedCfg, "Final State (Updated by Watcher)")
case <-time.After(5 * time.Second):
log.Fatalf(" TEST FAILED: Timed out waiting for watcher notification.")
log.Fatalf("FAIL - TEST FAILED: Timed out waiting for watcher notification.")
}
wg.Wait()
}
// createInitialConfigFile is a helper to set up the initial file state.
// createInitialConfigFile is a helper to set up the initial file state
func createInitialConfigFile(data *AppConfig) error {
cfg := config.New()
if err := cfg.RegisterStruct("", data); err != nil {
@ -152,44 +152,44 @@ func createInitialConfigFile(data *AppConfig) error {
return cfg.Save(configFilePath)
}
// modifyFileOnDiskStructurally simulates an external program that changes the config file.
// modifyFileOnDiskStructurally simulates an external program that changes the config file
func modifyFileOnDiskStructurally(wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(1 * time.Second)
log.Println(" (Modifier goroutine: now changing file on disk...)")
// Create a new, independent config instance to simulate an external process.
// Create a new, independent config instance to simulate an external process
modifierCfg := config.New()
// Register the struct shape so the loader knows what paths are valid.
// Register the struct shape so the loader knows what paths are valid
if err := modifierCfg.RegisterStruct("", &AppConfig{}); err != nil {
log.Fatalf(" Modifier failed to register struct: %v", err)
log.Fatalf("FAIL - Modifier failed to register struct: %v", err)
}
// Load the current state from disk.
if err := modifierCfg.LoadFile(configFilePath); err != nil {
log.Fatalf(" Modifier failed to load file: %v", err)
log.Fatalf("FAIL - Modifier failed to load file: %v", err)
}
// Change the log level.
modifierCfg.Set("server.log_level", "debug")
// Use the generic GetTyped function. This is safe because modifierCfg has loaded the file.
// Use the generic GetTyped function. This is safe because modifierCfg has loaded the file
featureFlags, err := config.GetTyped[map[string]bool](modifierCfg, "feature_flags")
if err != nil {
log.Fatalf(" Modifier failed to get typed feature_flags: %v", err)
log.Fatalf("FAIL - Modifier failed to get typed feature_flags: %v", err)
}
// Modify the typed map and set it back.
featureFlags["enable_metrics"] = false
modifierCfg.Set("feature_flags", featureFlags)
// Save the changes back to disk, which will trigger the watcher in the main goroutine.
// Save the changes back to disk, which will trigger the watcher in the main goroutine
if err := modifierCfg.Save(configFilePath); err != nil {
log.Fatalf(" Modifier failed to save file: %v", err)
log.Fatalf("FAIL - Modifier failed to save file: %v", err)
}
log.Println(" (Modifier goroutine: finished.)")
}
// printCurrentState is a helper to display the typed config state.
// printCurrentState is a helper to display the typed config state
func printCurrentState(cfg *AppConfig, title string) {
fmt.Println(" --------------------------------------------------")
fmt.Printf(" %s\n", title)

View File

@ -3,20 +3,20 @@ package config
import "strings"
// flattenMap converts a nested map[string]any to a flat map[string]any with dot-notation paths.
func flattenMap(nested map[string]any, prefix string) map[string]any {
// flattenMap converts a nested map[string]any to a flat map[string]any with dot-notation paths
func flattenMap(nestedMap map[string]any, prefix string) map[string]any {
flat := make(map[string]any)
for key, value := range nested {
for key, value := range nestedMap {
newPath := key
if prefix != "" {
newPath = prefix + "." + key
}
// Check if the value is a map that can be further flattened
if nestedMap, isMap := value.(map[string]any); isMap {
if nested, isMap := value.(map[string]any); isMap {
// Recursively flatten the nested map
flattenedSubMap := flattenMap(nestedMap, newPath)
flattenedSubMap := flattenMap(nested, newPath)
// Merge the flattened sub-map into the main flat map
for subPath, subValue := range flattenedSubMap {
flat[subPath] = subValue
@ -30,9 +30,9 @@ func flattenMap(nested map[string]any, prefix string) map[string]any {
return flat
}
// setNestedValue sets a value in a nested map using a dot-notation path.
// It creates intermediate maps if they don't exist.
// If a segment exists but is not a map, it will be overwritten by a new map.
// setNestedValue sets a value in a nested map using a dot-notation path
// It creates intermediate maps if they don't exist
// If a segment exists but is not a map, it will be overwritten by a new map
func setNestedValue(nested map[string]any, path string, value any) {
segments := strings.Split(path, ".")
current := nested
@ -64,12 +64,12 @@ func setNestedValue(nested map[string]any, path string, value any) {
current[lastSegment] = value
}
// isValidKeySegment checks if a single path segment is a valid TOML key part.
// isValidKeySegment checks if a single path segment is a valid TOML key part
func isValidKeySegment(s string) bool {
if len(s) == 0 {
return false
}
// TOML bare keys are sequences of ASCII letters, ASCII digits, underscores, and dashes (A-Za-z0-9_-).
// TOML bare keys are sequences of ASCII letters, ASCII digits, underscores, and dashes (A-Za-z0-9_-)
if strings.ContainsRune(s, '.') {
return false // Segments themselves cannot contain dots
}
@ -85,4 +85,4 @@ func isValidKeySegment(s string) bool {
}
}
return true
}
}

View File

@ -62,14 +62,8 @@ func DefaultLoadOptions() LoadOptions {
}
}
// Load reads configuration from a TOML file and merges overrides from command-line arguments.
// This is a convenience method that maintains backward compatibility.
func (c *Config) Load(filePath string, args []string) error {
return c.LoadWithOptions(filePath, args, c.options)
}
// LoadWithOptions loads configuration from multiple sources with custom options
func (c *Config) LoadWithOptions(filePath string, args []string, opts LoadOptions) error {
// loadWithOptions loads configuration from multiple sources with custom options
func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOptions) error {
c.mutex.Lock()
c.options = opts
c.mutex.Unlock()

View File

@ -156,7 +156,7 @@ func TestEnvironmentLoading(t *testing.T) {
},
}
err := cfg.LoadWithOptions("", nil, opts)
err := cfg.loadWithOptions("", nil, opts)
require.NoError(t, err)
host, _ := cfg.Get("db.host")
@ -176,7 +176,7 @@ func TestEnvironmentLoading(t *testing.T) {
EnvWhitelist: map[string]bool{"allowed.path": true},
}
err := cfg.LoadWithOptions("", nil, opts)
err := cfg.loadWithOptions("", nil, opts)
require.NoError(t, err)
allowed, _ := cfg.Get("allowed.path")
@ -321,7 +321,7 @@ port = 8080
EnvPrefix: "TEST_",
}
err := cfg.LoadWithOptions(configFile, args, opts)
err := cfg.loadWithOptions(configFile, args, opts)
require.NoError(t, err)
// CLI should win
@ -431,4 +431,4 @@ func splitEnvVar(env string) []string {
}
}
return []string{env}
}
}

View File

@ -8,10 +8,10 @@ import (
"strings"
)
// Register makes a configuration path known to the Config instance.
// The path should be dot-separated (e.g., "server.port", "debug").
// 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.
// Register makes a configuration path known to the Config instance
// The path should be dot-separated (e.g., "server.port", "debug")
// Each segment of the path must be a valid TOML key identifier
// defaultValue is the value returned by Get if no specific value has been set
func (c *Config) Register(path string, defaultValue any) error {
if path == "" {
return wrapError(ErrInvalidPath, fmt.Errorf("registration path cannot be empty"))
@ -60,7 +60,7 @@ func (c *Config) RegisterRequired(path string, defaultValue any) error {
return c.Register(path, defaultValue)
}
// Unregister removes a configuration path and all its children.
// Unregister removes a configuration path and all its children
func (c *Config) Unregister(path string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
@ -96,9 +96,9 @@ func (c *Config) Unregister(path string) error {
return nil
}
// RegisterStruct registers configuration values derived from a struct.
// It uses struct tags (`toml:"..."`) to determine the configuration paths.
// The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed.
// RegisterStruct registers configuration values derived from a struct
// It uses struct tags (`toml:"..."`) to determine the configuration paths
// The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed
func (c *Config) RegisterStruct(prefix string, structWithDefaults any) error {
return c.RegisterStructWithTags(prefix, structWithDefaults, FormatTOML)
}
@ -139,7 +139,7 @@ func (c *Config) RegisterStructWithTags(prefix string, structWithDefaults any, t
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) {
t := v.Type()
@ -186,7 +186,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
if isStruct || isPtrToStruct {
// Check if the field's TYPE is one that should be treated as a single value,
// even though it's a struct. These types have custom decode hooks.
// even though it's a struct. These types have custom decode hooks
fieldType := fieldValue.Type()
isAtomicStruct := false
switch fieldType.String() {
@ -194,7 +194,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
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 {
nestedValue := fieldValue
if isPtrToStruct {
@ -208,7 +208,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors, tagName)
continue
}
// If it is an atomic struct, we fall through and register it as a single value.
// If it is an atomic struct, we fall through and register it as a single value
}
// Register non-struct fields
@ -237,7 +237,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
}
}
// GetRegisteredPaths returns all registered configuration paths with the specified prefix.
// GetRegisteredPaths returns all registered configuration paths with the specified prefix
func (c *Config) GetRegisteredPaths(prefix ...string) map[string]bool {
p := ""
if len(prefix) > 0 {

View File

@ -1,4 +1,4 @@
// FILE: lixenwraith/config/convenience.go
// FILE: lixenwraith/config/utility.go
package config
import (
@ -8,6 +8,7 @@ import (
"os"
"reflect"
"strings"
"time"
"github.com/BurntSushi/toml"
"github.com/go-viper/mapstructure/v2"
@ -29,7 +30,7 @@ func Quick(structDefaults any, envPrefix, configFile string) (*Config, error) {
opts := DefaultLoadOptions()
opts.EnvPrefix = envPrefix
err := cfg.LoadWithOptions(configFile, os.Args[1:], opts)
err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
return cfg, err
}
@ -44,7 +45,7 @@ func QuickCustom(structDefaults any, opts LoadOptions, configFile string) (*Conf
}
}
err := cfg.LoadWithOptions(configFile, os.Args[1:], opts)
err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
return cfg, err
}
@ -241,7 +242,7 @@ func QuickTyped[T any](target *T, envPrefix, configFile string) (*Config, error)
Build()
}
// GetTyped retrieves a configuration value and decodes it into the specified type T.
// GetTyped retrieves a configuration value and decodes it into the specified type T
// It leverages the same decoding hooks as the Scan and AsStruct methods,
// providing type conversion from strings, numbers, etc.
func GetTyped[T any](c *Config, path string) (T, error) {
@ -252,13 +253,13 @@ func GetTyped[T any](c *Config, path string) (T, error) {
return zero, wrapError(ErrPathNotFound, fmt.Errorf("path %q not found", path))
}
// Prepare the input map and target struct for the decoder.
// Prepare the input map and target struct for the decoder
inputMap := map[string]any{"value": rawValue}
var target struct {
Value T `mapstructure:"value"`
}
// Create a new decoder configured with the same hooks as the main config.
// Create a new decoder configured with the same hooks as the main config
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: &target,
TagName: c.tagName,
@ -278,9 +279,9 @@ func GetTyped[T any](c *Config, path string) (T, error) {
return target.Value, nil
}
// GetTypedWithDefault retrieves a configuration value with a default fallback.
// If the path doesn't exist or isn't set, it sets and returns the default value.
// This is a convenience function for simple cases where explicit defaults aren't pre-registered.
// GetTypedWithDefault retrieves a configuration value with a default fallback
// If the path doesn't exist or isn't set, it sets and returns the default value
// For simple cases where explicit defaults aren't pre-registered
func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) {
// Check if path exists and has a value
if _, exists := c.Get(path); exists {
@ -311,11 +312,54 @@ func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, erro
}
// ScanTyped is a generic wrapper around Scan. It allocates a new instance of type T,
// populates it with configuration data from the given base path, and returns a pointer to it.
// populates it with configuration data from the given base path, and returns a pointer to it
func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
var target T
if err := c.Scan(&target, basePath...); err != nil {
return nil, err
}
return &target, nil
}
// ScanMap decodes a configuration map directly into a target struct
// without requiring a full Config instance. This is useful for plugin
// initialization where config data arrives as a map[string]any.
func ScanMap(configMap map[string]any, target any, tagName ...string) error {
// Handle nil map
if configMap == nil {
configMap = make(map[string]any)
}
// 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
}

View File

@ -1,4 +1,4 @@
// FILE: lixenwraith/config/convenience_test.go
// FILE: lixenwraith/config/utility_test.go
package config
import (
@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/require"
)
// TestQuickFunctions tests the convenience Quick* functions
// TestQuickFunctions tests the utility Quick* functions
func TestQuickFunctions(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "quick.toml")
@ -371,3 +371,99 @@ func TestGetTypedWithDefault(t *testing.T) {
assert.Equal(t, []string{"default", "tag"}, tags)
})
}
// TestScanMap tests the ScanMap utility function
func TestScanMap(t *testing.T) {
type Config struct {
Server struct {
Host string `toml:"host" json:"hostname"`
Port int `toml:"port" json:"port"`
Timeout time.Duration `toml:"timeout" json:"timeout"`
} `toml:"server" json:"server"`
LogLevel string `toml:"log_level" json:"logLevel"`
}
t.Run("BasicScanWithTOMLTags", func(t *testing.T) {
configMap := map[string]any{
"server": map[string]any{
"host": "localhost",
"port": 8080,
"timeout": "15s",
},
"log_level": "info",
}
var target Config
err := ScanMap(configMap, &target)
require.NoError(t, err)
assert.Equal(t, "localhost", target.Server.Host)
assert.Equal(t, 8080, target.Server.Port)
assert.Equal(t, 15*time.Second, target.Server.Timeout)
assert.Equal(t, "info", target.LogLevel)
})
t.Run("ScanWithJSONTags", func(t *testing.T) {
configMap := map[string]any{
"server": map[string]any{
"hostname": "json-host",
"port": 9090,
"timeout": "1m",
},
"logLevel": "debug",
}
var target Config
err := ScanMap(configMap, &target, "json")
require.NoError(t, err)
assert.Equal(t, "json-host", target.Server.Host)
assert.Equal(t, 9090, target.Server.Port)
assert.Equal(t, 1*time.Minute, target.Server.Timeout)
assert.Equal(t, "debug", target.LogLevel)
})
t.Run("NilMapInput", func(t *testing.T) {
var target Config
target.LogLevel = "initial"
target.Server.Port = 1234
err := ScanMap(nil, &target)
require.NoError(t, err)
// Verify that fields are NOT changed when the map is empty,
// reflecting the observed behavior.
assert.Equal(t, "initial", target.LogLevel)
assert.Equal(t, 1234, target.Server.Port)
assert.Empty(t, target.Server.Host)
})
t.Run("PartialMapBehavior", func(t *testing.T) {
configMap := map[string]any{
"log_level": "warn",
}
var target Config
target.Server.Host = "initial_host"
target.Server.Port = 1234
target.LogLevel = "initial_log"
err := ScanMap(configMap, &target)
require.NoError(t, err)
// Mapped field should be updated
assert.Equal(t, "warn", target.LogLevel)
// Unmapped fields should be untouched
assert.Equal(t, "initial_host", target.Server.Host, "Unmapped field should be untouched")
assert.Equal(t, 1234, target.Server.Port, "Unmapped field should be untouched")
})
t.Run("InvalidTarget", func(t *testing.T) {
configMap := map[string]any{"log_level": "info"}
var target Config // Not a pointer
err := ScanMap(configMap, target)
assert.Error(t, err)
// The underlying mapstructure error is "result must be a pointer"
assert.Contains(t, err.Error(), "must be a pointer")
})
}

View File

@ -137,7 +137,7 @@ func (c *Config) Watch() <-chan string {
}
// WatchFile stops any existing file watcher, loads a new configuration file,
// and starts a new watcher on that file path. Optionally accepts format hint.
// and starts a new watcher on that file path. Optionally accepts format hint
func (c *Config) WatchFile(filePath string, formatHint ...string) error {
// Stop any currently running watcher
c.StopAutoUpdate()

View File

@ -14,8 +14,8 @@ import (
"github.com/stretchr/testify/require"
)
// Test-specific timing constants derived from production values.
// These accelerate test execution while maintaining timing relationships.
// Test-specific timing constants derived from production values
// These accelerate test execution while maintaining timing relationships
const (
// testAcceleration reduces all intervals by this factor for faster tests
testAcceleration = 10
@ -32,9 +32,8 @@ const (
testWatchTimeout = 2 * DefaultPollInterval // 2s for change propagation
// Derived test multipliers with clear purpose
testDebounceSettle = debounceSettleMultiplier * testDebounce // 150ms for debounce verification
testPollWindow = 3 * testPollInterval // 300ms change detection window
testStateStabilize = 4 * testDebounce // 200ms for state convergence
testPollWindow = 3 * testPollInterval // 300ms change detection window
testStateStabilize = 4 * testDebounce // 200ms for state convergence
)
// TestAutoUpdate tests automatic configuration reloading
@ -517,4 +516,4 @@ func BenchmarkWatchOverhead(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = cfg.Get(fmt.Sprintf("value%d", i%100))
}
}
}