1 Commits

Author SHA1 Message Date
4c8d8960be 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.
2026-03-01 23:37:24 +00:00
3 changed files with 77 additions and 18 deletions

23
jety.go
View File

@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -33,7 +32,8 @@ type (
configPath string configPath string
configFileUsed string configFileUsed string
configType configType configType configType
mapConfig map[string]ConfigMap overrideConfig map[string]ConfigMap
fileConfig map[string]ConfigMap
defaultConfig map[string]ConfigMap defaultConfig map[string]ConfigMap
envConfig map[string]ConfigMap envConfig map[string]ConfigMap
combinedConfig map[string]ConfigMap combinedConfig map[string]ConfigMap
@@ -50,7 +50,8 @@ var (
func NewConfigManager() *ConfigManager { func NewConfigManager() *ConfigManager {
cm := ConfigManager{} cm := ConfigManager{}
cm.envConfig = make(map[string]ConfigMap) 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.defaultConfig = make(map[string]ConfigMap)
cm.combinedConfig = make(map[string]ConfigMap) cm.combinedConfig = make(map[string]ConfigMap)
envSet := os.Environ() envSet := os.Environ()
@@ -99,13 +100,21 @@ func (c *ConfigManager) collapse() {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
ccm := make(map[string]ConfigMap) ccm := make(map[string]ConfigMap)
// Precedence (highest to lowest): overrides (Set) > env > file > defaults
for k, v := range c.defaultConfig { for k, v := range c.defaultConfig {
ccm[k] = v 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 c.combinedConfig = ccm
} }
@@ -216,7 +225,7 @@ func (c *ConfigManager) ReadInConfig() error {
conf[lower] = ConfigMap{Key: k, Value: v} conf[lower] = ConfigMap{Key: k, Value: v}
} }
c.mutex.Lock() c.mutex.Lock()
c.mapConfig = conf c.fileConfig = conf
c.configFileUsed = configFile c.configFileUsed = configFile
c.mutex.Unlock() c.mutex.Unlock()
c.collapse() c.collapse()

View File

@@ -66,7 +66,7 @@ func TestNewConfigManager(t *testing.T) {
if cm.envConfig == nil { if cm.envConfig == nil {
t.Error("envConfig not initialized") t.Error("envConfig not initialized")
} }
if cm.mapConfig == nil { if cm.overrideConfig == nil {
t.Error("mapConfig not initialized") t.Error("mapConfig not initialized")
} }
if cm.defaultConfig == nil { 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") os.Setenv("PORT", "5000")
defer os.Unsetenv("PORT") defer os.Unsetenv("PORT")
@@ -575,9 +575,9 @@ func TestConfigFileOverridesEnv(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
// Config file should override env and default // Env should override config file (env > file > defaults)
if got := cm.GetInt("port"); got != 9000 { if got := cm.GetInt("port"); got != 5000 {
t.Errorf("GetInt(port) = %d, want 9000 (from file)", got) 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) 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)
}
}

View File

@@ -8,7 +8,7 @@ func (c *ConfigManager) SetBool(key string, value bool) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
lower := strings.ToLower(key) 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} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
} }
@@ -16,7 +16,7 @@ func (c *ConfigManager) SetString(key string, value string) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
lower := strings.ToLower(key) 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} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
} }
@@ -24,7 +24,7 @@ func (c *ConfigManager) Set(key string, value any) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
lower := strings.ToLower(key) 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} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
} }
@@ -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.mapConfig[lower]; !ok { if _, ok := c.overrideConfig[lower]; !ok {
if envVal, ok := c.envConfig[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} c.combinedConfig[lower] = ConfigMap{Key: key, Value: envVal.Value}
} else { } else {
c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
} }
} else { } else {
c.combinedConfig[lower] = c.mapConfig[lower] c.combinedConfig[lower] = c.overrideConfig[lower]
} }
} }