diff --git a/README.md b/README.md index b8734e5..f6b7bb1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,21 @@ # 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 ```bash -go get github.com/LixenWraith/config +go get github.com/lixenwraith/config ``` ## Quick Start @@ -15,7 +25,6 @@ package main import ( "log" - "github.com/lixenwraith/config" ) @@ -24,157 +33,33 @@ type AppConfig 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() { - // Define defaults - defaults := AppConfig{} + defaults := &AppConfig{} defaults.Server.Host = "localhost" 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") if err != nil { log.Fatal(err) } - // Access values - host, _ := cfg.String("server.host") - 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) + port, _ := cfg.Get("server.port") + log.Printf("Server port: %d", port.(int64)) } ``` -**config.toml:** -```toml -[server] -host = "production.example.com" -port = 9090 +## Documentation -[database] -url = "postgres://prod-db/myapp" -max_conns = 50 - -debug = false -``` - -**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 +- [Quick Start Guide](doc/quick-start.md) - Get up and running quickly +- [Builder Pattern](doc/builder.md) - Advanced configuration with the builder +- [Command Line](doc/cli.md) - CLI argument handling +- [Environment Variables](doc/env.md) - Environment variable configuration +- [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 ## License diff --git a/builder.go b/builder.go index 6da150d..39d0122 100644 --- a/builder.go +++ b/builder.go @@ -5,17 +5,16 @@ import ( "errors" "fmt" "os" + "reflect" ) -// 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 - -// Builder provides a fluent interface for building configurations +// Builder provides a fluent API for constructing a Config instance. It allows for +// chaining configuration options before final build of the config object. type Builder struct { cfg *Config opts LoadOptions defaults any + tagName string prefix string file string args []string @@ -23,6 +22,10 @@ type Builder struct { 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 func NewBuilder() *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 func (b *Builder) WithDefaults(defaults any) *Builder { b.defaults = defaults 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 func (b *Builder) WithPrefix(prefix string) *Builder { b.prefix = prefix @@ -86,72 +157,43 @@ func (b *Builder) WithEnvWhitelist(paths ...string) *Builder { 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 // 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 { if fn != nil { b.validators = append(b.validators, fn) } 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 } \ No newline at end of file diff --git a/builder_test.go b/builder_test.go deleted file mode 100644 index a22b542..0000000 --- a/builder_test.go +++ /dev/null @@ -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"]) - }) -} \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..6bfd1f7 --- /dev/null +++ b/cmd/main.go @@ -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 +*/ \ No newline at end of file diff --git a/cmd/test/main.go b/cmd/test/main.go deleted file mode 100644 index 7560ab3..0000000 --- a/cmd/test/main.go +++ /dev/null @@ -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") -} \ No newline at end of file diff --git a/config.go b/config.go index 5170c28..5a8f7ae 100644 --- a/config.go +++ b/config.go @@ -7,9 +7,14 @@ package config import ( "errors" "fmt" + "reflect" "sync" + "sync/atomic" ) +// Max config item value size to prevent misuse +const MaxValueSize = 1024 * 1024 // 1MB + // Errors var ( // ErrConfigNotFound indicates the specified configuration file was not found. @@ -19,65 +24,13 @@ var ( ErrCLIParse = errors.New("failed to parse command-line arguments") // ErrEnvParse indicates that parsing environment variables failed. + // TODO: use in loader:loadEnv or remove 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 type configItem struct { defaultValue any @@ -85,14 +38,31 @@ type configItem struct { 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 { - items map[string]configItem - mutex sync.RWMutex - options LoadOptions // Current load options - fileData map[string]any // Cached file data - envData map[string]any // Cached env data - cliData map[string]any // Cached CLI data + items map[string]configItem + mutex sync.RWMutex + options LoadOptions // Current load options + fileData map[string]any // Cached file data + envData map[string]any // Cached env data + cliData map[string]any // Cached CLI data + version atomic.Int64 + structCache *structCache + + // File watching support + watcher *watcher + configFilePath string // Track loaded file path } // New creates and initializes a new Config instance. @@ -142,9 +112,7 @@ func (c *Config) computeValue(path string, item configItem) any { return item.defaultValue } -// Get retrieves a configuration value using the path. -// It returns the current value based on configured precedence. -// The second return value indicates if the path was registered. +// Get retrieves a configuration value using the path and indicator if the path was registered func (c *Config) Get(path string) (any, bool) { c.mutex.RLock() 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. -// It sets the value in the highest priority source (typically CLI). -// Returns an error if the path is not registered. +// It sets the value in the highest priority source from the configured Sources. +// By default, this is SourceCLI. Returns an error if the path is not registered. +// To set a value in a specific source, use SetSource instead. func (c *Config) Set(path string, value any) error { return c.SetSource(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.invalidateCache() // Invalidate cache after changes return nil } @@ -242,6 +212,8 @@ func (c *Config) Reset() { item.currentValue = item.defaultValue c.items[path] = item } + + c.invalidateCache() // Invalidate cache after changes } // ResetSource clears all values from a specific source @@ -265,4 +237,55 @@ func (c *Config) ResetSource(source Source) { item.currentValue = c.computeValue(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 } \ No newline at end of file diff --git a/convenience.go b/convenience.go index 7dec015..20fdb2b 100644 --- a/convenience.go +++ b/convenience.go @@ -86,16 +86,22 @@ func (c *Config) GenerateFlags() *flag.FlagSet { // BindFlags updates configuration from parsed flag.FlagSet func (c *Config) BindFlags(fs *flag.FlagSet) error { var errors []error + needsInvalidation := false fs.Visit(func(f *flag.Flag) { value := f.Value.String() - parsed := parseValue(value) - - if err := c.SetSource(f.Name, SourceCLI, parsed); err != nil { + // Let mapstructure handle type conversion + if err := c.SetSource(f.Name, SourceCLI, value); err != nil { 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 { 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 } -// 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 func (c *Config) Debug() string { c.mutex.RLock() @@ -228,4 +224,13 @@ func (c *Config) Clone() *Config { } 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() } \ No newline at end of file diff --git a/decode.go b/decode.go new file mode 100644 index 0000000..d61bd91 --- /dev/null +++ b/decode.go @@ -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 +} \ No newline at end of file diff --git a/discovery.go b/discovery.go new file mode 100644 index 0000000..2c9c246 --- /dev/null +++ b/discovery.go @@ -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 +} \ No newline at end of file diff --git a/doc.go b/doc.go deleted file mode 100644 index db7dc15..0000000 --- a/doc.go +++ /dev/null @@ -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 \ No newline at end of file diff --git a/doc/access.md b/doc/access.md new file mode 100644 index 0000000..9699edf --- /dev/null +++ b/doc/access.md @@ -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 \ No newline at end of file diff --git a/doc/builder.md b/doc/builder.md new file mode 100644 index 0000000..cfb3391 --- /dev/null +++ b/doc/builder.md @@ -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 \ No newline at end of file diff --git a/doc/cli.md b/doc/cli.md new file mode 100644 index 0000000..186aeb1 --- /dev/null +++ b/doc/cli.md @@ -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 \ No newline at end of file diff --git a/doc/env.md b/doc/env.md new file mode 100644 index 0000000..e39c772 --- /dev/null +++ b/doc/env.md @@ -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 \ No newline at end of file diff --git a/doc/file.md b/doc/file.md new file mode 100644 index 0000000..56aa801 --- /dev/null +++ b/doc/file.md @@ -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 \ No newline at end of file diff --git a/doc/quick-start.md b/doc/quick-start.md new file mode 100644 index 0000000..9b496a1 --- /dev/null +++ b/doc/quick-start.md @@ -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 \ No newline at end of file diff --git a/doc/reconfiguration.md b/doc/reconfiguration.md new file mode 100644 index 0000000..a5073ea --- /dev/null +++ b/doc/reconfiguration.md @@ -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 \ No newline at end of file diff --git a/env_test.go b/env_test.go deleted file mode 100644 index 279c834..0000000 --- a/env_test.go +++ /dev/null @@ -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) - }) -} \ No newline at end of file diff --git a/helper.go b/helper.go index f7b1684..5529324 100644 --- a/helper.go +++ b/helper.go @@ -85,16 +85,4 @@ func isValidKeySegment(s string) bool { } } 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' } \ No newline at end of file diff --git a/io.go b/loader.go similarity index 79% rename from io.go rename to loader.go index 49c907b..46ce000 100644 --- a/io.go +++ b/loader.go @@ -1,4 +1,4 @@ -// File: lixenwraith/config/io.go +// File: lixenwraith/config/loader.go package config import ( @@ -7,12 +7,72 @@ import ( "fmt" "os" "path/filepath" - "strconv" "strings" "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. // This is a convenience method that maintains backward compatibility. func (c *Config) Load(filePath string, args []string) error { @@ -102,6 +162,11 @@ func (c *Config) loadFile(path string) error { c.mutex.Lock() defer c.mutex.Unlock() + // Track the config file path for watching + c.configFilePath = path + + defer c.invalidateCache() // Invalidate cache after changes + // Store in cache c.fileData = flattenedFileConfig @@ -111,6 +176,9 @@ func (c *Config) loadFile(path string) error { if item.values == nil { item.values = make(map[Source]any) } + if str, ok := value.(string); ok && len(str) > MaxValueSize { + return ErrValueSize + } item.values[SourceFile] = value item.currentValue = c.computeValue(path, item) c.items[path] = item @@ -123,45 +191,36 @@ func (c *Config) loadFile(path string) error { // loadEnv loads configuration from environment variables func (c *Config) loadEnv(opts LoadOptions) error { - // Default transform function transform := opts.EnvTransform if transform == nil { - transform = func(path string) string { - // Convert dots to underscores and uppercase - env := strings.ReplaceAll(path, ".", "_") - env = strings.ToUpper(env) - if opts.EnvPrefix != "" { - env = opts.EnvPrefix + env - } - return env - } + transform = defaultEnvTransform(opts.EnvPrefix) } c.mutex.Lock() defer c.mutex.Unlock() - // Clear previous env data + defer c.invalidateCache() // Invalidate cache after changes + c.envData = make(map[string]any) - // Check each registered path for corresponding env var for path, item := range c.items { - // Skip if whitelisted and not in whitelist if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] { continue } envVar := transform(path) if value, exists := os.LookupEnv(envVar); exists { - // Parse the string value - parsedValue := parseValue(value) - + // Store raw string value - mapstructure will handle conversion if item.values == nil { 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) 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) } - // Flatten CLI data flattenedCLI := flattenMap(parsedCLI, "") c.mutex.Lock() defer c.mutex.Unlock() - // Store in cache c.cliData = flattenedCLI - // Apply to registered paths for path, value := range flattenedCLI { if item, exists := c.items[path]; exists { if item.values == nil { @@ -194,9 +250,9 @@ func (c *Config) loadCLI(args []string) error { item.currentValue = c.computeValue(path, item) c.items[path] = item } - // Ignore unregistered paths from CLI } + c.invalidateCache() // Invalidate cache after changes return nil } @@ -260,20 +316,13 @@ func defaultEnvTransform(prefix string) EnvTransformFunc { } // 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 { - // Try boolean - if v, err := strconv.ParseBool(s); err == nil { - return v + if s == "true" { + return true } - - // Try int64 - 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 + if s == "false" { + return false } // Remove quotes if present @@ -281,7 +330,7 @@ func parseValue(s string) any { return s[1 : len(s)-1] } - // Return as string + // Return as string - mapstructure will convert as needed return s } @@ -370,14 +419,13 @@ func (c *Config) SaveSource(path string, source Source) error { c.mutex.RUnlock() - // Use the same atomic save logic + // Marshal using BurntSushi/toml var buf bytes.Buffer encoder := toml.NewEncoder(&buf) 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()) } diff --git a/register.go b/register.go index eeb466d..e10e589 100644 --- a/register.go +++ b/register.go @@ -6,8 +6,6 @@ import ( "os" "reflect" "strings" - - "github.com/mitchellh/mapstructure" ) // 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. // The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed. 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) // Handle pointer or direct struct value if v.Kind() == reflect.Ptr { 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() } 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 - // Use a helper function for recursive registration - c.registerFields(v, prefix, "", &errors) + // Use helper function for recursive registration with specified tag + c.registerFields(v, prefix, "", &errors, tagName) if len(errors) > 0 { 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 } -// 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. -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() for i := 0; i < v.NumField(); i++ { @@ -154,16 +151,13 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e continue } - // Get tag value or use field name - tag := field.Tag.Get("toml") + // Get tag value based on tagName parameter + tag := field.Tag.Get(tagName) if tag == "-" { - continue // Skip this field + continue } - // Check for additional tags - envTag := field.Tag.Get("env") // Explicit env var name - required := field.Tag.Get("required") == "true" - + // Fall back to field name if no tag key := field.Name if 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 currentPath := key if pathPrefix != "" { @@ -181,6 +179,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e currentPath = pathPrefix + key } + // TODO: use mapstructure instead of logic with reflection // Handle nested structs recursively fieldType := fieldValue.Type() 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 nestedPrefix := currentPath + "." - c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors) + c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors, tagName) continue } @@ -259,164 +258,12 @@ func (c *Config) GetRegisteredPathsWithDefaults(prefix string) map[string]any { return result } -// Scan decodes the configuration data under a specific base path -// 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. +// Scan decodes configuration into target using the unified unmarshal function func (c *Config) Scan(basePath string, target any) error { - // Validate target - 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 + return c.unmarshal(basePath, "", target) // Empty source means use merged state } -// 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 { - // Validate 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) + return c.unmarshal(basePath, source, target) } \ No newline at end of file diff --git a/source_test.go b/source_test.go deleted file mode 100644 index d54d330..0000000 --- a/source_test.go +++ /dev/null @@ -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) - }) -} \ No newline at end of file diff --git a/type.go b/type.go deleted file mode 100644 index 5b0420a..0000000 --- a/type.go +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/watch.go b/watch.go new file mode 100644 index 0000000..f18396b --- /dev/null +++ b/watch.go @@ -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 +} \ No newline at end of file diff --git a/watch_test.go b/watch_test.go new file mode 100644 index 0000000..6c92196 --- /dev/null +++ b/watch_test.go @@ -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)) + } +} \ No newline at end of file