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
// 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)

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
// FILE: lixenwraith/config/convenience.go
// FILE: lixenwraith/config/utility.go
package config
import (
@ -8,6 +8,7 @@ import (
"os"
"reflect"
"strings"
"time"
"github.com/BurntSushi/toml"
"github.com/go-viper/mapstructure/v2"
@ -29,7 +30,7 @@ func Quick(structDefaults any, envPrefix, configFile string) (*Config, error) {
opts := DefaultLoadOptions()
opts.EnvPrefix = envPrefix
err := cfg.LoadWithOptions(configFile, os.Args[1:], opts)
err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
return cfg, err
}
@ -44,7 +45,7 @@ func QuickCustom(structDefaults any, opts LoadOptions, configFile string) (*Conf
}
}
err := cfg.LoadWithOptions(configFile, os.Args[1:], opts)
err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
return cfg, err
}
@ -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 {
@ -319,3 +320,46 @@ func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
}
return &target, nil
}
// ScanMap decodes a configuration map directly into a target struct
// without requiring a full Config instance. This is useful for plugin
// initialization where config data arrives as a map[string]any.
func ScanMap(configMap map[string]any, target any, tagName ...string) error {
// Handle nil map
if configMap == nil {
configMap = make(map[string]any)
}
// Determine tag name
tag := "toml" // default
if len(tagName) > 0 && tagName[0] != "" {
tag = tagName[0]
}
// Create decoder with standard hooks
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: target,
TagName: tag,
WeaklyTypedInput: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
jsonNumberHookFunc(),
stringToNetIPHookFunc(),
stringToNetIPNetHookFunc(),
stringToURLHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
),
ZeroFields: true,
})
if err != nil {
return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err))
}
// Decode directly
if err := decoder.Decode(configMap); err != nil {
return wrapError(ErrDecode, fmt.Errorf("decode failed: %w", err))
}
return nil
}

View File

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

View File

@ -32,7 +32,6 @@ 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
)