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

View File

@@ -7,15 +7,24 @@ import (
"time" "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 { func (c *ConfigManager) Get(key string) any {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock() defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)] v, ok := c.resolve(key)
if !ok { if !ok {
v, ok = c.envConfig[strings.ToLower(key)] return nil
if !ok {
return nil
}
} }
return v.Value return v.Value
} }
@@ -23,12 +32,9 @@ func (c *ConfigManager) Get(key string) any {
func (c *ConfigManager) GetBool(key string) bool { func (c *ConfigManager) GetBool(key string) bool {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock() defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)] v, ok := c.resolve(key)
if !ok { if !ok {
v, ok = c.envConfig[strings.ToLower(key)] return false
if !ok {
return false
}
} }
val := v.Value val := v.Value
switch val := val.(type) { switch val := val.(type) {
@@ -54,12 +60,9 @@ func (c *ConfigManager) GetBool(key string) bool {
func (c *ConfigManager) GetDuration(key string) time.Duration { func (c *ConfigManager) GetDuration(key string) time.Duration {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock() defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)] v, ok := c.resolve(key)
if !ok { if !ok {
v, ok = c.envConfig[strings.ToLower(key)] return 0
if !ok {
return 0
}
} }
val := v.Value val := v.Value
switch val := val.(type) { switch val := val.(type) {
@@ -89,12 +92,9 @@ func (c *ConfigManager) GetDuration(key string) time.Duration {
func (c *ConfigManager) GetString(key string) string { func (c *ConfigManager) GetString(key string) string {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock() defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)] v, ok := c.resolve(key)
if !ok { if !ok {
v, ok = c.envConfig[strings.ToLower(key)] return ""
if !ok {
return ""
}
} }
switch val := v.Value.(type) { switch val := v.Value.(type) {
@@ -108,12 +108,9 @@ func (c *ConfigManager) GetString(key string) string {
func (c *ConfigManager) GetStringMap(key string) map[string]any { func (c *ConfigManager) GetStringMap(key string) map[string]any {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock() defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)] v, ok := c.resolve(key)
if !ok { if !ok {
v, ok = c.envConfig[strings.ToLower(key)] return nil
if !ok {
return nil
}
} }
switch val := v.Value.(type) { switch val := v.Value.(type) {
case map[string]any: case map[string]any:
@@ -126,12 +123,9 @@ func (c *ConfigManager) GetStringMap(key string) map[string]any {
func (c *ConfigManager) GetStringSlice(key string) []string { func (c *ConfigManager) GetStringSlice(key string) []string {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock() defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)] v, ok := c.resolve(key)
if !ok { if !ok {
v, ok = c.envConfig[strings.ToLower(key)] return nil
if !ok {
return nil
}
} }
switch val := v.Value.(type) { switch val := v.Value.(type) {
case []string: case []string:
@@ -155,12 +149,9 @@ func (c *ConfigManager) GetStringSlice(key string) []string {
func (c *ConfigManager) GetInt(key string) int { func (c *ConfigManager) GetInt(key string) int {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock() defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)] v, ok := c.resolve(key)
if !ok { if !ok {
v, ok = c.envConfig[strings.ToLower(key)] return 0
if !ok {
return 0
}
} }
switch val := v.Value.(type) { switch val := v.Value.(type) {
case int: case int:
@@ -187,12 +178,9 @@ func (c *ConfigManager) GetInt(key string) int {
func (c *ConfigManager) GetIntSlice(key string) []int { func (c *ConfigManager) GetIntSlice(key string) []int {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock() defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)] v, ok := c.resolve(key)
if !ok { if !ok {
v, ok = c.envConfig[strings.ToLower(key)] return nil
if !ok {
return nil
}
} }
switch val := v.Value.(type) { switch val := v.Value.(type) {
case []int: case []int:

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