11 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
6852ffbebb Merge pull request #2 from taigrr/cd/fix-set-env-prefix
fix!: SetEnvPrefix now re-reads env vars with prefix stripping
2026-03-01 15:42:57 -05:00
9ff1fdc5ee fix!: SetEnvPrefix now re-reads env vars with prefix stripping
SetEnvPrefix was broken: it set the prefix string but never re-read
environment variables. This meant collapse() and SetDefault() couldn't
match prefixed env vars to their unprefixed config keys, so defaults
always won over env vars.

The fix makes SetEnvPrefix behave identically to WithEnvPrefix: it
re-reads os.Environ(), strips the prefix from matching keys, and
stores the stripped keys in envConfig. The envPrefix field is removed
entirely since keys are always pre-stripped.

BREAKING CHANGE: SetEnvPrefix now filters env vars to only those
matching the prefix (previously all env vars were accessible).
This matches the documented and expected behavior.
2026-03-01 20:42:30 +00:00
7f2320f204 Merge pull request #1 from taigrr/cd/update-go-deps-and-tests
chore: update Go 1.26.0, deps, and test coverage to 97%
2026-02-24 02:08:49 -05:00
94b97a5825 chore(deps): update Go to 1.26.0, toml to v1.6.0, add tests for 97% coverage 2026-02-24 07:02:22 +00:00
21ea264b79 add nested env var doc 2026-01-24 20:58:47 -05:00
9 changed files with 621 additions and 146 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

@@ -52,6 +52,56 @@ func main() {
- **Thread-safe**: Safe for concurrent access
- **Config precedence**: config file > environment > defaults
## Nested Configuration
For nested config structures like:
```toml
[services.cloud]
var = "xyz"
timeout = "30s"
[services.cloud.auth]
client_id = "abc123"
```
Access nested values using `GetStringMap` and type assertions:
```go
services := jety.GetStringMap("services")
cloud := services["cloud"].(map[string]any)
varValue := cloud["var"].(string) // "xyz"
// For deeper nesting
auth := cloud["auth"].(map[string]any)
clientID := auth["client_id"].(string) // "abc123"
```
### Environment Variable Overrides
Environment variables use uppercase keys. For nested config, the env var name is the key in uppercase:
```bash
# Override top-level key
export PORT=9000
# For nested keys, use the full key name in uppercase
export SERVICES_CLOUD_VAR=override_value
```
With a prefix:
```go
cm := jety.NewConfigManager().WithEnvPrefix("MYAPP_")
```
```bash
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

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,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(c.envPrefix+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(c.envPrefix+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(c.envPrefix+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(c.envPrefix+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(c.envPrefix+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(c.envPrefix+key)]
if !ok {
return nil
}
return nil
}
switch val := v.Value.(type) {
case []string:
@@ -155,12 +149,9 @@ 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)]
v, ok := c.resolve(key)
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
if !ok {
return 0
}
return 0
}
switch val := v.Value.(type) {
case int:
@@ -187,12 +178,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(c.envPrefix+key)]
if !ok {
return nil
}
return nil
}
switch val := v.Value.(type) {
case []int:

4
go.mod
View File

@@ -1,8 +1,8 @@
module github.com/taigrr/jety
go 1.25.5
go 1.26.0
require (
github.com/BurntSushi/toml v1.3.2
github.com/BurntSushi/toml v1.6.0
gopkg.in/yaml.v3 v3.0.1
)

4
go.sum
View File

@@ -1,5 +1,5 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

201
jety.go
View File

@@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
@@ -23,23 +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
envPrefix string
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
}
)
@@ -48,42 +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)
cm.envPrefix = ""
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}
}
}
// Don't set envPrefix since keys are already stripped of prefix
c.envPrefix = ""
c.envConfig = parseEnv(prefix)
return c
}
@@ -93,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
@@ -137,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 {
@@ -152,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":
@@ -163,15 +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()
c.envPrefix = prefix
c.envConfig = parseEnv(prefix)
}
func (c *ConfigManager) ReadInConfig() error {
@@ -206,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()
@@ -214,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

@@ -1,7 +1,9 @@
package jety
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
@@ -64,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 {
@@ -552,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")
@@ -573,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)
}
}
@@ -758,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")
@@ -804,11 +795,6 @@ func TestWriteConfigUnsupportedType(t *testing.T) {
func TestSetEnvPrefix(t *testing.T) {
cm := NewConfigManager()
cm.SetEnvPrefix("PREFIX_")
// Verify it doesn't panic
if cm.envPrefix != "PREFIX_" {
t.Errorf("envPrefix = %q, want %q", cm.envPrefix, "PREFIX_")
}
}
func TestDeeplyNestedConfig(t *testing.T) {
@@ -1064,6 +1050,125 @@ enabled = false
}
}
func TestPackageLevelGetIntSlice(t *testing.T) {
defaultConfigManager = NewConfigManager()
Set("nums", []int{1, 2, 3})
got := GetIntSlice("nums")
if len(got) != 3 || got[0] != 1 {
t.Errorf("GetIntSlice() = %v, want [1 2 3]", got)
}
}
func TestPackageLevelSetConfigName(t *testing.T) {
defaultConfigManager = NewConfigManager()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.json"), []byte(`{"port": 1234}`), 0o644); err != nil {
t.Fatal(err)
}
SetConfigName("app")
defaultConfigManager.SetConfigDir(dir)
if err := SetConfigType("json"); err != nil {
t.Fatal(err)
}
if err := ReadInConfig(); err != nil {
t.Fatal(err)
}
if got := GetInt("port"); got != 1234 {
t.Errorf("GetInt(port) = %d, want 1234", got)
}
}
func TestPackageLevelSetEnvPrefix(t *testing.T) {
defaultConfigManager = NewConfigManager()
SetEnvPrefix("JETY_TEST_")
}
func TestPackageLevelWriteConfig(t *testing.T) {
defaultConfigManager = NewConfigManager()
dir := t.TempDir()
f := filepath.Join(dir, "out.json")
SetConfigFile(f)
if err := SetConfigType("json"); err != nil {
t.Fatal(err)
}
Set("key", "value")
if err := WriteConfig(); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(f)
if len(data) == 0 {
t.Error("WriteConfig produced empty file")
}
}
func TestPackageLevelGetStringMap(t *testing.T) {
defaultConfigManager = NewConfigManager()
Set("m", map[string]any{"a": 1})
got := GetStringMap("m")
if got == nil || got["a"] != 1 {
t.Errorf("GetStringMap() = %v", got)
}
}
func TestPackageLevelGetStringSlice(t *testing.T) {
defaultConfigManager = NewConfigManager()
Set("s", []string{"a", "b"})
got := GetStringSlice("s")
if len(got) != 2 || got[0] != "a" {
t.Errorf("GetStringSlice() = %v", got)
}
}
func TestGetStringNonStringValue(t *testing.T) {
cm := NewConfigManager()
cm.Set("num", 42)
if got := cm.GetString("num"); got != "42" {
t.Errorf("GetString(num) = %q, want %q", got, "42")
}
}
func TestGetIntInt64(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", int64(999))
if got := cm.GetInt("key"); got != 999 {
t.Errorf("GetInt(int64) = %d, want 999", got)
}
}
func TestGetIntUnknownType(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", struct{}{})
if got := cm.GetInt("key"); got != 0 {
t.Errorf("GetInt(struct) = %d, want 0", got)
}
}
func TestGetIntSliceInt64Values(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", []any{int64(10), int64(20)})
got := cm.GetIntSlice("key")
if len(got) != 2 || got[0] != 10 || got[1] != 20 {
t.Errorf("GetIntSlice(int64) = %v, want [10 20]", got)
}
}
func TestGetIntSliceNonSlice(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", "notaslice")
if got := cm.GetIntSlice("key"); got != nil {
t.Errorf("GetIntSlice(string) = %v, want nil", got)
}
}
func TestGetIntSliceUnknownInnerType(t *testing.T) {
cm := NewConfigManager()
cm.Set("key", []any{struct{}{}, true})
got := cm.GetIntSlice("key")
if len(got) != 0 {
t.Errorf("GetIntSlice(unknown types) = %v, want []", got)
}
}
func TestDeeplyNestedWriteConfig(t *testing.T) {
dir := t.TempDir()
@@ -1144,3 +1249,220 @@ func TestDeeplyNestedWriteConfig(t *testing.T) {
})
}
}
func TestSetEnvPrefixOverridesDefault(t *testing.T) {
// Subprocess test: env vars must exist before NewConfigManager is called.
if os.Getenv("TEST_SET_ENV_PREFIX") == "1" {
cm := NewConfigManager()
cm.SetEnvPrefix("MYAPP_")
cm.SetDefault("port", 8080)
if got := cm.GetInt("port"); got != 9999 {
fmt.Fprintf(os.Stderr, "GetInt(port) = %d, want 9999\n", got)
os.Exit(1)
}
if got := cm.GetString("host"); got != "envhost" {
fmt.Fprintf(os.Stderr, "GetString(host) = %q, want %q\n", got, "envhost")
os.Exit(1)
}
// Unprefixed var should not be visible.
if got := cm.GetString("other"); got != "" {
fmt.Fprintf(os.Stderr, "GetString(other) = %q, want empty\n", got)
os.Exit(1)
}
os.Exit(0)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestSetEnvPrefixOverridesDefault$")
cmd.Env = append(os.Environ(),
"TEST_SET_ENV_PREFIX=1",
"MYAPP_PORT=9999",
"MYAPP_HOST=envhost",
"OTHER=should_not_see",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("subprocess failed: %v\n%s", err, out)
}
}
func TestSetEnvPrefixWithSetDefault(t *testing.T) {
// SetDefault should pick up prefixed env vars after SetEnvPrefix.
if os.Getenv("TEST_SET_ENV_PREFIX_DEFAULT") == "1" {
cm := NewConfigManager()
cm.SetEnvPrefix("APP_")
cm.SetDefault("database_host", "localhost")
if got := cm.GetString("database_host"); got != "db.example.com" {
fmt.Fprintf(os.Stderr, "GetString(database_host) = %q, want %q\n", got, "db.example.com")
os.Exit(1)
}
os.Exit(0)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestSetEnvPrefixWithSetDefault$")
cmd.Env = append(os.Environ(),
"TEST_SET_ENV_PREFIX_DEFAULT=1",
"APP_DATABASE_HOST=db.example.com",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("subprocess failed: %v\n%s", err, out)
}
}
func TestPackageLevelSetEnvPrefixOverrides(t *testing.T) {
// Package-level SetEnvPrefix should work the same way.
if os.Getenv("TEST_PKG_SET_ENV_PREFIX") == "1" {
// Reset the default manager to pick up our env vars.
defaultConfigManager = NewConfigManager()
SetEnvPrefix("PKG_")
SetDefault("val", "default")
if got := GetString("val"); got != "from_env" {
fmt.Fprintf(os.Stderr, "GetString(val) = %q, want %q\n", got, "from_env")
os.Exit(1)
}
os.Exit(0)
}
cmd := exec.Command(os.Args[0], "-test.run=^TestPackageLevelSetEnvPrefixOverrides$")
cmd.Env = append(os.Environ(),
"TEST_PKG_SET_ENV_PREFIX=1",
"PKG_VAL=from_env",
)
out, err := cmd.CombinedOutput()
if err != nil {
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}
} 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}
}
}