3 Commits

Author SHA1 Message Date
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
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
6 changed files with 285 additions and 22 deletions

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

@@ -12,7 +12,7 @@ func (c *ConfigManager) Get(key string) any {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
@@ -25,7 +25,7 @@ func (c *ConfigManager) GetBool(key string) bool {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return false
}
@@ -56,7 +56,7 @@ func (c *ConfigManager) GetDuration(key string) time.Duration {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return 0
}
@@ -91,7 +91,7 @@ func (c *ConfigManager) GetString(key string) string {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return ""
}
@@ -110,7 +110,7 @@ func (c *ConfigManager) GetStringMap(key string) map[string]any {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
@@ -128,7 +128,7 @@ func (c *ConfigManager) GetStringSlice(key string) []string {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}
@@ -157,7 +157,7 @@ func (c *ConfigManager) GetInt(key string) int {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return 0
}
@@ -189,7 +189,7 @@ func (c *ConfigManager) GetIntSlice(key string) []int {
defer c.mutex.RUnlock()
v, ok := c.combinedConfig[strings.ToLower(key)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok = c.envConfig[strings.ToLower(key)]
if !ok {
return nil
}

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=

20
jety.go
View File

@@ -33,7 +33,6 @@ type (
configPath string
configFileUsed string
configType configType
envPrefix string
mapConfig map[string]ConfigMap
defaultConfig map[string]ConfigMap
envConfig map[string]ConfigMap
@@ -54,7 +53,6 @@ func NewConfigManager() *ConfigManager {
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 {
key, value, found := strings.Cut(env, "=")
@@ -82,8 +80,6 @@ func (c *ConfigManager) WithEnvPrefix(prefix string) *ConfigManager {
c.envConfig[lower] = ConfigMap{Key: withoutPrefix, Value: value}
}
}
// Don't set envPrefix since keys are already stripped of prefix
c.envPrefix = ""
return c
}
@@ -171,7 +167,21 @@ func (c *ConfigManager) SetConfigType(configType string) error {
func (c *ConfigManager) SetEnvPrefix(prefix string) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.envPrefix = prefix
// 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}
}
}
}
func (c *ConfigManager) ReadInConfig() error {

View File

@@ -1,7 +1,9 @@
package jety
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"testing"
@@ -804,11 +806,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 +1061,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 +1260,90 @@ 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)
}
}