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.
This commit is contained in:
2026-03-01 23:43:16 +00:00
parent 7335ecd39c
commit 5aadc84d50
4 changed files with 176 additions and 36 deletions

View File

@@ -67,3 +67,35 @@ func GetStringMap(key string) map[string]any {
func GetStringSlice(key string) []string { func GetStringSlice(key string) []string {
return defaultConfigManager.GetStringSlice(key) 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()
}

53
jety.go
View File

@@ -38,7 +38,6 @@ type (
envConfig map[string]ConfigMap envConfig map[string]ConfigMap
combinedConfig map[string]ConfigMap combinedConfig map[string]ConfigMap
mutex sync.RWMutex mutex sync.RWMutex
explicitDefaults bool
} }
) )
@@ -90,10 +89,48 @@ func (c *ConfigManager) ConfigFileUsed() string {
return c.configFileUsed return c.configFileUsed
} }
func (c *ConfigManager) UseExplicitDefaults(enable bool) { // IsSet checks whether a key has been set in any configuration source.
c.mutex.Lock() func (c *ConfigManager) IsSet(key string) bool {
defer c.mutex.Unlock() c.mutex.RLock()
c.explicitDefaults = enable 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() { func (c *ConfigManager) collapse() {
@@ -107,8 +144,10 @@ func (c *ConfigManager) collapse() {
for k, v := range c.fileConfig { for k, v := range c.fileConfig {
ccm[k] = v ccm[k] = v
} }
for k := range c.defaultConfig { for k, v := range c.envConfig {
if v, ok := c.envConfig[k]; ok { if _, inDefaults := c.defaultConfig[k]; inDefaults {
ccm[k] = v
} else if _, inFile := c.fileConfig[k]; inFile {
ccm[k] = v ccm[k] = v
} }
} }

View File

@@ -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) { func TestSetString(t *testing.T) {
cm := NewConfigManager() cm := NewConfigManager()
cm.SetString("name", "test") cm.SetString("name", "test")
@@ -1397,3 +1386,83 @@ func TestPrecedenceChain(t *testing.T) {
t.Errorf("extra: got %q, want defaultextra (default)", got) 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")
}
}

View File

@@ -33,14 +33,14 @@ func (c *ConfigManager) SetDefault(key string, value any) {
defer c.mutex.Unlock() defer c.mutex.Unlock()
lower := strings.ToLower(key) lower := strings.ToLower(key)
c.defaultConfig[lower] = ConfigMap{Key: key, Value: value} c.defaultConfig[lower] = ConfigMap{Key: key, Value: value}
if _, ok := c.overrideConfig[lower]; !ok { // Update combinedConfig respecting precedence: override > env > file > default
if envVal, ok := c.envConfig[lower]; ok { if v, ok := c.overrideConfig[lower]; ok {
c.overrideConfig[lower] = ConfigMap{Key: key, Value: envVal.Value} c.combinedConfig[lower] = v
c.combinedConfig[lower] = ConfigMap{Key: key, Value: envVal.Value} } 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 { } else {
c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
} }
} else {
c.combinedConfig[lower] = c.overrideConfig[lower]
}
} }