diff --git a/builder.go b/builder.go index b0f8cf0..9d4b655 100644 --- a/builder.go +++ b/builder.go @@ -82,7 +82,7 @@ func (b *Builder) Build() (*Config, error) { b.cfg.configFilePath = b.file // 2. Load configuration - loadErr := b.cfg.LoadWithOptions(b.file, b.args, b.opts) + loadErr := b.cfg.loadWithOptions(b.file, b.args, b.opts) if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) { // Return on fatal load errors. ErrConfigNotFound is not fatal. return nil, wrapError(ErrFileAccess, loadErr) diff --git a/constant.go b/constant.go index a1e973f..ce9ef8b 100644 --- a/constant.go +++ b/constant.go @@ -45,9 +45,6 @@ const ( EventPrecedenceChanged = "precedence" ) -// debounceSettleMultiplier ensures sufficient time for debounce to complete -const debounceSettleMultiplier = 3 // Wait 3x debounce period for value stabilization - // Channel and resource limits const ( DefaultMaxWatchers = 100 diff --git a/decode.go b/decode.go index 3f622a9..09cb8b6 100644 --- a/decode.go +++ b/decode.go @@ -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) } } } diff --git a/doc/architecture.md b/doc/architecture.md index 4d24490..1472be0 100644 --- a/doc/architecture.md +++ b/doc/architecture.md @@ -85,7 +85,7 @@ Live Reload: - **Security**: `WithSecurityOptions()` ### Source Loading (`loader.go`) -- **Multi-Source Loading**: `Load()`, `LoadWithOptions()` +- **Multi-Source Loading**: `loadWithOptions()` - **Individual Sources**: `LoadFile()`, `LoadEnv()`, `LoadCLI()` - **Persistence**: `Save()`, `SaveSource()`, `atomicWriteFile()` - **Environment Mapping**: `DiscoverEnv()`, `ExportEnv()`, `defaultEnvTransform()` @@ -105,11 +105,11 @@ Live Reload: - **Change Detection**: `checkAndReload()`, `performReload()`, `notifyWatchers()` - **Resource Management**: Max watcher limits, graceful shutdown -### Convenience API (`convenience.go`) +### Convenience API (`utility.go`) - **Quick Setup**: `Quick()`, `QuickCustom()`, `MustQuick()`, `QuickTyped()` - **Flag Integration**: `GenerateFlags()`, `BindFlags()` - **Type-Safe Access**: `GetTyped()`, `GetTypedWithDefault()`, `ScanTyped()` -- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()` +- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()`, `ScanMap()` ### File Discovery (`discovery.go`) - **Options**: `FileDiscoveryOptions` struct with search strategies diff --git a/doc/quick-guide_lixenwraith_config.md b/doc/quick-guide_lixenwraith_config.md index 50f127a..8e0fa07 100644 --- a/doc/quick-guide_lixenwraith_config.md +++ b/doc/quick-guide_lixenwraith_config.md @@ -103,6 +103,7 @@ These are the **preferred methods** for accessing configuration data * `AsStruct() (any, error)`: After using `Builder.WithTarget()`, this method returns the populated, type-safe target struct - This is the primary way to access config after initialization or live reload * `Scan(basePath string, target any)`: Populates a struct with values from a specific config path (e.g., `cfg.Scan("server", &serverConf)`) +* `ScanMap(configMap map[string]any, target any, tagName ...string)`: Decodes a map[string]any directly into a target struct, useful for plugins or custom config sources * `GetTyped[T](c *Config, path string) (T, error)`: Retrieves a single value and decodes it to type `T`, handling type conversion automatically * `ScanTyped[T](c *Config, basePath ...string) (*T, error)`: A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of type `T` @@ -155,10 +156,4 @@ These methods are for dynamic key-value access that require type assertion and s * `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 +* `Debug() string`: Returns a formatted string of all values for debugging \ No newline at end of file diff --git a/helper.go b/helper.go index 7ca110c..81366d5 100644 --- a/helper.go +++ b/helper.go @@ -3,20 +3,20 @@ package config import "strings" -// FlattenMap converts a nested map[string]any to a flat map[string]any with dot-notation paths -func FlattenMap(nested map[string]any, prefix string) map[string]any { +// flattenMap converts a nested map[string]any to a flat map[string]any with dot-notation paths +func flattenMap(nestedMap map[string]any, prefix string) map[string]any { flat := make(map[string]any) - for key, value := range nested { + for key, value := range nestedMap { newPath := key if prefix != "" { newPath = prefix + "." + key } // Check if the value is a map that can be further flattened - if nestedMap, isMap := value.(map[string]any); isMap { + if nested, isMap := value.(map[string]any); isMap { // Recursively flatten the nested map - flattenedSubMap := FlattenMap(nestedMap, newPath) + flattenedSubMap := flattenMap(nested, newPath) // Merge the flattened sub-map into the main flat map for subPath, subValue := range flattenedSubMap { flat[subPath] = subValue @@ -30,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 +// 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) { +func setNestedValue(nested map[string]any, path string, value any) { segments := strings.Split(path, ".") current := nested @@ -64,8 +64,8 @@ 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 } diff --git a/loader.go b/loader.go index 974b048..219a1ad 100644 --- a/loader.go +++ b/loader.go @@ -62,14 +62,8 @@ func DefaultLoadOptions() LoadOptions { } } -// Load reads configuration from a TOML file and merges overrides from command-line arguments. -// This is a convenience method that maintains backward compatibility. -func (c *Config) Load(filePath string, args []string) error { - return c.LoadWithOptions(filePath, args, c.options) -} - -// LoadWithOptions loads configuration from multiple sources with custom options -func (c *Config) LoadWithOptions(filePath string, args []string, opts LoadOptions) error { +// loadWithOptions loads configuration from multiple sources with custom options +func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOptions) error { c.mutex.Lock() c.options = opts c.mutex.Unlock() @@ -143,7 +137,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 +209,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 +495,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 +642,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/loader_test.go b/loader_test.go index c7f2ad5..df73f87 100644 --- a/loader_test.go +++ b/loader_test.go @@ -156,7 +156,7 @@ func TestEnvironmentLoading(t *testing.T) { }, } - err := cfg.LoadWithOptions("", nil, opts) + err := cfg.loadWithOptions("", nil, opts) require.NoError(t, err) host, _ := cfg.Get("db.host") @@ -176,7 +176,7 @@ func TestEnvironmentLoading(t *testing.T) { EnvWhitelist: map[string]bool{"allowed.path": true}, } - err := cfg.LoadWithOptions("", nil, opts) + err := cfg.loadWithOptions("", nil, opts) require.NoError(t, err) allowed, _ := cfg.Get("allowed.path") @@ -321,7 +321,7 @@ port = 8080 EnvPrefix: "TEST_", } - err := cfg.LoadWithOptions(configFile, args, opts) + err := cfg.loadWithOptions(configFile, args, opts) require.NoError(t, err) // CLI should win @@ -431,4 +431,4 @@ func splitEnvVar(env string) []string { } } return []string{env} -} +} \ No newline at end of file diff --git a/register.go b/register.go index 2dde99d..1425e20 100644 --- a/register.go +++ b/register.go @@ -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)) } } diff --git a/convenience.go b/utility.go similarity index 85% rename from convenience.go rename to utility.go index 72aee1f..438aac4 100644 --- a/convenience.go +++ b/utility.go @@ -1,4 +1,4 @@ -// FILE: lixenwraith/config/convenience.go +// FILE: lixenwraith/config/utility.go package config import ( @@ -8,6 +8,7 @@ import ( "os" "reflect" "strings" + "time" "github.com/BurntSushi/toml" "github.com/go-viper/mapstructure/v2" @@ -29,7 +30,7 @@ func Quick(structDefaults any, envPrefix, configFile string) (*Config, error) { opts := DefaultLoadOptions() opts.EnvPrefix = envPrefix - err := cfg.LoadWithOptions(configFile, os.Args[1:], opts) + err := cfg.loadWithOptions(configFile, os.Args[1:], opts) return cfg, err } @@ -44,7 +45,7 @@ func QuickCustom(structDefaults any, opts LoadOptions, configFile string) (*Conf } } - err := cfg.LoadWithOptions(configFile, os.Args[1:], opts) + err := cfg.loadWithOptions(configFile, os.Args[1:], opts) return cfg, err } @@ -180,7 +181,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) @@ -280,7 +281,7 @@ func GetTyped[T any](c *Config, path string) (T, error) { // 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 +// 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 { @@ -318,4 +319,47 @@ func ScanTyped[T any](c *Config, basePath ...string) (*T, error) { return nil, err } return &target, nil +} + +// ScanMap decodes a configuration map directly into a target struct +// without requiring a full Config instance. This is useful for plugin +// initialization where config data arrives as a map[string]any. +func ScanMap(configMap map[string]any, target any, tagName ...string) error { + // Handle nil map + if configMap == nil { + configMap = make(map[string]any) + } + + // Determine tag name + tag := "toml" // default + if len(tagName) > 0 && tagName[0] != "" { + tag = tagName[0] + } + + // Create decoder with standard hooks + decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + Result: target, + TagName: tag, + WeaklyTypedInput: true, + DecodeHook: mapstructure.ComposeDecodeHookFunc( + jsonNumberHookFunc(), + stringToNetIPHookFunc(), + stringToNetIPNetHookFunc(), + stringToURLHookFunc(), + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToTimeHookFunc(time.RFC3339), + mapstructure.StringToSliceHookFunc(","), + ), + ZeroFields: true, + }) + if err != nil { + return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err)) + } + + // Decode directly + if err := decoder.Decode(configMap); err != nil { + return wrapError(ErrDecode, fmt.Errorf("decode failed: %w", err)) + } + + return nil } \ No newline at end of file diff --git a/convenience_test.go b/utility_test.go similarity index 77% rename from convenience_test.go rename to utility_test.go index 67939f0..9f36b81 100644 --- a/convenience_test.go +++ b/utility_test.go @@ -1,4 +1,4 @@ -// FILE: lixenwraith/config/convenience_test.go +// FILE: lixenwraith/config/utility_test.go package config import ( @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -// TestQuickFunctions tests the convenience Quick* functions +// TestQuickFunctions tests the utility Quick* functions func TestQuickFunctions(t *testing.T) { tmpDir := t.TempDir() configFile := filepath.Join(tmpDir, "quick.toml") @@ -371,3 +371,99 @@ func TestGetTypedWithDefault(t *testing.T) { assert.Equal(t, []string{"default", "tag"}, tags) }) } + +// TestScanMap tests the ScanMap utility function +func TestScanMap(t *testing.T) { + type Config struct { + Server struct { + Host string `toml:"host" json:"hostname"` + Port int `toml:"port" json:"port"` + Timeout time.Duration `toml:"timeout" json:"timeout"` + } `toml:"server" json:"server"` + LogLevel string `toml:"log_level" json:"logLevel"` + } + + t.Run("BasicScanWithTOMLTags", func(t *testing.T) { + configMap := map[string]any{ + "server": map[string]any{ + "host": "localhost", + "port": 8080, + "timeout": "15s", + }, + "log_level": "info", + } + + var target Config + err := ScanMap(configMap, &target) + + require.NoError(t, err) + assert.Equal(t, "localhost", target.Server.Host) + assert.Equal(t, 8080, target.Server.Port) + assert.Equal(t, 15*time.Second, target.Server.Timeout) + assert.Equal(t, "info", target.LogLevel) + }) + + t.Run("ScanWithJSONTags", func(t *testing.T) { + configMap := map[string]any{ + "server": map[string]any{ + "hostname": "json-host", + "port": 9090, + "timeout": "1m", + }, + "logLevel": "debug", + } + + var target Config + err := ScanMap(configMap, &target, "json") + + require.NoError(t, err) + assert.Equal(t, "json-host", target.Server.Host) + assert.Equal(t, 9090, target.Server.Port) + assert.Equal(t, 1*time.Minute, target.Server.Timeout) + assert.Equal(t, "debug", target.LogLevel) + }) + + t.Run("NilMapInput", func(t *testing.T) { + var target Config + target.LogLevel = "initial" + target.Server.Port = 1234 + + err := ScanMap(nil, &target) + require.NoError(t, err) + + // Verify that fields are NOT changed when the map is empty, + // reflecting the observed behavior. + assert.Equal(t, "initial", target.LogLevel) + assert.Equal(t, 1234, target.Server.Port) + assert.Empty(t, target.Server.Host) + }) + + t.Run("PartialMapBehavior", func(t *testing.T) { + configMap := map[string]any{ + "log_level": "warn", + } + var target Config + target.Server.Host = "initial_host" + target.Server.Port = 1234 + target.LogLevel = "initial_log" + + err := ScanMap(configMap, &target) + require.NoError(t, err) + + // Mapped field should be updated + assert.Equal(t, "warn", target.LogLevel) + // Unmapped fields should be untouched + assert.Equal(t, "initial_host", target.Server.Host, "Unmapped field should be untouched") + assert.Equal(t, 1234, target.Server.Port, "Unmapped field should be untouched") + }) + + t.Run("InvalidTarget", func(t *testing.T) { + configMap := map[string]any{"log_level": "info"} + var target Config // Not a pointer + + err := ScanMap(configMap, target) + assert.Error(t, err) + // The underlying mapstructure error is "result must be a pointer" + assert.Contains(t, err.Error(), "must be a pointer") + }) +} \ No newline at end of file diff --git a/watch_test.go b/watch_test.go index 08dab00..6d66636 100644 --- a/watch_test.go +++ b/watch_test.go @@ -32,9 +32,8 @@ const ( testWatchTimeout = 2 * DefaultPollInterval // 2s for change propagation // Derived test multipliers with clear purpose - testDebounceSettle = debounceSettleMultiplier * testDebounce // 150ms for debounce verification - testPollWindow = 3 * testPollInterval // 300ms change detection window - testStateStabilize = 4 * testDebounce // 200ms for state convergence + testPollWindow = 3 * testPollInterval // 300ms change detection window + testStateStabilize = 4 * testDebounce // 200ms for state convergence ) // TestAutoUpdate tests automatic configuration reloading