From 4c8d8960be3466bdc0241d9507c040e86d8dc9cd Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Sun, 1 Mar 2026 23:37:24 +0000 Subject: [PATCH] fix(precedence): env vars now correctly override config file values The documented precedence is Set > env > file > defaults, but collapse() was using maps.Copy(ccm, mapConfig) which let file values (and Set values, stored in the same map) unconditionally overwrite env values. Split mapConfig into fileConfig (from ReadInConfig) and overrideConfig (from Set/SetString/SetBool). collapse() now applies layers in correct order: defaults, then file, then env (for known keys), then overrides. Added TestPrecedenceChain to verify the full layering. --- jety.go | 23 ++++++++++++++------ jety_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++++++----- setters.go | 12 +++++------ 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/jety.go b/jety.go index e55547f..a153faf 100644 --- a/jety.go +++ b/jety.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "maps" "os" "path/filepath" "strings" @@ -33,7 +32,8 @@ type ( configPath string configFileUsed string configType configType - mapConfig map[string]ConfigMap + overrideConfig map[string]ConfigMap + fileConfig map[string]ConfigMap defaultConfig map[string]ConfigMap envConfig map[string]ConfigMap combinedConfig map[string]ConfigMap @@ -50,7 +50,8 @@ var ( func NewConfigManager() *ConfigManager { cm := ConfigManager{} cm.envConfig = make(map[string]ConfigMap) - cm.mapConfig = make(map[string]ConfigMap) + cm.overrideConfig = make(map[string]ConfigMap) + cm.fileConfig = make(map[string]ConfigMap) cm.defaultConfig = make(map[string]ConfigMap) cm.combinedConfig = make(map[string]ConfigMap) envSet := os.Environ() @@ -99,13 +100,21 @@ func (c *ConfigManager) collapse() { c.mutex.Lock() defer c.mutex.Unlock() ccm := make(map[string]ConfigMap) + // Precedence (highest to lowest): overrides (Set) > env > file > defaults for k, v := range c.defaultConfig { ccm[k] = v - if _, ok := c.envConfig[k]; ok { - ccm[k] = c.envConfig[k] + } + for k, v := range c.fileConfig { + ccm[k] = v + } + for k := range c.defaultConfig { + if v, ok := c.envConfig[k]; ok { + ccm[k] = v } } - maps.Copy(ccm, c.mapConfig) + for k, v := range c.overrideConfig { + ccm[k] = v + } c.combinedConfig = ccm } @@ -216,7 +225,7 @@ func (c *ConfigManager) ReadInConfig() error { conf[lower] = ConfigMap{Key: k, Value: v} } c.mutex.Lock() - c.mapConfig = conf + c.fileConfig = conf c.configFileUsed = configFile c.mutex.Unlock() c.collapse() diff --git a/jety_test.go b/jety_test.go index 56d9f0f..e71e0db 100644 --- a/jety_test.go +++ b/jety_test.go @@ -66,7 +66,7 @@ func TestNewConfigManager(t *testing.T) { if cm.envConfig == nil { t.Error("envConfig not initialized") } - if cm.mapConfig == nil { + if cm.overrideConfig == nil { t.Error("mapConfig not initialized") } if cm.defaultConfig == nil { @@ -554,7 +554,7 @@ func TestEnvOverridesDefault(t *testing.T) { } } -func TestConfigFileOverridesEnv(t *testing.T) { +func TestEnvOverridesConfigFile(t *testing.T) { os.Setenv("PORT", "5000") defer os.Unsetenv("PORT") @@ -575,9 +575,9 @@ func TestConfigFileOverridesEnv(t *testing.T) { t.Fatal(err) } - // Config file should override env and default - if got := cm.GetInt("port"); got != 9000 { - t.Errorf("GetInt(port) = %d, want 9000 (from file)", got) + // Env should override config file (env > file > defaults) + if got := cm.GetInt("port"); got != 5000 { + t.Errorf("GetInt(port) = %d, want 5000 (env overrides file)", got) } } @@ -1347,3 +1347,53 @@ func TestPackageLevelSetEnvPrefixOverrides(t *testing.T) { t.Fatalf("subprocess failed: %v\n%s", err, out) } } + +func TestPrecedenceChain(t *testing.T) { + // Verify: Set > env > file > defaults + os.Setenv("PORT", "5000") + os.Setenv("HOST", "envhost") + os.Setenv("LOG", "envlog") + defer os.Unsetenv("PORT") + defer os.Unsetenv("HOST") + defer os.Unsetenv("LOG") + + dir := t.TempDir() + configFile := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(configFile, []byte("port: 9000\nhost: filehost\nlog: filelog\nname: filename"), 0o644); err != nil { + t.Fatal(err) + } + + cm := NewConfigManager() + cm.SetDefault("port", 8080) + cm.SetDefault("host", "defaulthost") + cm.SetDefault("log", "defaultlog") + cm.SetDefault("name", "defaultname") + cm.SetDefault("extra", "defaultextra") + + cm.SetConfigFile(configFile) + if err := cm.SetConfigType("yaml"); err != nil { + t.Fatal(err) + } + if err := cm.ReadInConfig(); err != nil { + t.Fatal(err) + } + + cm.Set("port", 1111) // Set overrides everything + + // port: Set(1111) > env(5000) > file(9000) > default(8080) → 1111 + if got := cm.GetInt("port"); got != 1111 { + t.Errorf("port: got %d, want 1111 (Set overrides all)", got) + } + // host: env(envhost) > file(filehost) > default(defaulthost) → envhost + if got := cm.GetString("host"); got != "envhost" { + t.Errorf("host: got %q, want envhost (env overrides file)", got) + } + // name: file(filename) > default(defaultname) → filename + if got := cm.GetString("name"); got != "filename" { + t.Errorf("name: got %q, want filename (file overrides default)", got) + } + // extra: only default → defaultextra + if got := cm.GetString("extra"); got != "defaultextra" { + t.Errorf("extra: got %q, want defaultextra (default)", got) + } +} diff --git a/setters.go b/setters.go index c5d04fa..b0b7b5a 100644 --- a/setters.go +++ b/setters.go @@ -8,7 +8,7 @@ func (c *ConfigManager) SetBool(key string, value bool) { c.mutex.Lock() defer c.mutex.Unlock() lower := strings.ToLower(key) - c.mapConfig[lower] = ConfigMap{Key: key, Value: value} + c.overrideConfig[lower] = ConfigMap{Key: key, Value: value} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} } @@ -16,7 +16,7 @@ func (c *ConfigManager) SetString(key string, value string) { c.mutex.Lock() defer c.mutex.Unlock() lower := strings.ToLower(key) - c.mapConfig[lower] = ConfigMap{Key: key, Value: value} + c.overrideConfig[lower] = ConfigMap{Key: key, Value: value} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} } @@ -24,7 +24,7 @@ func (c *ConfigManager) Set(key string, value any) { c.mutex.Lock() defer c.mutex.Unlock() lower := strings.ToLower(key) - c.mapConfig[lower] = ConfigMap{Key: key, Value: value} + c.overrideConfig[lower] = ConfigMap{Key: key, Value: value} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} } @@ -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.mapConfig[lower]; !ok { + if _, ok := c.overrideConfig[lower]; !ok { if envVal, ok := c.envConfig[lower]; ok { - c.mapConfig[lower] = ConfigMap{Key: key, Value: envVal.Value} + 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} } } else { - c.combinedConfig[lower] = c.mapConfig[lower] + c.combinedConfig[lower] = c.overrideConfig[lower] } }