29 Commits

Author SHA1 Message Date
c8cbb72ed7 feat(api): add Delete and Sub methods, update CI workflows
- Add Delete(key) to remove keys from all config layers
- Add Sub(key) to get a scoped ConfigManager for nested sections
- Update CodeQL workflow actions to v4/v5/v3
- Update govulncheck workflow checkout to v4
- Remove boilerplate comments from CodeQL workflow
- Add comprehensive tests for both new methods
- Update README with Sub() usage example and API docs
2026-03-07 11:03:48 +00:00
8b154b58ba Merge pull request #4 from taigrr/cd/getters-and-docs
feat(getters): add GetFloat64 and GetInt64, fix docs
2026-03-06 05:34:09 -05:00
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
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
c4c05732f5 update to fixup some race conditions 2026-01-24 20:29:43 -05:00
59b8a9078f fix config => jety 2023-11-03 17:22:02 -07:00
550537be3b setting using default vkey 2023-11-03 16:39:30 -07:00
95bdc4d109 upadte switch for any 2023-11-03 12:27:04 -07:00
64d37d936f add check for empty config file 2023-11-03 04:40:53 -07:00
62dd32f61b fix slice returners 2023-11-03 04:16:29 -07:00
9c5923bd4e fix mutex 2023-11-03 03:42:21 -07:00
5643d4d262 send back custom error 2023-11-03 03:18:37 -07:00
ee74c94359 add config collapse after reading in 2023-11-03 03:01:51 -07:00
e84645ccfa fix readme order 2023-11-03 02:25:19 -07:00
b2e7785e00 fix inverted negation 2023-11-03 02:24:06 -07:00
ef3350d66d add defaults to config file 2023-11-03 02:20:08 -07:00
ac7820de64 use flattened config instead of lowercased 2023-11-03 01:52:37 -07:00
12 changed files with 2357 additions and 172 deletions

View File

@@ -36,31 +36,20 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Install Go - name: Install Go
uses: actions/setup-go@v4 uses: actions/setup-go@v5
with: with:
go-version-file: go.mod go-version-file: go.mod
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v2 uses: github/codeql-action/init@v3
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v2 uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2 uses: github/codeql-action/analyze@v3

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: Run govulncheck name: Run govulncheck
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- id: govulncheck - id: govulncheck
uses: golang/govulncheck-action@v1 uses: golang/govulncheck-action@v1
with: with:

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 ./...

160
README.md
View File

@@ -1,9 +1,161 @@
# JETY # JETY
JSON, ENV, YAML, TOML [![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)
This is a package for collapsing multiple configuration stores (env+json, env+yaml, env+toml) and writing them back to a centralized config. JSON, ENV, TOML, YAML
It should behave similarly to the AutomaticEnv functionality of viper, but without some of the extra heft of the depedendencies it carries. A lightweight Go configuration management library supporting JSON, ENV, TOML, and YAML formats.
It provides viper-like `AutomaticEnv` functionality with fewer dependencies.
Originally built to support [grlx](http://github.com/gogrlx/grlx).
The inital purpose of this repo is to support the configuration requirements of [grlx](http://github.com/gogrlx/grlx), but development may continue to expand until more viper use cases and functionality are covered. ## Installation
```bash
go get github.com/taigrr/jety
```
Requires Go 1.26.1 or later.
## Quick Start
```go
package main
import "github.com/taigrr/jety"
func main() {
// Set defaults
jety.SetDefault("port", 8080)
jety.SetDefault("host", "localhost")
// Environment variables are loaded automatically
// e.g., PORT=9000 overrides the default
// Read from config file
jety.SetConfigFile("config.toml")
jety.SetConfigType("toml")
if err := jety.ReadInConfig(); err != nil {
// handle error
}
// Get values (Set > env > config file > default)
port := jety.GetInt("port")
host := jety.GetString("host")
}
```
## Features
- **Multiple formats**: JSON, TOML, YAML
- **Automatic env loading**: Environment variables loaded on init
- **Prefix filtering**: Filter env vars by prefix (e.g., `MYAPP_`)
- **Case-insensitive keys**: Keys normalized to lowercase
- **Type coercion**: Getters handle type conversion gracefully
- **Thread-safe**: Safe for concurrent access
- **Config precedence**: Set() > environment > config file > defaults
## Nested Configuration
For nested config structures like:
```toml
[services.cloud]
var = "xyz"
timeout = "30s"
[services.cloud.auth]
client_id = "abc123"
```
Use `Sub()` to get a scoped ConfigManager for a nested section:
```go
cloud := jety.Sub("services")
if cloud != nil {
inner := cloud.Sub("cloud")
if inner != nil {
varValue := inner.GetString("var") // "xyz"
timeout := inner.GetDuration("timeout") // 30s
}
}
```
Or access nested values directly with `GetStringMap` and type assertions:
```go
services := jety.GetStringMap("services")
cloud := services["cloud"].(map[string]any)
varValue := cloud["var"].(string) // "xyz"
```
### 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 both defaults and config file values for registered keys (keys that appear in defaults or the config file).
## API
### Configuration
| Function | Description |
| --------------------- | --------------------------------------------- |
| `SetConfigFile(path)` | Set config file path |
| `SetConfigDir(dir)` | Set config directory |
| `SetConfigName(name)` | Set config file name (without extension) |
| `SetConfigType(type)` | Set config type: `"toml"`, `"yaml"`, `"json"` |
| `ReadInConfig()` | Read config file |
| `WriteConfig()` | Write config to file |
### Values
| Function | Description |
| ------------------------ | ------------------------ |
| `Set(key, value)` | Set a value |
| `SetDefault(key, value)` | Set a default value |
| `Delete(key)` | Remove a key |
| `Sub(key)` | Get scoped sub-config |
| `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]any |
| `IsSet(key)` | Check if key has a value |
| `AllKeys()` | List all known keys |
| `AllSettings()` | Get all values as a map |
### Environment
| Function | Description |
| ----------------------- | --------------------------------------------------- |
| `WithEnvPrefix(prefix)` | Filter env vars by prefix (strips prefix from keys) |
| `SetEnvPrefix(prefix)` | Set prefix for env var lookups |
## License
See [LICENSE](LICENSE) file.

View File

@@ -1,4 +1,4 @@
package config package jety
import "time" import "time"
@@ -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)
} }
@@ -40,8 +48,8 @@ func Set(key string, value any) {
defaultConfigManager.Set(key, value) defaultConfigManager.Set(key, value)
} }
func WriteConfig() { func WriteConfig() error {
defaultConfigManager.WriteConfig() return defaultConfigManager.WriteConfig()
} }
func ConfigFileUsed() string { func ConfigFileUsed() string {
@@ -67,3 +75,43 @@ 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 Delete(key string) {
defaultConfigManager.Delete(key)
}
func Sub(key string) *ConfigManager {
return defaultConfigManager.Sub(key)
}
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()
}

31
doc.go Normal file
View File

@@ -0,0 +1,31 @@
// Package jety provides configuration management supporting JSON, ENV, TOML, and YAML formats.
//
// It offers viper-like AutomaticEnv functionality with minimal dependencies, allowing
// configuration to be loaded from files and environment variables with automatic merging.
//
// Configuration sources are layered with the following precedence (highest to lowest):
// - Values set via Set() or SetString()/SetBool()
// - Environment variables (optionally filtered by prefix)
// - Values from config file via ReadInConfig()
// - Default values set via SetDefault()
//
// Basic usage:
//
// jety.SetConfigFile("/etc/myapp/config.yaml")
// jety.SetConfigType("yaml")
// jety.SetEnvPrefix("MYAPP_")
// jety.SetDefault("port", 8080)
//
// if err := jety.ReadInConfig(); err != nil {
// log.Fatal(err)
// }
//
// port := jety.GetInt("port")
//
// For multiple independent configurations, create separate ConfigManager instances:
//
// cm := jety.NewConfigManager()
// cm.SetConfigFile("/etc/myapp/config.toml")
// cm.SetConfigType("toml")
// cm.ReadInConfig()
package jety

View File

@@ -1,4 +1,4 @@
package config package jety
import ( import (
"fmt" "fmt"
@@ -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(c.envPrefix+key)] return nil
if !ok {
return nil
}
} }
return v.Value return v.Value
} }
@@ -23,53 +32,37 @@ 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(c.envPrefix+key)] return false
if !ok {
return false
}
} }
val := v.Value val := v.Value
switch val := val.(type) { switch val := val.(type) {
case bool: case bool:
return val return val
case string: case string:
if strings.ToLower(val) == "true" { return strings.EqualFold(val, "true")
return true
}
return false
case int: case int:
if val == 0 { return val != 0
return false case float32:
} return val != 0
return true case float64:
case float32, float64: return val != 0
if val == 0 { case time.Duration:
return false return val > 0
}
return true
case nil: case nil:
return false return false
case time.Duration:
if val == 0 || val < 0 {
return false
}
return true
default: default:
return val.(bool) return false
} }
} }
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(c.envPrefix+key)] return 0
if !ok {
return 0
}
} }
val := v.Value val := v.Value
switch val := val.(type) { switch val := val.(type) {
@@ -83,6 +76,8 @@ func (c *ConfigManager) GetDuration(key string) time.Duration {
return d return d
case int: case int:
return time.Duration(val) return time.Duration(val)
case int64:
return time.Duration(val)
case float32: case float32:
return time.Duration(val) return time.Duration(val)
case float64: case float64:
@@ -90,20 +85,16 @@ func (c *ConfigManager) GetDuration(key string) time.Duration {
case nil: case nil:
return 0 return 0
default: default:
return val.(time.Duration) return 0
} }
} }
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(c.envPrefix+key)] return ""
if !ok {
return ""
}
} }
switch val := v.Value.(type) { switch val := v.Value.(type) {
@@ -117,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(c.envPrefix+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:
@@ -135,34 +123,99 @@ 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(c.envPrefix+key)] return nil
if !ok {
return nil
}
} }
switch val := v.Value.(type) { switch val := v.Value.(type) {
case []string: case []string:
return val return val
case []any:
var ret []string
for _, v := range val {
switch v := v.(type) {
case string:
ret = append(ret, v)
default:
ret = append(ret, fmt.Sprintf("%v", v))
}
}
return ret
default: default:
return nil return nil
} }
} }
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(c.envPrefix+key)] return 0
if !ok {
return 0
}
} }
switch val := v.Value.(type) { switch val := v.Value.(type) {
case int: case int:
return val return val
case int64:
return int(val)
case string: case string:
i, err := strconv.Atoi(val) i, err := strconv.Atoi(val)
if err != nil { if err != nil {
@@ -183,16 +236,38 @@ 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(c.envPrefix+key)] return nil
if !ok {
return nil
}
} }
switch val := v.Value.(type) { switch val := v.Value.(type) {
case []int: case []int:
return val return val
case []any:
var ret []int
for _, v := range val {
switch v := v.(type) {
case int:
ret = append(ret, v)
case int64:
ret = append(ret, int(v))
case string:
i, err := strconv.Atoi(v)
if err != nil {
continue
}
ret = append(ret, i)
case float32:
ret = append(ret, int(v))
case float64:
ret = append(ret, int(v))
case nil:
continue
default:
continue
}
}
return ret
default: default:
return nil return nil
} }

4
go.mod
View File

@@ -1,8 +1,8 @@
module github.com/taigrr/jety module github.com/taigrr/jety
go 1.21.3 go 1.26.1
require ( require (
github.com/BurntSushi/toml v1.3.2 github.com/BurntSushi/toml v1.6.0
gopkg.in/yaml.v3 v3.0.1 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.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

277
jety.go
View File

@@ -1,10 +1,11 @@
package config package jety
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"sync" "sync"
@@ -21,58 +22,74 @@ 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
envPrefix string overrideConfig map[string]ConfigMap
mapConfig 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
} }
) )
var ErrConfigFileNotFound = errors.New("config File Not Found") var (
ErrConfigFileNotFound = errors.New("config file not found")
ErrConfigFileEmpty = errors.New("config file is empty")
)
func NewConfigManager() *ConfigManager { // parseEnv reads environment variables, optionally filtering by prefix,
cm := ConfigManager{} // and returns a map keyed by lowercased (and prefix-stripped) variable names.
cm.envConfig = make(map[string]ConfigMap) func parseEnv(prefix string) map[string]ConfigMap {
cm.mapConfig = make(map[string]ConfigMap) result := make(map[string]ConfigMap)
cm.defaultConfig = make(map[string]ConfigMap) for _, env := range os.Environ() {
cm.combinedConfig = make(map[string]ConfigMap) key, value, found := strings.Cut(env, "=")
cm.envPrefix = "" if !found {
envSet := os.Environ() continue
for _, env := range envSet { }
kv := strings.Split(env, "=") if prefix != "" {
lower := strings.ToLower(kv[0]) stripped, ok := strings.CutPrefix(key, prefix)
cm.envConfig[lower] = ConfigMap{Key: kv[0], Value: kv[1]} 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()
c.envPrefix = prefix c.envConfig = parseEnv(prefix)
envSet := os.Environ()
c.envConfig = make(map[string]ConfigMap)
for _, env := range envSet {
kv := strings.Split(env, "=")
if strings.HasPrefix(kv[0], prefix) {
withoutPrefix := strings.TrimPrefix(kv[0], prefix)
lower := strings.ToLower(withoutPrefix)
c.envConfig[lower] = ConfigMap{Key: withoutPrefix, Value: kv[1]}
}
}
return c return c
} }
@@ -82,23 +99,69 @@ func (c *ConfigManager) ConfigFileUsed() string {
return c.configFileUsed return c.configFileUsed
} }
func (c *ConfigManager) UseExplicitDefaults(enable bool) { // IsSet checks whether a key has been set in any configuration source.
c.mutex.Lock() func (c *ConfigManager) IsSet(key string) bool {
defer c.mutex.Unlock() c.mutex.RLock()
c.explicitDefaults = enable defer c.mutex.RUnlock()
lower := strings.ToLower(key)
if _, ok := c.combinedConfig[lower]; ok {
return true
}
_, ok := c.envConfig[lower]
return ok
}
// AllKeys returns all keys from all configuration sources, deduplicated.
func (c *ConfigManager) AllKeys() []string {
c.mutex.RLock()
defer c.mutex.RUnlock()
seen := make(map[string]struct{})
var keys []string
for k := range c.combinedConfig {
if _, ok := seen[k]; !ok {
seen[k] = struct{}{}
keys = append(keys, k)
}
}
for k := range c.envConfig {
if _, ok := seen[k]; !ok {
seen[k] = struct{}{}
keys = append(keys, k)
}
}
return keys
}
// AllSettings returns all settings as a flat map of key to value.
func (c *ConfigManager) AllSettings() map[string]any {
c.mutex.RLock()
defer c.mutex.RUnlock()
result := make(map[string]any, len(c.combinedConfig))
for k, v := range c.combinedConfig {
result[k] = v.Value
}
return result
} }
func (c *ConfigManager) collapse() { func (c *ConfigManager) collapse() {
c.mutex.RLock() c.mutex.Lock()
defer c.mutex.RUnlock() defer c.mutex.Unlock()
ccm := make(map[string]ConfigMap) ccm := make(map[string]ConfigMap)
// Precedence (highest to lowest): overrides (Set) > env > file > defaults
for k, v := range c.defaultConfig { for k, v := range c.defaultConfig {
ccm[k] = v ccm[k] = v
if _, ok := c.envConfig[k]; ok { }
ccm[k] = c.envConfig[k] for k, v := range c.fileConfig {
ccm[k] = v
}
for k, v := range c.envConfig {
if _, inDefaults := c.defaultConfig[k]; inDefaults {
ccm[k] = v
} else if _, inFile := c.fileConfig[k]; inFile {
ccm[k] = v
} }
} }
for k, v := range c.mapConfig { for k, v := range c.overrideConfig {
ccm[k] = v ccm[k] = v
} }
c.combinedConfig = ccm c.combinedConfig = ccm
@@ -107,6 +170,13 @@ 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)
for _, v := range c.combinedConfig {
flattenedConfig[v.Key] = v.Value
}
switch c.configType { switch c.configType {
case ConfigTypeTOML: case ConfigTypeTOML:
f, err := os.Create(c.configFileUsed) f, err := os.Create(c.configFileUsed)
@@ -115,7 +185,7 @@ func (c *ConfigManager) WriteConfig() error {
} }
defer f.Close() defer f.Close()
enc := toml.NewEncoder(f) enc := toml.NewEncoder(f)
err = enc.Encode(c.combinedConfig) err = enc.Encode(flattenedConfig)
return err return err
case ConfigTypeYAML: case ConfigTypeYAML:
f, err := os.Create(c.configFileUsed) f, err := os.Create(c.configFileUsed)
@@ -124,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(c.combinedConfig) 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 {
@@ -133,14 +205,16 @@ func (c *ConfigManager) WriteConfig() error {
} }
defer f.Close() defer f.Close()
enc := json.NewEncoder(f) enc := json.NewEncoder(f)
return enc.Encode(c.combinedConfig) return enc.Encode(flattenedConfig)
default: default:
return fmt.Errorf("config type %s not supported", c.configType) return fmt.Errorf("config type %s not supported", c.configType)
} }
} }
func (c *ConfigManager) SetConfigType(configType string) error { func (c *ConfigManager) SetConfigType(ct string) error {
switch configType { c.mutex.Lock()
defer c.mutex.Unlock()
switch ct {
case "toml": case "toml":
c.configType = ConfigTypeTOML c.configType = ConfigTypeTOML
case "yaml": case "yaml":
@@ -148,20 +222,42 @@ 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.envPrefix = prefix c.mutex.Lock()
defer c.mutex.Unlock()
c.envConfig = parseEnv(prefix)
} }
func (c *ConfigManager) ReadInConfig() error { func (c *ConfigManager) ReadInConfig() error {
c.mutex.Lock() c.mutex.RLock()
defer c.mutex.Unlock() configFile := c.configFileUsed
// assume config = map[string]any if configFile == "" && c.configPath != "" && c.configName != "" {
confFileData, err := readFile(c.configFileUsed, c.configType) ext := ""
switch c.configType {
case ConfigTypeTOML:
ext = ".toml"
case ConfigTypeYAML:
ext = ".yaml"
case ConfigTypeJSON:
ext = ".json"
}
configFile = filepath.Join(c.configPath, c.configName+ext)
}
configType := c.configType
c.mutex.RUnlock()
if configFile == "" {
return errors.New("no config file specified: use SetConfigFile or SetConfigDir + SetConfigName")
}
confFileData, err := readFile(configFile, configType)
if err != nil { if err != nil {
return err return err
} }
@@ -170,38 +266,73 @@ func (c *ConfigManager) ReadInConfig() error {
lower := strings.ToLower(k) lower := strings.ToLower(k)
conf[lower] = ConfigMap{Key: k, Value: v} conf[lower] = ConfigMap{Key: k, Value: v}
} }
c.mapConfig = conf c.mutex.Lock()
c.fileConfig = conf
c.configFileUsed = configFile
c.mutex.Unlock()
c.collapse()
return nil return nil
} }
func readFile(filename string, fileType configType) (map[string]any, error) { func readFile(filename string, fileType configType) (map[string]any, error) {
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) 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)
} }
} }
// Sub returns a new ConfigManager rooted at the given key. The key must
// refer to a map value (e.g., from a nested TOML/YAML/JSON section).
// The returned ConfigManager has the nested map loaded as its default
// config, and does not inherit the parent's environment prefix, overrides,
// or config file. Returns nil if the key does not exist or is not a map.
func (c *ConfigManager) Sub(key string) *ConfigManager {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.resolve(key)
if !ok {
return nil
}
m, ok := v.Value.(map[string]any)
if !ok {
return nil
}
sub := NewConfigManager()
for k, val := range m {
lower := strings.ToLower(k)
sub.defaultConfig[lower] = ConfigMap{Key: k, Value: val}
sub.combinedConfig[lower] = ConfigMap{Key: k, Value: val}
}
return sub
}
func (c *ConfigManager) SetConfigDir(path string) { func (c *ConfigManager) SetConfigDir(path string) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()

1723
jety_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
package config package jety
import ( import (
"strings" "strings"
@@ -8,7 +8,7 @@ func (c *ConfigManager) SetBool(key string, value bool) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
lower := strings.ToLower(key) lower := strings.ToLower(key)
c.mapConfig[lower] = ConfigMap{Key: key, Value: value} c.overrideConfig[lower] = ConfigMap{Key: key, Value: value}
c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
} }
@@ -16,7 +16,7 @@ func (c *ConfigManager) SetString(key string, value string) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
lower := strings.ToLower(key) lower := strings.ToLower(key)
c.mapConfig[lower] = ConfigMap{Key: key, Value: value} c.overrideConfig[lower] = ConfigMap{Key: key, Value: value}
c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
} }
@@ -24,19 +24,36 @@ func (c *ConfigManager) Set(key string, value any) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
lower := strings.ToLower(key) lower := strings.ToLower(key)
c.mapConfig[lower] = ConfigMap{Key: key, Value: value} c.overrideConfig[lower] = ConfigMap{Key: key, Value: value}
c.combinedConfig[lower] = ConfigMap{Key: key, Value: value} c.combinedConfig[lower] = ConfigMap{Key: key, Value: value}
} }
// Delete removes a key from all configuration layers (overrides, file,
// defaults) and rebuilds the combined configuration. Environment variables
// are not affected since they are loaded from the process environment.
func (c *ConfigManager) Delete(key string) {
c.mutex.Lock()
lower := strings.ToLower(key)
delete(c.overrideConfig, lower)
delete(c.fileConfig, lower)
delete(c.defaultConfig, lower)
delete(c.combinedConfig, lower)
c.mutex.Unlock()
}
func (c *ConfigManager) SetDefault(key string, value any) { func (c *ConfigManager) SetDefault(key string, value any) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
lower := strings.ToLower(key) lower := strings.ToLower(key)
c.defaultConfig[lower] = ConfigMap{Key: key, Value: value} c.defaultConfig[lower] = ConfigMap{Key: key, Value: value}
if _, ok := c.mapConfig[lower]; !ok { // Update combinedConfig respecting precedence: override > env > file > default
if envVal, ok := c.envConfig[lower]; !ok { if v, ok := c.overrideConfig[lower]; ok {
c.mapConfig[lower] = envVal c.combinedConfig[lower] = v
c.combinedConfig[lower] = envVal } 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] = ConfigMap{Key: key, Value: value}
} }
} }