diff --git a/README.md b/README.md index 1f6e299..869b91e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/builder.go b/builder.go index 922f641..b0f8cf0 100644 --- a/builder.go +++ b/builder.go @@ -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")) diff --git a/builder_test.go b/builder_test.go index fa921e3..a79d29d 100644 --- a/builder_test.go +++ b/builder_test.go @@ -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") }) -} +} \ No newline at end of file diff --git a/config.go b/config.go index f292511..8e82ffd 100644 --- a/config.go +++ b/config.go @@ -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) } diff --git a/convenience.go b/convenience.go index 8f3cf95..72aee1f 100644 --- a/convenience.go +++ b/convenience.go @@ -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 { diff --git a/decode.go b/decode.go index 5991d5f..3f622a9 100644 --- a/decode.go +++ b/decode.go @@ -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() { diff --git a/doc/config-llm-guide.md b/doc/quick-guide_lixenwraith_config.md similarity index 62% rename from doc/config-llm-guide.md rename to doc/quick-guide_lixenwraith_config.md index 7d65392..50f127a 100644 --- a/doc/config-llm-guide.md +++ b/doc/quick-guide_lixenwraith_config.md @@ -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. \ No newline at end of file +* `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 \ No newline at end of file diff --git a/error.go b/error.go index 76c34ed..600d97f 100644 --- a/error.go +++ b/error.go @@ -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. diff --git a/example/main.go b/example/main.go index 0291f4d..b3f7670 100644 --- a/example/main.go +++ b/example/main.go @@ -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) diff --git a/helper.go b/helper.go index a836bc9..7ca110c 100644 --- a/helper.go +++ b/helper.go @@ -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 } @@ -85,4 +85,4 @@ func isValidKeySegment(s string) bool { } } return true -} +} \ No newline at end of file diff --git a/loader.go b/loader.go index 5411ab8..974b048 100644 --- a/loader.go +++ b/loader.go @@ -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 diff --git a/register.go b/register.go index c4329bc..2dde99d 100644 --- a/register.go +++ b/register.go @@ -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 { diff --git a/watch.go b/watch.go index 858fe1b..a9fed1c 100644 --- a/watch.go +++ b/watch.go @@ -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() diff --git a/watch_test.go b/watch_test.go index 4a2fb21..08dab00 100644 --- a/watch_test.go +++ b/watch_test.go @@ -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 @@ -517,4 +517,4 @@ func BenchmarkWatchOverhead(b *testing.B) { for i := 0; i < b.N; i++ { _, _ = cfg.Get(fmt.Sprintf("value%d", i%100)) } -} +} \ No newline at end of file