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

+82 -54
View File
@@ -13,6 +13,7 @@ import (
// configItem holds configuration values from different sources
type configItem struct {
required bool
defaultValue any
values map[Source]any // Values from each source
currentValue any // Computed value based on precedence
@@ -22,8 +23,10 @@ type configItem struct {
type structCache struct {
target any // User-provided struct pointer
targetType reflect.Type // Cached type for validation
version int64 // Version for invalidation
populated bool // Whether cache is valid
snapshot any // Immutable cached result; callers receive copies
prefix string
version int64 // Version for invalidation
populated bool // Whether cache is valid
mu sync.RWMutex
}
@@ -35,20 +38,22 @@ type SecurityOptions struct {
}
// Config manages application configuration. It can be used in two primary ways:
// 1. As a dynamic key-value store, accessed via methods like Get(), String(), and Int64()
// 2. As a source for a type-safe struct, populated via BuildAndScan() or AsStruct()
// 1. As a dynamic key-value store, accessed via Get() and GetTyped[T]()
// 2. As a source for typed structs, populated via Scan() or AsStruct()
type Config struct {
items map[string]configItem
tagName string
fileFormat string // Separate from tagName: toml, json, yaml, or auto
securityOpts *SecurityOptions
mutex sync.RWMutex
options LoadOptions // Current load options
fileData map[string]any // Cached file data
envData map[string]any // Cached env data
cliData map[string]any // Cached CLI data
version atomic.Int64
structCache *structCache
items map[string]configItem
tagName string
fileFormat string // Separate from tagName: toml or auto
securityOpts *SecurityOptions
mutex sync.RWMutex
options LoadOptions // Current load options
fileData map[string]any // Cached file data
fileComments []string
fileGeneration uint64 // Invalidates staged watcher reloads
envData map[string]any // Cached env data
cliData map[string]any // Cached CLI data
version atomic.Int64
structCache *structCache
// CLI paths that matched no registered path (reset on each CLI load)
unknownCLIKeys []string
@@ -74,7 +79,7 @@ func New() *Config {
// NewWithOptions creates a new Config instance with custom load options
func NewWithOptions(opts LoadOptions) *Config {
c := New()
c.options = opts
c.options = cloneLoadOptions(opts)
return c
}
@@ -83,17 +88,19 @@ func (c *Config) SetLoadOptions(opts LoadOptions) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.options = opts
c.options = cloneLoadOptions(opts)
// Recompute all current values based on new precedence
for path, item := range c.items {
item.currentValue = c.computeValue(item)
c.items[path] = item
}
c.invalidateCache()
}
// SetPrecedence updates source precedence with validation
func (c *Config) SetPrecedence(sources ...Source) error {
sources = slices.Clone(sources)
// Validate all required sources present
required := map[Source]bool{
SourceDefault: false,
@@ -167,10 +174,10 @@ func (c *Config) GetPrecedence() []Source {
// Use "auto" to detect based on file extension
func (c *Config) SetFileFormat(format string) error {
switch format {
case FormatTOML, FormatJSON, FormatYAML, FormatAuto:
case FormatTOML, FormatAuto:
// Valid formats
default:
return wrapError(ErrFileFormat, fmt.Errorf("unsupported file format %q, must be one of: toml, json, yaml, auto", format))
return wrapError(ErrFileFormat, fmt.Errorf("unsupported file format %q, must be one of: toml, auto", format))
}
c.mutex.Lock()
@@ -185,6 +192,7 @@ func (c *Config) SetSecurityOptions(opts SecurityOptions) {
c.mutex.Lock()
defer c.mutex.Unlock()
c.securityOpts = &opts
c.fileGeneration++
}
// Get retrieves a configuration value using the path and indicator if the path was registered
@@ -197,7 +205,7 @@ func (c *Config) Get(path string) (any, bool) {
return nil, false
}
return item.currentValue, true
return cloneOwned(item.currentValue), true
}
// GetSource retrieves a value from a specific source
@@ -211,7 +219,10 @@ func (c *Config) GetSource(path string, source Source) (any, bool) {
}
val, exists := item.values[source]
return val, exists
if source == SourceDefault {
return cloneOwned(item.defaultValue), true
}
return cloneOwned(val), exists
}
// Set updates a configuration value for the given path
@@ -219,14 +230,19 @@ func (c *Config) GetSource(path string, source Source) (any, bool) {
// By default, this is SourceCLI. Returns an error if the path is not registered
// To set a value in a specific source, use SetSource instead
func (c *Config) Set(path string, value any) error {
return c.SetSource(c.options.Sources[0], path, value)
c.mutex.Lock()
defer c.mutex.Unlock()
return c.setSourceLocked(c.options.Sources[0], path, value)
}
// SetSource sets a value for a specific source
func (c *Config) SetSource(source Source, path string, value any) error {
c.mutex.Lock()
defer c.mutex.Unlock()
return c.setSourceLocked(source, path, value)
}
func (c *Config) setSourceLocked(source Source, path string, value any) error {
item, registered := c.items[path]
if !registered {
return wrapError(ErrPathNotRegistered, fmt.Errorf("path %s is not registered", path))
@@ -240,7 +256,29 @@ func (c *Config) SetSource(source Source, path string, value any) error {
item.values = make(map[Source]any)
}
item.values[source] = value
if !validSource(source) {
return wrapError(ErrTypeMismatch, fmt.Errorf("invalid source %q", source))
}
if err := validateValue(item, value); err != nil {
return err
}
owned, err := copyValue(value)
if err != nil {
return err
}
value = owned
if source == SourceDefault {
if item.defaultValue != nil {
target := reflect.New(reflect.TypeOf(item.defaultValue))
if err := decodeConfig(value, target.Interface()); err != nil {
return err
}
value = target.Elem().Interface()
}
item.defaultValue = value
} else {
item.values[source] = value
}
item.currentValue = c.computeValue(item)
c.items[path] = item
@@ -271,7 +309,7 @@ func (c *Config) GetSources(path string) map[Source]any {
result := make(map[Source]any)
for source, value := range item.values {
result[source] = value
result[source] = cloneOwned(value)
}
return result
}
@@ -285,6 +323,8 @@ func (c *Config) Reset() {
c.fileData = make(map[string]any)
c.envData = make(map[string]any)
c.cliData = make(map[string]any)
c.unknownCLIKeys = nil
c.fileGeneration++
// Reset all items to default values
for path, item := range c.items {
@@ -305,10 +345,12 @@ func (c *Config) ResetSource(source Source) {
switch source {
case SourceFile:
c.fileData = make(map[string]any)
c.fileGeneration++
case SourceEnv:
c.envData = make(map[string]any)
case SourceCLI:
c.cliData = make(map[string]any)
c.unknownCLIKeys = nil
}
// Remove source values from all items
@@ -323,22 +365,24 @@ func (c *Config) ResetSource(source Source) {
// AsStruct returns the populated struct if in type-aware mode
func (c *Config) AsStruct() (any, error) {
if c.structCache == nil || c.structCache.target == nil {
c.mutex.RLock()
defer c.mutex.RUnlock()
if c.structCache == nil {
return nil, wrapError(ErrNotConfigured, fmt.Errorf("no target struct configured"))
}
c.structCache.mu.RLock()
currentVersion := c.version.Load()
needsUpdate := !c.structCache.populated || c.structCache.version != currentVersion
c.structCache.mu.RUnlock()
if needsUpdate {
if err := c.populateStruct(); err != nil {
cache := c.structCache
cache.mu.Lock()
defer cache.mu.Unlock()
version := c.version.Load()
if !cache.populated || cache.version != version {
next := reflect.New(cache.targetType)
if err := decodeConfig(navigateToPath(c.nestedLocked(""), cache.prefix), next.Interface()); err != nil {
return nil, err
}
cache.snapshot = next.Interface()
cache.version, cache.populated = version, true
}
return c.structCache.target, nil
return copyValue(cache.snapshot)
}
// UnknownCLIKeys returns CLI paths that matched no registered config path
@@ -352,6 +396,9 @@ func (c *Config) UnknownCLIKeys() []string {
func (c *Config) computeValue(item configItem) any {
// Check sources in precedence order
for _, source := range c.options.Sources {
if source == SourceDefault {
return item.defaultValue
}
if val, exists := item.values[source]; exists && val != nil {
return val
}
@@ -361,25 +408,6 @@ func (c *Config) computeValue(item configItem) any {
return item.defaultValue
}
// populateStruct updates the cached struct representation using unified unmarshal
func (c *Config) populateStruct() error {
c.structCache.mu.Lock()
defer c.structCache.mu.Unlock()
currentVersion := c.version.Load()
if c.structCache.populated && c.structCache.version == currentVersion {
return nil
}
if err := c.unmarshal("", c.structCache.target); err != nil {
return wrapError(ErrDecode, fmt.Errorf("failed to populate struct cache: %w", err))
}
c.structCache.version = currentVersion
c.structCache.populated = true
return nil
}
// invalidateCache override Set methods to invalidate cache
func (c *Config) invalidateCache() {
c.version.Add(1)