6 Commits

Author SHA1 Message Date
e5f7cc7fae ci: add test workflow with race detector and staticcheck 2026-03-02 00:09:51 +00:00
91b69246fa refactor: extract parseEnv helper, add doc comments, fix param shadow
- Extract parseEnv() to deduplicate env parsing in NewConfigManager,
  WithEnvPrefix, and SetEnvPrefix (was 3 copies of the same logic).
- Add doc comments to ConfigMap, ConfigManager, NewConfigManager,
  WithEnvPrefix, SetEnvPrefix, IsSet, AllKeys, AllSettings.
- Rename configType parameter in SetConfigType to avoid shadowing
  the configType type.
2026-03-01 23:45:17 +00:00
b16df4e1a9 fix(safety): eliminate TOCTOU race in readFile, guard WriteConfig, DRY getters
- readFile now opens the file first, then stats via the fd (no race
  between stat and open). Uses toml.NewDecoder instead of DecodeFile.
- WriteConfig returns an error if no config file has been set.
- YAML WriteConfig now calls enc.Close() to flush properly.
- Extract resolve() helper to deduplicate the combinedConfig→envConfig
  fallback pattern across all 9 getter methods.
2026-03-01 23:44:16 +00:00
5aadc84d50 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.
2026-03-01 23:43:16 +00:00
7335ecd39c Merge pull request #3 from taigrr/cd/fix-precedence
fix(precedence): env vars now correctly override config file values
2026-03-01 18:40:39 -05:00
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
6 changed files with 359 additions and 147 deletions

22
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: test
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.24", "1.26"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}
- run: go test -race -count=1 ./...
- run: go vet ./...
- run: go install honnef.co/go/tools/cmd/staticcheck@latest && staticcheck ./...

View File

@@ -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()
}

View File

@@ -7,29 +7,35 @@ import (
"time"
)
// resolve looks up a key in combinedConfig, falling back to envConfig.
func (c *ConfigManager) resolve(key string) (ConfigMap, bool) {
lower := strings.ToLower(key)
if v, ok := c.combinedConfig[lower]; ok {
return v, true
}
if v, ok := c.envConfig[lower]; ok {
return v, true
}
return ConfigMap{}, false
}
func (c *ConfigManager) Get(key string) any {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
return nil
}
}
return v.Value
}
func (c *ConfigManager) GetBool(key string) bool {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
return false
}
}
val := v.Value
switch val := val.(type) {
case bool:
@@ -54,13 +60,10 @@ func (c *ConfigManager) GetBool(key string) bool {
func (c *ConfigManager) GetDuration(key string) time.Duration {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
return 0
}
}
val := v.Value
switch val := val.(type) {
case time.Duration:
@@ -89,13 +92,10 @@ func (c *ConfigManager) GetDuration(key string) time.Duration {
func (c *ConfigManager) GetString(key string) string {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
return ""
}
}
switch val := v.Value.(type) {
case string:
@@ -108,13 +108,10 @@ func (c *ConfigManager) GetString(key string) string {
func (c *ConfigManager) GetStringMap(key string) map[string]any {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
return nil
}
}
switch val := v.Value.(type) {
case map[string]any:
return val
@@ -126,13 +123,10 @@ func (c *ConfigManager) GetStringMap(key string) map[string]any {
func (c *ConfigManager) GetStringSlice(key string) []string {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
return nil
}
}
switch val := v.Value.(type) {
case []string:
return val
@@ -155,13 +149,10 @@ func (c *ConfigManager) GetStringSlice(key string) []string {
func (c *ConfigManager) GetInt(key string) int {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
return 0
}
}
switch val := v.Value.(type) {
case int:
return val
@@ -187,13 +178,10 @@ func (c *ConfigManager) GetInt(key string) int {
func (c *ConfigManager) GetIntSlice(key string) []int {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
return nil
}
}
switch val := v.Value.(type) {
case []int:
return val

191
jety.go
View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
@@ -23,22 +22,25 @@ const (
type (
configType string
// ConfigMap holds a configuration entry with its original key name and value.
ConfigMap struct {
Key string
Value any
}
// ConfigManager manages layered configuration from defaults, files,
// environment variables, and programmatic overrides.
ConfigManager struct {
configName string
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
mutex sync.RWMutex
explicitDefaults bool
}
)
@@ -47,39 +49,47 @@ var (
ErrConfigFileEmpty = errors.New("config file is empty")
)
func NewConfigManager() *ConfigManager {
cm := ConfigManager{}
cm.envConfig = make(map[string]ConfigMap)
cm.mapConfig = make(map[string]ConfigMap)
cm.defaultConfig = make(map[string]ConfigMap)
cm.combinedConfig = make(map[string]ConfigMap)
envSet := os.Environ()
for _, env := range envSet {
// parseEnv reads environment variables, optionally filtering by prefix,
// and returns a map keyed by lowercased (and prefix-stripped) variable names.
func parseEnv(prefix string) map[string]ConfigMap {
result := make(map[string]ConfigMap)
for _, env := range os.Environ() {
key, value, found := strings.Cut(env, "=")
if !found {
continue
}
lower := strings.ToLower(key)
cm.envConfig[lower] = ConfigMap{Key: key, Value: value}
if prefix != "" {
stripped, ok := strings.CutPrefix(key, prefix)
if !ok {
continue
}
return &cm
key = stripped
}
result[strings.ToLower(key)] = ConfigMap{Key: key, Value: value}
}
return result
}
// NewConfigManager creates a new ConfigManager with all environment
// variables loaded. Use [ConfigManager.WithEnvPrefix] or
// [ConfigManager.SetEnvPrefix] to filter by prefix.
func NewConfigManager() *ConfigManager {
return &ConfigManager{
envConfig: parseEnv(""),
overrideConfig: make(map[string]ConfigMap),
fileConfig: make(map[string]ConfigMap),
defaultConfig: make(map[string]ConfigMap),
combinedConfig: make(map[string]ConfigMap),
}
}
// WithEnvPrefix filters environment variables to only those starting with
// the given prefix, stripping the prefix from key names. Returns the
// ConfigManager for chaining.
func (c *ConfigManager) WithEnvPrefix(prefix string) *ConfigManager {
c.mutex.Lock()
defer c.mutex.Unlock()
envSet := os.Environ()
c.envConfig = make(map[string]ConfigMap)
for _, env := range envSet {
key, value, found := strings.Cut(env, "=")
if !found {
continue
}
if withoutPrefix, ok := strings.CutPrefix(key, prefix); ok {
lower := strings.ToLower(withoutPrefix)
c.envConfig[lower] = ConfigMap{Key: withoutPrefix, Value: value}
}
}
c.envConfig = parseEnv(prefix)
return c
}
@@ -89,29 +99,80 @@ 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() {
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, v := range c.envConfig {
if _, inDefaults := c.defaultConfig[k]; inDefaults {
ccm[k] = v
} else if _, inFile := c.fileConfig[k]; inFile {
ccm[k] = v
}
}
maps.Copy(ccm, c.mapConfig)
for k, v := range c.overrideConfig {
ccm[k] = v
}
c.combinedConfig = ccm
}
func (c *ConfigManager) WriteConfig() error {
c.mutex.RLock()
defer c.mutex.RUnlock()
if c.configFileUsed == "" {
return errors.New("no config file specified")
}
flattenedConfig := make(map[string]any)
for _, v := range c.combinedConfig {
flattenedConfig[v.Key] = v.Value
@@ -133,8 +194,10 @@ func (c *ConfigManager) WriteConfig() error {
}
defer f.Close()
enc := yaml.NewEncoder(f)
err = enc.Encode(flattenedConfig)
if err = enc.Encode(flattenedConfig); err != nil {
return err
}
return enc.Close()
case ConfigTypeJSON:
f, err := os.Create(c.configFileUsed)
if err != nil {
@@ -148,10 +211,10 @@ func (c *ConfigManager) WriteConfig() error {
}
}
func (c *ConfigManager) SetConfigType(configType string) error {
func (c *ConfigManager) SetConfigType(ct string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
switch configType {
switch ct {
case "toml":
c.configType = ConfigTypeTOML
case "yaml":
@@ -159,29 +222,17 @@ func (c *ConfigManager) SetConfigType(configType string) error {
case "json":
c.configType = ConfigTypeJSON
default:
return fmt.Errorf("config type %s not supported", configType)
return fmt.Errorf("config type %s not supported", ct)
}
return nil
}
// SetEnvPrefix filters environment variables to only those starting with
// the given prefix, stripping the prefix from key names.
func (c *ConfigManager) SetEnvPrefix(prefix string) {
c.mutex.Lock()
defer c.mutex.Unlock()
// Re-read environment variables, stripping the prefix from matching keys.
// This mirrors WithEnvPrefix behavior so that prefixed env vars are
// accessible by their unprefixed key name.
envSet := os.Environ()
c.envConfig = make(map[string]ConfigMap)
for _, env := range envSet {
key, value, found := strings.Cut(env, "=")
if !found {
continue
}
if withoutPrefix, ok := strings.CutPrefix(key, prefix); ok {
lower := strings.ToLower(withoutPrefix)
c.envConfig[lower] = ConfigMap{Key: withoutPrefix, Value: value}
}
}
c.envConfig = parseEnv(prefix)
}
func (c *ConfigManager) ReadInConfig() error {
@@ -216,7 +267,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()
@@ -224,33 +275,33 @@ func (c *ConfigManager) ReadInConfig() error {
}
func readFile(filename string, fileType configType) (map[string]any, error) {
fileData := make(map[string]any)
if d, err := os.Stat(filename); os.IsNotExist(err) {
f, err := os.Open(filename)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrConfigFileNotFound
} else if d.Size() == 0 {
}
return nil, err
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, err
}
if info.Size() == 0 {
return nil, ErrConfigFileEmpty
}
fileData := make(map[string]any)
switch fileType {
case ConfigTypeTOML:
_, err := toml.DecodeFile(filename, &fileData)
_, err := toml.NewDecoder(f).Decode(&fileData)
return fileData, err
case ConfigTypeYAML:
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
d := yaml.NewDecoder(f)
err = d.Decode(&fileData)
err := yaml.NewDecoder(f).Decode(&fileData)
return fileData, err
case ConfigTypeJSON:
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
err = json.NewDecoder(f).Decode(&fileData)
err := json.NewDecoder(f).Decode(&fileData)
return fileData, err
default:
return nil, fmt.Errorf("config type %s not supported", fileType)

View File

@@ -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)
}
}
@@ -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")
@@ -1347,3 +1336,133 @@ 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)
}
}
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

@@ -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 envVal, ok := c.envConfig[lower]; ok {
c.mapConfig[lower] = ConfigMap{Key: key, Value: envVal.Value}
c.combinedConfig[lower] = ConfigMap{Key: key, Value: envVal.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] = ConfigMap{Key: key, Value: value}
}
} else {
c.combinedConfig[lower] = c.mapConfig[lower]
}
}