v0.1.1 helpers update to public, docs and comments update

This commit is contained in:
2025-11-11 03:48:58 -05:00
parent 00193cf096
commit 7bcd90df3a
14 changed files with 196 additions and 191 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,23 +63,22 @@ 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
@ -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")

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

@ -180,7 +180,7 @@ func (c *Config) Dump() error {
nestedData := make(map[string]any)
for path, item := range c.items {
setNestedValue(nestedData, path, item.currentValue)
SetNestedValue(nestedData, path, item.currentValue)
}
encoder := toml.NewEncoder(os.Stdout)
@ -241,7 +241,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 +252,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 +278,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
// This is a convenience function 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,7 +311,7 @@ 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 {

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 := ""
@ -42,13 +42,13 @@ func (c *Config) unmarshal(source Source, target any, basePath ...string) error
if source == "" {
// Use current merged state
for path, item := range c.items {
setNestedValue(nestedMap, path, item.currentValue)
SetNestedValue(nestedMap, path, item.currentValue)
}
} else {
// Use specific source
for path, item := range c.items {
if val, exists := item.values[source]; exists {
setNestedValue(nestedMap, path, val)
SetNestedValue(nestedMap, path, val)
}
}
}
@ -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

@ -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,63 @@ 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)`)
* `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 +125,40 @@ 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
### Utility Functions
* `FlattenMap(nested map[string]any, prefix string) map[string]any`: Converts a nested map to a flat map with dot-notation keys
* `SetNestedValue(nested map[string]any, path string, value any)`: Sets a value in a nested map using a dot-notation path
* `IsValidKeySegment(s string) bool`: Checks if a string is a valid path segment

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,8 +3,8 @@ 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(nested map[string]any, prefix string) map[string]any {
flat := make(map[string]any)
for key, value := range nested {
@ -16,7 +16,7 @@ func flattenMap(nested map[string]any, prefix string) map[string]any {
// Check if the value is a map that can be further flattened
if nestedMap, isMap := value.(map[string]any); isMap {
// Recursively flatten the nested map
flattenedSubMap := flattenMap(nestedMap, newPath)
flattenedSubMap := FlattenMap(nestedMap, newPath)
// Merge the flattened sub-map into the main flat map
for subPath, subValue := range flattenedSubMap {
flat[subPath] = subValue
@ -30,10 +30,10 @@ 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.
func setNestedValue(nested map[string]any, path string, value any) {
// 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.
func isValidKeySegment(s string) bool {
// 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
}

View File

@ -143,7 +143,7 @@ func (c *Config) Save(path string) error {
nestedData := make(map[string]any)
for itemPath, item := range c.items {
setNestedValue(nestedData, itemPath, item.currentValue)
SetNestedValue(nestedData, itemPath, item.currentValue)
}
c.mutex.RUnlock()
@ -215,7 +215,7 @@ func (c *Config) SaveSource(path string, source Source) error {
nestedData := make(map[string]any)
for itemPath, item := range c.items {
if val, exists := item.values[source]; exists {
setNestedValue(nestedData, itemPath, val)
SetNestedValue(nestedData, itemPath, val)
}
}
@ -501,7 +501,7 @@ func (c *Config) loadCLI(args []string) error {
return err // Already wrapped with error category in parseArgs
}
flattenedCLI := flattenMap(parsedCLI, "")
flattenedCLI := FlattenMap(parsedCLI, "")
if len(flattenedCLI) == 0 {
return nil // No CLI args to process.
}
@ -648,13 +648,13 @@ func parseArgs(args []string) (map[string]any, error) {
// Validate keyPath segments
segments := strings.Split(keyPath, ".")
for _, segment := range segments {
if !isValidKeySegment(segment) {
if !IsValidKeySegment(segment) {
return nil, wrapError(ErrInvalidPath, fmt.Errorf("invalid command-line key segment %q in path %q", segment, keyPath))
}
}
// Always store as a string. Let Scan handle final type conversion.
setNestedValue(result, keyPath, valueStr)
SetNestedValue(result, keyPath, valueStr)
}
return result, nil

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"))
@ -20,7 +20,7 @@ func (c *Config) Register(path string, defaultValue any) error {
// Validate path segments
segments := strings.Split(path, ".")
for _, segment := range segments {
if !isValidKeySegment(segment) {
if !IsValidKeySegment(segment) {
return wrapError(ErrInvalidPath, fmt.Errorf("invalid path segment %q in path %q", segment, path))
}
}
@ -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

@ -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