e5.2.1 Improved interfaces of functions using path and prefix.

This commit is contained in:
2025-07-20 20:53:22 -04:00
parent 06cddbe00e
commit 3aa2ab30d6
11 changed files with 105 additions and 114 deletions

View File

@ -99,7 +99,7 @@ func TestBuilder(t *testing.T) {
require.NoError(t, err)
// Verify paths were registered
paths := cfg.GetRegisteredPaths("")
paths := cfg.GetRegisteredPaths()
assert.True(t, paths["db.host"])
assert.True(t, paths["db.port"])
assert.True(t, paths["cache.ttl"])

View File

@ -94,7 +94,7 @@ func (c *Config) SetLoadOptions(opts LoadOptions) error {
// Recompute all current values based on new precedence
for path, item := range c.items {
item.currentValue = c.computeValue(path, item)
item.currentValue = c.computeValue(item)
c.items[path] = item
}
@ -102,7 +102,7 @@ func (c *Config) SetLoadOptions(opts LoadOptions) error {
}
// computeValue determines the current value based on precedence
func (c *Config) computeValue(path string, item configItem) any {
func (c *Config) computeValue(item configItem) any {
// Check sources in precedence order
for _, source := range c.options.Sources {
if val, exists := item.values[source]; exists && val != nil {
@ -146,11 +146,11 @@ func (c *Config) GetSource(path string, source Source) (any, bool) {
// 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(path, c.options.Sources[0], value)
return c.SetSource(c.options.Sources[0], path, value)
}
// SetSource sets a value for a specific source
func (c *Config) SetSource(path string, source Source, value any) error {
func (c *Config) SetSource(source Source, path string, value any) error {
c.mutex.Lock()
defer c.mutex.Unlock()
@ -168,7 +168,7 @@ func (c *Config) SetSource(path string, source Source, value any) error {
}
item.values[source] = value
item.currentValue = c.computeValue(path, item)
item.currentValue = c.computeValue(item)
c.items[path] = item
// Update source cache
@ -240,7 +240,7 @@ func (c *Config) ResetSource(source Source) {
// Remove source values from all items
for path, item := range c.items {
delete(item.values, source)
item.currentValue = c.computeValue(path, item)
item.currentValue = c.computeValue(item)
c.items[path] = item
}
@ -274,7 +274,7 @@ func (c *Config) AsStruct() (any, error) {
// Target populates the provided struct with current configuration
func (c *Config) Target(out any) error {
return c.Scan("", out)
return c.Scan(out)
}
// populateStruct updates the cached struct representation using unified unmarshal
@ -287,7 +287,7 @@ func (c *Config) populateStruct() error {
return nil
}
if err := c.unmarshal("", "", c.structCache.target); err != nil {
if err := c.unmarshal("", c.structCache.target); err != nil {
return fmt.Errorf("failed to populate struct cache: %w", err)
}

View File

@ -158,9 +158,9 @@ func TestSourcePrecedence(t *testing.T) {
cfg.Register("test.value", "default")
// Set values in different sources
cfg.SetSource("test.value", SourceFile, "from-file")
cfg.SetSource("test.value", SourceEnv, "from-env")
cfg.SetSource("test.value", SourceCLI, "from-cli")
cfg.SetSource(SourceFile, "test.value", "from-file")
cfg.SetSource(SourceEnv, "test.value", "from-env")
cfg.SetSource(SourceCLI, "test.value", "from-cli")
// Default precedence: CLI > Env > File > Default
val, _ := cfg.Get("test.value")
@ -216,20 +216,20 @@ func TestTypeConversion(t *testing.T) {
require.NoError(t, err)
// Test string conversions from environment
cfg.SetSource("int", SourceEnv, "100")
cfg.SetSource("float", SourceEnv, "2.718")
cfg.SetSource("bool", SourceEnv, "false")
cfg.SetSource("duration", SourceEnv, "1m30s")
cfg.SetSource("time", SourceEnv, "2024-12-25T10:00:00Z")
cfg.SetSource("ip", SourceEnv, "192.168.1.1")
cfg.SetSource("ipnet", SourceEnv, "10.0.0.0/8")
cfg.SetSource("url", SourceEnv, "https://example.com:8080/path")
cfg.SetSource("strings", SourceEnv, "x,y,z")
cfg.SetSource(SourceEnv, "int", "100")
cfg.SetSource(SourceEnv, "float", "2.718")
cfg.SetSource(SourceEnv, "bool", "false")
cfg.SetSource(SourceEnv, "duration", "1m30s")
cfg.SetSource(SourceEnv, "time", "2024-12-25T10:00:00Z")
cfg.SetSource(SourceEnv, "ip", "192.168.1.1")
cfg.SetSource(SourceEnv, "ipnet", "10.0.0.0/8")
cfg.SetSource(SourceEnv, "url", "https://example.com:8080/path")
cfg.SetSource(SourceEnv, "strings", "x,y,z")
// cfg.SetSource("ints", SourceEnv, "7,8,9") // failure due to mapstructure limitation
// Scan into struct
var result TestConfig
err = cfg.Scan("", &result)
err = cfg.Scan(&result)
require.NoError(t, err)
assert.Equal(t, int64(100), result.IntValue)
@ -295,7 +295,7 @@ func TestConcurrentAccess(t *testing.T) {
path := fmt.Sprintf("path%d", j)
source := sources[j%len(sources)]
value := fmt.Sprintf("source%d-value%d", id, j)
if err := cfg.SetSource(path, source, value); err != nil {
if err := cfg.SetSource(source, path, value); err != nil {
errors <- fmt.Errorf("source writer %d: %v", id, err)
}
}
@ -364,9 +364,9 @@ func TestResetFunctionality(t *testing.T) {
cfg.Register("test2", "default2")
// Set values in different sources
cfg.SetSource("test1", SourceFile, "file1")
cfg.SetSource("test1", SourceEnv, "env1")
cfg.SetSource("test2", SourceCLI, "cli2")
cfg.SetSource(SourceFile, "test1", "file1")
cfg.SetSource(SourceEnv, "test1", "env1")
cfg.SetSource(SourceCLI, "test2", "cli2")
t.Run("ResetSingleSource", func(t *testing.T) {
cfg.ResetSource(SourceEnv)

View File

@ -93,7 +93,7 @@ func (c *Config) BindFlags(fs *flag.FlagSet) error {
fs.Visit(func(f *flag.Flag) {
value := f.Value.String()
// Let mapstructure handle type conversion
if err := c.SetSource(f.Name, SourceCLI, value); err != nil {
if err := c.SetSource(SourceCLI, f.Name, value); err != nil {
errors = append(errors, fmt.Errorf("flag %s: %w", f.Name, err))
} else {
needsInvalidation = true
@ -276,9 +276,9 @@ func GetTyped[T any](c *Config, path string) (T, error) {
// 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.
func ScanTyped[T any](c *Config, basePath string) (*T, error) {
func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
var target T
if err := c.Scan(basePath, &target); err != nil {
if err := c.Scan(&target, basePath...); err != nil {
return nil, err
}
return &target, nil

View File

@ -200,7 +200,7 @@ func TestValidation(t *testing.T) {
cfg2.Register("test", "default")
// Value equals default but from different source
cfg2.SetSource("test", SourceEnv, "default")
cfg2.SetSource(SourceEnv, "test", "default")
err := cfg2.Validate("test")
assert.NoError(t, err) // Should pass because env provided value
@ -213,9 +213,9 @@ func TestDebugAndDump(t *testing.T) {
cfg.Register("server.host", "localhost")
cfg.Register("server.port", 8080)
cfg.SetSource("server.host", SourceFile, "filehost")
cfg.SetSource("server.host", SourceEnv, "envhost")
cfg.SetSource("server.port", SourceCLI, "9999")
cfg.SetSource(SourceFile, "server.host", "filehost")
cfg.SetSource(SourceEnv, "server.host", "envhost")
cfg.SetSource(SourceCLI, "server.port", "9999")
t.Run("Debug", func(t *testing.T) {
debug := cfg.Debug()
@ -258,8 +258,8 @@ func TestClone(t *testing.T) {
cfg.Register("original.value", "default")
cfg.Register("shared.value", "shared")
cfg.SetSource("original.value", SourceFile, "filevalue")
cfg.SetSource("shared.value", SourceEnv, "envvalue")
cfg.SetSource(SourceFile, "original.value", "filevalue")
cfg.SetSource(SourceEnv, "shared.value", "envvalue")
clone := cfg.Clone()
require.NotNil(t, clone)

View File

@ -14,7 +14,18 @@ import (
// unmarshal is the single authoritative function for decoding configuration
// into target structures. All public decoding methods delegate to this.
func (c *Config) unmarshal(basePath string, source Source, target any) error {
func (c *Config) unmarshal(source Source, target any, basePath ...string) error {
// Parse variadic basePath
path := ""
switch len(basePath) {
case 0:
// Use default empty path
case 1:
path = basePath[0]
default:
return fmt.Errorf("too many basePath arguments: expected 0 or 1, got %d", len(basePath))
}
// Validate target
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
@ -42,7 +53,7 @@ func (c *Config) unmarshal(basePath string, source Source, target any) error {
}
// Navigate to basePath section
sectionData := navigateToPath(nestedMap, basePath)
sectionData := navigateToPath(nestedMap, path)
// Ensure we have a map to decode, normalizing if necessary.
sectionMap, err := normalizeMap(sectionData)
@ -51,7 +62,7 @@ func (c *Config) unmarshal(basePath string, source Source, target any) error {
sectionMap = make(map[string]any) // Empty section is valid.
} else {
// Path points to a non-map value, which is an error for Scan.
return fmt.Errorf("path %q refers to non-map value (type %T)", basePath, sectionData)
return fmt.Errorf("path %q refers to non-map value (type %T)", path, sectionData)
}
}
@ -69,42 +80,12 @@ func (c *Config) unmarshal(basePath string, source Source, target any) error {
}
if err := decoder.Decode(sectionMap); err != nil {
return fmt.Errorf("decode failed for path %q: %w", basePath, err)
return fmt.Errorf("decode failed for path %q: %w", path, err)
}
return nil
}
// // Ensure we have a map to decode
// sectionMap, ok := sectionData.(map[string]any)
// if !ok {
// if sectionData == nil {
// sectionMap = make(map[string]any) // Empty section
// } else {
// return fmt.Errorf("path %q refers to non-map value (type %T)", basePath, sectionData)
// }
// }
//
// // Create decoder with comprehensive hooks
// decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
// Result: target,
// TagName: c.tagName,
// WeaklyTypedInput: true,
// DecodeHook: c.getDecodeHook(),
// ZeroFields: true,
// Metadata: nil,
// })
// if err != nil {
// return fmt.Errorf("decoder creation failed: %w", err)
// }
//
// if err := decoder.Decode(sectionMap); err != nil {
// return fmt.Errorf("decode failed for path %q: %w", basePath, err)
// }
//
// return nil
// }
// 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 {

View File

@ -50,22 +50,22 @@ func TestScanWithComplexTypes(t *testing.T) {
require.NoError(t, err)
// Set values from different sources
cfg.SetSource("network.ip", SourceEnv, "192.168.1.100")
cfg.SetSource("network.subnet", SourceEnv, "192.168.1.0/24")
cfg.SetSource("network.endpoint", SourceEnv, "https://api.example.com:8443/v1")
cfg.SetSource("network.timeout", SourceFile, "2m30s")
cfg.SetSource("network.retry.count", SourceFile, int64(5))
cfg.SetSource("network.retry.interval", SourceFile, "10s")
cfg.SetSource("tags", SourceCLI, "prod,staging,test")
cfg.SetSource("ports", SourceFile, []any{int64(80), int64(443), int64(8080)})
cfg.SetSource("labels", SourceFile, map[string]any{
cfg.SetSource(SourceEnv, "network.ip", "192.168.1.100")
cfg.SetSource(SourceEnv, "network.subnet", "192.168.1.0/24")
cfg.SetSource(SourceEnv, "network.endpoint", "https://api.example.com:8443/v1")
cfg.SetSource(SourceFile, "network.timeout", "2m30s")
cfg.SetSource(SourceFile, "network.retry.count", int64(5))
cfg.SetSource(SourceFile, "network.retry.interval", "10s")
cfg.SetSource(SourceCLI, "tags", "prod,staging,test")
cfg.SetSource(SourceFile, "ports", []any{int64(80), int64(443), int64(8080)})
cfg.SetSource(SourceFile, "labels", map[string]any{
"env": "production",
"version": "1.2.3",
})
// Scan into struct
var result AppConfig
err = cfg.Scan("", &result)
err = cfg.Scan(&result)
require.NoError(t, err)
// Verify conversions
@ -100,7 +100,7 @@ func TestScanWithBasePath(t *testing.T) {
// Scan only the server section
var server ServerConfig
err := cfg.Scan("app.server", &server)
err := cfg.Scan(&server, "app.server")
require.NoError(t, err)
assert.Equal(t, "appserver", server.Host)
@ -109,7 +109,7 @@ func TestScanWithBasePath(t *testing.T) {
// Test non-existent base path
var empty ServerConfig
err = cfg.Scan("app.nonexistent", &empty)
err = cfg.Scan(&empty, "app.nonexistent")
assert.NoError(t, err) // Should not error, just empty
assert.Equal(t, "", empty.Host)
assert.Equal(t, 0, empty.Port)
@ -124,9 +124,9 @@ func TestScanFromSource(t *testing.T) {
cfg := New()
cfg.Register("value", "default")
cfg.SetSource("value", SourceFile, "fromfile")
cfg.SetSource("value", SourceEnv, "fromenv")
cfg.SetSource("value", SourceCLI, "fromcli")
cfg.SetSource(SourceFile, "value", "fromfile")
cfg.SetSource(SourceEnv, "value", "fromenv")
cfg.SetSource(SourceCLI, "value", "fromcli")
tests := []struct {
source Source
@ -141,7 +141,7 @@ func TestScanFromSource(t *testing.T) {
for _, tt := range tests {
t.Run(string(tt.source), func(t *testing.T) {
var result Config
err := cfg.ScanSource("", tt.source, &result)
err := cfg.ScanSource(tt.source, &result)
require.NoError(t, err)
assert.Equal(t, tt.expected, result.Value)
})
@ -165,7 +165,7 @@ func TestInvalidScanTargets(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := cfg.Scan("", tt.target)
err := cfg.Scan(tt.target)
assert.Error(t, err)
assert.Contains(t, err.Error(), tt.expectErr)
})
@ -185,7 +185,7 @@ func TestCustomTypeConversion(t *testing.T) {
cfg.Set("ip", "not-an-ip")
var result Config
err := cfg.Scan("", &result)
err := cfg.Scan(&result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid IP address")
})
@ -199,7 +199,7 @@ func TestCustomTypeConversion(t *testing.T) {
cfg.Set("network", "invalid-cidr")
var result Config
err := cfg.Scan("", &result)
err := cfg.Scan(&result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid CIDR")
})
@ -213,7 +213,7 @@ func TestCustomTypeConversion(t *testing.T) {
cfg.Set("endpoint", "://invalid-url")
var result Config
err := cfg.Scan("", &result)
err := cfg.Scan(&result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid URL")
})
@ -232,7 +232,7 @@ func TestCustomTypeConversion(t *testing.T) {
cfg.Set("ip", string(longIP))
var result Config
err := cfg.Scan("", &result)
err := cfg.Scan(&result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid IP length")
})
@ -251,7 +251,7 @@ func TestCustomTypeConversion(t *testing.T) {
cfg.Set("url", longURL)
var result Config
err := cfg.Scan("", &result)
err := cfg.Scan(&result)
assert.Error(t, err)
assert.Contains(t, err.Error(), "URL too long")
})
@ -286,7 +286,7 @@ func TestZeroFields(t *testing.T) {
}{Field: "initial"},
}
err := cfg.Scan("", &result)
err := cfg.Scan(&result)
require.NoError(t, err)
// ZeroFields should reset all fields before decoding
@ -317,7 +317,7 @@ func TestWeaklyTypedInput(t *testing.T) {
cfg.Set("string_from_bool", true)
var result Config
err := cfg.Scan("", &result)
err := cfg.Scan(&result)
require.NoError(t, err)
assert.Equal(t, 42, result.IntFromString)

View File

@ -43,7 +43,7 @@ portNum := port.(int64) // Won't panic - type is enforced
```go
// Get value from specific source
envPort, exists := cfg.GetSource("server.port", config.SourceEnv)
envPort, exists := cfg.GetSource(config.SourceEnv, "server.port")
if exists {
log.Printf("Port from environment: %v", envPort)
}
@ -68,7 +68,7 @@ var serverConfig struct {
} `toml:"tls"`
}
if err := cfg.Scan("server", &serverConfig); err != nil {
if err := cfg.Scan(&serverConfig, "server"); err != nil {
log.Fatal(err)
}
@ -158,11 +158,11 @@ if err := cfg.Set("server.port", int64(9090)); err != nil {
```go
// Set value in specific source
cfg.SetSource("server.port", config.SourceEnv, "8080")
cfg.SetSource("debug", config.SourceCLI, true)
cfg.SetSource(config.SourceEnv, "server.port", "8080")
cfg.SetSource(config.SourceCLI, "debug", true)
// File source typically set via LoadFile, but can be manual
cfg.SetSource("feature.enabled", config.SourceFile, true)
cfg.SetSource(config.SourceFile, "feature.enabled", true)
```
### Batch Updates

View File

@ -204,7 +204,7 @@ func (c *Config) loadFile(path string) error {
delete(item.values, SourceFile)
}
// Recompute the current value based on new source precedence.
item.currentValue = c.computeValue(path, item)
item.currentValue = c.computeValue(item)
c.items[path] = item
}
@ -261,7 +261,7 @@ func (c *Config) loadEnv(opts LoadOptions) error {
item.values = make(map[Source]any)
}
item.values[SourceEnv] = value // Store as string
item.currentValue = c.computeValue(path, item)
item.currentValue = c.computeValue(item)
c.items[path] = item
c.envData[path] = value
}
@ -296,7 +296,7 @@ func (c *Config) loadCLI(args []string) error {
item.values = make(map[Source]any)
}
item.values[SourceCLI] = value
item.currentValue = c.computeValue(path, item)
item.currentValue = c.computeValue(item)
c.items[path] = item
}
}

View File

@ -375,9 +375,9 @@ func TestAtomicSave(t *testing.T) {
})
t.Run("SaveSpecificSource", func(t *testing.T) {
cfg.SetSource("server.host", SourceEnv, "envhost")
cfg.SetSource("server.port", SourceEnv, "7777")
cfg.SetSource("server.port", SourceFile, "6666")
cfg.SetSource(SourceEnv, "server.host", "envhost")
cfg.SetSource(SourceEnv, "server.port", "7777")
cfg.SetSource(SourceFile, "server.port", "6666")
savePath := filepath.Join(tmpDir, "env-only.toml")
err := cfg.SaveSource(savePath, SourceEnv)

View File

@ -46,7 +46,7 @@ func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) e
// Check if the environment variable exists and load it
if value, exists := os.LookupEnv(envVar); exists {
parsed := parseValue(value)
return c.SetSource(path, SourceEnv, parsed)
return c.SetSource(SourceEnv, path, parsed)
}
return nil
@ -230,7 +230,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
if envTag != "" && err == nil {
if value, exists := os.LookupEnv(envTag); exists {
parsed := parseValue(value)
if setErr := c.SetSource(currentPath, SourceEnv, parsed); setErr != nil {
if setErr := c.SetSource(SourceEnv, currentPath, parsed); setErr != nil {
*errors = append(*errors, fmt.Sprintf("field %s%s env %s: %v", fieldPath, field.Name, envTag, setErr))
}
}
@ -239,13 +239,18 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
}
// GetRegisteredPaths returns all registered configuration paths with the specified prefix.
func (c *Config) GetRegisteredPaths(prefix string) map[string]bool {
func (c *Config) GetRegisteredPaths(prefix ...string) map[string]bool {
p := ""
if len(prefix) > 0 {
p = prefix[0]
}
c.mutex.RLock()
defer c.mutex.RUnlock()
result := make(map[string]bool)
for path := range c.items {
if strings.HasPrefix(path, prefix) {
if strings.HasPrefix(path, p) {
result[path] = true
}
}
@ -254,13 +259,18 @@ func (c *Config) GetRegisteredPaths(prefix string) map[string]bool {
}
// GetRegisteredPathsWithDefaults returns paths with their default values
func (c *Config) GetRegisteredPathsWithDefaults(prefix string) map[string]any {
func (c *Config) GetRegisteredPathsWithDefaults(prefix ...string) map[string]any {
p := ""
if len(prefix) > 0 {
p = prefix[0]
}
c.mutex.RLock()
defer c.mutex.RUnlock()
result := make(map[string]any)
for path, item := range c.items {
if strings.HasPrefix(path, prefix) {
if strings.HasPrefix(path, p) {
result[path] = item.defaultValue
}
}
@ -269,11 +279,11 @@ func (c *Config) GetRegisteredPathsWithDefaults(prefix string) map[string]any {
}
// Scan decodes configuration into target using the unified unmarshal function
func (c *Config) Scan(basePath string, target any) error {
return c.unmarshal(basePath, "", target) // Empty source means use merged state
func (c *Config) Scan(target any, basePath ...string) error {
return c.unmarshal("", target, basePath...)
}
// ScanSource decodes configuration from specific source using unified unmarshal
func (c *Config) ScanSource(basePath string, source Source, target any) error {
return c.unmarshal(basePath, source, target)
func (c *Config) ScanSource(source Source, target any, basePath ...string) error {
return c.unmarshal(source, target, basePath...)
}