v0.2.0 toml-only, harden concurrency and env export, dep updates
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-28 16:55:58 -04:00
1 parent 3c35601179
commit e08467bf38
42 files changed
+2533 -4274

No files matched your search

+78 -139
View File
@@ -8,10 +8,6 @@ import (
"os"
"reflect"
"strings"
"time"
"github.com/BurntSushi/toml"
"github.com/go-viper/mapstructure/v2"
)
// Quick creates a fully configured Config instance with a single call
@@ -89,27 +85,21 @@ func (c *Config) GenerateFlags() *flag.FlagSet {
// BindFlags updates configuration from parsed flag.FlagSet
func (c *Config) BindFlags(fs *flag.FlagSet) error {
var errors []error
needsInvalidation := false
fs.Visit(func(f *flag.Flag) {
value := f.Value.String()
// Let mapstructure handle type conversion
if err := c.SetSource(SourceCLI, f.Name, value); err != nil {
errors = append(errors, fmt.Errorf("flag %s: %w", f.Name, err))
} else {
needsInvalidation = true
if fs == nil {
return wrapError(ErrCLIParse, fmt.Errorf("nil flag set"))
}
values := make(map[string]any)
fs.Visit(func(f *flag.Flag) { values[f.Name] = f.Value.String() })
c.mutex.Lock()
defer c.mutex.Unlock()
for path := range values {
if _, ok := c.items[path]; !ok {
return wrapError(ErrCLIParse, fmt.Errorf("failed to bind unregistered flag %q: %w", path, ErrPathNotRegistered))
}
})
if needsInvalidation {
c.invalidateCache() // Batch invalidation after all flags
}
if len(errors) > 0 {
return wrapError(ErrCLIParse, fmt.Errorf("failed to bind %d flags: %w", len(errors), errors[0]))
if err := c.replaceSourceLocked(SourceCLI, values); err != nil {
return wrapError(ErrCLIParse, err)
}
return nil
}
@@ -120,6 +110,13 @@ func (c *Config) Validate(required ...string) error {
defer c.mutex.RUnlock()
var missing []string
if len(required) == 0 {
for path, item := range c.items {
if item.required {
required = append(required, path)
}
}
}
for _, path := range required {
item, exists := c.items[path]
@@ -132,7 +129,11 @@ func (c *Config) Validate(required ...string) error {
if reflect.DeepEqual(item.currentValue, item.defaultValue) {
// Check if any source provided a value
hasValue := false
for _, val := range item.values {
for _, source := range c.options.Sources {
if source == SourceDefault {
break
}
val := item.values[source]
if val != nil {
hasValue = true
break
@@ -177,59 +178,50 @@ func (c *Config) Debug() string {
// Dump writes the current configuration to stdout in TOML format
func (c *Config) Dump() error {
c.mutex.RLock()
defer c.mutex.RUnlock()
nestedData := c.nestedLocked("")
c.mutex.RUnlock()
nestedData := make(map[string]any)
for path, item := range c.items {
setNestedValue(nestedData, path, item.currentValue)
data, err := marshalConfig(nestedData)
if err != nil {
return err
}
encoder := toml.NewEncoder(os.Stdout)
if err := encoder.Encode(nestedData); err != nil {
return wrapError(ErrDecode, err)
}
return nil
_, err = os.Stdout.Write(data)
return err
}
// Clone creates a deep copy of the configuration
func (c *Config) Clone() *Config {
c.mutex.RLock()
defer c.mutex.RUnlock()
clone := &Config{
items: make(map[string]configItem),
options: c.options,
fileData: make(map[string]any),
envData: make(map[string]any),
cliData: make(map[string]any),
clone := NewWithOptions(c.options)
clone.tagName, clone.fileFormat = c.tagName, c.fileFormat
clone.configFilePath = c.configFilePath
clone.fileComments = append([]string(nil), c.fileComments...)
clone.unknownCLIKeys = append([]string(nil), c.unknownCLIKeys...)
if c.securityOpts != nil {
opts := *c.securityOpts
clone.securityOpts = &opts
}
// Deep copy items
for path, item := range c.items {
newItem := configItem{
defaultValue: item.defaultValue,
currentValue: item.currentValue,
values: make(map[Source]any),
}
next := configItem{defaultValue: cloneOwned(item.defaultValue), currentValue: cloneOwned(item.currentValue), values: make(map[Source]any)}
for source, value := range item.values {
newItem.values[source] = value
next.values[source] = cloneOwned(value)
}
clone.items[path] = newItem
next.required = item.required
clone.items[path] = next
}
// Copy cache data
for k, v := range c.fileData {
clone.fileData[k] = v
clone.fileData[k] = cloneOwned(v)
}
for k, v := range c.envData {
clone.envData[k] = v
clone.envData[k] = cloneOwned(v)
}
for k, v := range c.cliData {
clone.cliData[k] = v
clone.cliData[k] = cloneOwned(v)
}
if c.structCache != nil {
clone.structCache = &structCache{targetType: c.structCache.targetType, prefix: c.structCache.prefix}
}
return clone
}
@@ -253,62 +245,42 @@ func GetTyped[T any](c *Config, path string) (T, error) {
return zero, wrapError(ErrPathNotFound, fmt.Errorf("path %q not found", path))
}
// Prepare the input map and target struct for the decoder
inputMap := map[string]any{"value": rawValue}
var target struct {
Value T `mapstructure:"value"`
var target T
if err := decodeConfig(rawValue, &target); err != nil {
return zero, err
}
// Create a new decoder configured with the same hooks as the main config
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: &target,
TagName: c.tagName,
WeaklyTypedInput: true,
DecodeHook: c.getDecodeHook(),
Metadata: nil,
})
if err != nil {
return zero, wrapError(ErrDecode, fmt.Errorf("failed to create decoder for path %q: %w", path, err))
}
// Decode the single value.
if err := decoder.Decode(inputMap); err != nil {
return zero, wrapError(ErrDecode, fmt.Errorf("failed to decode value for path %q into type %T: %w", path, zero, err))
}
return target.Value, nil
return target, nil
}
// GetTypedWithDefault retrieves a configuration value with a default fallback
// If the path doesn't exist or isn't set, it sets and returns the default value
// For simple cases where explicit defaults aren't pre-registered
func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) {
// Check if path exists and has a value
if _, exists := c.Get(path); exists {
// Path exists, try to decode the current value
result, err := GetTyped[T](c, path)
if err == nil {
return result, nil
c.mutex.Lock()
if item, exists := c.items[path]; exists {
raw := item.currentValue
c.mutex.Unlock()
var result T
if err := decodeConfig(raw, &result); err != nil {
return result, err
}
// Type conversion failed, fall through to set default
return result, nil
}
// Path doesn't exist or value not set - register and set default
// This handles the case where the path wasn't pre-registered
if err := c.Register(path, defaultValue); err != nil {
// Path might already be registered with incompatible type
// Try to just set the value
if setErr := c.Set(path, defaultValue); setErr != nil {
return defaultValue, wrapError(ErrPathNotRegistered, fmt.Errorf("%w : failed to register or set default for path %q", ErrPathNotRegistered, path))
}
if err := validatePath(path); err != nil {
c.mutex.Unlock()
return defaultValue, err
}
// Set the default value
if err := c.Set(path, defaultValue); err != nil {
return defaultValue, wrapError(ErrTypeMismatch, fmt.Errorf("%w : failed to set default value for path %q", ErrTypeMismatch, path))
owned, err := copyValue(defaultValue)
if err == nil {
err = c.registerLocked(path, owned)
}
return defaultValue, nil
c.mutex.Unlock()
if err != nil {
return defaultValue, err
}
var result T
err = decodeConfig(owned, &result)
return result, err
}
// ScanTyped is a generic wrapper around Scan. It allocates a new instance of type T,
@@ -325,41 +297,8 @@ func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
// without requiring a full Config instance. This is useful for plugin
// initialization where config data arrives as a map[string]any.
func ScanMap(configMap map[string]any, target any, tagName ...string) error {
// Handle nil map
if configMap == nil {
configMap = make(map[string]any)
if len(tagName) > 1 || len(tagName) == 1 && tagName[0] != "" && tagName[0] != FormatTOML {
return wrapError(ErrTypeMismatch, fmt.Errorf("only toml tags are supported"))
}
// Determine tag name
tag := "toml" // default
if len(tagName) > 0 && tagName[0] != "" {
tag = tagName[0]
}
// Create decoder with standard hooks
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: target,
TagName: tag,
WeaklyTypedInput: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
jsonNumberHookFunc(),
stringToNetIPHookFunc(),
stringToNetIPNetHookFunc(),
stringToURLHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
),
ZeroFields: true,
})
if err != nil {
return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err))
}
// Decode directly
if err := decoder.Decode(configMap); err != nil {
return wrapError(ErrDecode, fmt.Errorf("decode failed: %w", err))
}
return nil
}
return decodeConfig(configMap, target)
}