19 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
12 changed files with 2306 additions and 173 deletions

View File

@@ -36,31 +36,20 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Install Go
uses: actions/setup-go@v4
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# 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
uses: github/codeql-action/autobuild@v3
- 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
name: Run govulncheck
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- id: govulncheck
uses: golang/govulncheck-action@v1
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 ./...

158
README.md
View File

@@ -1,9 +1,161 @@
# 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
This is a package for collapsing multiple configuration stores (env+json, env+yaml, env+toml) and writing them back to a centralized config.
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).
It should behave similarly to the AutomaticEnv functionality of viper, but without some of the extra heft of the depedendencies it carries.
## Installation
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.
```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"
@@ -20,6 +20,14 @@ func SetConfigName(name string) {
defaultConfigManager.SetConfigName(name)
}
func GetFloat64(key string) float64 {
return defaultConfigManager.GetFloat64(key)
}
func GetInt64(key string) int64 {
return defaultConfigManager.GetInt64(key)
}
func GetInt(key string) int {
return defaultConfigManager.GetInt(key)
}
@@ -40,8 +48,8 @@ func Set(key string, value any) {
defaultConfigManager.Set(key, value)
}
func WriteConfig() {
defaultConfigManager.WriteConfig()
func WriteConfig() error {
return defaultConfigManager.WriteConfig()
}
func ConfigFileUsed() string {
@@ -67,3 +75,43 @@ 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 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 (
"fmt"
@@ -7,70 +7,63 @@ 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)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok := c.resolve(key)
if !ok {
return nil
}
}
return v.Value
}
func (c *ConfigManager) GetBool(key string) bool {
c.mutex.RLock()
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.resolve(key)
if !ok {
return false
}
}
val := v.Value
switch val := val.(type) {
case bool:
return val
case string:
if strings.ToLower(val) == "true" {
return true
}
return false
return strings.EqualFold(val, "true")
case int:
if val == 0 {
return false
}
return true
case float32, float64:
if val == 0 {
return false
}
return true
return val != 0
case float32:
return val != 0
case float64:
return val != 0
case time.Duration:
return val > 0
case nil:
return false
case time.Duration:
if val == 0 || val < 0 {
return false
}
return true
default:
return val.(bool)
return false
}
}
func (c *ConfigManager) GetDuration(key string) time.Duration {
c.mutex.RLock()
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.resolve(key)
if !ok {
return 0
}
}
val := v.Value
switch val := val.(type) {
case time.Duration:
@@ -83,6 +76,8 @@ func (c *ConfigManager) GetDuration(key string) time.Duration {
return d
case int:
return time.Duration(val)
case int64:
return time.Duration(val)
case float32:
return time.Duration(val)
case float64:
@@ -90,21 +85,17 @@ func (c *ConfigManager) GetDuration(key string) time.Duration {
case nil:
return 0
default:
return val.(time.Duration)
return 0
}
}
func (c *ConfigManager) GetString(key string) string {
c.mutex.RLock()
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.resolve(key)
if !ok {
return ""
}
}
switch val := v.Value.(type) {
case string:
@@ -117,13 +108,10 @@ 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)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok := c.resolve(key)
if !ok {
return nil
}
}
switch val := v.Value.(type) {
case map[string]any:
return val
@@ -135,13 +123,10 @@ 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)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok := c.resolve(key)
if !ok {
return nil
}
}
switch val := v.Value.(type) {
case []string:
return val
@@ -161,19 +146,76 @@ func (c *ConfigManager) GetStringSlice(key string) []string {
}
}
func (c *ConfigManager) GetInt(key string) int {
func (c *ConfigManager) GetFloat64(key string) float64 {
c.mutex.RLock()
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.resolve(key)
if !ok {
return 0
}
switch val := v.Value.(type) {
case float64:
return val
case float32:
return float64(val)
case int:
return float64(val)
case int64:
return float64(val)
case string:
f, err := strconv.ParseFloat(val, 64)
if err != nil {
return 0
}
return f
case nil:
return 0
default:
return 0
}
}
func (c *ConfigManager) GetInt64(key string) int64 {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.resolve(key)
if !ok {
return 0
}
switch val := v.Value.(type) {
case int64:
return val
case int:
return int64(val)
case string:
i, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return 0
}
return i
case float32:
return int64(val)
case float64:
return int64(val)
case nil:
return 0
default:
return 0
}
}
func (c *ConfigManager) GetInt(key string) int {
c.mutex.RLock()
defer c.mutex.RUnlock()
v, ok := c.resolve(key)
if !ok {
return 0
}
switch val := v.Value.(type) {
case int:
return val
case int64:
return int(val)
case string:
i, err := strconv.Atoi(val)
if err != nil {
@@ -194,13 +236,10 @@ 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)]
if !ok {
v, ok = c.envConfig[strings.ToLower(c.envPrefix+key)]
v, ok := c.resolve(key)
if !ok {
return nil
}
}
switch val := v.Value.(type) {
case []int:
return val
@@ -210,6 +249,8 @@ func (c *ConfigManager) GetIntSlice(key string) []int {
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 {

4
go.mod
View File

@@ -1,8 +1,8 @@
module github.com/taigrr/jety
go 1.21.3
go 1.26.1
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=

241
jety.go
View File

@@ -1,10 +1,11 @@
package config
package jety
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
@@ -21,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
overrideConfig map[string]ConfigMap
fileConfig map[string]ConfigMap
defaultConfig map[string]ConfigMap
envConfig map[string]ConfigMap
combinedConfig map[string]ConfigMap
mutex sync.RWMutex
explicitDefaults bool
}
)
@@ -46,36 +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 {
kv := strings.Split(env, "=")
lower := strings.ToLower(kv[0])
cm.envConfig[lower] = ConfigMap{Key: kv[0], Value: kv[1]}
// 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
}
return &cm
if prefix != "" {
stripped, ok := strings.CutPrefix(key, prefix)
if !ok {
continue
}
key = stripped
}
result[strings.ToLower(key)] = ConfigMap{Key: key, Value: value}
}
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()
c.envPrefix = 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]}
}
}
c.envConfig = parseEnv(prefix)
return c
}
@@ -85,23 +99,69 @@ 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.RLock()
defer c.mutex.RUnlock()
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
}
}
for k, v := range c.mapConfig {
for k, v := range c.overrideConfig {
ccm[k] = v
}
c.combinedConfig = ccm
@@ -110,6 +170,9 @@ func (c *ConfigManager) collapse() {
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
@@ -131,8 +194,10 @@ func (c *ConfigManager) WriteConfig() error {
}
defer f.Close()
enc := yaml.NewEncoder(f)
err = enc.Encode(flattenedConfig)
if err = enc.Encode(flattenedConfig); err != nil {
return err
}
return enc.Close()
case ConfigTypeJSON:
f, err := os.Create(c.configFileUsed)
if err != nil {
@@ -146,8 +211,10 @@ func (c *ConfigManager) WriteConfig() error {
}
}
func (c *ConfigManager) SetConfigType(configType string) error {
switch configType {
func (c *ConfigManager) SetConfigType(ct string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
switch ct {
case "toml":
c.configType = ConfigTypeTOML
case "yaml":
@@ -155,18 +222,42 @@ 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.envPrefix = prefix
c.mutex.Lock()
defer c.mutex.Unlock()
c.envConfig = parseEnv(prefix)
}
func (c *ConfigManager) ReadInConfig() error {
// assume config = map[string]any
confFileData, err := readFile(c.configFileUsed, c.configType)
c.mutex.RLock()
configFile := c.configFileUsed
if configFile == "" && c.configPath != "" && c.configName != "" {
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 {
return err
}
@@ -176,46 +267,72 @@ 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()
return nil
}
func readFile(filename string, fileType configType) (map[string]any, error) {
fileData := make(map[string]any)
if d, err := os.Stat(filename); os.IsNotExist(err) {
f, err := os.Open(filename)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrConfigFileNotFound
} else if d.Size() == 0 {
}
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)
}
}
// 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) {
c.mutex.Lock()
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 (
"strings"
@@ -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,23 +24,36 @@ 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}
}
// 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) {
c.mutex.Lock()
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] = envVal
c.combinedConfig[lower] = envVal
// 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] = ConfigMap{Key: key, Value: value}
}
} else {
c.combinedConfig[lower] = c.mapConfig[lower]
}
}