v0.1.2 minor refactor, helpers back to private, utility update

This commit is contained in:
2025-11-14 13:02:19 -05:00
parent 7bcd90df3a
commit f4a19aa72a
12 changed files with 178 additions and 53 deletions

View File

@ -82,7 +82,7 @@ func (b *Builder) Build() (*Config, error) {
b.cfg.configFilePath = b.file b.cfg.configFilePath = b.file
// 2. Load configuration // 2. Load configuration
loadErr := b.cfg.LoadWithOptions(b.file, b.args, b.opts) loadErr := b.cfg.loadWithOptions(b.file, b.args, b.opts)
if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) { if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) {
// Return on fatal load errors. ErrConfigNotFound is not fatal. // Return on fatal load errors. ErrConfigNotFound is not fatal.
return nil, wrapError(ErrFileAccess, loadErr) return nil, wrapError(ErrFileAccess, loadErr)

View File

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

View File

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

View File

@ -85,7 +85,7 @@ Live Reload:
- **Security**: `WithSecurityOptions()` - **Security**: `WithSecurityOptions()`
### Source Loading (`loader.go`) ### Source Loading (`loader.go`)
- **Multi-Source Loading**: `Load()`, `LoadWithOptions()` - **Multi-Source Loading**: `loadWithOptions()`
- **Individual Sources**: `LoadFile()`, `LoadEnv()`, `LoadCLI()` - **Individual Sources**: `LoadFile()`, `LoadEnv()`, `LoadCLI()`
- **Persistence**: `Save()`, `SaveSource()`, `atomicWriteFile()` - **Persistence**: `Save()`, `SaveSource()`, `atomicWriteFile()`
- **Environment Mapping**: `DiscoverEnv()`, `ExportEnv()`, `defaultEnvTransform()` - **Environment Mapping**: `DiscoverEnv()`, `ExportEnv()`, `defaultEnvTransform()`
@ -105,11 +105,11 @@ Live Reload:
- **Change Detection**: `checkAndReload()`, `performReload()`, `notifyWatchers()` - **Change Detection**: `checkAndReload()`, `performReload()`, `notifyWatchers()`
- **Resource Management**: Max watcher limits, graceful shutdown - **Resource Management**: Max watcher limits, graceful shutdown
### Convenience API (`convenience.go`) ### Convenience API (`utility.go`)
- **Quick Setup**: `Quick()`, `QuickCustom()`, `MustQuick()`, `QuickTyped()` - **Quick Setup**: `Quick()`, `QuickCustom()`, `MustQuick()`, `QuickTyped()`
- **Flag Integration**: `GenerateFlags()`, `BindFlags()` - **Flag Integration**: `GenerateFlags()`, `BindFlags()`
- **Type-Safe Access**: `GetTyped()`, `GetTypedWithDefault()`, `ScanTyped()` - **Type-Safe Access**: `GetTyped()`, `GetTypedWithDefault()`, `ScanTyped()`
- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()` - **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()`, `ScanMap()`
### File Discovery (`discovery.go`) ### File Discovery (`discovery.go`)
- **Options**: `FileDiscoveryOptions` struct with search strategies - **Options**: `FileDiscoveryOptions` struct with search strategies

View File

@ -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 * `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)`) * `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 * `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` * `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`
@ -156,9 +157,3 @@ These methods are for dynamic key-value access that require type assertion and s
* `Save(path string)`: Atomically saves the current merged configuration state to a file * `Save(path string)`: Atomically saves the current merged configuration state to a file
* `Clone() *Config`: Creates a deep copy of the configuration state * `Clone() *Config`: Creates a deep copy of the configuration state
* `Debug() string`: Returns a formatted string of all values for debugging * `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

@ -3,20 +3,20 @@ package config
import "strings" import "strings"
// FlattenMap converts a nested map[string]any to a flat map[string]any with dot-notation paths // 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 { func flattenMap(nestedMap map[string]any, prefix string) map[string]any {
flat := make(map[string]any) flat := make(map[string]any)
for key, value := range nested { for key, value := range nestedMap {
newPath := key newPath := key
if prefix != "" { if prefix != "" {
newPath = prefix + "." + key newPath = prefix + "." + key
} }
// Check if the value is a map that can be further flattened // 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 // Recursively flatten the nested map
flattenedSubMap := FlattenMap(nestedMap, newPath) flattenedSubMap := flattenMap(nested, newPath)
// Merge the flattened sub-map into the main flat map // Merge the flattened sub-map into the main flat map
for subPath, subValue := range flattenedSubMap { for subPath, subValue := range flattenedSubMap {
flat[subPath] = subValue flat[subPath] = subValue
@ -30,10 +30,10 @@ func FlattenMap(nested map[string]any, prefix string) map[string]any {
return flat 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 // 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 // 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, ".") segments := strings.Split(path, ".")
current := nested current := nested
@ -64,8 +64,8 @@ func SetNestedValue(nested map[string]any, path string, value any) {
current[lastSegment] = value current[lastSegment] = value
} }
// IsValidKeySegment checks if a single path segment is a valid TOML key part // isValidKeySegment checks if a single path segment is a valid TOML key part
func IsValidKeySegment(s string) bool { func isValidKeySegment(s string) bool {
if len(s) == 0 { if len(s) == 0 {
return false return false
} }

View File

@ -62,14 +62,8 @@ func DefaultLoadOptions() LoadOptions {
} }
} }
// Load reads configuration from a TOML file and merges overrides from command-line arguments. // loadWithOptions loads configuration from multiple sources with custom options
// This is a convenience method that maintains backward compatibility. func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOptions) error {
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 {
c.mutex.Lock() c.mutex.Lock()
c.options = opts c.options = opts
c.mutex.Unlock() c.mutex.Unlock()
@ -143,7 +137,7 @@ func (c *Config) Save(path string) error {
nestedData := make(map[string]any) nestedData := make(map[string]any)
for itemPath, item := range c.items { for itemPath, item := range c.items {
SetNestedValue(nestedData, itemPath, item.currentValue) setNestedValue(nestedData, itemPath, item.currentValue)
} }
c.mutex.RUnlock() c.mutex.RUnlock()
@ -215,7 +209,7 @@ func (c *Config) SaveSource(path string, source Source) error {
nestedData := make(map[string]any) nestedData := make(map[string]any)
for itemPath, item := range c.items { for itemPath, item := range c.items {
if val, exists := item.values[source]; exists { 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 return err // Already wrapped with error category in parseArgs
} }
flattenedCLI := FlattenMap(parsedCLI, "") flattenedCLI := flattenMap(parsedCLI, "")
if len(flattenedCLI) == 0 { if len(flattenedCLI) == 0 {
return nil // No CLI args to process. return nil // No CLI args to process.
} }
@ -648,13 +642,13 @@ func parseArgs(args []string) (map[string]any, error) {
// Validate keyPath segments // Validate keyPath segments
segments := strings.Split(keyPath, ".") segments := strings.Split(keyPath, ".")
for _, segment := range segments { 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)) 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. // Always store as a string. Let Scan handle final type conversion.
SetNestedValue(result, keyPath, valueStr) setNestedValue(result, keyPath, valueStr)
} }
return result, nil return result, nil

View File

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

View File

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

View File

@ -1,4 +1,4 @@
// FILE: lixenwraith/config/convenience.go // FILE: lixenwraith/config/utility.go
package config package config
import ( import (
@ -8,6 +8,7 @@ import (
"os" "os"
"reflect" "reflect"
"strings" "strings"
"time"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"github.com/go-viper/mapstructure/v2" "github.com/go-viper/mapstructure/v2"
@ -29,7 +30,7 @@ func Quick(structDefaults any, envPrefix, configFile string) (*Config, error) {
opts := DefaultLoadOptions() opts := DefaultLoadOptions()
opts.EnvPrefix = envPrefix opts.EnvPrefix = envPrefix
err := cfg.LoadWithOptions(configFile, os.Args[1:], opts) err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
return cfg, err 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 return cfg, err
} }
@ -180,7 +181,7 @@ func (c *Config) Dump() error {
nestedData := make(map[string]any) nestedData := make(map[string]any)
for path, item := range c.items { for path, item := range c.items {
SetNestedValue(nestedData, path, item.currentValue) setNestedValue(nestedData, path, item.currentValue)
} }
encoder := toml.NewEncoder(os.Stdout) 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 // GetTypedWithDefault retrieves a configuration value with a default fallback
// If the path doesn't exist or isn't set, it sets and returns the default value // If the path doesn't exist or isn't set, it sets and returns the default value
// 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) { func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) {
// Check if path exists and has a value // Check if path exists and has a value
if _, exists := c.Get(path); exists { if _, exists := c.Get(path); exists {
@ -319,3 +320,46 @@ func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
} }
return &target, nil return &target, nil
} }
// ScanMap decodes a configuration map directly into a target struct
// without requiring a full Config instance. This is useful for plugin
// initialization where config data arrives as a map[string]any.
func ScanMap(configMap map[string]any, target any, tagName ...string) error {
// Handle nil map
if configMap == nil {
configMap = make(map[string]any)
}
// Determine tag name
tag := "toml" // default
if len(tagName) > 0 && tagName[0] != "" {
tag = tagName[0]
}
// Create decoder with standard hooks
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: target,
TagName: tag,
WeaklyTypedInput: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
jsonNumberHookFunc(),
stringToNetIPHookFunc(),
stringToNetIPNetHookFunc(),
stringToURLHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
),
ZeroFields: true,
})
if err != nil {
return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err))
}
// Decode directly
if err := decoder.Decode(configMap); err != nil {
return wrapError(ErrDecode, fmt.Errorf("decode failed: %w", err))
}
return nil
}

View File

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

View File

@ -32,9 +32,8 @@ const (
testWatchTimeout = 2 * DefaultPollInterval // 2s for change propagation testWatchTimeout = 2 * DefaultPollInterval // 2s for change propagation
// Derived test multipliers with clear purpose // Derived test multipliers with clear purpose
testDebounceSettle = debounceSettleMultiplier * testDebounce // 150ms for debounce verification testPollWindow = 3 * testPollInterval // 300ms change detection window
testPollWindow = 3 * testPollInterval // 300ms change detection window testStateStabilize = 4 * testDebounce // 200ms for state convergence
testStateStabilize = 4 * testDebounce // 200ms for state convergence
) )
// TestAutoUpdate tests automatic configuration reloading // TestAutoUpdate tests automatic configuration reloading