e4.0.0 Refactored, file watcher and improved builder, doc update

This commit is contained in:
2025-07-17 03:44:08 -04:00
parent 16dc829fd5
commit 2934ea9548
25 changed files with 3567 additions and 1828 deletions

161
README.md
View File

@ -1,11 +1,21 @@
# Config # Config
Thread-safe configuration management for Go with support for TOML files, environment variables, command-line arguments, and defaults with configurable precedence. Thread-safe configuration management for Go applications with support for multiple sources (files, environment variables, command-line arguments, defaults) and configurable precedence.
## Features
- **Multiple Sources**: Load configuration from defaults, files, environment variables, and CLI arguments
- **Configurable Precedence**: Control which sources override others
- **Type Safety**: Struct-based configuration with automatic validation
- **Thread-Safe**: Concurrent access with read-write locking
- **File Watching**: Automatic reloading on configuration changes
- **Source Tracking**: Know exactly where each value came from
- **Tag Support**: Use `toml`, `json`, or `yaml` struct tags
## Installation ## Installation
```bash ```bash
go get github.com/LixenWraith/config go get github.com/lixenwraith/config
``` ```
## Quick Start ## Quick Start
@ -15,7 +25,6 @@ package main
import ( import (
"log" "log"
"github.com/lixenwraith/config" "github.com/lixenwraith/config"
) )
@ -24,157 +33,33 @@ type AppConfig struct {
Host string `toml:"host"` Host string `toml:"host"`
Port int `toml:"port"` Port int `toml:"port"`
} `toml:"server"` } `toml:"server"`
Database struct {
URL string `toml:"url"`
MaxConns int `toml:"max_conns"`
} `toml:"database"`
Debug bool `toml:"debug"` Debug bool `toml:"debug"`
} }
func main() { func main() {
// Define defaults defaults := &AppConfig{}
defaults := AppConfig{}
defaults.Server.Host = "localhost" defaults.Server.Host = "localhost"
defaults.Server.Port = 8080 defaults.Server.Port = 8080
defaults.Database.URL = "postgres://localhost/myapp"
defaults.Database.MaxConns = 10
// Initialize with environment prefix and config file
cfg, err := config.Quick(defaults, "MYAPP_", "config.toml") cfg, err := config.Quick(defaults, "MYAPP_", "config.toml")
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Access values port, _ := cfg.Get("server.port")
host, _ := cfg.String("server.host") log.Printf("Server port: %d", port.(int64))
port, _ := cfg.Int64("server.port")
dbURL, _ := cfg.String("database.url")
debug, _ := cfg.Bool("debug")
log.Printf("Server: %s:%d, DB: %s, Debug: %v", host, port, dbURL, debug)
} }
``` ```
**config.toml:** ## Documentation
```toml
[server]
host = "production.example.com"
port = 9090
[database] - [Quick Start Guide](doc/quick-start.md) - Get up and running quickly
url = "postgres://prod-db/myapp" - [Builder Pattern](doc/builder.md) - Advanced configuration with the builder
max_conns = 50 - [Command Line](doc/cli.md) - CLI argument handling
- [Environment Variables](doc/env.md) - Environment variable configuration
debug = false - [Configuration Files](doc/file.md) - File loading and formats
``` - [Access Patterns](doc/access.md) - Getting and setting values
- [Live Reconfiguration](doc/reconfiguration.md) - File watching and updates
**Usage:**
```bash
# Override with environment variables
export MYAPP_SERVER_PORT=8443
export MYAPP_DEBUG=true
# Override with CLI arguments
./myapp --server.port=9999 --debug
```
## Key Features
- **Multiple Sources**: Defaults → File → Environment → CLI (configurable order)
- **Type Safety**: Automatic conversion with detailed error messages
- **Thread-Safe**: Concurrent reads with protected writes
- **Builder Pattern**: Fluent interface for advanced configuration
- **Source Tracking**: See which source provided each value
- **Zero Dependencies**: Only stdlib + minimal parsers
## Common Patterns
### Custom Precedence
```go
cfg, _ := config.NewBuilder().
WithDefaults(defaults).
WithSources(
config.SourceEnv, // Env vars highest priority
config.SourceFile,
config.SourceCLI,
config.SourceDefault,
).
Build()
```
### Environment Variable Mapping
```go
// Custom env var names
opts := config.LoadOptions{
EnvTransform: func(path string) string {
switch path {
case "server.port": return "PORT"
case "database.url": return "DATABASE_URL"
default: return ""
}
},
}
cfg.LoadWithOptions("config.toml", os.Args[1:], opts)
```
### Validation
```go
// Register and validate required fields
cfg.RegisterRequired("api.key", "")
cfg.RegisterRequired("database.url", "")
if err := cfg.Validate("api.key", "database.url"); err != nil {
log.Fatal("Missing required config: ", err)
}
```
### Source Inspection
```go
// See all sources for a value
sources := cfg.GetSources("server.port")
for source, value := range sources {
fmt.Printf("%s: %v\n", source, value)
}
// Get value from specific source
envPort, exists := cfg.GetSource("server.port", config.SourceEnv)
```
### Struct Scanning
```go
var serverConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
}
cfg.Scan("server", &serverConfig)
```
### Environment Whitelist
```go
// Only load specific env vars
cfg, _ := config.NewBuilder().
WithDefaults(defaults).
WithEnvPrefix("MYAPP_").
WithEnvWhitelist("api.key", "database.password").
Build()
```
## API Reference
### Core Methods
- `Quick(defaults, envPrefix, configFile)` - Quick initialization
- `Register(path, defaultValue)` - Register configuration path
- `Get/String/Int64/Bool/Float64(path)` - Type-safe accessors
- `Set(path, value)` - Update configuration
- `Validate(paths...)` - Ensure required values are set
### Advanced Methods
- `NewBuilder()` - Create custom configuration
- `GetSource(path, source)` - Get value from specific source
- `GetSources(path)` - Get all source values
- `Scan(basePath, target)` - Unmarshal into struct
- `Clone()` - Deep copy configuration
- `Debug()` - Show all values and sources
## License ## License

View File

@ -5,17 +5,16 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"reflect"
) )
// ValidatorFunc defines the signature for a function that can validate a Config instance. // Builder provides a fluent API for constructing a Config instance. It allows for
// It receives the fully loaded *Config object and should return an error if validation fails. // chaining configuration options before final build of the config object.
type ValidatorFunc func(c *Config) error
// Builder provides a fluent interface for building configurations
type Builder struct { type Builder struct {
cfg *Config cfg *Config
opts LoadOptions opts LoadOptions
defaults any defaults any
tagName string
prefix string prefix string
file string file string
args []string args []string
@ -23,6 +22,10 @@ type Builder struct {
validators []ValidatorFunc validators []ValidatorFunc
} }
// ValidatorFunc defines the signature for a function that can validate a Config instance.
// It receives the fully loaded *Config object and should return an error if validation fails.
type ValidatorFunc func(c *Config) error
// NewBuilder creates a new configuration builder // NewBuilder creates a new configuration builder
func NewBuilder() *Builder { func NewBuilder() *Builder {
return &Builder{ return &Builder{
@ -33,12 +36,80 @@ func NewBuilder() *Builder {
} }
} }
// Build creates the Config instance with all specified options
func (b *Builder) Build() (*Config, error) {
if b.err != nil {
return nil, b.err
}
// Use tagName if set, default to "toml"
tagName := b.tagName
if tagName == "" {
tagName = "toml"
}
// Register defaults if provided
if b.defaults != nil {
if err := b.cfg.RegisterStructWithTags(b.prefix, b.defaults, tagName); err != nil {
return nil, fmt.Errorf("failed to register defaults: %w", err)
}
}
// Register defaults if provided
if b.defaults != nil {
if err := b.cfg.RegisterStruct(b.prefix, b.defaults); err != nil {
return nil, fmt.Errorf("failed to register defaults: %w", err)
}
}
// Load configuration
loadErr := b.cfg.LoadWithOptions(b.file, b.args, b.opts)
if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) {
// Return on fatal load errors. ErrConfigNotFound is not fatal.
return nil, loadErr
}
// Run validators
for _, validator := range b.validators {
if err := validator(b.cfg); err != nil {
return nil, fmt.Errorf("configuration validation failed: %w", err)
}
}
// ErrConfigNotFound or nil
return b.cfg, loadErr
}
// MustBuild is like Build but panics on error
func (b *Builder) MustBuild() *Config {
cfg, err := b.Build()
if err != nil {
// Ignore ErrConfigNotFound for app to proceed with defaults/env vars
if !errors.Is(err, ErrConfigNotFound) {
panic(fmt.Sprintf("config build failed: %v", err))
}
}
return cfg
}
// WithDefaults sets the struct containing default values // WithDefaults sets the struct containing default values
func (b *Builder) WithDefaults(defaults any) *Builder { func (b *Builder) WithDefaults(defaults any) *Builder {
b.defaults = defaults b.defaults = defaults
return b return b
} }
// WithTagName sets the struct tag name to use for field mapping
// Supported values: "toml" (default), "json", "yaml"
func (b *Builder) WithTagName(tagName string) *Builder {
switch tagName {
case "toml", "json", "yaml":
b.tagName = tagName
default:
b.err = fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName)
}
return b
}
// WithPrefix sets the prefix for struct registration // WithPrefix sets the prefix for struct registration
func (b *Builder) WithPrefix(prefix string) *Builder { func (b *Builder) WithPrefix(prefix string) *Builder {
b.prefix = prefix b.prefix = prefix
@ -86,72 +157,43 @@ func (b *Builder) WithEnvWhitelist(paths ...string) *Builder {
return b return b
} }
// WithTarget enables type-aware mode for the builder
func (b *Builder) WithTarget(target any) *Builder {
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
b.err = fmt.Errorf("WithTarget requires non-nil pointer to struct, got %T", target)
return b
}
elem := rv.Elem()
if elem.Kind() != reflect.Struct {
b.err = fmt.Errorf("WithTarget requires pointer to struct, got pointer to %v", elem.Kind())
return b
}
// Initialize struct cache
if b.cfg.structCache == nil {
b.cfg.structCache = &structCache{
target: target,
targetType: elem.Type(),
}
}
// Register struct fields automatically
if b.defaults == nil {
b.defaults = target
}
return b
}
// WithValidator adds a validation function that runs at the end of the build process // WithValidator adds a validation function that runs at the end of the build process
// Multiple validators can be added and are executed in the order they are added // Multiple validators can be added and are executed in the order they are added
// Validation runs after all sources are loaded
// If any validator returns error, build fails without running subsequent validators
func (b *Builder) WithValidator(fn ValidatorFunc) *Builder { func (b *Builder) WithValidator(fn ValidatorFunc) *Builder {
if fn != nil { if fn != nil {
b.validators = append(b.validators, fn) b.validators = append(b.validators, fn)
} }
return b return b
}
// Build creates the Config instance with all specified options
func (b *Builder) Build() (*Config, error) {
if b.err != nil {
return nil, b.err
}
// Register defaults if provided
if b.defaults != nil {
if err := b.cfg.RegisterStruct(b.prefix, b.defaults); err != nil {
return nil, fmt.Errorf("failed to register defaults: %w", err)
}
}
// Load configuration
loadErr := b.cfg.LoadWithOptions(b.file, b.args, b.opts)
if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) {
// Return on fatal load errors. ErrConfigNotFound is not fatal.
return nil, loadErr
}
// Run validators
for _, validator := range b.validators {
if err := validator(b.cfg); err != nil {
return nil, fmt.Errorf("configuration validation failed: %w", err)
}
}
// ErrConfigNotFound or nil
return b.cfg, loadErr
}
// MustBuild is like Build but panics on error
func (b *Builder) MustBuild() *Config {
cfg, err := b.Build()
if err != nil {
// Ignore ErrConfigNotFound as it is not a fatal error for MustBuild.
// The application can proceed with defaults/env vars.
if !errors.Is(err, ErrConfigNotFound) {
panic(fmt.Sprintf("config build failed: %v", err))
}
}
return cfg
}
// BuildAndScan builds and unmarshals the final configuration into the provided target struct pointer
func (b *Builder) BuildAndScan(target any) error {
cfg, err := b.Build()
if err != nil && !errors.Is(err, ErrConfigNotFound) {
return err
}
// Use Scan to populate the target struct.
// The prefix used during registration is the base path for scanning.
if err := cfg.Scan(b.prefix, target); err != nil {
return fmt.Errorf("failed to scan final config into target: %w", err)
}
// ErrConfigNotFound or nil
return err
} }

View File

@ -1,339 +0,0 @@
// File: lixenwraith/config/builder_test.go
package config_test
import (
"errors"
"os"
"testing"
"github.com/lixenwraith/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuilder(t *testing.T) {
t.Run("Basic Builder", func(t *testing.T) {
type AppConfig struct {
Name string `toml:"name"`
Version string `toml:"version"`
Debug bool `toml:"debug"`
}
defaults := AppConfig{
Name: "testapp",
Version: "1.0.0",
Debug: false,
}
cfg, err := config.NewBuilder().
WithDefaults(defaults).
WithPrefix("app.").
Build()
require.NoError(t, err)
// Check registered paths
paths := cfg.GetRegisteredPaths("app.")
assert.Len(t, paths, 3)
// Check values
name, err := cfg.String("app.name")
require.NoError(t, err)
assert.Equal(t, "testapp", name)
})
t.Run("Builder with All Options", func(t *testing.T) {
os.Setenv("BUILDER_SERVER_PORT", "5555")
defer os.Unsetenv("BUILDER_SERVER_PORT")
type Config struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
API struct {
Key string `toml:"key"`
Timeout int `toml:"timeout"`
} `toml:"api"`
}
defaults := Config{}
defaults.Server.Host = "localhost"
defaults.Server.Port = 8080
defaults.API.Timeout = 30
cfg, err := config.NewBuilder().
WithDefaults(defaults).
WithEnvPrefix("BUILDER_").
WithArgs([]string{"--api.key=test-key"}).
WithSources(
config.SourceCLI,
config.SourceEnv,
config.SourceDefault,
).
WithEnvWhitelist("server.port", "api.key").
Build()
require.NoError(t, err)
// CLI should provide api.key
apiKey, err := cfg.String("api.key")
require.NoError(t, err)
assert.Equal(t, "test-key", apiKey)
// Env should provide server.port (whitelisted)
port, err := cfg.Int64("server.port")
require.NoError(t, err)
assert.Equal(t, int64(5555), port)
// Non-whitelisted env should not load
os.Setenv("BUILDER_API_TIMEOUT", "99")
defer os.Unsetenv("BUILDER_API_TIMEOUT")
cfg2, err := config.NewBuilder().
WithDefaults(defaults).
WithEnvPrefix("BUILDER_").
WithEnvWhitelist("server.port"). // api.timeout NOT whitelisted
Build()
require.NoError(t, err)
timeout, err := cfg2.Int64("api.timeout")
require.NoError(t, err)
assert.Equal(t, int64(30), timeout, "non-whitelisted env should not load")
})
t.Run("Builder Custom Transform", func(t *testing.T) {
os.Setenv("PORT", "3333")
os.Setenv("DB_URL", "postgres://custom")
defer func() {
os.Unsetenv("PORT")
os.Unsetenv("DB_URL")
}()
type Config struct {
Server struct {
Port int `toml:"port"`
} `toml:"server"`
Database struct {
URL string `toml:"url"`
} `toml:"database"`
}
cfg, err := config.NewBuilder().
WithDefaults(Config{}).
WithEnvTransform(func(path string) string {
switch path {
case "server.port":
return "PORT"
case "database.url":
return "DB_URL"
default:
return ""
}
}).
Build()
require.NoError(t, err)
port, err := cfg.Int64("server.port")
require.NoError(t, err)
assert.Equal(t, int64(3333), port)
dbURL, err := cfg.String("database.url")
require.NoError(t, err)
assert.Equal(t, "postgres://custom", dbURL)
})
t.Run("MustBuild Panic", func(t *testing.T) {
assert.Panics(t, func() {
config.NewBuilder().
WithDefaults("not a struct").
MustBuild()
})
})
t.Run("Builder with Validator", func(t *testing.T) {
type Config struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
MaxConns int `toml:"max_conns"`
}
defaults := Config{}
defaults.Server.Host = "localhost"
defaults.Server.Port = 8080
defaults.MaxConns = 100
// Validator that fails
failingValidator := func(c *config.Config) error {
port, err := c.Int64("server.port")
if err != nil {
return err
}
if port == 8080 {
return errors.New("port 8080 is not allowed")
}
return nil
}
// Validator that succeeds
passingValidator := func(c *config.Config) error {
host, err := c.String("server.host")
if err != nil {
return err
}
if host == "" {
return errors.New("host cannot be empty")
}
return nil
}
// Test case 1: Validator fails
_, err := config.NewBuilder().
WithDefaults(defaults).
WithValidator(failingValidator).
Build()
require.Error(t, err)
assert.Contains(t, err.Error(), "port 8080 is not allowed")
// Test case 2: Validator passes
cfg, err := config.NewBuilder().
WithDefaults(defaults).
WithArgs([]string{"--server.port=9000"}). // Change the port so it passes
WithValidator(failingValidator).
WithValidator(passingValidator).
Build()
require.NoError(t, err)
assert.NotNil(t, cfg)
port, _ := cfg.Int64("server.port")
assert.Equal(t, int64(9000), port)
// Test case 3: MustBuild panics on validation failure
assert.PanicsWithError(t, "configuration validation failed: port 8080 is not allowed", func() {
config.NewBuilder().
WithDefaults(defaults).
WithValidator(failingValidator).
MustBuild()
})
})
}
func TestQuickFunctions(t *testing.T) {
t.Run("Quick Success", func(t *testing.T) {
type Config struct {
App struct {
Name string `toml:"name"`
} `toml:"app"`
}
defaults := Config{}
defaults.App.Name = "quicktest"
cfg, err := config.Quick(defaults, "QUICK_", "")
require.NoError(t, err)
name, err := cfg.String("app.name")
require.NoError(t, err)
assert.Equal(t, "quicktest", name)
})
t.Run("QuickCustom", func(t *testing.T) {
opts := config.LoadOptions{
Sources: []config.Source{
config.SourceDefault,
config.SourceEnv,
},
EnvPrefix: "CUSTOM_",
}
cfg, err := config.QuickCustom(nil, opts, "")
require.NoError(t, err)
assert.NotNil(t, cfg)
})
t.Run("MustQuick Panic", func(t *testing.T) {
assert.Panics(t, func() {
config.MustQuick("invalid", "TEST_", "")
})
})
}
func TestConvenienceFunctions(t *testing.T) {
t.Run("Validate", func(t *testing.T) {
cfg := config.New()
cfg.Register("required1", "")
cfg.Register("required2", 0)
cfg.Register("optional", "has-default")
// Initial validation should fail
err := cfg.Validate("required1", "required2")
assert.Error(t, err, "expected validation to fail for empty values")
// Set required values
cfg.Set("required1", "value1")
cfg.Set("required2", 42)
// Now should pass
err = cfg.Validate("required1", "required2")
assert.NoError(t, err)
// Validate unregistered path
err = cfg.Validate("unregistered")
assert.Error(t, err, "expected error for unregistered path")
})
t.Run("Debug Output", func(t *testing.T) {
cfg := config.New()
cfg.Register("test.value", "default")
cfg.SetSource("test.value", config.SourceFile, "from-file")
cfg.SetSource("test.value", config.SourceEnv, "from-env")
debug := cfg.Debug()
// Should contain key information
assert.NotEmpty(t, debug)
// Should show sources - checking for actual source string values
assert.Contains(t, debug, "file")
assert.Contains(t, debug, "from-file")
assert.Contains(t, debug, "env")
assert.Contains(t, debug, "from-env")
})
t.Run("Clone", func(t *testing.T) {
cfg := config.New()
cfg.Register("original", "value")
cfg.Set("original", "modified")
// Clone configuration
clone := cfg.Clone()
// Clone should have same values
val, err := clone.String("original")
require.NoError(t, err)
assert.Equal(t, "modified", val)
// Modifying clone should not affect original
clone.Set("original", "clone-modified")
origVal, err := cfg.String("original")
require.NoError(t, err)
assert.Equal(t, "modified", origVal, "original should not be affected by clone modification")
})
t.Run("GetRegisteredPathsWithDefaults", func(t *testing.T) {
cfg := config.New()
cfg.Register("app.name", "myapp")
cfg.Register("app.version", "1.0.0")
cfg.Register("server.port", 8080)
// Get paths with defaults
paths := cfg.GetRegisteredPathsWithDefaults("app.")
assert.Len(t, paths, 2)
assert.Equal(t, "myapp", paths["app.name"])
assert.Equal(t, "1.0.0", paths["app.version"])
})
}

168
cmd/main.go Normal file
View File

@ -0,0 +1,168 @@
// FILE: example/watch_demo.go
package main
import (
"context"
"errors"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/lixenwraith/config"
)
// AppConfig represents our application configuration
type AppConfig struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
Database struct {
URL string `toml:"url"`
MaxConns int `toml:"max_conns"`
IdleTimeout time.Duration `toml:"idle_timeout"`
} `toml:"database"`
Features struct {
RateLimit bool `toml:"rate_limit"`
Caching bool `toml:"caching"`
} `toml:"features"`
}
func main() {
// Create configuration with defaults
defaults := &AppConfig{}
defaults.Server.Host = "localhost"
defaults.Server.Port = 8080
defaults.Database.MaxConns = 10
defaults.Database.IdleTimeout = 30 * time.Second
// Build configuration
cfg, err := config.NewBuilder().
WithDefaults(defaults).
WithEnvPrefix("MYAPP_").
WithFile("config.toml").
Build()
if err != nil && !errors.Is(err, config.ErrConfigNotFound) {
log.Fatal("Failed to load config:", err)
}
// Enable auto-update with custom options
watchOpts := config.WatchOptions{
PollInterval: 500 * time.Millisecond, // Check twice per second
Debounce: 200 * time.Millisecond, // Quick response
MaxWatchers: 10,
ReloadTimeout: 2 * time.Second,
VerifyPermissions: true, // SECURITY: Detect permission changes
}
cfg.AutoUpdateWithOptions(watchOpts)
defer cfg.StopAutoUpdate()
// Start watching for changes
changes := cfg.Watch()
// Context for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
// Log initial configuration
logConfig(cfg)
// Watch for changes
go func() {
for {
select {
case <-ctx.Done():
return
case path := <-changes:
handleConfigChange(cfg, path)
}
}
}()
// Main loop
log.Println("Watching for configuration changes. Edit config.toml to see updates.")
log.Println("Press Ctrl+C to exit.")
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-sigCh:
log.Println("Shutting down...")
return
case <-ticker.C:
// Periodic health check
var port int64
port, _ = cfg.Get("server.port")
log.Printf("Server still running on port %d", port)
}
}
}
func handleConfigChange(cfg *config.Config, path string) {
switch path {
case "__file_deleted__":
log.Println("⚠️ Config file was deleted!")
case "__permissions_changed__":
log.Println("⚠️ SECURITY: Config file permissions changed!")
case "__reload_error__":
log.Printf("❌ Failed to reload config: %s", path)
case "__reload_timeout__":
log.Println("⚠️ Config reload timed out")
default:
// Normal configuration change
value, _ := cfg.Get(path)
log.Printf("📝 Config changed: %s = %v", path, value)
// Handle specific changes
switch path {
case "server.port":
log.Println("Port changed - server restart required")
case "database.url":
log.Println("Database URL changed - reconnection required")
case "features.rate_limit":
if cfg.Bool("features.rate_limit") {
log.Println("Rate limiting enabled")
} else {
log.Println("Rate limiting disabled")
}
}
}
}
func logConfig(cfg *config.Config) {
log.Println("Current configuration:")
log.Printf(" Server: %s:%d", cfg.String("server.host"), cfg.Int("server.port"))
log.Printf(" Database: %s (max_conns=%d)",
cfg.String("database.url"),
cfg.Int("database.max_conns"))
log.Printf(" Features: rate_limit=%v, caching=%v",
cfg.Bool("features.rate_limit"),
cfg.Bool("features.caching"))
}
// Example config.toml file:
/*
[server]
host = "localhost"
port = 8080
[database]
url = "postgres://localhost/myapp"
max_conns = 25
idle_timeout = "30s"
[features]
rate_limit = true
caching = false
*/

View File

@ -1,301 +0,0 @@
// File: lixenwraith/cmd/test/main.go
// Test program for the config package
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"github.com/lixenwraith/config"
)
// AppConfig represents a simple application configuration
type AppConfig struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
Database struct {
URL string `toml:"url"`
MaxConns int `toml:"max_conns"`
} `toml:"database"`
API struct {
Key string `toml:"key" env:"CUSTOM_API_KEY"` // Custom env mapping
Timeout int `toml:"timeout"`
} `toml:"api"`
Debug bool `toml:"debug"`
LogFile string `toml:"log_file"`
}
func main() {
fmt.Println("=== Config Package Feature Test ===\n")
// Test directories
tempDir := os.TempDir()
configPath := filepath.Join(tempDir, "test_config.toml")
defer os.Remove(configPath)
// Set up test environment variables
setupEnvironment()
defer cleanupEnvironment()
// Run feature tests
testQuickStart()
testBuilder()
testSourceTracking()
testEnvironmentFeatures()
testValidation()
testUtilities()
fmt.Println("\n=== All Tests Complete ===")
}
func testQuickStart() {
fmt.Println("=== Test 1: Quick Start ===")
// Define defaults
defaults := AppConfig{}
defaults.Server.Host = "localhost"
defaults.Server.Port = 8080
defaults.Database.URL = "postgres://localhost/testdb"
defaults.Database.MaxConns = 10
defaults.Debug = false
// Quick initialization
cfg, err := config.Quick(defaults, "TEST_", "")
if err != nil {
log.Fatalf("Quick init failed: %v", err)
}
// Access values
host, _ := cfg.String("server.host")
port, _ := cfg.Int64("server.port")
fmt.Printf("Quick config - Host: %s, Port: %d\n", host, port)
// Verify env override (TEST_DEBUG=true was set)
debug, _ := cfg.Bool("debug")
fmt.Printf("Debug from env: %v (should be true)\n", debug)
}
func testBuilder() {
fmt.Println("\n=== Test 2: Builder Pattern ===")
defaults := AppConfig{}
defaults.Server.Port = 8080
defaults.API.Timeout = 30
// Custom precedence: Env > File > CLI > Default
cfg, err := config.NewBuilder().
WithDefaults(defaults).
WithEnvPrefix("APP_").
WithSources(
config.SourceEnv,
config.SourceFile,
config.SourceCLI,
config.SourceDefault,
).
WithArgs([]string{"--server.port=9999"}).
Build()
if err != nil {
log.Fatalf("Builder failed: %v", err)
}
// ENV should win over CLI due to custom precedence
port, _ := cfg.Int64("server.port")
fmt.Printf("Port with Env > CLI precedence: %d (should be 7070 from env)\n", port)
}
func testSourceTracking() {
fmt.Println("\n=== Test 3: Source Tracking ===")
cfg := config.New()
cfg.Register("test.value", "default")
// Set from multiple sources
cfg.SetSource("test.value", config.SourceFile, "from-file")
cfg.SetSource("test.value", config.SourceEnv, "from-env")
cfg.SetSource("test.value", config.SourceCLI, "from-cli")
// Show all sources
sources := cfg.GetSources("test.value")
fmt.Println("All sources for test.value:")
for source, value := range sources {
fmt.Printf(" %s: %v\n", source, value)
}
// Get from specific source
envVal, exists := cfg.GetSource("test.value", config.SourceEnv)
fmt.Printf("Value from env source: %v (exists: %v)\n", envVal, exists)
// Current value (default precedence)
current, _ := cfg.String("test.value")
fmt.Printf("Current value: %s (should be from-cli)\n", current)
}
func testEnvironmentFeatures() {
fmt.Println("\n=== Test 4: Environment Features ===")
cfg := config.New()
cfg.Register("api.key", "")
cfg.Register("api.secret", "")
cfg.Register("database.host", "localhost")
// Test 4a: Custom env transform
fmt.Println("\n4a. Custom Environment Transform:")
opts := config.LoadOptions{
Sources: []config.Source{config.SourceEnv, config.SourceDefault},
EnvTransform: func(path string) string {
switch path {
case "api.key":
return "CUSTOM_API_KEY"
case "database.host":
return "DB_HOST"
default:
return ""
}
},
}
cfg.LoadWithOptions("", nil, opts)
apiKey, _ := cfg.String("api.key")
fmt.Printf("API Key from CUSTOM_API_KEY: %s\n", apiKey)
// Test 4b: Discover environment variables
fmt.Println("\n4b. Environment Discovery:")
cfg2 := config.New()
cfg2.Register("server.port", 8080)
cfg2.Register("debug", false)
cfg2.Register("api.timeout", 30)
discovered := cfg2.DiscoverEnv("TEST_")
fmt.Println("Discovered env vars with TEST_ prefix:")
for path, envVar := range discovered {
fmt.Printf(" %s -> %s\n", path, envVar)
}
// Test 4c: Export configuration as env vars
fmt.Println("\n4c. Export as Environment:")
cfg2.Set("server.port", 3000)
cfg2.Set("debug", true)
exports := cfg2.ExportEnv("EXPORT_")
fmt.Println("Non-default values exported:")
for env, value := range exports {
fmt.Printf(" export %s=%s\n", env, value)
}
// Test 4d: RegisterWithEnv
fmt.Println("\n4d. RegisterWithEnv:")
cfg3 := config.New()
err := cfg3.RegisterWithEnv("special.value", "default", "SPECIAL_ENV_VAR")
if err != nil {
fmt.Printf("RegisterWithEnv error: %v\n", err)
}
special, _ := cfg3.String("special.value")
fmt.Printf("Value from SPECIAL_ENV_VAR: %s\n", special)
}
func testValidation() {
fmt.Println("\n=== Test 5: Validation ===")
cfg := config.New()
cfg.RegisterRequired("api.key", "")
cfg.RegisterRequired("database.url", "")
cfg.Register("optional.setting", "default")
// Should fail validation
err := cfg.Validate("api.key", "database.url")
if err != nil {
fmt.Printf("Validation failed as expected: %v\n", err)
}
// Set required values
cfg.Set("api.key", "secret-key")
cfg.Set("database.url", "postgres://localhost/db")
// Should pass validation
err = cfg.Validate("api.key", "database.url")
if err == nil {
fmt.Println("Validation passed after setting required values")
}
}
func testUtilities() {
fmt.Println("\n=== Test 6: Utility Features ===")
// Create config with some data
cfg := config.New()
cfg.Register("app.name", "testapp")
cfg.Register("app.version", "1.0.0")
cfg.Register("server.port", 8080)
cfg.SetSource("app.version", config.SourceFile, "1.1.0")
cfg.SetSource("server.port", config.SourceEnv, 9090)
// Test 6a: Debug output
fmt.Println("\n6a. Debug Output:")
debug := cfg.Debug()
fmt.Printf("Debug info (first 200 chars): %.200s...\n", debug)
// Test 6b: Clone
fmt.Println("\n6b. Clone Configuration:")
clone := cfg.Clone()
clone.Set("app.name", "cloned-app")
original, _ := cfg.String("app.name")
cloned, _ := clone.String("app.name")
fmt.Printf("Original app.name: %s, Cloned: %s\n", original, cloned)
// Test 6c: Reset source
fmt.Println("\n6c. Reset Sources:")
sources := cfg.GetSources("server.port")
fmt.Printf("Sources before reset: %v\n", sources)
cfg.ResetSource(config.SourceEnv)
sources = cfg.GetSources("server.port")
fmt.Printf("Sources after env reset: %v\n", sources)
// Test 6d: Save and load specific source
fmt.Println("\n6d. Save/Load Specific Source:")
tempFile := filepath.Join(os.TempDir(), "source_test.toml")
defer os.Remove(tempFile)
err := cfg.SaveSource(tempFile, config.SourceFile)
if err != nil {
fmt.Printf("SaveSource error: %v\n", err)
} else {
fmt.Println("Saved SourceFile values to temp file")
}
// Test 6e: GetRegisteredPaths
fmt.Println("\n6e. Registered Paths:")
paths := cfg.GetRegisteredPaths("app.")
fmt.Printf("Paths with 'app.' prefix: %v\n", paths)
pathsWithDefaults := cfg.GetRegisteredPathsWithDefaults("app.")
for path, def := range pathsWithDefaults {
fmt.Printf(" %s: %v\n", path, def)
}
}
func setupEnvironment() {
// Set test environment variables
os.Setenv("TEST_DEBUG", "true")
os.Setenv("TEST_SERVER_PORT", "6666")
os.Setenv("APP_SERVER_PORT", "7070")
os.Setenv("CUSTOM_API_KEY", "env-api-key")
os.Setenv("DB_HOST", "env-db-host")
os.Setenv("SPECIAL_ENV_VAR", "special-value")
}
func cleanupEnvironment() {
os.Unsetenv("TEST_DEBUG")
os.Unsetenv("TEST_SERVER_PORT")
os.Unsetenv("APP_SERVER_PORT")
os.Unsetenv("CUSTOM_API_KEY")
os.Unsetenv("DB_HOST")
os.Unsetenv("SPECIAL_ENV_VAR")
}

159
config.go
View File

@ -7,9 +7,14 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"reflect"
"sync" "sync"
"sync/atomic"
) )
// Max config item value size to prevent misuse
const MaxValueSize = 1024 * 1024 // 1MB
// Errors // Errors
var ( var (
// ErrConfigNotFound indicates the specified configuration file was not found. // ErrConfigNotFound indicates the specified configuration file was not found.
@ -19,65 +24,13 @@ var (
ErrCLIParse = errors.New("failed to parse command-line arguments") ErrCLIParse = errors.New("failed to parse command-line arguments")
// ErrEnvParse indicates that parsing environment variables failed. // ErrEnvParse indicates that parsing environment variables failed.
// TODO: use in loader:loadEnv or remove
ErrEnvParse = errors.New("failed to parse environment variables") ErrEnvParse = errors.New("failed to parse environment variables")
// ErrValueSize indicates a value larger than MaxValueSize
ErrValueSize = fmt.Errorf("value size exceeds maximum %d bytes", MaxValueSize)
) )
// Source represents a configuration source
type Source string
const (
SourceDefault Source = "default"
SourceFile Source = "file"
SourceEnv Source = "env"
SourceCLI Source = "cli"
)
// LoadMode defines how configuration sources are processed
type LoadMode int
const (
// LoadModeReplace completely replaces values (default behavior)
LoadModeReplace LoadMode = iota
// LoadModeMerge merges maps/structs instead of replacing
LoadModeMerge
)
// EnvTransformFunc converts a configuration path to an environment variable name
type EnvTransformFunc func(path string) string
// LoadOptions configures how configuration is loaded from multiple sources
type LoadOptions struct {
// Sources defines the precedence order (first = highest priority)
// Default: [SourceCLI, SourceEnv, SourceFile, SourceDefault]
Sources []Source
// EnvPrefix is prepended to environment variable names
// Example: "MYAPP_" transforms "server.port" to "MYAPP_SERVER_PORT"
EnvPrefix string
// EnvTransform customizes how paths map to environment variables
// If nil, uses default transformation (dots to underscores, uppercase)
EnvTransform EnvTransformFunc
// LoadMode determines how values are merged
LoadMode LoadMode
// EnvWhitelist limits which paths are checked for env vars (nil = all)
EnvWhitelist map[string]bool
// SkipValidation skips path validation during load
SkipValidation bool
}
// DefaultLoadOptions returns the standard load options
func DefaultLoadOptions() LoadOptions {
return LoadOptions{
Sources: []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault},
LoadMode: LoadModeReplace,
}
}
// configItem holds configuration values from different sources // configItem holds configuration values from different sources
type configItem struct { type configItem struct {
defaultValue any defaultValue any
@ -85,14 +38,31 @@ type configItem struct {
currentValue any // Computed value based on precedence currentValue any // Computed value based on precedence
} }
// Config manages application configuration loaded from multiple sources. // structCache manages the typed representation of configuration
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
mu sync.RWMutex
}
// 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()
type Config struct { type Config struct {
items map[string]configItem items map[string]configItem
mutex sync.RWMutex mutex sync.RWMutex
options LoadOptions // Current load options options LoadOptions // Current load options
fileData map[string]any // Cached file data fileData map[string]any // Cached file data
envData map[string]any // Cached env data envData map[string]any // Cached env data
cliData map[string]any // Cached CLI data cliData map[string]any // Cached CLI data
version atomic.Int64
structCache *structCache
// File watching support
watcher *watcher
configFilePath string // Track loaded file path
} }
// New creates and initializes a new Config instance. // New creates and initializes a new Config instance.
@ -142,9 +112,7 @@ func (c *Config) computeValue(path string, item configItem) any {
return item.defaultValue return item.defaultValue
} }
// Get retrieves a configuration value using the path. // Get retrieves a configuration value using the path and indicator if the path was registered
// It returns the current value based on configured precedence.
// The second return value indicates if the path was registered.
func (c *Config) Get(path string) (any, bool) { func (c *Config) Get(path string) (any, bool) {
c.mutex.RLock() c.mutex.RLock()
defer c.mutex.RUnlock() defer c.mutex.RUnlock()
@ -172,8 +140,9 @@ func (c *Config) GetSource(path string, source Source) (any, bool) {
} }
// Set updates a configuration value for the given path. // Set updates a configuration value for the given path.
// It sets the value in the highest priority source (typically CLI). // It sets the value in the highest priority source from the configured Sources.
// Returns an error if the path is not registered. // 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 { func (c *Config) Set(path string, value any) error {
return c.SetSource(path, c.options.Sources[0], value) return c.SetSource(path, c.options.Sources[0], value)
} }
@ -206,6 +175,7 @@ func (c *Config) SetSource(path string, source Source, value any) error {
c.cliData[path] = value c.cliData[path] = value
} }
c.invalidateCache() // Invalidate cache after changes
return nil return nil
} }
@ -242,6 +212,8 @@ func (c *Config) Reset() {
item.currentValue = item.defaultValue item.currentValue = item.defaultValue
c.items[path] = item c.items[path] = item
} }
c.invalidateCache() // Invalidate cache after changes
} }
// ResetSource clears all values from a specific source // ResetSource clears all values from a specific source
@ -265,4 +237,55 @@ func (c *Config) ResetSource(source Source) {
item.currentValue = c.computeValue(path, item) item.currentValue = c.computeValue(path, item)
c.items[path] = item c.items[path] = item
} }
c.invalidateCache() // Invalidate cache after changes
}
// Override Set methods to invalidate cache
func (c *Config) invalidateCache() {
c.version.Add(1)
}
// AsStruct returns the populated struct if in type-aware mode
func (c *Config) AsStruct() (any, error) {
if c.structCache == nil || c.structCache.target == nil {
return nil, 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 {
return nil, err
}
}
return c.structCache.target, nil
}
// Target populates the provided struct with current configuration
func (c *Config) Target(out any) error {
return c.Scan("", out)
}
// 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 fmt.Errorf("failed to populate struct cache: %w", err)
}
c.structCache.version = currentVersion
c.structCache.populated = true
return nil
} }

View File

@ -86,16 +86,22 @@ func (c *Config) GenerateFlags() *flag.FlagSet {
// BindFlags updates configuration from parsed flag.FlagSet // BindFlags updates configuration from parsed flag.FlagSet
func (c *Config) BindFlags(fs *flag.FlagSet) error { func (c *Config) BindFlags(fs *flag.FlagSet) error {
var errors []error var errors []error
needsInvalidation := false
fs.Visit(func(f *flag.Flag) { fs.Visit(func(f *flag.Flag) {
value := f.Value.String() value := f.Value.String()
parsed := parseValue(value) // Let mapstructure handle type conversion
if err := c.SetSource(f.Name, SourceCLI, value); err != nil {
if err := c.SetSource(f.Name, SourceCLI, parsed); err != nil {
errors = append(errors, fmt.Errorf("flag %s: %w", f.Name, err)) errors = append(errors, fmt.Errorf("flag %s: %w", f.Name, err))
} else {
needsInvalidation = true
} }
}) })
if needsInvalidation {
c.invalidateCache() // Batch invalidation after all flags
}
if len(errors) > 0 { if len(errors) > 0 {
return fmt.Errorf("failed to bind %d flags: %w", len(errors), errors[0]) return fmt.Errorf("failed to bind %d flags: %w", len(errors), errors[0])
} }
@ -141,16 +147,6 @@ func (c *Config) Validate(required ...string) error {
return nil return nil
} }
// Watch returns a channel that receives updates when configuration values change
// This is useful for hot-reloading configurations
// Note: This is a placeholder for future enhancement
func (c *Config) Watch() <-chan string {
// TODO: Implement file watching and config reload
ch := make(chan string)
close(ch) // Close immediately for now
return ch
}
// Debug returns a formatted string showing all configuration values and their sources // Debug returns a formatted string showing all configuration values and their sources
func (c *Config) Debug() string { func (c *Config) Debug() string {
c.mutex.RLock() c.mutex.RLock()
@ -228,4 +224,13 @@ func (c *Config) Clone() *Config {
} }
return clone return clone
}
// QuickTyped creates a fully configured Config with a typed target
func QuickTyped[T any](target *T, envPrefix, configFile string) (*Config, error) {
return NewBuilder().
WithTarget(target).
WithEnvPrefix(envPrefix).
WithFile(configFile).
Build()
} }

218
decode.go Normal file
View File

@ -0,0 +1,218 @@
// FILE: lixenwraith/config/decode.go
package config
import (
"fmt"
"net"
"net/url"
"reflect"
"strings"
"time"
"github.com/mitchellh/mapstructure"
)
// unmarshal is the single authoritative function for decoding configuration
// into target structures. All public decoding methods delegate to this.
func (c *Config) unmarshal(basePath string, source Source, target any) error {
// Validate target
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return fmt.Errorf("unmarshal target must be non-nil pointer, got %T", target)
}
c.mutex.RLock()
defer c.mutex.RUnlock()
// Build nested map based on source selection
nestedMap := make(map[string]any)
if source == "" {
// Use current merged state
for path, item := range c.items {
setNestedValue(nestedMap, path, item.currentValue)
}
} else {
// Use specific source
for path, item := range c.items {
if val, exists := item.values[source]; exists {
setNestedValue(nestedMap, path, val)
}
}
}
// Navigate to basePath section
sectionData := navigateToPath(nestedMap, basePath)
// Ensure we have a map to decode
sectionMap, ok := sectionData.(map[string]any)
if !ok {
if sectionData == nil {
sectionMap = make(map[string]any) // Empty section
} else {
return fmt.Errorf("path %q refers to non-map value (type %T)", basePath, sectionData)
}
}
// Create decoder with comprehensive hooks
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: target,
TagName: "toml",
WeaklyTypedInput: true,
DecodeHook: c.getDecodeHook(),
ZeroFields: true,
Metadata: nil,
})
if err != nil {
return fmt.Errorf("decoder creation failed: %w", err)
}
if err := decoder.Decode(sectionMap); err != nil {
return fmt.Errorf("decode failed for path %q: %w", basePath, err)
}
return nil
}
// getDecodeHook returns the composite decode hook for all type conversions
func (c *Config) getDecodeHook() mapstructure.DecodeHookFunc {
return mapstructure.ComposeDecodeHookFunc(
// Standard hooks
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
// Network types
stringToNetIPHookFunc(),
stringToNetIPNetHookFunc(),
stringToURLHookFunc(),
// Custom application hooks
c.customDecodeHook(),
)
}
// stringToNetIPHookFunc handles net.IP conversion
func stringToNetIPHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) {
if f.Kind() != reflect.String {
return data, nil
}
if t != reflect.TypeOf(net.IP{}) {
return data, nil
}
// SECURITY: Validate IP string format to prevent injection
str := data.(string)
if len(str) > 45 { // Max IPv6 length
return nil, fmt.Errorf("invalid IP length: %d", len(str))
}
ip := net.ParseIP(str)
if ip == nil {
return nil, fmt.Errorf("invalid IP address: %s", str)
}
return ip, nil
}
}
// stringToNetIPNetHookFunc handles net.IPNet conversion
func stringToNetIPNetHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) {
if f.Kind() != reflect.String {
return data, nil
}
if t != reflect.TypeOf(net.IPNet{}) && t != reflect.TypeOf(&net.IPNet{}) {
return data, nil
}
str := data.(string)
// SECURITY: Validate CIDR format
if len(str) > 49 { // Max IPv6 CIDR length
return nil, fmt.Errorf("invalid CIDR length: %d", len(str))
}
_, ipnet, err := net.ParseCIDR(str)
if err != nil {
return nil, fmt.Errorf("invalid CIDR: %w", err)
}
if t == reflect.TypeOf(&net.IPNet{}) {
return ipnet, nil
}
return *ipnet, nil
}
}
// stringToURLHookFunc handles url.URL conversion
func stringToURLHookFunc() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) {
if f.Kind() != reflect.String {
return data, nil
}
if t != reflect.TypeOf(url.URL{}) && t != reflect.TypeOf(&url.URL{}) {
return data, nil
}
str := data.(string)
// SECURITY: Validate URL length to prevent DoS
if len(str) > 2048 {
return nil, fmt.Errorf("URL too long: %d bytes", len(str))
}
u, err := url.Parse(str)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
if t == reflect.TypeOf(&url.URL{}) {
return u, nil
}
return *u, nil
}
}
// customDecodeHook allows for application-specific type conversions
func (c *Config) customDecodeHook() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data interface{}) (interface{}, error) {
// SECURITY: Add custom validation for application types here
// Example: Rate limit parsing, permission validation, etc.
// Pass through by default
return data, nil
}
}
// navigateToPath traverses nested map to reach the specified path
func navigateToPath(nested map[string]any, path string) any {
if path == "" {
return nested
}
path = strings.TrimSuffix(path, ".")
if path == "" {
return nested
}
segments := strings.Split(path, ".")
current := any(nested)
for _, segment := range segments {
currentMap, ok := current.(map[string]any)
if !ok {
return nil
}
value, exists := currentMap[segment]
if !exists {
return nil
}
current = value
}
return current
}

128
discovery.go Normal file
View File

@ -0,0 +1,128 @@
// FILE: lixenwraith/config/discovery.go
package config
import (
"os"
"path/filepath"
"strings"
)
// FileDiscoveryOptions configures automatic config file discovery
type FileDiscoveryOptions struct {
// Base name of config file (without extension)
Name string
// Extensions to try (in order)
Extensions []string
// Custom search paths (in addition to defaults)
Paths []string
// Environment variable to check for explicit path
EnvVar string
// CLI flag to check (e.g., "--config" or "-c")
CLIFlag string
// Whether to search in XDG config directories
UseXDG bool
// Whether to search in current directory
UseCurrentDir bool
}
// DefaultDiscoveryOptions returns sensible defaults
func DefaultDiscoveryOptions(appName string) FileDiscoveryOptions {
return FileDiscoveryOptions{
Name: appName,
Extensions: []string{".toml", ".conf", ".config"},
EnvVar: strings.ToUpper(appName) + "_CONFIG",
CLIFlag: "--config",
UseXDG: true,
UseCurrentDir: true,
}
}
// WithFileDiscovery enables automatic config file discovery
func (b *Builder) WithFileDiscovery(opts FileDiscoveryOptions) *Builder {
// Check CLI args first (highest priority)
if opts.CLIFlag != "" && len(b.args) > 0 {
for i, arg := range b.args {
if arg == opts.CLIFlag && i+1 < len(b.args) {
b.file = b.args[i+1]
return b
}
if strings.HasPrefix(arg, opts.CLIFlag+"=") {
b.file = strings.TrimPrefix(arg, opts.CLIFlag+"=")
return b
}
}
}
// Check environment variable
if opts.EnvVar != "" {
if path := os.Getenv(opts.EnvVar); path != "" {
b.file = path
return b
}
}
// Build search paths
var searchPaths []string
// Custom paths first
searchPaths = append(searchPaths, opts.Paths...)
// Current directory
if opts.UseCurrentDir {
if cwd, err := os.Getwd(); err == nil {
searchPaths = append(searchPaths, cwd)
}
}
// XDG paths
if opts.UseXDG {
searchPaths = append(searchPaths, getXDGConfigPaths(opts.Name)...)
}
// Search for config file
for _, dir := range searchPaths {
for _, ext := range opts.Extensions {
path := filepath.Join(dir, opts.Name+ext)
if _, err := os.Stat(path); err == nil {
b.file = path
return b
}
}
}
// No file found is not an error - app can run with defaults/env
return b
}
// getXDGConfigPaths returns XDG-compliant config search paths
func getXDGConfigPaths(appName string) []string {
var paths []string
// XDG_CONFIG_HOME
if xdgHome := os.Getenv("XDG_CONFIG_HOME"); xdgHome != "" {
paths = append(paths, filepath.Join(xdgHome, appName))
} else if home := os.Getenv("HOME"); home != "" {
paths = append(paths, filepath.Join(home, ".config", appName))
}
// XDG_CONFIG_DIRS
if xdgDirs := os.Getenv("XDG_CONFIG_DIRS"); xdgDirs != "" {
for _, dir := range filepath.SplitList(xdgDirs) {
paths = append(paths, filepath.Join(dir, appName))
}
} else {
// Default system paths
paths = append(paths,
filepath.Join("/etc/xdg", appName),
filepath.Join("/etc", appName),
)
}
return paths
}

60
doc.go
View File

@ -1,60 +0,0 @@
// File: lixenwraith/config/doc.go
// Package config provides thread-safe configuration management for Go applications
// with support for multiple sources: TOML files, environment variables, command-line
// arguments, and default values with configurable precedence.
//
// Features:
// - Multiple configuration sources with customizable precedence
// - Thread-safe operations using sync.RWMutex
// - Automatic type conversions for common Go types
// - Struct registration with tag support
// - Environment variable auto-discovery and mapping
// - Builder pattern for easy initialization
// - Source tracking to see where values originated
// - Configuration validation
// - Zero dependencies (only stdlib + toml parser + mapstructure)
//
// Quick Start:
//
// type Config struct {
// Server struct {
// Host string `toml:"host"`
// Port int `toml:"port"`
// } `toml:"server"`
// }
//
// defaults := Config{}
// defaults.Server.Host = "localhost"
// defaults.Server.Port = 8080
//
// cfg, err := config.Quick(defaults, "MYAPP_", "config.toml")
// if err != nil {
// log.Fatal(err)
// }
//
// host, _ := cfg.String("server.host")
// port, _ := cfg.Int64("server.port")
//
// Default Precedence (highest to lowest):
// 1. Command-line arguments (--server.port=9090)
// 2. Environment variables (MYAPP_SERVER_PORT=9090)
// 3. Configuration file (config.toml)
// 4. Default values
//
// Custom Precedence:
//
// cfg, err := config.NewBuilder().
// WithDefaults(defaults).
// WithSources(
// config.SourceEnv, // Environment the highest priority
// config.SourceCLI,
// config.SourceFile,
// config.SourceDefault,
// ).
// Build()
//
// Thread Safety:
// All operations are thread-safe. The package uses read-write mutexes to allow
// concurrent reads while protecting writes.
package config

376
doc/access.md Normal file
View File

@ -0,0 +1,376 @@
# Access Patterns
This guide covers all methods for getting and setting configuration values, type conversions, and working with structured data.
**Always Register First**: Register paths before setting values
**Use Type Assertions**: After struct registration, types are guaranteed
## Getting Values
### Basic Get
```go
// Get returns (value, exists)
value, exists := cfg.Get("server.port")
if !exists {
log.Fatal("server.port not configured")
}
// Type assertion (safe after registration)
port := value.(int64)
```
### Type-Safe Access
When using struct registration, types are guaranteed:
```go
type Config struct {
Server struct {
Port int64 `toml:"port"`
Host string `toml:"host"`
} `toml:"server"`
}
cfg.RegisterStruct("", &Config{})
// After registration, type assertions are safe
port, _ := cfg.Get("server.port")
portNum := port.(int64) // Won't panic - type is enforced
```
### Get from Specific Source
```go
// Get value from specific source
envPort, exists := cfg.GetSource("server.port", config.SourceEnv)
if exists {
log.Printf("Port from environment: %v", envPort)
}
// Check all sources
sources := cfg.GetSources("server.port")
for source, value := range sources {
log.Printf("%s: %v", source, value)
}
```
### Struct Scanning
```go
// Scan into struct
var serverConfig struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
TLS struct {
Enabled bool `toml:"enabled"`
Cert string `toml:"cert"`
} `toml:"tls"`
}
if err := cfg.Scan("server", &serverConfig); err != nil {
log.Fatal(err)
}
// Use structured data
log.Printf("Server: %s:%d", serverConfig.Host, serverConfig.Port)
```
### Target Population
```go
// Populate entire config struct
var config AppConfig
if err := cfg.Target(&config); err != nil {
log.Fatal(err)
}
// Or with builder pattern
var config AppConfig
cfg, _ := config.NewBuilder().
WithTarget(&config).
Build()
// Access directly
fmt.Println(config.Server.Port)
```
### Type-Aware Mode
```go
var conf AppConfig
cfg, _ := config.NewBuilder().
WithTarget(&conf).
Build()
// Get updated struct anytime
latest, err := cfg.AsStruct()
if err != nil {
log.Fatal(err)
}
appConfig := latest.(*AppConfig)
```
## Setting Values
### Basic Set
```go
// Set updates the highest priority source (default: CLI)
if err := cfg.Set("server.port", int64(9090)); err != nil {
log.Fatal(err) // Error if path not registered
}
```
### Set in Specific Source
```go
// Set value in specific source
cfg.SetSource("server.port", config.SourceEnv, "8080")
cfg.SetSource("debug", config.SourceCLI, true)
// File source typically set via LoadFile, but can be manual
cfg.SetSource("feature.enabled", config.SourceFile, true)
```
### Batch Updates
```go
// Multiple updates
updates := map[string]interface{}{
"server.port": int64(9090),
"server.host": "0.0.0.0",
"database.maxconns": int64(50),
}
for path, value := range updates {
if err := cfg.Set(path, value); err != nil {
log.Printf("Failed to set %s: %v", path, err)
}
}
```
## Type Conversions
The package uses mapstructure for flexible type conversion:
```go
// These all work for a string field
cfg.Set("name", "value") // Direct string
cfg.Set("name", 123) // Number → "123"
cfg.Set("name", true) // Boolean → "true"
// For int64 fields
cfg.Set("port", int64(8080)) // Direct
cfg.Set("port", "8080") // String → int64
cfg.Set("port", 8080.0) // Float → int64
cfg.Set("port", int(8080)) // int → int64
```
### Duration Handling
```go
type Config struct {
Timeout time.Duration `toml:"timeout"`
}
// All these work
cfg.Set("timeout", 30*time.Second) // Direct duration
cfg.Set("timeout", "30s") // String parsing
cfg.Set("timeout", "5m30s") // Complex duration
```
### Network Types
```go
type Config struct {
IP net.IP `toml:"ip"`
CIDR net.IPNet `toml:"cidr"`
URL url.URL `toml:"url"`
}
// Automatic parsing
cfg.Set("ip", "192.168.1.1")
cfg.Set("cidr", "10.0.0.0/8")
cfg.Set("url", "https://example.com:8080/path")
```
### Slice Handling
```go
type Config struct {
Tags []string `toml:"tags"`
Ports []int `toml:"ports"`
}
// Direct slice
cfg.Set("tags", []string{"prod", "stable"})
// Comma-separated string (from env/CLI)
cfg.Set("tags", "prod,stable,v2")
// Number arrays
cfg.Set("ports", []int{8080, 8081, 8082})
```
## Checking Configuration
### Path Registration
```go
// Check if path is registered
if _, exists := cfg.Get("server.port"); !exists {
log.Fatal("server.port not registered")
}
// Get all registered paths
paths := cfg.GetRegisteredPaths("server.")
for path := range paths {
log.Printf("Registered: %s", path)
}
// With default values
defaults := cfg.GetRegisteredPathsWithDefaults("")
for path, defaultVal := range defaults {
log.Printf("%s = %v (default)", path, defaultVal)
}
```
### Validation
```go
// Check required fields
if err := cfg.Validate("api.key", "database.url"); err != nil {
log.Fatal("Missing required config:", err)
}
// Custom validation
requiredPorts := []string{"server.port", "metrics.port"}
for _, path := range requiredPorts {
if val, exists := cfg.Get(path); exists {
if port := val.(int64); port < 1024 {
log.Fatalf("%s must be >= 1024", path)
}
}
}
```
### Source Inspection
```go
// Debug specific value
path := "server.port"
log.Printf("=== %s ===", path)
log.Printf("Current: %v", cfg.Get(path))
sources := cfg.GetSources(path)
for source, value := range sources {
log.Printf(" %s: %v", source, value)
}
```
## Advanced Patterns
### Dynamic Configuration
```go
// Change configuration at runtime
func updatePort(cfg *config.Config, port int64) error {
if port < 1 || port > 65535 {
return fmt.Errorf("invalid port: %d", port)
}
return cfg.Set("server.port", port)
}
```
### Configuration Facade
```go
type ConfigFacade struct {
cfg *config.Config
}
func (f *ConfigFacade) ServerPort() int64 {
val, _ := f.cfg.Get("server.port")
return val.(int64)
}
func (f *ConfigFacade) SetServerPort(port int64) error {
return f.cfg.Set("server.port", port)
}
func (f *ConfigFacade) DatabaseURL() string {
val, _ := f.cfg.Get("database.url")
return val.(string)
}
```
### Default Fallbacks
```go
// Helper for optional configuration
func getOrDefault(cfg *config.Config, path string, defaultVal interface{}) interface{} {
if val, exists := cfg.Get(path); exists {
return val
}
return defaultVal
}
// Usage
timeout := getOrDefault(cfg, "timeout", 30*time.Second).(time.Duration)
```
## Thread Safety
All access methods are thread-safe:
```go
// Safe concurrent access
var wg sync.WaitGroup
// Multiple readers
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
port, _ := cfg.Get("server.port")
log.Printf("Port: %v", port)
}()
}
// Concurrent writes are safe too
wg.Add(1)
go func() {
defer wg.Done()
cfg.Set("counter", atomic.AddInt64(&counter, 1))
}()
wg.Wait()
```
## Debugging
### View All Configuration
```go
// Debug output
fmt.Println(cfg.Debug())
// Dump as TOML
cfg.Dump() // Writes to stdout
```
### Clone for Testing
```go
// Create isolated copy for testing
testCfg := cfg.Clone()
testCfg.Set("server.port", int64(0)) // Random port for tests
```
## See Also
- [Live Reconfiguration](reconfiguration.md) - Reacting to changes
- [Builder Pattern](builder.md) - Type-aware configuration
- [Environment Variables](env.md) - Environment value access

295
doc/builder.md Normal file
View File

@ -0,0 +1,295 @@
# Builder Pattern
The builder pattern provides fine-grained control over configuration initialization and loading behavior.
## Basic Builder Usage
```go
cfg, err := config.NewBuilder().
WithDefaults(defaultStruct).
WithEnvPrefix("MYAPP_").
WithFile("config.toml").
Build()
```
## Builder Methods
### WithDefaults
Register a struct containing default values:
```go
type Config struct {
Host string `toml:"host"`
Port int `toml:"port"`
}
defaults := &Config{
Host: "localhost",
Port: 8080,
}
cfg, _ := config.NewBuilder().
WithDefaults(defaults).
Build()
```
### WithTarget
Enable type-aware mode with automatic struct population:
```go
var appConfig Config
cfg, _ := config.NewBuilder().
WithTarget(&appConfig). // Registers struct and enables AsStruct()
WithFile("config.toml").
Build()
// Access populated struct
populated, _ := cfg.AsStruct()
config := populated.(*Config)
```
### WithTagName
Use different struct tags for field mapping:
```go
type Config struct {
Server struct {
Host string `json:"host"` // Using JSON tags
Port int `json:"port"`
} `json:"server"`
}
cfg, _ := config.NewBuilder().
WithDefaults(&Config{}).
WithTagName("json"). // Use json tags instead of toml
Build()
```
Supported tag names: `toml` (default), `json`, `yaml`
### WithPrefix
Add a prefix to all registered paths:
```go
cfg, _ := config.NewBuilder().
WithDefaults(serverConfig).
WithPrefix("server"). // All paths prefixed with "server."
Build()
// Access as "server.host" instead of just "host"
host, _ := cfg.Get("server.host")
```
### WithEnvPrefix
Set environment variable prefix:
```go
cfg, err := config.NewBuilder().
WithEnvPrefix("MYAPP_").
Build()
// Reads from MYAPP_SERVER_PORT for "server.port"
```
### WithSources
Configure source precedence order:
```go
// Environment variables take highest priority
cfg, _ := config.NewBuilder().
WithSources(
config.SourceEnv,
config.SourceFile,
config.SourceCLI,
config.SourceDefault,
).
Build()
```
### WithEnvTransform
Custom environment variable name mapping:
```go
cfg, _ := config.NewBuilder().
WithEnvTransform(func(path string) string {
// Custom mapping logic
switch path {
case "server.port":
return "PORT" // Use $PORT instead of $MYAPP_SERVER_PORT
case "database.url":
return "DATABASE_URL"
default:
// Default transformation
return "MYAPP_" + strings.ToUpper(
strings.ReplaceAll(path, ".", "_"),
)
}
}).
Build()
```
### WithEnvWhitelist
Limit which configuration paths check environment variables:
```go
cfg, _ := config.NewBuilder().
WithEnvWhitelist(
"server.port",
"database.url",
"api.key",
). // Only these paths read from env
Build()
```
### WithValidator
Add validation functions that run after loading:
```go
cfg, _ := config.NewBuilder().
WithDefaults(defaults).
WithValidator(func(c *config.Config) error {
// Validate port range
port, _ := c.Get("server.port")
if p := port.(int64); p < 1024 || p > 65535 {
return fmt.Errorf("port must be between 1024-65535")
}
return nil
}).
WithValidator(func(c *config.Config) error {
// Validate required fields
return c.Validate("api.key", "database.url")
}).
Build()
```
### WithFile
Set configuration file path:
```go
cfg, _ := config.NewBuilder().
WithFile("/etc/myapp/config.toml").
Build()
```
### WithArgs
Override command-line arguments (default is os.Args[1:]):
```go
cfg, _ := config.NewBuilder().
WithArgs([]string{"--debug", "--server.port=9090"}).
Build()
```
### WithFileDiscovery
Enable automatic configuration file discovery:
```go
cfg, _ := config.NewBuilder().
WithFileDiscovery(config.FileDiscoveryOptions{
Name: "myapp",
Extensions: []string{".toml", ".conf"},
EnvVar: "MYAPP_CONFIG",
CLIFlag: "--config",
UseXDG: true,
}).
Build()
```
This searches for configuration files in:
1. Path specified by `--config` flag
2. Path in `$MYAPP_CONFIG` environment variable
3. Current directory
4. XDG config directories (`~/.config/myapp/`, `/etc/myapp/`)
## Advanced Patterns
### Type-Safe Configuration Access
```go
type AppConfig struct {
Server ServerConfig `toml:"server"`
DB DBConfig `toml:"database"`
}
var conf AppConfig
cfg, _ := config.NewBuilder().
WithTarget(&conf).
WithFile("config.toml").
Build()
// Direct struct access after building
fmt.Printf("Port: %d\n", conf.Server.Port)
// Or get updated struct anytime
latest, _ := cfg.AsStruct()
appConf := latest.(*AppConfig)
```
### Multi-Stage Validation
```go
cfg, err := config.NewBuilder().
WithDefaults(defaults).
// Stage 1: Validate structure
WithValidator(validateStructure).
// Stage 2: Validate values
WithValidator(validateRanges).
// Stage 3: Validate relationships
WithValidator(validateRelationships).
Build()
func validateStructure(c *config.Config) error {
required := []string{"server.host", "server.port", "database.url"}
return c.Validate(required...)
}
func validateRanges(c *config.Config) error {
port, _ := c.Get("server.port")
if p := port.(int64); p < 1 || p > 65535 {
return fmt.Errorf("invalid port: %d", p)
}
return nil
}
func validateRelationships(c *config.Config) error {
// Validate that related values make sense together
// e.g., if SSL is enabled, ensure cert paths are set
return nil
}
```
### Error Handling
The builder accumulates errors and returns them on `Build()`:
```go
cfg, err := config.NewBuilder().
WithTarget(nil). // Error: nil target
WithTagName("invalid"). // Error: unsupported tag
Build()
if err != nil {
// err contains first error encountered
}
```
For panic on error use `MustBuild()`
## See Also
- [Environment Variables](env.md) - Environment configuration details
- [Live Reconfiguration](reconfiguration.md) - File watching with builder

193
doc/cli.md Normal file
View File

@ -0,0 +1,193 @@
# Command Line Arguments
The config package supports command-line argument parsing with flexible formats and automatic type conversion.
## Argument Formats
### Key-Value Pairs
```bash
# Space-separated
./myapp --server.port 8080 --database.url "postgres://localhost/db"
# Equals-separated
./myapp --server.port=8080 --database.url=postgres://localhost/db
# Mixed formats
./myapp --server.port 8080 --debug=true
```
### Boolean Flags
```bash
# Boolean flags don't require a value (assumed true)
./myapp --debug --verbose
# Explicit boolean values
./myapp --debug=true --verbose=false
```
### Nested Paths
Use dot notation for nested configuration:
```bash
./myapp --server.host=0.0.0.0 --server.port=9090 --server.tls.enabled=true
```
## Type Conversion
Command-line values are automatically converted to match registered types:
```go
type Config struct {
Port int64 `toml:"port"`
Timeout time.Duration `toml:"timeout"`
Ratio float64 `toml:"ratio"`
Enabled bool `toml:"enabled"`
Tags []string `toml:"tags"`
}
// All these are parsed correctly:
// --port=8080 → int64(8080)
// --timeout=30s → time.Duration(30 * time.Second)
// --ratio=0.95 → float64(0.95)
// --enabled=true → bool(true)
// --tags=prod,stable → []string{"prod", "stable"}
```
## Integration with flag Package
### Generate flag.FlagSet
```go
// Generate flags from registered configuration
fs := cfg.GenerateFlags()
// Parse command line
if err := fs.Parse(os.Args[1:]); err != nil {
log.Fatal(err)
}
// Apply parsed flags to configuration
if err := cfg.BindFlags(fs); err != nil {
log.Fatal(err)
}
```
### Custom Flag Registration
```go
fs := flag.NewFlagSet("myapp", flag.ContinueOnError)
// Add custom flags
verbose := fs.Bool("v", false, "verbose output")
configFile := fs.String("config", "config.toml", "config file path")
// Parse
fs.Parse(os.Args[1:])
// Use custom flags
if *verbose {
log.SetLevel(log.DebugLevel)
}
// Load config with custom file path
cfg, _ := config.NewBuilder().
WithFile(*configFile).
Build()
// Bind remaining flags
cfg.BindFlags(fs)
```
## Precedence and Overrides
Command-line arguments have the highest precedence by default:
```go
// Default precedence: CLI > Env > File > Default
cfg, _ := config.Quick(defaults, "APP_", "config.toml")
// Even if config.toml sets port=8080 and APP_PORT=9090,
// --port=7070 will win
```
Change precedence if needed:
```go
cfg, _ := config.NewBuilder().
WithSources(
config.SourceEnv, // Env highest
config.SourceCLI, // Then CLI
config.SourceFile, // Then file
config.SourceDefault, // Finally defaults
).
Build()
```
## Argument Parsing Details
### Validation
- Paths must use valid identifiers (letters, numbers, underscore, dash)
- No leading/trailing dots in paths
- Empty segments not allowed (no `..` in paths)
### Special Cases
```bash
# Double dash stops flag parsing
./myapp --port=8080 -- --not-a-flag
# Single dash flags are ignored (not GNU-style)
./myapp -p 8080 # Ignored, use --port
# Quoted values preserve spaces
./myapp --message="Hello World" --name='John Doe'
# Escape quotes in values
./myapp --json="{\"key\": \"value\"}"
```
### Value Parsing Rules
1. **Booleans**: `true`, `false` (case-sensitive)
2. **Numbers**: Standard decimal notation
3. **Strings**: Quoted or unquoted (quotes removed if present)
4. **Lists**: Comma-separated (when target type is slice)
## Override Arguments
```go
// Parse custom arguments instead of os.Args
customArgs := []string{"--debug", "--port=9090"}
cfg, _ := config.NewBuilder().
WithArgs(customArgs).
Build()
```
## Error Handling
CLI parsing errors are returned from `Build()` or `LoadCLI()`:
```go
cfg, err := config.NewBuilder().
WithDefaults(&Config{}).
Build()
if err != nil {
switch {
case errors.Is(err, config.ErrCLIParse):
log.Fatal("Invalid command line arguments:", err)
default:
log.Fatal("Configuration error:", err)
}
}
```
## See Also
- [Environment Variables](env.md) - Environment variable handling
- [Access Patterns](access.md) - Retrieving parsed values

195
doc/env.md Normal file
View File

@ -0,0 +1,195 @@
# Environment Variables
The config package provides flexible environment variable support with automatic name transformation, custom mappings, and whitelist capabilities.
## Basic Usage
Environment variables are automatically mapped from configuration paths:
```go
cfg, _ := config.Quick(defaults, "MYAPP_", "config.toml")
// These environment variables are automatically loaded:
// MYAPP_SERVER_PORT → server.port
// MYAPP_DATABASE_URL → database.url
// MYAPP_LOG_LEVEL → log.level
// MYAPP_FEATURES_ENABLED → features.enabled
```
## Name Transformation
### Default Transformation
By default, paths are transformed as follows:
1. Dots (`.`) become underscores (`_`)
2. Converted to uppercase
3. Prefix is prepended
```go
// Path transformations:
// server.port → MYAPP_SERVER_PORT
// database.url → MYAPP_DATABASE_URL
// tls.cert.path → MYAPP_TLS_CERT_PATH
// maxRetries → MYAPP_MAXRETRIES
```
### Custom Transformation
Define custom environment variable mappings:
```go
cfg, _ := config.NewBuilder().
WithEnvTransform(func(path string) string {
switch path {
case "server.port":
return "PORT" // Use $PORT directly
case "database.url":
return "DATABASE_URL"
case "api.key":
return "API_KEY"
default:
// Fallback to default transformation
return "MYAPP_" + strings.ToUpper(
strings.ReplaceAll(path, ".", "_"),
)
}
}).
Build()
```
### No Transformation
Return empty string to skip environment lookup:
```go
cfg, _ := config.NewBuilder().
WithEnvTransform(func(path string) string {
// Only allow specific env vars
allowed := map[string]string{
"port": "PORT",
"database": "DATABASE_URL",
}
return allowed[path] // Empty string if not in map
}).
Build()
```
## Explicit Environment Variable Mapping
Use the `env` struct tag for explicit mappings:
```go
type Config struct {
Port int `toml:"port" env:"PORT"`
Database string `toml:"database" env:"DATABASE_URL"`
APIKey string `toml:"api_key" env:"API_KEY"`
}
// These use the explicit env tag names, ignoring prefix
cfg, _ := config.NewBuilder().
WithDefaults(&Config{}).
WithEnvPrefix("MYAPP_"). // Not used for tagged fields
Build()
```
Or register with explicit environment variable:
```go
cfg.RegisterWithEnv("server.port", 8080, "PORT")
cfg.RegisterWithEnv("database.url", "localhost", "DATABASE_URL")
```
## Environment Variable Whitelist
Limit which paths can be set via environment:
```go
cfg, _ := config.NewBuilder().
WithEnvWhitelist(
"server.port",
"database.url",
"api.key",
"log.level",
). // Only these paths read from environment
Build()
```
## Type Conversion
Environment variables (strings) are automatically converted to the registered type:
```bash
# Booleans
export MYAPP_DEBUG=true
export MYAPP_VERBOSE=false
# Numbers
export MYAPP_PORT=8080
export MYAPP_TIMEOUT=30
export MYAPP_RATIO=0.95
# Durations
export MYAPP_TIMEOUT=30s
export MYAPP_INTERVAL=5m
# Lists (comma-separated)
export MYAPP_TAGS=prod,stable,v2
```
## Manual Environment Loading
Load environment variables at any time:
```go
cfg := config.New()
cfg.RegisterStruct("", &Config{})
// Load with prefix
if err := cfg.LoadEnv("MYAPP_"); err != nil {
log.Fatal(err)
}
// Or use existing options
if err := cfg.LoadWithOptions("", nil, config.LoadOptions{
EnvPrefix: "MYAPP_",
Sources: []config.Source{config.SourceEnv},
}); err != nil {
log.Fatal(err)
}
```
## Discovering Environment Variables
Find which environment variables are set:
```go
// Discover all env vars matching registered paths
discovered := cfg.DiscoverEnv("MYAPP_")
for path, envVar := range discovered {
log.Printf("%s is set via %s", path, envVar)
}
```
## Precedence Examples
Default precedence: CLI > Env > File > Default
Custom precedence (Env > File > CLI > Default):
```go
cfg, _ := config.NewBuilder().
WithSources(
config.SourceEnv,
config.SourceFile,
config.SourceCLI,
config.SourceDefault,
).
Build()
```
## See Also
- [Command Line](cli.md) - CLI argument handling
- [File Configuration](file.md) - Configuration file formats
- [Access Patterns](access.md) - Retrieving values

310
doc/file.md Normal file
View File

@ -0,0 +1,310 @@
# Configuration Files
The config package supports TOML configuration files with automatic loading, discovery, and atomic saving.
## TOML Format
TOML (Tom's Obvious, Minimal Language) is the supported configuration format:
```toml
# Basic values
host = "localhost"
port = 8080
debug = false
# Nested sections
[server]
host = "0.0.0.0"
port = 9090
timeout = "30s"
[database]
url = "postgres://localhost/mydb"
max_conns = 25
timeout = "5s"
# Arrays
[features]
enabled = ["auth", "api", "metrics"]
# Inline tables
tls = { enabled = true, cert = "/path/to/cert", key = "/path/to/key" }
```
## Loading Configuration Files
### Basic Loading
```go
cfg := config.New()
cfg.RegisterStruct("", &Config{})
if err := cfg.LoadFile("config.toml"); err != nil {
if errors.Is(err, config.ErrConfigNotFound) {
log.Println("Config file not found, using defaults")
} else {
log.Fatal("Failed to load config:", err)
}
}
```
### With Builder
```go
cfg, err := config.NewBuilder().
WithDefaults(&Config{}).
WithFile("/etc/myapp/config.toml").
Build()
```
### Multiple File Attempts
```go
// Try multiple locations
locations := []string{
"./config.toml",
"~/.config/myapp/config.toml",
"/etc/myapp/config.toml",
}
var cfg *config.Config
var err error
for _, path := range locations {
cfg, err = config.NewBuilder().
WithDefaults(&Config{}).
WithFile(path).
Build()
if err == nil || !errors.Is(err, config.ErrConfigNotFound) {
break
}
}
```
## Automatic File Discovery
Use file discovery to find configuration automatically:
```go
cfg, _ := config.NewBuilder().
WithDefaults(&Config{}).
WithFileDiscovery(config.FileDiscoveryOptions{
Name: "myapp",
Extensions: []string{".toml", ".conf"},
EnvVar: "MYAPP_CONFIG",
CLIFlag: "--config",
UseXDG: true,
UseCurrentDir: true,
Paths: []string{"/opt/myapp"},
}).
Build()
```
Search order:
1. CLI flag: `--config=/path/to/config.toml`
2. Environment variable: `$MYAPP_CONFIG`
3. Current directory: `./myapp.toml`, `./myapp.conf`
4. XDG config: `~/.config/myapp/myapp.toml`
5. System paths: `/etc/myapp/myapp.toml`
6. Custom paths: `/opt/myapp/myapp.toml`
## Saving Configuration
### Save Current State
```go
// Save all current values atomically
if err := cfg.Save("config.toml"); err != nil {
log.Fatal("Failed to save config:", err)
}
```
The save operation is atomic - it writes to a temporary file then renames it.
### Save Specific Source
```go
// Save only values from environment variables
if err := cfg.SaveSource("env-config.toml", config.SourceEnv); err != nil {
log.Fatal(err)
}
// Save only file-loaded values
if err := cfg.SaveSource("file-only.toml", config.SourceFile); err != nil {
log.Fatal(err)
}
```
### Generate Default Configuration
```go
// Create a default config file
defaults := &Config{}
// ... set default values ...
cfg, _ := config.NewBuilder().
WithDefaults(defaults).
Build()
// Save defaults as config template
if err := cfg.SaveSource("config.toml.example", config.SourceDefault); err != nil {
log.Fatal(err)
}
```
## File Structure Mapping
TOML structure maps directly to dot-notation paths:
```toml
# Maps to "debug"
debug = true
[server]
# Maps to "server.host"
host = "localhost"
# Maps to "server.port"
port = 8080
[server.tls]
# Maps to "server.tls.enabled"
enabled = true
# Maps to "server.tls.cert"
cert = "/path/to/cert"
[[users]]
# Array elements: "users.0.name", "users.0.role"
name = "admin"
role = "administrator"
[[users]]
# Array elements: "users.1.name", "users.1.role"
name = "user"
role = "standard"
```
## Type Handling
TOML types map to Go types:
```toml
# Strings
name = "myapp"
multiline = """
Line one
Line two
"""
# Numbers
port = 8080 # int64
timeout = 30 # int64
ratio = 0.95 # float64
max_size = 1_000_000 # int64 (underscores allowed)
# Booleans
enabled = true
debug = false
# Dates/Times (RFC 3339)
created_at = 2024-01-15T09:30:00Z
expires = 2024-12-31
# Arrays
ports = [8080, 8081, 8082]
tags = ["production", "stable"]
# Tables (objects)
[database]
host = "localhost"
port = 5432
# Array of tables
[[servers]]
name = "web1"
host = "10.0.0.1"
[[servers]]
name = "web2"
host = "10.0.0.2"
```
## Error Handling
File loading can produce several error types:
```go
err := cfg.LoadFile("config.toml")
if err != nil {
switch {
case errors.Is(err, config.ErrConfigNotFound):
// File doesn't exist - often not fatal
log.Println("No config file, using defaults")
case strings.Contains(err.Error(), "failed to parse TOML"):
// TOML syntax error
log.Fatal("Invalid TOML syntax:", err)
case strings.Contains(err.Error(), "failed to read"):
// Permission or I/O error
log.Fatal("Cannot read config file:", err)
default:
log.Fatal("Config error:", err)
}
}
```
## Security Considerations
### File Permissions
```go
// After saving, verify permissions
info, err := os.Stat("config.toml")
if err == nil {
mode := info.Mode()
if mode&0077 != 0 {
log.Warn("Config file is world/group readable")
// Fix permissions
os.Chmod("config.toml", 0600)
}
}
```
### Size Limits
Files and values have size limits:
- Maximum file size: ~10MB (10 * MaxValueSize)
- Maximum value size: 1MB
## Partial Loading
Load only specific sections:
```go
var serverCfg ServerConfig
if err := cfg.Scan("server", &serverCfg); err != nil {
log.Fatal(err)
}
var dbCfg DatabaseConfig
if err := cfg.Scan("database", &dbCfg); err != nil {
log.Fatal(err)
}
```
## Best Practices
1. **Use Example Files**: Generate `.example` files with defaults
2. **Check Permissions**: Ensure config files aren't world-readable
3. **Validate After Load**: Add validators to check loaded values
4. **Handle Missing Files**: Missing config files often aren't fatal
5. **Use Atomic Saves**: The built-in Save method is atomic
6. **Document Structure**: Comment your TOML files thoroughly
## See Also
- [Live Reconfiguration](reconfiguration.md) - Automatic file reloading
- [Builder Pattern](builder.md) - File discovery options
- [Access Patterns](access.md) - Working with loaded values

176
doc/quick-start.md Normal file
View File

@ -0,0 +1,176 @@
# Quick Start Guide
This guide gets you up and running with the config package in minutes.
## Basic Usage
The simplest way to use the config package is with the `Quick` function:
```go
package main
import (
"log"
"github.com/lixenwraith/config"
)
// Define your configuration structure
type Config struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
Database struct {
URL string `toml:"url"`
MaxConns int `toml:"max_conns"`
} `toml:"database"`
Debug bool `toml:"debug"`
}
func main() {
// Create defaults
defaults := &Config{}
defaults.Server.Host = "localhost"
defaults.Server.Port = 8080
defaults.Database.URL = "postgres://localhost/mydb"
defaults.Database.MaxConns = 10
defaults.Debug = false
// Initialize configuration
cfg, err := config.Quick(
defaults, // Default values from struct
"MYAPP_", // Environment variable prefix
"config.toml", // Configuration file path
)
if err != nil {
log.Fatal(err)
}
// Access values
port, _ := cfg.Get("server.port")
dbURL, _ := cfg.Get("database.url")
log.Printf("Server running on port %d", port.(int64))
log.Printf("Database URL: %s", dbURL.(string))
}
```
## Configuration Sources
The package loads configuration from multiple sources in this default order (highest to lowest priority):
1. **Command-line arguments** - Override everything
2. **Environment variables** - Override file and defaults
3. **Configuration file** - Override defaults
4. **Default values** - Base configuration
### Command-Line Arguments
```bash
./myapp --server.port=9090 --debug
```
### Environment Variables
```bash
export MYAPP_SERVER_PORT=9090
export MYAPP_DATABASE_URL="postgres://prod/mydb"
export MYAPP_DEBUG=true
```
### Configuration File (config.toml)
```toml
[server]
host = "0.0.0.0"
port = 8080
[database]
url = "postgres://localhost/mydb"
max_conns = 25
debug = false
```
## Type Safety
The package uses struct tags to ensure type safety. When you register a struct, the types are enforced:
```go
// This struct defines the expected types
type Config struct {
Port int64 `toml:"port"` // Must be a number
Host string `toml:"host"` // Must be a string
Debug bool `toml:"debug"` // Must be a boolean
}
// Type assertions are safe after registration
port, _ := cfg.Get("port")
portNum := port.(int64) // Safe - type is guaranteed
```
## Error Handling
The package validates types during loading:
```go
cfg, err := config.Quick(defaults, "APP_", "config.toml")
if err != nil {
// Handle errors like:
// - Invalid TOML syntax
// - Type mismatches (e.g., string value for int field)
// - File permissions issues
log.Fatal(err)
}
```
## Common Patterns
### Required Fields
```go
// Register required configuration
cfg.RegisterRequired("api.key", "")
cfg.RegisterRequired("database.url", "")
// Validate all required fields are set
if err := cfg.Validate("api.key", "database.url"); err != nil {
log.Fatal("Missing required configuration:", err)
}
```
### Using Different Struct Tags
```go
// Use JSON tags instead of TOML
type Config struct {
Server struct {
Host string `json:"host"`
Port int `json:"port"`
} `json:"server"`
}
cfg, _ := config.NewBuilder().
WithTarget(&Config{}).
WithTagName("json").
WithFile("config.toml").
Build()
```
### Checking Value Sources
```go
// See which source provided a value
port, _ := cfg.Get("server.port")
sources := cfg.GetSources("server.port")
for source, value := range sources {
log.Printf("server.port from %s: %v", source, value)
}
```
## Next Steps
- [Builder Pattern](builder.md) - Advanced configuration options
- [Environment Variables](env.md) - Detailed environment variable handling
- [Access Patterns](access.md) - All ways to get and set values

355
doc/reconfiguration.md Normal file
View File

@ -0,0 +1,355 @@
# Live Reconfiguration
The config package supports automatic configuration reloading when files change, enabling zero-downtime reconfiguration.
## Basic File Watching
### Enable Auto-Update
```go
cfg, _ := config.NewBuilder().
WithDefaults(&Config{}).
WithFile("config.toml").
Build()
// Enable automatic reloading
cfg.AutoUpdate()
// Your application continues running
// Config reloads automatically when file changes
// Stop watching when done
defer cfg.StopAutoUpdate()
```
### Watch for Changes
```go
// Get notified of configuration changes
changes := cfg.Watch()
go func() {
for path := range changes {
log.Printf("Configuration changed: %s", path)
// React to specific changes
switch path {
case "server.port":
// Restart server with new port
restartServer()
case "log.level":
// Update log level
updateLogLevel()
}
}
}()
```
## Watch Options
### Custom Watch Configuration
```go
opts := config.WatchOptions{
PollInterval: 500 * time.Millisecond, // Check every 500ms
Debounce: 200 * time.Millisecond, // Wait 200ms after changes
MaxWatchers: 50, // Limit concurrent watchers
ReloadTimeout: 10 * time.Second, // Timeout for reload
VerifyPermissions: true, // Security check
}
cfg.AutoUpdateWithOptions(opts)
```
### Watch Without Auto-Update
```go
// Just watch, don't auto-reload
changes := cfg.WatchWithOptions(config.WatchOptions{
PollInterval: time.Second,
})
// Manually reload when desired
go func() {
for range changes {
if shouldReload() {
cfg.LoadFile("config.toml")
}
}
}()
```
## Change Detection
### Value Changes
The watcher detects and notifies about:
- New values added
- Existing values modified
- Values removed
- Type changes
```go
changes := cfg.Watch()
for path := range changes {
newVal, exists := cfg.Get(path)
if !exists {
log.Printf("Removed: %s", path)
continue
}
sources := cfg.GetSources(path)
fileVal, hasFile := sources[config.SourceFile]
log.Printf("Changed: %s = %v (from file: %v)",
path, newVal, hasFile)
}
```
### Special Notifications
```go
changes := cfg.Watch()
for notification := range changes {
switch notification {
case "file_deleted":
log.Warn("Config file was deleted")
case "permissions_changed":
log.Error("Config file permissions changed - potential security issue")
case "reload_timeout":
log.Error("Config reload timed out")
default:
if strings.HasPrefix(notification, "reload_error:") {
log.Error("Reload error:", notification)
} else {
// Normal path change
handleConfigChange(notification)
}
}
}
```
## Debouncing
Rapid file changes are automatically debounced:
```go
// Multiple rapid saves to config.toml
// Only triggers one reload after debounce period
opts := config.WatchOptions{
PollInterval: 100 * time.Millisecond,
Debounce: 500 * time.Millisecond, // Wait 500ms
}
cfg.AutoUpdateWithOptions(opts)
```
## Permission Monitoring
```go
opts := config.WatchOptions{
VerifyPermissions: true, // Enabled by default
}
cfg.AutoUpdateWithOptions(opts)
// Detects if file becomes world-writable
changes := cfg.Watch()
for change := range changes {
if change == "permissions_changed" {
// File permissions changed
// Possible security breach
alert("Config file permissions modified!")
}
}
```
## Pattern: Reconfiguration
```go
type Server struct {
cfg *config.Config
listener net.Listener
mu sync.RWMutex
}
func (s *Server) watchConfig() {
changes := s.cfg.Watch()
for path := range changes {
switch {
case strings.HasPrefix(path, "server."):
s.scheduleRestart()
case path == "log.level":
s.updateLogLevel()
case strings.HasPrefix(path, "feature."):
s.reloadFeatures()
}
}
}
func (s *Server) scheduleRestart() {
s.mu.Lock()
defer s.mu.Unlock()
// Graceful restart logic
log.Info("Scheduling server restart for config changes")
// ... drain connections, restart listener ...
}
```
## Pattern: Feature Flags
```go
type FeatureFlags struct {
cfg *config.Config
mu sync.RWMutex
}
func (ff *FeatureFlags) Watch() {
changes := ff.cfg.Watch()
for path := range changes {
if strings.HasPrefix(path, "features.") {
feature := strings.TrimPrefix(path, "features.")
enabled, _ := ff.cfg.Get(path)
log.Printf("Feature %s: %v", feature, enabled)
ff.notifyFeatureChange(feature, enabled.(bool))
}
}
}
func (ff *FeatureFlags) IsEnabled(feature string) bool {
ff.mu.RLock()
defer ff.mu.RUnlock()
val, exists := ff.cfg.Get("features." + feature)
return exists && val.(bool)
}
```
## Pattern: Multi-Stage Reload
```go
func watchConfigWithValidation(cfg *config.Config) {
changes := cfg.Watch()
for range changes {
// Stage 1: Snapshot current config
backup := cfg.Clone()
// Stage 2: Validate new configuration
if err := validateNewConfig(cfg); err != nil {
log.Error("Invalid configuration:", err)
continue
}
// Stage 3: Apply changes
if err := applyConfigChanges(cfg, backup); err != nil {
log.Error("Failed to apply changes:", err)
// Could restore from backup here
continue
}
log.Info("Configuration successfully reloaded")
}
}
```
## Monitoring
### Watch Status
```go
// Check if watching is active
if cfg.IsWatching() {
log.Printf("Auto-update is enabled")
log.Printf("Active watchers: %d", cfg.WatcherCount())
}
```
### Resource Management
```go
// Limit watchers to prevent resource exhaustion
opts := config.WatchOptions{
MaxWatchers: 10, // Max 10 concurrent watch channels
}
// Watchers beyond limit receive closed channels
cfg.AutoUpdateWithOptions(opts)
```
## Best Practices
1. **Always Stop Watching**: Use `defer cfg.StopAutoUpdate()` to clean up
2. **Handle All Notifications**: Check for special error notifications
3. **Validate After Reload**: Ensure new config is valid before applying
4. **Use Debouncing**: Prevent reload storms from rapid edits
5. **Monitor Permissions**: Enable permission verification for security
6. **Graceful Updates**: Plan how your app handles config changes
7. **Log Changes**: Audit configuration modifications
## Limitations
- File watching uses polling (not inotify/kqueue)
- No support for watching multiple files
- Changes only detected for registered paths
- Reloads entire file (no partial updates)
## Common Issues
### Changes Not Detected
```go
// Ensure path is registered before watching
cfg.Register("new.value", "default")
// Now changes to new.value will be detected
```
### Rapid Reloads
```go
// Increase debounce to prevent rapid reloads
opts := config.WatchOptions{
Debounce: 2 * time.Second, // Wait 2s after changes stop
}
```
### Memory Leaks
```go
// Always stop watching to prevent goroutine leaks
watcher := cfg.Watch()
// Use context for cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
for {
select {
case change := <-watcher:
handleChange(change)
case <-ctx.Done():
return
}
}
}()
```
## See Also
- [File Configuration](file.md) - File format and loading
- [Access Patterns](access.md) - Reacting to changed values
- [Builder Pattern](builder.md) - Setting up watching with builder

View File

@ -1,225 +0,0 @@
// File: lixenwraith/config/env_test.go
package config_test
import (
"os"
"testing"
"github.com/lixenwraith/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEnvironmentVariables(t *testing.T) {
t.Run("Basic Environment Loading", func(t *testing.T) {
// Set up environment
envVars := map[string]string{
"TEST_SERVER_HOST": "env-host",
"TEST_SERVER_PORT": "9999",
"TEST_DEBUG": "true",
}
for k, v := range envVars {
os.Setenv(k, v)
defer os.Unsetenv(k)
}
cfg := config.New()
cfg.Register("server.host", "default-host")
cfg.Register("server.port", 8080)
cfg.Register("debug", false)
// Load environment variables
err := cfg.LoadEnv("TEST_")
require.NoError(t, err)
// Verify values
host, _ := cfg.String("server.host")
assert.Equal(t, "env-host", host)
port, _ := cfg.Int64("server.port")
assert.Equal(t, int64(9999), port)
debug, _ := cfg.Bool("debug")
assert.True(t, debug)
})
t.Run("Custom Environment Transform", func(t *testing.T) {
os.Setenv("PORT", "3000")
os.Setenv("DATABASE_URL", "postgres://localhost/test")
defer func() {
os.Unsetenv("PORT")
os.Unsetenv("DATABASE_URL")
}()
cfg := config.New()
cfg.Register("server.port", 8080)
cfg.Register("database.url", "sqlite://memory")
opts := config.LoadOptions{
Sources: []config.Source{config.SourceEnv, config.SourceDefault},
EnvTransform: func(path string) string {
mapping := map[string]string{
"server.port": "PORT",
"database.url": "DATABASE_URL",
}
return mapping[path]
},
}
err := cfg.LoadWithOptions("", nil, opts)
require.NoError(t, err)
port, _ := cfg.Int64("server.port")
assert.Equal(t, int64(3000), port)
dbURL, _ := cfg.String("database.url")
assert.Equal(t, "postgres://localhost/test", dbURL)
})
t.Run("Environment Discovery", func(t *testing.T) {
// Set up various env vars
envVars := map[string]string{
"APP_SERVER_HOST": "discovered",
"APP_SERVER_PORT": "4444",
"APP_UNREGISTERED": "ignored",
}
for k, v := range envVars {
os.Setenv(k, v)
defer os.Unsetenv(k)
}
cfg := config.New()
cfg.Register("server.host", "default")
cfg.Register("server.port", 8080)
cfg.Register("server.timeout", 30)
// Discover which registered paths have env vars
discovered := cfg.DiscoverEnv("APP_")
// Should find 2 env vars
assert.Len(t, discovered, 2)
assert.Equal(t, "APP_SERVER_HOST", discovered["server.host"])
assert.Equal(t, "APP_SERVER_PORT", discovered["server.port"])
assert.NotContains(t, discovered, "unregistered")
})
t.Run("Environment Whitelist", func(t *testing.T) {
envVars := map[string]string{
"SECRET_API_KEY": "secret-value",
"SECRET_DATABASE_PASSWORD": "db-pass",
"SECRET_SERVER_PORT": "5555",
}
for k, v := range envVars {
os.Setenv(k, v)
defer os.Unsetenv(k)
}
cfg := config.New()
cfg.Register("api.key", "")
cfg.Register("database.password", "")
cfg.Register("server.port", 8080)
opts := config.LoadOptions{
Sources: []config.Source{config.SourceEnv, config.SourceDefault},
EnvPrefix: "SECRET_",
EnvWhitelist: map[string]bool{
"api.key": true,
"database.password": true,
// server.port is NOT whitelisted
},
}
cfg.LoadWithOptions("", nil, opts)
// Whitelisted values should load
apiKey, _ := cfg.String("api.key")
assert.Equal(t, "secret-value", apiKey)
dbPass, _ := cfg.String("database.password")
assert.Equal(t, "db-pass", dbPass)
// Non-whitelisted should use default
port, _ := cfg.Int64("server.port")
assert.Equal(t, int64(8080), port)
})
t.Run("RegisterWithEnv", func(t *testing.T) {
os.Setenv("CUSTOM_PORT", "6666")
defer os.Unsetenv("CUSTOM_PORT")
cfg := config.New()
// Register with explicit env mapping
err := cfg.RegisterWithEnv("server.port", 8080, "CUSTOM_PORT")
require.NoError(t, err)
// Should immediately have env value
port, _ := cfg.Int64("server.port")
assert.Equal(t, int64(6666), port)
})
t.Run("Export Environment", func(t *testing.T) {
cfg := config.New()
cfg.Register("app.name", "myapp")
cfg.Register("app.version", "1.0.0")
cfg.Register("server.port", 8080)
// Set some non-default values
cfg.Set("app.version", "2.0.0")
cfg.Set("server.port", 9090)
// Export as env vars
exports := cfg.ExportEnv("EXPORT_")
// Should export non-default values
assert.Equal(t, "2.0.0", exports["EXPORT_APP_VERSION"])
assert.Equal(t, "9090", exports["EXPORT_SERVER_PORT"])
// Should not export defaults
assert.NotContains(t, exports, "EXPORT_APP_NAME")
})
t.Run("Type Parsing from Environment", func(t *testing.T) {
envVars := map[string]string{
"TYPES_STRING": "hello world",
"TYPES_INT": "42",
"TYPES_FLOAT": "3.14159",
"TYPES_BOOL_TRUE": "true",
"TYPES_BOOL_FALSE": "false",
"TYPES_QUOTED": `"quoted string"`,
}
for k, v := range envVars {
os.Setenv(k, v)
defer os.Unsetenv(k)
}
cfg := config.New()
cfg.Register("string", "")
cfg.Register("int", 0)
cfg.Register("float", 0.0)
cfg.Register("bool.true", false)
cfg.Register("bool.false", true)
cfg.Register("quoted", "")
cfg.LoadEnv("TYPES_")
// Verify type conversions
s, _ := cfg.String("string")
assert.Equal(t, "hello world", s)
i, _ := cfg.Int64("int")
assert.Equal(t, int64(42), i)
f, _ := cfg.Float64("float")
assert.Equal(t, 3.14159, f)
bt, _ := cfg.Bool("bool.true")
assert.True(t, bt)
bf, _ := cfg.Bool("bool.false")
assert.False(t, bf)
q, _ := cfg.String("quoted")
assert.Equal(t, "quoted string", q)
})
}

View File

@ -85,16 +85,4 @@ func isValidKeySegment(s string) bool {
} }
} }
return true return true
}
// isAlpha checks if a character is a letter (A-Z, a-z)
// Note: not used, potential future use.
func isAlpha(c rune) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
// isNumeric checks if a character is a digit (0-9)
// Note: not used, potential future use.
func isNumeric(c rune) bool {
return c >= '0' && c <= '9'
} }

View File

@ -1,4 +1,4 @@
// File: lixenwraith/config/io.go // File: lixenwraith/config/loader.go
package config package config
import ( import (
@ -7,12 +7,72 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
) )
// Source represents a configuration source, used to define load precedence
type Source string
const (
// SourceDefault represents use of registered default values
SourceDefault Source = "default"
// SourceFile represents values loaded from a configuration file
SourceFile Source = "file"
// SourceEnv represents values loaded from environment variables
SourceEnv Source = "env"
// SourceCLI represents values loaded from command-line arguments
SourceCLI Source = "cli"
)
// LoadMode defines how configuration sources are processed
type LoadMode int
const (
// LoadModeReplace completely replaces values (default behavior)
LoadModeReplace LoadMode = iota
// LoadModeMerge merges maps/structs instead of replacing
// TODO: future implementation
LoadModeMerge
)
// EnvTransformFunc converts a configuration path to an environment variable name
type EnvTransformFunc func(path string) string
// LoadOptions configures how configuration is loaded from multiple sources
type LoadOptions struct {
// Sources defines the precedence order (first = highest priority)
// Default: [SourceCLI, SourceEnv, SourceFile, SourceDefault]
Sources []Source
// EnvPrefix is prepended to environment variable names
// Example: "MYAPP_" transforms "server.port" to "MYAPP_SERVER_PORT"
EnvPrefix string
// EnvTransform customizes how paths map to environment variables
// If nil, uses default transformation (dots to underscores, uppercase)
EnvTransform EnvTransformFunc
// LoadMode determines how values are merged
LoadMode LoadMode
// EnvWhitelist limits which paths are checked for env vars (nil = all)
EnvWhitelist map[string]bool
// SkipValidation skips path validation during load
SkipValidation bool
}
// DefaultLoadOptions returns the standard load options
func DefaultLoadOptions() LoadOptions {
return LoadOptions{
Sources: []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault},
LoadMode: LoadModeReplace,
}
}
// Load reads configuration from a TOML file and merges overrides from command-line arguments. // Load reads configuration from a TOML file and merges overrides from command-line arguments.
// This is a convenience method that maintains backward compatibility. // This is a convenience method that maintains backward compatibility.
func (c *Config) Load(filePath string, args []string) error { func (c *Config) Load(filePath string, args []string) error {
@ -102,6 +162,11 @@ func (c *Config) loadFile(path string) error {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
// Track the config file path for watching
c.configFilePath = path
defer c.invalidateCache() // Invalidate cache after changes
// Store in cache // Store in cache
c.fileData = flattenedFileConfig c.fileData = flattenedFileConfig
@ -111,6 +176,9 @@ func (c *Config) loadFile(path string) error {
if item.values == nil { if item.values == nil {
item.values = make(map[Source]any) item.values = make(map[Source]any)
} }
if str, ok := value.(string); ok && len(str) > MaxValueSize {
return ErrValueSize
}
item.values[SourceFile] = value item.values[SourceFile] = value
item.currentValue = c.computeValue(path, item) item.currentValue = c.computeValue(path, item)
c.items[path] = item c.items[path] = item
@ -123,45 +191,36 @@ func (c *Config) loadFile(path string) error {
// loadEnv loads configuration from environment variables // loadEnv loads configuration from environment variables
func (c *Config) loadEnv(opts LoadOptions) error { func (c *Config) loadEnv(opts LoadOptions) error {
// Default transform function
transform := opts.EnvTransform transform := opts.EnvTransform
if transform == nil { if transform == nil {
transform = func(path string) string { transform = defaultEnvTransform(opts.EnvPrefix)
// Convert dots to underscores and uppercase
env := strings.ReplaceAll(path, ".", "_")
env = strings.ToUpper(env)
if opts.EnvPrefix != "" {
env = opts.EnvPrefix + env
}
return env
}
} }
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
// Clear previous env data defer c.invalidateCache() // Invalidate cache after changes
c.envData = make(map[string]any) c.envData = make(map[string]any)
// Check each registered path for corresponding env var
for path, item := range c.items { for path, item := range c.items {
// Skip if whitelisted and not in whitelist
if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] { if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] {
continue continue
} }
envVar := transform(path) envVar := transform(path)
if value, exists := os.LookupEnv(envVar); exists { if value, exists := os.LookupEnv(envVar); exists {
// Parse the string value // Store raw string value - mapstructure will handle conversion
parsedValue := parseValue(value)
if item.values == nil { if item.values == nil {
item.values = make(map[Source]any) item.values = make(map[Source]any)
} }
item.values[SourceEnv] = parsedValue if len(value) > MaxValueSize {
return ErrValueSize
}
item.values[SourceEnv] = value // Store as string
item.currentValue = c.computeValue(path, item) item.currentValue = c.computeValue(path, item)
c.items[path] = item c.items[path] = item
c.envData[path] = parsedValue c.envData[path] = value
} }
} }
@ -175,16 +234,13 @@ func (c *Config) loadCLI(args []string) error {
return fmt.Errorf("%w: %w", ErrCLIParse, err) return fmt.Errorf("%w: %w", ErrCLIParse, err)
} }
// Flatten CLI data
flattenedCLI := flattenMap(parsedCLI, "") flattenedCLI := flattenMap(parsedCLI, "")
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
// Store in cache
c.cliData = flattenedCLI c.cliData = flattenedCLI
// Apply to registered paths
for path, value := range flattenedCLI { for path, value := range flattenedCLI {
if item, exists := c.items[path]; exists { if item, exists := c.items[path]; exists {
if item.values == nil { if item.values == nil {
@ -194,9 +250,9 @@ func (c *Config) loadCLI(args []string) error {
item.currentValue = c.computeValue(path, item) item.currentValue = c.computeValue(path, item)
c.items[path] = item c.items[path] = item
} }
// Ignore unregistered paths from CLI
} }
c.invalidateCache() // Invalidate cache after changes
return nil return nil
} }
@ -260,20 +316,13 @@ func defaultEnvTransform(prefix string) EnvTransformFunc {
} }
// parseValue attempts to parse a string into appropriate types // parseValue attempts to parse a string into appropriate types
// Only basic parse, complex parsing is deferred to mapstructure's decode hooks
func parseValue(s string) any { func parseValue(s string) any {
// Try boolean if s == "true" {
if v, err := strconv.ParseBool(s); err == nil { return true
return v
} }
if s == "false" {
// Try int64 return false
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
return v
}
// Try float64
if v, err := strconv.ParseFloat(s, 64); err == nil {
return v
} }
// Remove quotes if present // Remove quotes if present
@ -281,7 +330,7 @@ func parseValue(s string) any {
return s[1 : len(s)-1] return s[1 : len(s)-1]
} }
// Return as string // Return as string - mapstructure will convert as needed
return s return s
} }
@ -370,14 +419,13 @@ func (c *Config) SaveSource(path string, source Source) error {
c.mutex.RUnlock() c.mutex.RUnlock()
// Use the same atomic save logic // Marshal using BurntSushi/toml
var buf bytes.Buffer var buf bytes.Buffer
encoder := toml.NewEncoder(&buf) encoder := toml.NewEncoder(&buf)
if err := encoder.Encode(nestedData); err != nil { if err := encoder.Encode(nestedData); err != nil {
return fmt.Errorf("failed to marshal config data to TOML: %w", err) return fmt.Errorf("failed to marshal %s source data to TOML: %w", source, err)
} }
// ... (rest of atomic save logic same as Save method)
return atomicWriteFile(path, buf.Bytes()) return atomicWriteFile(path, buf.Bytes())
} }

View File

@ -6,8 +6,6 @@ import (
"os" "os"
"reflect" "reflect"
"strings" "strings"
"github.com/mitchellh/mapstructure"
) )
// Register makes a configuration path known to the Config instance. // Register makes a configuration path known to the Config instance.
@ -102,24 +100,37 @@ func (c *Config) Unregister(path string) error {
// It uses struct tags (`toml:"..."`) to determine the configuration paths. // It uses struct tags (`toml:"..."`) to determine the configuration paths.
// The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed. // The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed.
func (c *Config) RegisterStruct(prefix string, structWithDefaults any) error { func (c *Config) RegisterStruct(prefix string, structWithDefaults any) error {
return c.RegisterStructWithTags(prefix, structWithDefaults, "toml")
}
// RegisterStructWithTags is like RegisterStruct but allows custom tag names
func (c *Config) RegisterStructWithTags(prefix string, structWithDefaults any, tagName string) error {
v := reflect.ValueOf(structWithDefaults) v := reflect.ValueOf(structWithDefaults)
// Handle pointer or direct struct value // Handle pointer or direct struct value
if v.Kind() == reflect.Ptr { if v.Kind() == reflect.Ptr {
if v.IsNil() { if v.IsNil() {
return fmt.Errorf("RegisterStruct requires a non-nil struct pointer or value") return fmt.Errorf("RegisterStructWithTags requires a non-nil struct pointer or value")
} }
v = v.Elem() v = v.Elem()
} }
if v.Kind() != reflect.Struct { if v.Kind() != reflect.Struct {
return fmt.Errorf("RegisterStruct requires a struct or struct pointer, got %T", structWithDefaults) return fmt.Errorf("RegisterStructWithTags requires a struct or struct pointer, got %T", structWithDefaults)
}
// Validate tag name
switch tagName {
case "toml", "json", "yaml":
// Supported tags
default:
return fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName)
} }
var errors []string var errors []string
// Use a helper function for recursive registration // Use helper function for recursive registration with specified tag
c.registerFields(v, prefix, "", &errors) c.registerFields(v, prefix, "", &errors, tagName)
if len(errors) > 0 { if len(errors) > 0 {
return fmt.Errorf("failed to register %d field(s): %s", len(errors), strings.Join(errors, "; ")) return fmt.Errorf("failed to register %d field(s): %s", len(errors), strings.Join(errors, "; "))
@ -128,22 +139,8 @@ func (c *Config) RegisterStruct(prefix string, structWithDefaults any) error {
return nil return nil
} }
// RegisterStructWithTags is like RegisterStruct but allows custom tag names
func (c *Config) RegisterStructWithTags(prefix string, structWithDefaults any, tagName string) error {
// Save current tag preference
oldTag := "toml"
// Temporarily use custom tag
// Note: This would require modifying registerFields to accept tagName parameter
// For now, we'll keep using "toml" tag
_ = oldTag
_ = tagName
return c.RegisterStruct(prefix, structWithDefaults)
}
// registerFields is a helper function that handles the recursive field registration. // registerFields is a helper function that handles the recursive field registration.
func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, errors *[]string) { func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, errors *[]string, tagName string) {
t := v.Type() t := v.Type()
for i := 0; i < v.NumField(); i++ { for i := 0; i < v.NumField(); i++ {
@ -154,16 +151,13 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
continue continue
} }
// Get tag value or use field name // Get tag value based on tagName parameter
tag := field.Tag.Get("toml") tag := field.Tag.Get(tagName)
if tag == "-" { if tag == "-" {
continue // Skip this field continue
} }
// Check for additional tags // Fall back to field name if no tag
envTag := field.Tag.Get("env") // Explicit env var name
required := field.Tag.Get("required") == "true"
key := field.Name key := field.Name
if tag != "" { if tag != "" {
parts := strings.Split(tag, ",") parts := strings.Split(tag, ",")
@ -172,6 +166,10 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
} }
} }
// Check for additional tags
envTag := field.Tag.Get("env") // Explicit env var name
required := field.Tag.Get("required") == "true"
// Build full path // Build full path
currentPath := key currentPath := key
if pathPrefix != "" { if pathPrefix != "" {
@ -181,6 +179,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
currentPath = pathPrefix + key currentPath = pathPrefix + key
} }
// TODO: use mapstructure instead of logic with reflection
// Handle nested structs recursively // Handle nested structs recursively
fieldType := fieldValue.Type() fieldType := fieldValue.Type()
isStruct := fieldValue.Kind() == reflect.Struct isStruct := fieldValue.Kind() == reflect.Struct
@ -199,7 +198,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
// For nested structs, append a dot and continue recursion // For nested structs, append a dot and continue recursion
nestedPrefix := currentPath + "." nestedPrefix := currentPath + "."
c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors) c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors, tagName)
continue continue
} }
@ -259,164 +258,12 @@ func (c *Config) GetRegisteredPathsWithDefaults(prefix string) map[string]any {
return result return result
} }
// Scan decodes the configuration data under a specific base path // Scan decodes configuration into target using the unified unmarshal function
// into the target struct or map. It operates on the current, merged configuration state.
// The target must be a non-nil pointer to a struct or map.
// It uses the "toml" struct tag for mapping fields.
func (c *Config) Scan(basePath string, target any) error { func (c *Config) Scan(basePath string, target any) error {
// Validate target return c.unmarshal(basePath, "", target) // Empty source means use merged state
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return fmt.Errorf("target of Scan must be a non-nil pointer, got %T", target)
}
c.mutex.RLock() // Read lock is sufficient
// Build the full nested map from the current state of registered items
fullNestedMap := make(map[string]any)
for path, item := range c.items {
setNestedValue(fullNestedMap, path, item.currentValue)
}
c.mutex.RUnlock() // Unlock before decoding
var sectionData any = fullNestedMap
// Navigate to the specific section if basePath is provided
if basePath != "" {
// Allow trailing dot for convenience
basePath = strings.TrimSuffix(basePath, ".")
if basePath == "" { // Handle case where input was just "."
// Use the full map
} else {
segments := strings.Split(basePath, ".")
current := any(fullNestedMap)
found := true
for _, segment := range segments {
currentMap, ok := current.(map[string]any)
if !ok {
// Path segment does not lead to a map/table
found = false
break
}
value, exists := currentMap[segment]
if !exists {
// The requested path segment does not exist in the current config
found = false
break
}
current = value
}
if !found {
// If the path doesn't fully exist, decode an empty map into the target.
sectionData = make(map[string]any)
} else {
sectionData = current
}
}
}
// Ensure the final data we are decoding from is actually a map
sectionMap, ok := sectionData.(map[string]any)
if !ok {
// This can happen if the basePath points to a non-map value
return fmt.Errorf("configuration path %q does not refer to a scannable section (map), but to type %T", basePath, sectionData)
}
// Use mapstructure to decode the relevant section map into the target
decoderConfig := &mapstructure.DecoderConfig{
Result: target,
TagName: "toml", // Use the same tag name for consistency
WeaklyTypedInput: true, // Allow conversions
DecodeHook: mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
),
}
decoder, err := mapstructure.NewDecoder(decoderConfig)
if err != nil {
return fmt.Errorf("failed to create mapstructure decoder: %w", err)
}
err = decoder.Decode(sectionMap)
if err != nil {
return fmt.Errorf("failed to scan section %q into %T: %w", basePath, target, err)
}
return nil
} }
// ScanSource scans configuration from a specific source // ScanSource decodes configuration from specific source using unified unmarshal
func (c *Config) ScanSource(basePath string, source Source, target any) error { func (c *Config) ScanSource(basePath string, source Source, target any) error {
// Validate target return c.unmarshal(basePath, source, target)
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return fmt.Errorf("target of ScanSource must be a non-nil pointer, got %T", target)
}
c.mutex.RLock()
// Build nested map from specific source only
nestedMap := make(map[string]any)
for path, item := range c.items {
if val, exists := item.values[source]; exists {
setNestedValue(nestedMap, path, val)
}
}
c.mutex.RUnlock()
// Rest of the logic is similar to Scan
var sectionData any = nestedMap
if basePath != "" {
basePath = strings.TrimSuffix(basePath, ".")
if basePath != "" {
segments := strings.Split(basePath, ".")
current := any(nestedMap)
for _, segment := range segments {
currentMap, ok := current.(map[string]any)
if !ok {
sectionData = make(map[string]any)
break
}
value, exists := currentMap[segment]
if !exists {
sectionData = make(map[string]any)
break
}
current = value
}
sectionData = current
}
}
sectionMap, ok := sectionData.(map[string]any)
if !ok {
return fmt.Errorf("path %q does not refer to a map in source %s", basePath, source)
}
decoderConfig := &mapstructure.DecoderConfig{
Result: target,
TagName: "toml",
WeaklyTypedInput: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
),
}
decoder, err := mapstructure.NewDecoder(decoderConfig)
if err != nil {
return fmt.Errorf("failed to create decoder: %w", err)
}
return decoder.Decode(sectionMap)
} }

View File

@ -1,219 +0,0 @@
// File: lixenwraith/config/source_test.go
package config_test
import (
"os"
"path/filepath"
"testing"
"github.com/lixenwraith/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMultiSourceConfiguration(t *testing.T) {
t.Run("Source Precedence", func(t *testing.T) {
cfg := config.New()
cfg.Register("test.value", "default")
// Set values in different sources
cfg.SetSource("test.value", config.SourceFile, "from-file")
cfg.SetSource("test.value", config.SourceEnv, "from-env")
cfg.SetSource("test.value", config.SourceCLI, "from-cli")
// Default precedence: CLI > Env > File > Default
val, _ := cfg.String("test.value")
assert.Equal(t, "from-cli", val)
// Change precedence
opts := config.LoadOptions{
Sources: []config.Source{
config.SourceEnv,
config.SourceCLI,
config.SourceFile,
config.SourceDefault,
},
}
cfg.SetLoadOptions(opts)
// Now env should win
val, _ = cfg.String("test.value")
assert.Equal(t, "from-env", val)
})
t.Run("Source Tracking", func(t *testing.T) {
cfg := config.New()
cfg.Register("server.port", 8080)
// Set from multiple sources
cfg.SetSource("server.port", config.SourceFile, 9090)
cfg.SetSource("server.port", config.SourceEnv, 7070)
// Get all sources
sources := cfg.GetSources("server.port")
// Should have 2 sources
assert.Len(t, sources, 2)
assert.Equal(t, 9090, sources[config.SourceFile])
assert.Equal(t, 7070, sources[config.SourceEnv])
})
t.Run("GetSource", func(t *testing.T) {
cfg := config.New()
cfg.Register("api.key", "default-key")
cfg.SetSource("api.key", config.SourceEnv, "env-key")
// Get from specific source
envVal, exists := cfg.GetSource("api.key", config.SourceEnv)
assert.True(t, exists)
assert.Equal(t, "env-key", envVal)
// Get from missing source
_, exists = cfg.GetSource("api.key", config.SourceFile)
assert.False(t, exists)
})
t.Run("Reset Sources", func(t *testing.T) {
cfg := config.New()
cfg.Register("test1", "default1")
cfg.Register("test2", "default2")
// Set values
cfg.SetSource("test1", config.SourceFile, "file1")
cfg.SetSource("test1", config.SourceEnv, "env1")
cfg.SetSource("test2", config.SourceCLI, "cli2")
// Reset specific source
cfg.ResetSource(config.SourceEnv)
// Env value should be gone
_, exists := cfg.GetSource("test1", config.SourceEnv)
assert.False(t, exists)
// Other sources remain
fileVal, _ := cfg.GetSource("test1", config.SourceFile)
assert.Equal(t, "file1", fileVal)
// Reset all
cfg.Reset()
// All values should be defaults
val1, _ := cfg.String("test1")
val2, _ := cfg.String("test2")
assert.Equal(t, "default1", val1)
assert.Equal(t, "default2", val2)
})
t.Run("LoadWithOptions Integration", func(t *testing.T) {
// Create temp config file
tmpdir := t.TempDir()
configFile := filepath.Join(tmpdir, "test.toml")
configContent := `
[server]
host = "file-host"
port = 8080
[feature]
enabled = true
`
require.NoError(t, os.WriteFile(configFile, []byte(configContent), 0644))
// Set environment
os.Setenv("TEST_SERVER_PORT", "9090")
os.Setenv("TEST_FEATURE_ENABLED", "false")
t.Cleanup(func() {
os.Unsetenv("TEST_SERVER_PORT")
os.Unsetenv("TEST_FEATURE_ENABLED")
})
cfg := config.New()
cfg.Register("server.host", "default-host")
cfg.Register("server.port", 7070)
cfg.Register("feature.enabled", false)
// Load with custom precedence (File highest)
opts := config.LoadOptions{
Sources: []config.Source{
config.SourceFile,
config.SourceEnv,
config.SourceCLI,
config.SourceDefault,
},
EnvPrefix: "TEST_",
}
err := cfg.LoadWithOptions(configFile, []string{"--server.host=cli-host"}, opts)
require.NoError(t, err)
// File should win for all values
host, _ := cfg.String("server.host")
assert.Equal(t, "file-host", host)
port, _ := cfg.Int64("server.port")
assert.Equal(t, int64(8080), port)
enabled, _ := cfg.Bool("feature.enabled")
assert.True(t, enabled)
})
t.Run("ScanSource", func(t *testing.T) {
type ServerConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
}
cfg := config.New()
cfg.Register("server.host", "default")
cfg.Register("server.port", 8080)
// Set different values in different sources
cfg.SetSource("server.host", config.SourceFile, "file-host")
cfg.SetSource("server.port", config.SourceFile, 8080)
cfg.SetSource("server.host", config.SourceEnv, "env-host")
cfg.SetSource("server.port", config.SourceEnv, 9090)
// Scan from specific source
var fileConfig ServerConfig
err := cfg.ScanSource("server", config.SourceFile, &fileConfig)
require.NoError(t, err)
assert.Equal(t, "file-host", fileConfig.Host)
assert.Equal(t, 8080, fileConfig.Port)
var envConfig ServerConfig
err = cfg.ScanSource("server", config.SourceEnv, &envConfig)
require.NoError(t, err)
assert.Equal(t, "env-host", envConfig.Host)
assert.Equal(t, 9090, envConfig.Port)
})
t.Run("SaveSource", func(t *testing.T) {
cfg := config.New()
cfg.Register("app.name", "myapp")
cfg.Register("app.version", "1.0.0")
cfg.Register("server.port", 8080)
// Set values in different sources
cfg.SetSource("app.name", config.SourceFile, "fileapp")
cfg.SetSource("app.version", config.SourceEnv, "2.0.0")
cfg.SetSource("server.port", config.SourceCLI, 9090)
// Save only env source
tmpfile := filepath.Join(t.TempDir(), "config-source.toml")
err := cfg.SaveSource(tmpfile, config.SourceEnv)
require.NoError(t, err)
// Load saved file and verify
newCfg := config.New()
newCfg.Register("app.version", "")
newCfg.LoadFile(tmpfile)
version, _ := newCfg.String("app.version")
assert.Equal(t, "2.0.0", version)
// Should not have other source values
name, _ := newCfg.String("app.name")
assert.Empty(t, name)
})
}

162
type.go
View File

@ -1,162 +0,0 @@
// File: lixenwraith/config/type.go
package config
import (
"fmt"
"reflect"
"strconv"
)
// String retrieves a string configuration value using the path.
// Attempts conversion from common types if the stored value isn't already a string.
func (c *Config) String(path string) (string, error) {
val, found := c.Get(path)
if !found {
return "", fmt.Errorf("path not registered: %s", path)
}
if val == nil {
return "", nil // Treat nil as empty string for convenience
}
if strVal, ok := val.(string); ok {
return strVal, nil
}
// Attempt conversion for common types
switch v := val.(type) {
case fmt.Stringer:
return v.String(), nil
case []byte:
return string(v), nil
case int, int8, int16, int32, int64:
return strconv.FormatInt(reflect.ValueOf(val).Int(), 10), nil
case uint, uint8, uint16, uint32, uint64:
return strconv.FormatUint(reflect.ValueOf(val).Uint(), 10), nil
case float32, float64:
return strconv.FormatFloat(reflect.ValueOf(val).Float(), 'f', -1, 64), nil
case bool:
return strconv.FormatBool(v), nil
case error:
return v.Error(), nil
default:
return "", fmt.Errorf("cannot convert type %T to string for path %s", val, path)
}
}
// Int64 retrieves an int64 configuration value using the path.
// Attempts conversion from numeric types, parsable strings, and booleans.
func (c *Config) Int64(path string) (int64, error) {
val, found := c.Get(path)
if !found {
return 0, fmt.Errorf("path not registered: %s", path)
}
if val == nil {
return 0, fmt.Errorf("value for path %s is nil, cannot convert to int64", path)
}
// Use reflection for broader compatibility with numeric types
v := reflect.ValueOf(val)
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int(), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
u := v.Uint()
// Check for potential overflow converting uint64 to int64
maxInt64 := int64(^uint64(0) >> 1)
if u > uint64(maxInt64) {
return 0, fmt.Errorf("cannot convert unsigned integer %d (type %T) to int64 for path %s: overflow", u, val, path)
}
return int64(u), nil
case reflect.Float32, reflect.Float64:
// Truncate float to int
return int64(v.Float()), nil
case reflect.String:
s := v.String()
if i, err := strconv.ParseInt(s, 0, 64); err == nil { // Use base 0 for auto-detection (e.g., "0xFF")
return i, nil
} else {
if f, ferr := strconv.ParseFloat(s, 64); ferr == nil {
return int64(f), nil // Truncate
}
// Return the original integer parsing error if float also fails
return 0, fmt.Errorf("cannot convert string %q to int64 for path %s: %w", s, path, err)
}
case reflect.Bool:
if v.Bool() {
return 1, nil
}
return 0, nil
}
return 0, fmt.Errorf("cannot convert type %T to int64 for path %s", val, path)
}
// Bool retrieves a boolean configuration value using the path.
// Attempts conversion from numeric types (0=false, non-zero=true) and parsable strings.
func (c *Config) Bool(path string) (bool, error) {
val, found := c.Get(path)
if !found {
return false, fmt.Errorf("path not registered: %s", path)
}
if val == nil {
return false, fmt.Errorf("value for path %s is nil, cannot convert to bool", path)
}
v := reflect.ValueOf(val)
switch v.Kind() {
case reflect.Bool:
return v.Bool(), nil
case reflect.String:
s := v.String()
if b, err := strconv.ParseBool(s); err == nil {
return b, nil
} else {
return false, fmt.Errorf("cannot convert string %q to bool for path %s: %w", s, path, err)
}
// Numeric interpretation: 0 is false, non-zero is true
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() != 0, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return v.Uint() != 0, nil
case reflect.Float32, reflect.Float64:
return v.Float() != 0, nil
}
return false, fmt.Errorf("cannot convert type %T to bool for path %s", val, path)
}
// Float64 retrieves a float64 configuration value using the path.
// Attempts conversion from numeric types, parsable strings, and booleans.
func (c *Config) Float64(path string) (float64, error) {
val, found := c.Get(path)
if !found {
return 0.0, fmt.Errorf("path not registered: %s", path)
}
if val == nil {
return 0.0, fmt.Errorf("value for path %s is nil, cannot convert to float64", path)
}
v := reflect.ValueOf(val)
switch v.Kind() {
case reflect.Float32, reflect.Float64:
return v.Float(), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return float64(v.Int()), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return float64(v.Uint()), nil
case reflect.String:
s := v.String()
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f, nil
} else {
return 0.0, fmt.Errorf("cannot convert string %q to float64 for path %s: %w", s, path, err)
}
case reflect.Bool:
if v.Bool() {
return 1.0, nil
}
return 0.0, nil
}
return 0.0, fmt.Errorf("cannot convert type %T to float64 for path %s", val, path)
}

429
watch.go Normal file
View File

@ -0,0 +1,429 @@
// File: lixenwraith/config/watch.go
package config
import (
"context"
"fmt"
"github.com/BurntSushi/toml"
"os"
"reflect"
"sync"
"sync/atomic"
"time"
)
// WatchOptions configures file watching behavior
type WatchOptions struct {
// PollInterval for file stat checks (minimum 100ms)
PollInterval time.Duration
// Debounce duration to avoid rapid reloads
Debounce time.Duration
// MaxWatchers limits concurrent watch channels
MaxWatchers int
// ReloadTimeout for file reload operations
ReloadTimeout time.Duration
// VerifyPermissions checks file hasn't been replaced with different permissions
VerifyPermissions bool
}
// DefaultWatchOptions returns sensible defaults for file watching
func DefaultWatchOptions() WatchOptions {
return WatchOptions{
PollInterval: time.Second, // Check every second
Debounce: 500 * time.Millisecond,
MaxWatchers: 100, // Prevent resource exhaustion
ReloadTimeout: 5 * time.Second,
VerifyPermissions: true,
}
}
// watcher manages file watching state
type watcher struct {
mu sync.RWMutex
ctx context.Context
cancel context.CancelFunc
opts WatchOptions
filePath string
lastModTime time.Time
lastSize int64
lastMode os.FileMode
watching atomic.Bool
reloadInProgress atomic.Bool
watchers map[int64]chan string // subscriber channels
watcherID atomic.Int64
debounceTimer *time.Timer
}
// configWatcher extends Config with watching capabilities
type configWatcher struct {
*Config
watcher *watcher
}
// AutoUpdate enables automatic configuration reloading when the file changes
func (c *Config) AutoUpdate() {
c.AutoUpdateWithOptions(DefaultWatchOptions())
}
// AutoUpdateWithOptions enables automatic configuration reloading with custom options
func (c *Config) AutoUpdateWithOptions(opts WatchOptions) {
// Validate options
if opts.PollInterval < 100*time.Millisecond {
opts.PollInterval = 100 * time.Millisecond // Minimum poll interval
}
if opts.MaxWatchers <= 0 {
opts.MaxWatchers = 100
}
if opts.ReloadTimeout <= 0 {
opts.ReloadTimeout = 5 * time.Second
}
c.mutex.Lock()
defer c.mutex.Unlock()
// Check if we have a file to watch
filePath := c.getConfigFilePath()
if filePath == "" {
// No file configured, nothing to watch
return
}
// Initialize watcher if needed
if c.watcher == nil {
ctx, cancel := context.WithCancel(context.Background())
c.watcher = &watcher{
ctx: ctx,
cancel: cancel,
opts: opts,
filePath: filePath,
watchers: make(map[int64]chan string),
}
// Get initial file state
if info, err := os.Stat(filePath); err == nil {
c.watcher.lastModTime = info.ModTime()
c.watcher.lastSize = info.Size()
c.watcher.lastMode = info.Mode()
}
// Start watching
go c.watcher.watchLoop(c)
}
}
// StopAutoUpdate stops automatic configuration reloading
func (c *Config) StopAutoUpdate() {
c.mutex.Lock()
defer c.mutex.Unlock()
if c.watcher != nil {
c.watcher.stop()
c.watcher = nil
}
}
// Watch returns a channel that receives paths of changed configuration values
func (c *Config) Watch() <-chan string {
return c.WatchWithOptions(DefaultWatchOptions())
}
// WatchWithOptions returns a channel with custom watch options
func (c *Config) WatchWithOptions(opts WatchOptions) <-chan string {
// First ensure auto-update is running
c.AutoUpdateWithOptions(opts)
c.mutex.RLock()
watcher := c.watcher
c.mutex.RUnlock()
if watcher == nil {
// No file to watch, return closed channel
ch := make(chan string)
close(ch)
return ch
}
return watcher.subscribe()
}
// IsWatching returns true if auto-update is enabled
func (c *Config) IsWatching() bool {
c.mutex.RLock()
defer c.mutex.RUnlock()
return c.watcher != nil && c.watcher.watching.Load()
}
// WatcherCount returns the number of active watch channels
func (c *Config) WatcherCount() int {
c.mutex.RLock()
defer c.mutex.RUnlock()
if c.watcher == nil {
return 0
}
c.watcher.mu.RLock()
defer c.watcher.mu.RUnlock()
return len(c.watcher.watchers)
}
// watchLoop is the main file watching loop
func (w *watcher) watchLoop(c *Config) {
if !w.watching.CompareAndSwap(false, true) {
return // Already watching
}
defer w.watching.Store(false)
ticker := time.NewTicker(w.opts.PollInterval)
defer ticker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
w.checkAndReload(c)
}
}
}
// checkAndReload checks if file changed and triggers reload
func (w *watcher) checkAndReload(c *Config) {
info, err := os.Stat(w.filePath)
if err != nil {
if os.IsNotExist(err) {
// File was deleted, notify watchers
w.notifyWatchers("file_deleted")
}
return
}
// Check for changes
changed := false
// Compare modification time and size
if !info.ModTime().Equal(w.lastModTime) || info.Size() != w.lastSize {
changed = true
}
// SECURITY: Verify permissions haven't changed suspiciously
if w.opts.VerifyPermissions && w.lastMode != 0 {
if info.Mode() != w.lastMode {
// Permission change detected
if (info.Mode() & 0077) != (w.lastMode & 0077) {
// World/group permissions changed - potential security issue
w.notifyWatchers("permissions_changed")
// Don't reload on permission change for security
return
}
}
}
if changed {
// Update tracked state
w.lastModTime = info.ModTime()
w.lastSize = info.Size()
w.lastMode = info.Mode()
// Debounce rapid changes
w.mu.Lock()
if w.debounceTimer != nil {
w.debounceTimer.Stop()
}
w.debounceTimer = time.AfterFunc(w.opts.Debounce, func() {
w.performReload(c)
})
w.mu.Unlock()
}
}
// performReload reloads the configuration file
func (w *watcher) performReload(c *Config) {
// Prevent concurrent reloads
if !w.reloadInProgress.CompareAndSwap(false, true) {
return
}
defer w.reloadInProgress.Store(false)
// Create a timeout context for reload
ctx, cancel := context.WithTimeout(w.ctx, w.opts.ReloadTimeout)
defer cancel()
// Track what changed
oldValues := c.snapshot()
// Reload file in a goroutine with timeout
done := make(chan error, 1)
go func() {
done <- c.reloadFileAtomic(w.filePath)
}()
select {
case err := <-done:
if err != nil {
// Reload failed, notify error
w.notifyWatchers(fmt.Sprintf("reload_error:%v", err))
return
}
// Compare and notify changes
newValues := c.snapshot()
for path, newVal := range newValues {
if oldVal, existed := oldValues[path]; !existed || !reflect.DeepEqual(oldVal, newVal) {
w.notifyWatchers(path)
}
}
// Check for deletions
for path := range oldValues {
if _, exists := newValues[path]; !exists {
w.notifyWatchers(path)
}
}
case <-ctx.Done():
// Reload timeout
w.notifyWatchers("reload_timeout")
}
}
// subscribe creates a new watcher channel
func (w *watcher) subscribe() <-chan string {
w.mu.Lock()
defer w.mu.Unlock()
// Check watcher limit
if len(w.watchers) >= w.opts.MaxWatchers {
// Return closed channel to prevent resource exhaustion
ch := make(chan string)
close(ch)
return ch
}
// Create buffered channel to prevent blocking
ch := make(chan string, 10)
id := w.watcherID.Add(1)
w.watchers[id] = ch
// Cleanup goroutine
go func() {
<-w.ctx.Done()
w.mu.Lock()
delete(w.watchers, id)
close(ch)
w.mu.Unlock()
}()
return ch
}
// notifyWatchers sends change notification to all subscribers
func (w *watcher) notifyWatchers(path string) {
w.mu.RLock()
defer w.mu.RUnlock()
for id, ch := range w.watchers {
select {
case ch <- path:
// Sent successfully
default:
// Channel full or closed, skip
// Could implement removal of dead watchers here
_ = id
}
}
}
// stop terminates the watcher
func (w *watcher) stop() {
w.cancel()
// Stop debounce timer
w.mu.Lock()
if w.debounceTimer != nil {
w.debounceTimer.Stop()
}
w.mu.Unlock()
// Wait for watch loop to exit
for w.watching.Load() {
time.Sleep(10 * time.Millisecond)
}
}
// getConfigFilePath returns the current config file path
func (c *Config) getConfigFilePath() string {
// Access the tracked config file path
return c.configFilePath
}
// snapshot creates a snapshot of current values
func (c *Config) snapshot() map[string]any {
c.mutex.RLock()
defer c.mutex.RUnlock()
snapshot := make(map[string]any, len(c.items))
for path, item := range c.items {
snapshot[path] = item.currentValue
}
return snapshot
}
// reloadFileAtomic atomically reloads the configuration file
func (c *Config) reloadFileAtomic(filePath string) error {
// Read file
fileData, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}
// SECURITY: Check file size to prevent DoS
if len(fileData) > MaxValueSize*10 { // 10MB max for config file
return fmt.Errorf("config file too large: %d bytes", len(fileData))
}
// Parse TOML
fileConfig := make(map[string]any)
if err := toml.Unmarshal(fileData, &fileConfig); err != nil {
return fmt.Errorf("failed to parse TOML: %w", err)
}
// Flatten the configuration
flattenedFileConfig := flattenMap(fileConfig, "")
// Apply atomically
c.mutex.Lock()
defer c.mutex.Unlock()
// Clear old file data
c.fileData = make(map[string]any)
// Apply new values
for path, value := range flattenedFileConfig {
if item, exists := c.items[path]; exists {
if item.values == nil {
item.values = make(map[Source]any)
}
item.values[SourceFile] = value
item.currentValue = c.computeValue(path, item)
c.items[path] = item
c.fileData[path] = value
}
}
// Remove file values not in new config
for path, item := range c.items {
if _, exists := flattenedFileConfig[path]; !exists {
delete(item.values, SourceFile)
item.currentValue = c.computeValue(path, item)
c.items[path] = item
}
}
c.invalidateCache()
return nil
}

364
watch_test.go Normal file
View File

@ -0,0 +1,364 @@
// FILE: lixenwraith/config/watch_test.go
package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
)
func TestAutoUpdate(t *testing.T) {
// Create temporary config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml")
initialConfig := `
[server]
port = 8080
host = "localhost"
[features]
enabled = true
`
if err := os.WriteFile(configPath, []byte(initialConfig), 0644); err != nil {
t.Fatal("Failed to write initial config:", err)
}
// Create config with defaults
type TestConfig struct {
Server struct {
Port int `toml:"port"`
Host string `toml:"host"`
} `toml:"server"`
Features struct {
Enabled bool `toml:"enabled"`
} `toml:"features"`
}
defaults := &TestConfig{}
defaults.Server.Port = 3000
defaults.Server.Host = "0.0.0.0"
// Build config
cfg, err := NewBuilder().
WithDefaults(defaults).
WithFile(configPath).
Build()
if err != nil {
t.Fatal("Failed to build config:", err)
}
// Verify initial values
if port, _ := cfg.Get("server.port"); port.(int64) != 8080 {
t.Errorf("Expected port 8080, got %d", port)
}
// Enable auto-update with fast polling
opts := WatchOptions{
PollInterval: 100 * time.Millisecond,
Debounce: 50 * time.Millisecond,
MaxWatchers: 10,
}
cfg.AutoUpdateWithOptions(opts)
defer cfg.StopAutoUpdate()
// Start watching
changes := cfg.Watch()
// Collect changes
var mu sync.Mutex
changedPaths := make(map[string]bool)
go func() {
for path := range changes {
mu.Lock()
changedPaths[path] = true
mu.Unlock()
}
}()
// Update config file
updatedConfig := `
[server]
port = 9090
host = "0.0.0.0"
[features]
enabled = false
`
if err := os.WriteFile(configPath, []byte(updatedConfig), 0644); err != nil {
t.Fatal("Failed to update config:", err)
}
// Wait for changes to be detected
time.Sleep(300 * time.Millisecond)
// Verify new values
if port, _ := cfg.Get("server.port"); port.(int64) != 9090 {
t.Errorf("Expected port 9090 after update, got %d", port)
}
if host, _ := cfg.Get("server.host"); host.(string) != "0.0.0.0" {
t.Errorf("Expected host 0.0.0.0 after update, got %s", host)
}
if enabled, _ := cfg.Get("features.enabled"); enabled.(bool) != false {
t.Errorf("Expected features.enabled to be false after update")
}
// Check that changes were notified
mu.Lock()
defer mu.Unlock()
expectedChanges := []string{"server.port", "server.host", "features.enabled"}
for _, path := range expectedChanges {
if !changedPaths[path] {
t.Errorf("Expected change notification for %s", path)
}
}
}
func TestWatchFileDeleted(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml")
// Create initial config
if err := os.WriteFile(configPath, []byte(`test = "value"`), 0644); err != nil {
t.Fatal("Failed to write config:", err)
}
cfg := New()
cfg.Register("test", "default")
if err := cfg.LoadFile(configPath); err != nil {
t.Fatal("Failed to load config:", err)
}
// Enable watching
opts := WatchOptions{
PollInterval: 100 * time.Millisecond,
Debounce: 50 * time.Millisecond,
}
cfg.AutoUpdateWithOptions(opts)
defer cfg.StopAutoUpdate()
changes := cfg.Watch()
// Delete file
if err := os.Remove(configPath); err != nil {
t.Fatal("Failed to delete config:", err)
}
// Wait for deletion detection
select {
case path := <-changes:
if path != "file_deleted" {
t.Errorf("Expected file_deleted, got %s", path)
}
case <-time.After(500 * time.Millisecond):
t.Error("Timeout waiting for deletion notification")
}
}
func TestWatchPermissionChange(t *testing.T) {
// Skip on Windows where permission model is different
if runtime.GOOS == "windows" {
t.Skip("Skipping permission test on Windows")
}
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml")
// Create config with specific permissions
if err := os.WriteFile(configPath, []byte(`test = "value"`), 0644); err != nil {
t.Fatal("Failed to write config:", err)
}
cfg := New()
cfg.Register("test", "default")
if err := cfg.LoadFile(configPath); err != nil {
t.Fatal("Failed to load config:", err)
}
// Enable watching with permission verification
opts := WatchOptions{
PollInterval: 100 * time.Millisecond,
Debounce: 50 * time.Millisecond,
VerifyPermissions: true,
}
cfg.AutoUpdateWithOptions(opts)
defer cfg.StopAutoUpdate()
changes := cfg.Watch()
// Change permissions to world-writable (security risk)
if err := os.Chmod(configPath, 0666); err != nil {
t.Fatal("Failed to change permissions:", err)
}
// Wait for permission change detection
select {
case path := <-changes:
if path != "permissions_changed" {
t.Errorf("Expected permissions_changed, got %s", path)
}
case <-time.After(500 * time.Millisecond):
t.Error("Timeout waiting for permission change notification")
}
}
func TestMaxWatchers(t *testing.T) {
cfg := New()
cfg.Register("test", "value")
// Create config file
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml")
if err := os.WriteFile(configPath, []byte(`test = "value"`), 0644); err != nil {
t.Fatal("Failed to write config:", err)
}
if err := cfg.LoadFile(configPath); err != nil {
t.Fatal("Failed to load config:", err)
}
// Enable watching with low max watchers
opts := WatchOptions{
PollInterval: 100 * time.Millisecond,
MaxWatchers: 3,
}
cfg.AutoUpdateWithOptions(opts)
defer cfg.StopAutoUpdate()
// Create maximum allowed watchers
channels := make([]<-chan string, 0, 4)
for i := 0; i < 4; i++ {
ch := cfg.Watch()
channels = append(channels, ch)
// Check if channel is open
select {
case _, ok := <-ch:
if !ok && i < 3 {
t.Errorf("Channel %d should be open", i)
} else if ok && i == 3 {
t.Error("Channel 3 should be closed (max watchers exceeded)")
}
default:
// Channel is open and empty, expected for first 3
if i == 3 {
// Try to receive with timeout to verify it's closed
select {
case _, ok := <-ch:
if ok {
t.Error("Channel 3 should be closed")
}
case <-time.After(10 * time.Millisecond):
t.Error("Channel 3 should be closed immediately")
}
}
}
}
}
func TestDebounce(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "test.toml")
// Create initial config
if err := os.WriteFile(configPath, []byte(`value = 1`), 0644); err != nil {
t.Fatal("Failed to write config:", err)
}
cfg := New()
cfg.Register("value", 0)
if err := cfg.LoadFile(configPath); err != nil {
t.Fatal("Failed to load config:", err)
}
// Enable watching with longer debounce
opts := WatchOptions{
PollInterval: 50 * time.Millisecond,
Debounce: 200 * time.Millisecond,
}
cfg.AutoUpdateWithOptions(opts)
defer cfg.StopAutoUpdate()
changes := cfg.Watch()
changeCount := 0
go func() {
for range changes {
changeCount++
}
}()
// Make rapid changes
for i := 2; i <= 5; i++ {
content := fmt.Sprintf(`value = %d`, i)
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
t.Fatal("Failed to write config:", err)
}
time.Sleep(50 * time.Millisecond) // Less than debounce period
}
// Wait for debounce to complete
time.Sleep(300 * time.Millisecond)
// Should only see one change due to debounce
if changeCount != 1 {
t.Errorf("Expected 1 change due to debounce, got %d", changeCount)
}
// Verify final value
val, _ := cfg.Get("value")
if val.(int64) != 5 {
t.Errorf("Expected final value 5, got %d", val)
}
}
// Benchmark file watching overhead
func BenchmarkWatchOverhead(b *testing.B) {
tmpDir := b.TempDir()
configPath := filepath.Join(tmpDir, "bench.toml")
// Create config with many values
var configContent string
for i := 0; i < 100; i++ {
configContent += fmt.Sprintf("value%d = %d\n", i, i)
}
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
b.Fatal("Failed to write config:", err)
}
cfg := New()
for i := 0; i < 100; i++ {
cfg.Register(fmt.Sprintf("value%d", i), 0)
}
if err := cfg.LoadFile(configPath); err != nil {
b.Fatal("Failed to load config:", err)
}
// Enable watching
opts := WatchOptions{
PollInterval: 100 * time.Millisecond,
}
cfg.AutoUpdateWithOptions(opts)
defer cfg.StopAutoUpdate()
// Benchmark value retrieval with watching enabled
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = cfg.Get(fmt.Sprintf("value%d", i%100))
}
}