7 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
8 changed files with 456 additions and 174 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 # 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 JSON, ENV, TOML, YAML
A lightweight Go configuration management library supporting JSON, ENV, TOML, and YAML formats. 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 go get github.com/taigrr/jety
``` ```
Requires Go 1.25.5 or later. Requires Go 1.26.1 or later.
## Quick Start ## Quick Start
@@ -36,7 +39,7 @@ func main() {
// handle error // handle error
} }
// Get values (config file > env > default) // Get values (Set > env > config file > default)
port := jety.GetInt("port") port := jety.GetInt("port")
host := jety.GetString("host") host := jety.GetString("host")
} }
@@ -50,7 +53,7 @@ func main() {
- **Case-insensitive keys**: Keys normalized to lowercase - **Case-insensitive keys**: Keys normalized to lowercase
- **Type coercion**: Getters handle type conversion gracefully - **Type coercion**: Getters handle type conversion gracefully
- **Thread-safe**: Safe for concurrent access - **Thread-safe**: Safe for concurrent access
- **Config precedence**: config file > environment > defaults - **Config precedence**: Set() > environment > config file > defaults
## Nested Configuration ## Nested Configuration
@@ -100,37 +103,7 @@ export MYAPP_PORT=9000
export MYAPP_SERVICES_CLOUD_VAR=override_value export MYAPP_SERVICES_CLOUD_VAR=override_value
``` ```
**Note**: Environment variables override defaults but config files take highest precedence. **Note**: Environment variables override both defaults and config file values for registered keys (keys that appear in defaults or the config file).
## 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 `=`)
## API ## API
@@ -154,11 +127,16 @@ export MYAPP_SERVICES_CLOUD_VAR=override_value
| `Get(key)` | Get raw value | | `Get(key)` | Get raw value |
| `GetString(key)` | Get as string | | `GetString(key)` | Get as string |
| `GetInt(key)` | Get as int | | `GetInt(key)` | Get as int |
| `GetInt64(key)` | Get as int64 |
| `GetFloat64(key)` | Get as float64 |
| `GetBool(key)` | Get as bool | | `GetBool(key)` | Get as bool |
| `GetDuration(key)` | Get as time.Duration | | `GetDuration(key)` | Get as time.Duration |
| `GetStringSlice(key)` | Get as []string | | `GetStringSlice(key)` | Get as []string |
| `GetIntSlice(key)` | Get as []int | | `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 ### Environment

View File

@@ -20,6 +20,14 @@ func SetConfigName(name string) {
defaultConfigManager.SetConfigName(name) 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 { func GetInt(key string) int {
return defaultConfigManager.GetInt(key) return defaultConfigManager.GetInt(key)
} }
@@ -67,3 +75,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:
@@ -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 { 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 +236,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:

2
go.mod
View File

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

200
jety.go
View File

@@ -22,23 +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
overrideConfig map[string]ConfigMap overrideConfig map[string]ConfigMap
fileConfig 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
mutex sync.RWMutex mutex sync.RWMutex
explicitDefaults bool
} }
) )
@@ -47,40 +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.overrideConfig = make(map[string]ConfigMap) result := make(map[string]ConfigMap)
cm.fileConfig = make(map[string]ConfigMap) for _, env := range os.Environ() {
cm.defaultConfig = make(map[string]ConfigMap)
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
} }
@@ -90,10 +99,48 @@ 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() {
@@ -107,8 +154,10 @@ func (c *ConfigManager) collapse() {
for k, v := range c.fileConfig { for k, v := range c.fileConfig {
ccm[k] = v ccm[k] = v
} }
for k := range c.defaultConfig { for k, v := range c.envConfig {
if v, ok := c.envConfig[k]; ok { if _, inDefaults := c.defaultConfig[k]; inDefaults {
ccm[k] = v
} else if _, inFile := c.fileConfig[k]; inFile {
ccm[k] = v ccm[k] = v
} }
} }
@@ -121,6 +170,9 @@ func (c *ConfigManager) collapse() {
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
@@ -142,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 {
@@ -157,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":
@@ -168,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 {
@@ -233,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

@@ -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) { func TestSetAndGetBool(t *testing.T) {
cm := NewConfigManager() cm := NewConfigManager()
@@ -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) { func TestSetString(t *testing.T) {
cm := NewConfigManager() cm := NewConfigManager()
cm.SetString("name", "test") cm.SetString("name", "test")
@@ -1397,3 +1458,99 @@ func TestPrecedenceChain(t *testing.T) {
t.Errorf("extra: got %q, want defaultextra (default)", got) 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

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