Files
jety/jety.go
Tai Groot 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

325 lines
7.4 KiB
Go

package jety
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/BurntSushi/toml"
"gopkg.in/yaml.v3"
)
const (
ConfigTypeTOML configType = "toml"
ConfigTypeYAML configType = "yaml"
ConfigTypeJSON configType = "json"
)
type (
configType string
ConfigMap struct {
Key string
Value any
}
ConfigManager struct {
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
}
)
var (
ErrConfigFileNotFound = errors.New("config file not found")
ErrConfigFileEmpty = errors.New("config file is empty")
)
func NewConfigManager() *ConfigManager {
cm := ConfigManager{}
cm.envConfig = make(map[string]ConfigMap)
cm.overrideConfig = make(map[string]ConfigMap)
cm.fileConfig = make(map[string]ConfigMap)
cm.defaultConfig = make(map[string]ConfigMap)
cm.combinedConfig = make(map[string]ConfigMap)
envSet := os.Environ()
for _, env := range envSet {
key, value, found := strings.Cut(env, "=")
if !found {
continue
}
lower := strings.ToLower(key)
cm.envConfig[lower] = ConfigMap{Key: key, Value: value}
}
return &cm
}
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}
}
}
return c
}
func (c *ConfigManager) ConfigFileUsed() string {
c.mutex.RLock()
defer c.mutex.RUnlock()
return c.configFileUsed
}
// 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
}
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.overrideConfig {
ccm[k] = v
}
c.combinedConfig = ccm
}
func (c *ConfigManager) WriteConfig() error {
c.mutex.RLock()
defer c.mutex.RUnlock()
flattenedConfig := make(map[string]any)
for _, v := range c.combinedConfig {
flattenedConfig[v.Key] = v.Value
}
switch c.configType {
case ConfigTypeTOML:
f, err := os.Create(c.configFileUsed)
if err != nil {
return err
}
defer f.Close()
enc := toml.NewEncoder(f)
err = enc.Encode(flattenedConfig)
return err
case ConfigTypeYAML:
f, err := os.Create(c.configFileUsed)
if err != nil {
return err
}
defer f.Close()
enc := yaml.NewEncoder(f)
err = enc.Encode(flattenedConfig)
return err
case ConfigTypeJSON:
f, err := os.Create(c.configFileUsed)
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
return enc.Encode(flattenedConfig)
default:
return fmt.Errorf("config type %s not supported", c.configType)
}
}
func (c *ConfigManager) SetConfigType(configType string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
switch configType {
case "toml":
c.configType = ConfigTypeTOML
case "yaml":
c.configType = ConfigTypeYAML
case "json":
c.configType = ConfigTypeJSON
default:
return fmt.Errorf("config type %s not supported", configType)
}
return nil
}
func (c *ConfigManager) SetEnvPrefix(prefix string) {
c.mutex.Lock()
defer c.mutex.Unlock()
// 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 {
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
}
conf := make(map[string]ConfigMap)
for k, v := range confFileData {
lower := strings.ToLower(k)
conf[lower] = ConfigMap{Key: k, Value: v}
}
c.mutex.Lock()
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) {
return nil, ErrConfigFileNotFound
} else if d.Size() == 0 {
return nil, ErrConfigFileEmpty
}
switch fileType {
case ConfigTypeTOML:
_, err := toml.DecodeFile(filename, &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)
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)
return fileData, err
default:
return nil, fmt.Errorf("config type %s not supported", fileType)
}
}
func (c *ConfigManager) SetConfigDir(path string) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.configPath = path
}
func (c *ConfigManager) SetConfigName(name string) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.configName = name
}
func (c *ConfigManager) SetConfigFile(file string) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.configFileUsed = file
}