From 5aadc84d5098a113cd8bead1e7cc4df4a31e2139 Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Sun, 1 Mar 2026 23:43:16 +0000 Subject: [PATCH] feat: add IsSet, AllKeys, AllSettings; fix env precedence for file-only keys - collapse() now applies env vars for keys present in fileConfig, not just defaultConfig. Previously, env vars couldn't override file values unless a default was also set for that key. - SetDefault no longer pollutes overrideConfig; it correctly resolves the value by checking override > env > file > default. - Remove unused explicitDefaults field and UseExplicitDefaults method. - Add IsSet, AllKeys, AllSettings methods + package-level wrappers. - Add missing package-level wrappers: Get, SetBool, SetString, SetConfigDir, WithEnvPrefix. - Add tests for all new methods and the env-over-file-without-default fix. --- default.go | 32 ++++++++++++++++++ jety.go | 73 +++++++++++++++++++++++++++++++---------- jety_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++++------- setters.go | 16 ++++----- 4 files changed, 176 insertions(+), 36 deletions(-) diff --git a/default.go b/default.go index 8e36d87..1413c99 100644 --- a/default.go +++ b/default.go @@ -67,3 +67,35 @@ func GetStringMap(key string) map[string]any { func GetStringSlice(key string) []string { return defaultConfigManager.GetStringSlice(key) } + +func Get(key string) any { + return defaultConfigManager.Get(key) +} + +func SetBool(key string, value bool) { + defaultConfigManager.SetBool(key, value) +} + +func SetString(key string, value string) { + defaultConfigManager.SetString(key, value) +} + +func SetConfigDir(path string) { + defaultConfigManager.SetConfigDir(path) +} + +func WithEnvPrefix(prefix string) *ConfigManager { + return defaultConfigManager.WithEnvPrefix(prefix) +} + +func IsSet(key string) bool { + return defaultConfigManager.IsSet(key) +} + +func AllKeys() []string { + return defaultConfigManager.AllKeys() +} + +func AllSettings() map[string]any { + return defaultConfigManager.AllSettings() +} diff --git a/jety.go b/jety.go index a153faf..0f42691 100644 --- a/jety.go +++ b/jety.go @@ -28,17 +28,16 @@ type ( } ConfigManager struct { - configName string - configPath string - configFileUsed string - configType configType - overrideConfig map[string]ConfigMap - fileConfig map[string]ConfigMap - defaultConfig map[string]ConfigMap - envConfig map[string]ConfigMap - combinedConfig map[string]ConfigMap - mutex sync.RWMutex - explicitDefaults bool + configName string + configPath string + configFileUsed string + configType configType + overrideConfig map[string]ConfigMap + fileConfig map[string]ConfigMap + defaultConfig map[string]ConfigMap + envConfig map[string]ConfigMap + combinedConfig map[string]ConfigMap + mutex sync.RWMutex } ) @@ -90,10 +89,48 @@ func (c *ConfigManager) ConfigFileUsed() string { return c.configFileUsed } -func (c *ConfigManager) UseExplicitDefaults(enable bool) { - c.mutex.Lock() - defer c.mutex.Unlock() - c.explicitDefaults = enable +// IsSet checks whether a key has been set in any configuration source. +func (c *ConfigManager) IsSet(key string) bool { + c.mutex.RLock() + defer c.mutex.RUnlock() + lower := strings.ToLower(key) + if _, ok := c.combinedConfig[lower]; ok { + return true + } + _, ok := c.envConfig[lower] + return ok +} + +// AllKeys returns all keys from all configuration sources, deduplicated. +func (c *ConfigManager) AllKeys() []string { + c.mutex.RLock() + defer c.mutex.RUnlock() + seen := make(map[string]struct{}) + var keys []string + for k := range c.combinedConfig { + if _, ok := seen[k]; !ok { + seen[k] = struct{}{} + keys = append(keys, k) + } + } + for k := range c.envConfig { + if _, ok := seen[k]; !ok { + seen[k] = struct{}{} + keys = append(keys, k) + } + } + return keys +} + +// AllSettings returns all settings as a flat map of key to value. +func (c *ConfigManager) AllSettings() map[string]any { + c.mutex.RLock() + defer c.mutex.RUnlock() + result := make(map[string]any, len(c.combinedConfig)) + for k, v := range c.combinedConfig { + result[k] = v.Value + } + return result } func (c *ConfigManager) collapse() { @@ -107,8 +144,10 @@ func (c *ConfigManager) collapse() { for k, v := range c.fileConfig { ccm[k] = v } - for k := range c.defaultConfig { - if v, ok := c.envConfig[k]; ok { + for k, v := range c.envConfig { + if _, inDefaults := c.defaultConfig[k]; inDefaults { + ccm[k] = v + } else if _, inFile := c.fileConfig[k]; inFile { ccm[k] = v } } diff --git a/jety_test.go b/jety_test.go index e71e0db..fece21c 100644 --- a/jety_test.go +++ b/jety_test.go @@ -760,17 +760,6 @@ func TestPackageLevelFunctions(t *testing.T) { } } -func TestUseExplicitDefaults(t *testing.T) { - cm := NewConfigManager() - cm.UseExplicitDefaults(true) - - // Just verify it doesn't panic and the field is set - cm.SetDefault("key", "value") - if got := cm.GetString("key"); got != "value" { - t.Errorf("GetString(key) = %q, want %q", got, "value") - } -} - func TestSetString(t *testing.T) { cm := NewConfigManager() cm.SetString("name", "test") @@ -1397,3 +1386,83 @@ func TestPrecedenceChain(t *testing.T) { t.Errorf("extra: got %q, want defaultextra (default)", got) } } + +func TestIsSet(t *testing.T) { + cm := NewConfigManager() + cm.Set("exists", "yes") + + if !cm.IsSet("exists") { + t.Error("IsSet(exists) = false, want true") + } + if cm.IsSet("nope") { + t.Error("IsSet(nope) = true, want false") + } +} + +func TestAllKeys(t *testing.T) { + cm := NewConfigManager() + cm.SetDefault("a", 1) + cm.Set("b", 2) + + keys := cm.AllKeys() + found := make(map[string]bool) + for _, k := range keys { + found[k] = true + } + if !found["a"] || !found["b"] { + t.Errorf("AllKeys() = %v, want to contain a and b", keys) + } +} + +func TestAllSettings(t *testing.T) { + cm := NewConfigManager() + cm.Set("port", 8080) + cm.Set("host", "localhost") + + settings := cm.AllSettings() + if settings["port"] != 8080 || settings["host"] != "localhost" { + t.Errorf("AllSettings() = %v", settings) + } +} + +func TestEnvOverridesFileWithoutDefault(t *testing.T) { + // Bug fix: env should override file even when no default is set for that key + os.Setenv("HOST", "envhost") + defer os.Unsetenv("HOST") + + dir := t.TempDir() + configFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configFile, []byte("host: filehost"), 0o644); err != nil { + t.Fatal(err) + } + + cm := NewConfigManager() + cm.SetConfigFile(configFile) + if err := cm.SetConfigType("yaml"); err != nil { + t.Fatal(err) + } + if err := cm.ReadInConfig(); err != nil { + t.Fatal(err) + } + + // No SetDefault("host", ...) was called — env should still win + if got := cm.GetString("host"); got != "envhost" { + t.Errorf("GetString(host) = %q, want envhost (env overrides file even without default)", got) + } +} + +func TestPackageLevelIsSet(t *testing.T) { + defaultConfigManager = NewConfigManager() + Set("x", 1) + if !IsSet("x") { + t.Error("IsSet(x) = false") + } +} + +func TestPackageLevelGet(t *testing.T) { + defaultConfigManager = NewConfigManager() + Set("key", "val") + if Get("key") != "val" { + t.Error("Get(key) failed") + } +} diff --git a/setters.go b/setters.go index b0b7b5a..ebaf3d9 100644 --- a/setters.go +++ b/setters.go @@ -33,14 +33,14 @@ func (c *ConfigManager) SetDefault(key string, value any) { defer c.mutex.Unlock() lower := strings.ToLower(key) c.defaultConfig[lower] = ConfigMap{Key: key, Value: value} - if _, ok := c.overrideConfig[lower]; !ok { - if envVal, ok := c.envConfig[lower]; ok { - c.overrideConfig[lower] = ConfigMap{Key: key, Value: envVal.Value} - c.combinedConfig[lower] = ConfigMap{Key: key, Value: envVal.Value} - } else { - c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} - } + // Update combinedConfig respecting precedence: override > env > file > default + if v, ok := c.overrideConfig[lower]; ok { + c.combinedConfig[lower] = v + } else if v, ok := c.envConfig[lower]; ok { + c.combinedConfig[lower] = ConfigMap{Key: key, Value: v.Value} + } else if v, ok := c.fileConfig[lower]; ok { + c.combinedConfig[lower] = v } else { - c.combinedConfig[lower] = c.overrideConfig[lower] + c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} } }