v0.1.2 minor refactor, helpers back to private, utility update
This commit is contained in:
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
18
helper.go
18
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
|
||||
}
|
||||
|
||||
20
loader.go
20
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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")
|
||||
})
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user