From 3aa2ab30d67ed7b357d862ea59f6e79ffe488d23f081a80e38d1f0e378a93163 Mon Sep 17 00:00:00 2001 From: Lixen Wraith Date: Sun, 20 Jul 2025 20:53:22 -0400 Subject: [PATCH] e5.2.1 Improved interfaces of functions using path and prefix. --- builder_test.go | 2 +- config.go | 16 +++++++-------- config_test.go | 34 +++++++++++++++---------------- convenience.go | 6 +++--- convenience_test.go | 12 +++++------ decode.go | 49 ++++++++++++++------------------------------- decode_test.go | 48 ++++++++++++++++++++++---------------------- doc/access.md | 10 ++++----- loader.go | 6 +++--- loader_test.go | 6 +++--- register.go | 30 ++++++++++++++++++--------- 11 files changed, 105 insertions(+), 114 deletions(-) diff --git a/builder_test.go b/builder_test.go index d9564ea..afcc982 100644 --- a/builder_test.go +++ b/builder_test.go @@ -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"]) diff --git a/config.go b/config.go index f257dc6..699ed8b 100644 --- a/config.go +++ b/config.go @@ -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) } diff --git a/config_test.go b/config_test.go index ca0829c..4bbd867 100644 --- a/config_test.go +++ b/config_test.go @@ -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) diff --git a/convenience.go b/convenience.go index 1e6e488..cacec4b 100644 --- a/convenience.go +++ b/convenience.go @@ -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 diff --git a/convenience_test.go b/convenience_test.go index 3370cb6..dc2d7da 100644 --- a/convenience_test.go +++ b/convenience_test.go @@ -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) diff --git a/decode.go b/decode.go index 5e16cf0..85d341f 100644 --- a/decode.go +++ b/decode.go @@ -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 { diff --git a/decode_test.go b/decode_test.go index e91466c..555c344 100644 --- a/decode_test.go +++ b/decode_test.go @@ -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) diff --git a/doc/access.md b/doc/access.md index 1be3767..997c204 100644 --- a/doc/access.md +++ b/doc/access.md @@ -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 diff --git a/loader.go b/loader.go index 1f2ee9b..e53d75e 100644 --- a/loader.go +++ b/loader.go @@ -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 } } diff --git a/loader_test.go b/loader_test.go index 44eb38c..26882c8 100644 --- a/loader_test.go +++ b/loader_test.go @@ -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) diff --git a/register.go b/register.go index a546c98..9218c57 100644 --- a/register.go +++ b/register.go @@ -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...) } \ No newline at end of file