9 Commits

Author SHA1 Message Date
dd7e2e3ecb feat(getters): add GetFloat64 and GetInt64, fix docs
- Add GetFloat64 and GetInt64 methods to ConfigManager and package-level API
- Fix README precedence: actual order is Set > env > file > default
  (was incorrectly documented as file > env > default)
- Fix GetStringMap return type in API table: map[string]any, not map[string]string
- Bump Go to 1.26.1
- Add tests for all new getters (coverage 94.7% → 95.2%)
2026-03-06 10:32:42 +00:00
60253426ae docs: update README for v0.3.0
- Add CI badges (test + govulncheck)
- Bump minimum Go version to 1.26
- Add IsSet, AllKeys, AllSettings to API table
- Remove outdated migration guide
2026-03-02 00:13:33 +00:00
f338b2c662 ci: drop Go 1.24 matrix, only test 1.26 2026-03-02 00:10:26 +00:00
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
8 changed files with 524 additions and 183 deletions

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

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

View File

@@ -1,5 +1,8 @@
# JETY
[![test](https://github.com/taigrr/jety/actions/workflows/test.yml/badge.svg)](https://github.com/taigrr/jety/actions/workflows/test.yml)
[![govulncheck](https://github.com/taigrr/jety/actions/workflows/govulncheck.yml/badge.svg)](https://github.com/taigrr/jety/actions/workflows/govulncheck.yml)
JSON, ENV, TOML, YAML
A lightweight Go configuration management library supporting JSON, ENV, TOML, and YAML formats.
@@ -12,7 +15,7 @@ Originally built to support [grlx](http://github.com/gogrlx/grlx).
go get github.com/taigrr/jety
```
Requires Go 1.25.5 or later.
Requires Go 1.26.1 or later.
## Quick Start
@@ -36,7 +39,7 @@ func main() {
// handle error
}
// Get values (config file > env > default)
// Get values (Set > env > config file > default)
port := jety.GetInt("port")
host := jety.GetString("host")
}
@@ -50,7 +53,7 @@ func main() {
- **Case-insensitive keys**: Keys normalized to lowercase
- **Type coercion**: Getters handle type conversion gracefully
- **Thread-safe**: Safe for concurrent access
- **Config precedence**: config file > environment > defaults
- **Config precedence**: Set() > environment > config file > defaults
## Nested Configuration
@@ -100,37 +103,7 @@ export MYAPP_PORT=9000
export MYAPP_SERVICES_CLOUD_VAR=override_value
```
**Note**: Environment variables override defaults but config files take highest precedence.
## Migration Guide
### From v0.x to v1.x
#### Breaking Changes
1. **`WriteConfig()` now returns `error`**
```go
// Before
jety.WriteConfig()
// After
if err := jety.WriteConfig(); err != nil {
// handle error
}
// Or if you want to ignore the error:
_ = jety.WriteConfig()
```
2. **Go 1.25.5 minimum required**
Update your Go version or pin to an older jety release.
#### Non-Breaking Improvements
- Getters (`GetBool`, `GetInt`, `GetDuration`) now return zero values instead of panicking on unknown types
- Added `int64` support in `GetInt`, `GetIntSlice`, and `GetDuration`
- Improved env var parsing (handles values containing `=`)
**Note**: Environment variables override both defaults and config file values for registered keys (keys that appear in defaults or the config file).
## API
@@ -154,11 +127,16 @@ export MYAPP_SERVICES_CLOUD_VAR=override_value
| `Get(key)` | Get raw value |
| `GetString(key)` | Get as string |
| `GetInt(key)` | Get as int |
| `GetInt64(key)` | Get as int64 |
| `GetFloat64(key)` | Get as float64 |
| `GetBool(key)` | Get as bool |
| `GetDuration(key)` | Get as time.Duration |
| `GetStringSlice(key)` | Get as []string |
| `GetIntSlice(key)` | Get as []int |
| `GetStringMap(key)` | Get as map[string]string |
| `GetStringMap(key)` | Get as map[string]any |
| `IsSet(key)` | Check if key has a value |
| `AllKeys()` | List all known keys |
| `AllSettings()` | Get all values as a map |
### Environment

View File

@@ -20,6 +20,14 @@ func SetConfigName(name string) {
defaultConfigManager.SetConfigName(name)
}
func GetFloat64(key string) float64 {
return defaultConfigManager.GetFloat64(key)
}
func GetInt64(key string) int64 {
return defaultConfigManager.GetInt64(key)
}
func GetInt(key string) int {
return defaultConfigManager.GetInt(key)
}
@@ -67,3 +75,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,15 +7,24 @@ 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)]
v, ok := c.resolve(key)
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
return nil
}
return v.Value
}
@@ -23,12 +32,9 @@ func (c *ConfigManager) Get(key string) any {
func (c *ConfigManager) GetBool(key string) bool {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return false
}
return false
}
val := v.Value
switch val := val.(type) {
@@ -54,12 +60,9 @@ 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)]
v, ok := c.resolve(key)
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return 0
}
return 0
}
val := v.Value
switch val := val.(type) {
@@ -89,12 +92,9 @@ 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)]
v, ok := c.resolve(key)
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return ""
}
return ""
}
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 {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
return nil
}
switch val := v.Value.(type) {
case map[string]any:
@@ -126,12 +123,9 @@ 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)]
v, ok := c.resolve(key)
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
return nil
}
switch val := v.Value.(type) {
case []string:
@@ -152,15 +146,70 @@ func (c *ConfigManager) GetStringSlice(key string) []string {
}
}
func (c *ConfigManager) GetFloat64(key string) float64 {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.resolve(key)
if !ok {
return 0
}
switch val := v.Value.(type) {
case float64:
return val
case float32:
return float64(val)
case int:
return float64(val)
case int64:
return float64(val)
case string:
f, err := strconv.ParseFloat(val, 64)
if err != nil {
return 0
}
return f
case nil:
return 0
default:
return 0
}
}
func (c *ConfigManager) GetInt64(key string) int64 {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.resolve(key)
if !ok {
return 0
}
switch val := v.Value.(type) {
case int64:
return val
case int:
return int64(val)
case string:
i, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return 0
}
return i
case float32:
return int64(val)
case float64:
return int64(val)
case nil:
return 0
default:
return 0
}
}
func (c *ConfigManager) GetInt(key string) int {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
v, ok := c.resolve(key)
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return 0
}
return 0
}
switch val := v.Value.(type) {
case int:
@@ -187,12 +236,9 @@ 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)]
v, ok := c.resolve(key)
if !ok {
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
return nil
}
switch val := v.Value.(type) {
case []int:

2
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/taigrr/jety
go 1.26.0
go 1.26.1
require (
github.com/BurntSushi/toml v1.6.0

211
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
defaultConfig map[string]ConfigMap
envConfig map[string]ConfigMap
combinedConfig map[string]ConfigMap
mutex sync.RWMutex
explicitDefaults bool
configName string
configPath string
configFileUsed string
configType configType
overrideConfig map[string]ConfigMap
fileConfig map[string]ConfigMap
defaultConfig map[string]ConfigMap
envConfig map[string]ConfigMap
combinedConfig map[string]ConfigMap
mutex sync.RWMutex
}
)
@@ -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
}
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 {
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)
return err
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) {
return nil, ErrConfigFileNotFound
} else if d.Size() == 0 {
f, err := os.Open(filename)
if err != nil {
if os.IsNotExist(err) {
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
}
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 {
@@ -120,6 +120,78 @@ func TestSetAndGetInt(t *testing.T) {
}
}
func TestSetAndGetInt64(t *testing.T) {
cm := NewConfigManager()
tests := []struct {
name string
value any
want int64
}{
{"int64", int64(9223372036854775807), 9223372036854775807},
{"int", 42, 42},
{"string", "123456789012345", 123456789012345},
{"float64", 99.9, 99},
{"float32", float32(50.5), 50},
{"invalid string", "not-a-number", 0},
{"nil", nil, 0},
{"unknown type", struct{}{}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm.Set("key", tt.value)
got := cm.GetInt64("key")
if got != tt.want {
t.Errorf("GetInt64() = %d, want %d", got, tt.want)
}
})
}
}
func TestSetAndGetFloat64(t *testing.T) {
cm := NewConfigManager()
tests := []struct {
name string
value any
want float64
}{
{"float64", 3.14159, 3.14159},
{"float32", float32(2.5), 2.5},
{"int", 42, 42.0},
{"int64", int64(100), 100.0},
{"string", "1.618", 1.618},
{"invalid string", "not-a-float", 0},
{"nil", nil, 0},
{"unknown type", struct{}{}, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cm.Set("key", tt.value)
got := cm.GetFloat64("key")
if got != tt.want {
t.Errorf("GetFloat64() = %f, want %f", got, tt.want)
}
})
}
}
func TestGetFloat64NotSet(t *testing.T) {
cm := NewConfigManager()
if got := cm.GetFloat64("nonexistent"); got != 0 {
t.Errorf("GetFloat64(nonexistent) = %f, want 0", got)
}
}
func TestGetInt64NotSet(t *testing.T) {
cm := NewConfigManager()
if got := cm.GetInt64("nonexistent"); got != 0 {
t.Errorf("GetInt64(nonexistent) = %d, want 0", got)
}
}
func TestSetAndGetBool(t *testing.T) {
cm := NewConfigManager()
@@ -554,7 +626,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 +647,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 +832,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 +1408,149 @@ 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")
}
}
func TestPackageLevelGetFloat64(t *testing.T) {
defaultConfigManager = NewConfigManager()
Set("rate", 3.14)
if got := GetFloat64("rate"); got != 3.14 {
t.Errorf("GetFloat64(rate) = %f, want 3.14", got)
}
}
func TestPackageLevelGetInt64(t *testing.T) {
defaultConfigManager = NewConfigManager()
Set("bignum", int64(9223372036854775807))
if got := GetInt64("bignum"); got != 9223372036854775807 {
t.Errorf("GetInt64(bignum) = %d, want 9223372036854775807", got)
}
}

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