diff --git a/.gitignore b/.gitignore index 9c9b403..3b3200b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,4 @@ log logs script *.log -bin -example +bin \ No newline at end of file diff --git a/builder.go b/builder.go index 5540b64..6f0a9b5 100644 --- a/builder.go +++ b/builder.go @@ -48,11 +48,20 @@ func (b *Builder) Build() (*Config, error) { tagName = "toml" } - // Register defaults if provided + // The logic for registering defaults must be prioritized: + // 1. If WithDefaults() was called, it takes precedence. + // 2. If not, but WithTarget() was called, use the target struct for defaults. if b.defaults != nil { + // WithDefaults() was called explicitly. if err := b.cfg.RegisterStructWithTags(b.prefix, b.defaults, tagName); err != nil { return nil, fmt.Errorf("failed to register defaults: %w", err) } + } else if b.cfg.structCache != nil && b.cfg.structCache.target != nil { + // No explicit defaults, so use the target struct as the source of defaults. + // This is the behavior the tests rely on. + if err := b.cfg.RegisterStructWithTags(b.prefix, b.cfg.structCache.target, tagName); err != nil { + return nil, fmt.Errorf("failed to register target struct as defaults: %w", err) + } } // Explicitly set the file path on the config object so the watcher can find it, @@ -179,10 +188,12 @@ func (b *Builder) WithTarget(target any) *Builder { } } - // Register struct fields automatically - if b.defaults == nil { - b.defaults = target - } + // NOTE: removed since it would cause issues when an empty struct is passed + // TODO: may cause issue in other scenarios, test extensively + // // Register struct fields automatically + // if b.defaults == nil { + // b.defaults = target + // } return b } diff --git a/doc/builder.md b/doc/builder.md index cfb3391..6abba10 100644 --- a/doc/builder.md +++ b/doc/builder.md @@ -214,7 +214,61 @@ This searches for configuration files in: 3. Current directory 4. XDG config directories (`~/.config/myapp/`, `/etc/myapp/`) -## Advanced Patterns +## Method Interaction and Precedence + +While most builder methods can be chained in any order, it's important to understand how `WithDefaults` and `WithTarget` interact to define the default configuration values. + +### `WithDefaults` Has Precedence + +**Rule:** If `WithDefaults()` is used anywhere in the chain, it will **always** be the definitive source for default values. + +This is the recommended approach for clarity and explicitness. It cleanly separates the struct that defines the defaults from the struct that will be populated. + +**Example (Recommended Pattern):** + +```go +// initialData contains the fallback values. +initialData := &AppConfig{ + Server: ServerConfig{Port: 8080}, +} + +// target is an empty shell for population. +var target AppConfig + +// WithDefaults explicitly sets the defaults. +// WithTarget sets up the config for type-safe decoding. +cfg, err := config.NewBuilder(). + WithTarget(&target). + WithDefaults(initialData). + WithFile("config.toml"). + Build() +``` + +In this scenario, the `target` struct is *only* used for type information and `AsStruct()` functionality; its initial (zero) values are not used as defaults as per below. + +### Using `WithTarget` for Defaults + +**Rule:** If `WithDefaults()` is **not** used, the struct passed to `WithTarget()` will serve as the source of default values. + +This provides a convenient shorthand for simpler cases where the initial state of your application's config struct *is* the desired default state. The unit tests for the package rely on this behavior. + +**Example (Convenience Pattern):** + +```go +// The initial state of this struct will be used as the defaults. +target := &AppConfig{ + Server: ServerConfig{Port: 8080}, +} + +// Since WithDefaults() is absent, the builder uses `target` +// for both defaults and for type-safe decoding. +cfg, err := config.NewBuilder(). + WithTarget(&target). + WithFile("config.toml"). + Build() +``` + +## Usage Patterns ### Type-Safe Configuration Access diff --git a/example/main.go b/example/main.go new file mode 100644 index 0000000..c8c0ee8 --- /dev/null +++ b/example/main.go @@ -0,0 +1,232 @@ +// FILE: lixenwraith/config/example/main.go +package main + +import ( + "fmt" + "log" + "os" + "strconv" + "sync" + "time" + + "config" +) + +// AppConfig defines a richer configuration structure to showcase more features. +type AppConfig struct { + Server struct { + Host string `toml:"host"` + Port int64 `toml:"port"` + LogLevel string `toml:"log_level"` + } `toml:"server"` + FeatureFlags map[string]bool `toml:"feature_flags"` +} + +const configFilePath = "config.toml" + +func main() { + // ========================================================================= + // PART 1: INITIAL SETUP + // Create a clean config.toml file on disk for our program to read. + // ========================================================================= + log.Println("---") + log.Println("โžก๏ธ PART 1: Creating initial configuration file...") + + // Defer cleanup to run at the very end of the program. + defer func() { + log.Println("---") + log.Println("๐Ÿงน Cleaning up...") + os.Remove(configFilePath) + // Unset the environment variable we use for testing. + os.Unsetenv("APP_SERVER_PORT") + log.Printf("Removed %s and unset APP_SERVER_PORT.", configFilePath) + }() + + initialData := &AppConfig{} + initialData.Server.Host = "localhost" + initialData.Server.Port = 8080 + initialData.Server.LogLevel = "info" + initialData.FeatureFlags = map[string]bool{"enable_metrics": true} + + if err := createInitialConfigFile(initialData); err != nil { + log.Fatalf("โŒ Failed during initial file creation: %v", err) + } + log.Printf("โœ… Initial configuration saved to %s.", configFilePath) + + // ========================================================================= + // PART 2: RECOMMENDED CONFIGURATION USING THE BUILDER + // This demonstrates source precedence, validation, and type-safe targets. + // ========================================================================= + log.Println("---") + log.Println("โžก๏ธ PART 2: Configuring manager with the Builder...") + + // Set an environment variable to demonstrate source precedence (Env > File). + os.Setenv("APP_SERVER_PORT", "8888") + log.Println(" (Set environment variable APP_SERVER_PORT=8888)") + + // Create a "target" struct. The builder will automatically populate this + // and keep it updated when using `AsStruct()`. + target := &AppConfig{} + + // Define a custom validator function. + validator := func(c *config.Config) error { + p, _ := c.Get("server.port") + // 'p' can be an int64 (from defaults/TOML) or a string (from environment variables). + + var port int64 + var err error + + switch v := p.(type) { + case string: + // If it's a string from an env var, parse it. + port, err = strconv.ParseInt(v, 10, 64) + if err != nil { + return fmt.Errorf("could not parse port from string '%s': %w", v, err) + } + case int64: + // If it's already an int64, just use it. + port = v + default: + // Handle any other unexpected types. + return fmt.Errorf("unexpected type for server.port: %T", p) + } + + if port < 1024 || port > 65535 { + return fmt.Errorf("port %d is outside the recommended range (1024-65535)", port) + } + return nil + } + + // Use the builder to chain multiple configuration options. + builder := config.NewBuilder(). + WithTarget(target). // Enables type-safe `AsStruct()` and auto-registration. + WithDefaults(initialData). // Explicitly set the source of defaults. + WithFile(configFilePath). // Specifies the config file to read. + WithEnvPrefix("APP_"). // Sets prefix for environment variables (e.g., APP_SERVER_PORT). + WithValidator(validator) // Adds a validation function to run at the end of the build. + + // Build the final config object. + cfg, err := builder.Build() + if err != nil { + log.Fatalf("โŒ Builder failed: %v", err) + } + + log.Println("โœ… Builder finished successfully. Initial values loaded.") + initialTarget, _ := cfg.AsStruct() + printCurrentState(initialTarget.(*AppConfig), "Initial State (Env overrides File)") + + // ========================================================================= + // PART 3: DYNAMIC RELOADING WITH THE WATCHER + // We'll now modify the file and verify the watcher updates the config. + // ========================================================================= + log.Println("---") + log.Println("โžก๏ธ PART 3: Testing the file watcher...") + + // Use WithOptions to demonstrate customizing the watcher. + watchOpts := config.WatchOptions{ + PollInterval: 250 * time.Millisecond, + Debounce: 100 * time.Millisecond, + } + cfg.AutoUpdateWithOptions(watchOpts) + changes := cfg.Watch() + log.Println("โœ… Watcher is now active with custom options.") + + // Start a goroutine to modify the file after a short delay. + var wg sync.WaitGroup + wg.Add(1) + go modifyFileOnDiskStructurally(&wg) + log.Println(" (Modifier goroutine dispatched to change file in 1 second...)") + + log.Println(" (Waiting for watcher notification...)") + select { + case path := <-changes: + log.Printf("โœ… Watcher detected a change for path: '%s'", path) + log.Println(" Verifying in-memory config using AsStruct()...") + + // Retrieve the updated, type-safe struct. + updatedTarget, err := cfg.AsStruct() + if err != nil { + log.Fatalf("โŒ AsStruct() failed after update: %v", err) + } + + // Type-assert and verify the new values. + typedCfg := updatedTarget.(*AppConfig) + expectedLevel := "debug" + if typedCfg.Server.LogLevel != expectedLevel { + log.Fatalf("โŒ VERIFICATION FAILED: Expected log_level '%s', but got '%s'.", expectedLevel, typedCfg.Server.LogLevel) + } + + log.Println("โœ… VERIFICATION SUCCESSFUL: In-memory config was updated by the watcher.") + printCurrentState(typedCfg, "Final State (Updated by Watcher)") + + case <-time.After(5 * time.Second): + log.Fatalf("โŒ TEST FAILED: Timed out waiting for watcher notification.") + } + + wg.Wait() +} + +// createInitialConfigFile is a helper to set up the initial file state. +func createInitialConfigFile(data *AppConfig) error { + cfg := config.New() + if err := cfg.RegisterStruct("", data); err != nil { + return err + } + return cfg.Save(configFilePath) +} + +// modifyFileOnDiskStructurally simulates an external program robustly changing the config file. +func modifyFileOnDiskStructurally(wg *sync.WaitGroup) { + defer wg.Done() + time.Sleep(1 * time.Second) + log.Println(" (Modifier goroutine: now changing file on disk...)") + + modifierCfg := config.New() + if err := modifierCfg.RegisterStruct("", &AppConfig{}); err != nil { + log.Fatalf("โŒ Modifier failed to register struct: %v", err) + } + if err := modifierCfg.LoadFile(configFilePath); err != nil { + log.Fatalf("โŒ Modifier failed to load file: %v", err) + } + + // Change the log level and add a new feature flag. + modifierCfg.Set("server.log_level", "debug") + + rawFlags, _ := modifierCfg.Get("feature_flags") + newFlags := make(map[string]any) + + // Use a type switch to robustly handle the map, regardless of its source. + switch flags := rawFlags.(type) { + case map[string]bool: + for k, v := range flags { + newFlags[k] = v + } + case map[string]any: + for k, v := range flags { + newFlags[k] = v + } + default: + log.Fatalf("โŒ Modifier encountered unexpected type for feature_flags: %T", rawFlags) + } + + // Now modify the generic map and set it back. + newFlags["enable_tracing"] = false + modifierCfg.Set("feature_flags", newFlags) + + if err := modifierCfg.Save(configFilePath); err != nil { + log.Fatalf("โŒ Modifier failed to save file: %v", err) + } + log.Println(" (Modifier goroutine: finished.)") +} + +// printCurrentState is a helper to display the typed config state. +func printCurrentState(cfg *AppConfig, title string) { + fmt.Println(" --------------------------------------------------") + fmt.Printf(" %s\n", title) + fmt.Println(" --------------------------------------------------") + fmt.Printf(" Server Host: %s\n", cfg.Server.Host) + fmt.Printf(" Server Port: %d\n", cfg.Server.Port) + fmt.Printf(" Server Log Level: %s\n", cfg.Server.LogLevel) + fmt.Printf(" Feature Flags: %v\n", cfg.FeatureFlags) + fmt.Println(" --------------------------------------------------") +} \ No newline at end of file diff --git a/go.mod b/go.mod index ffaa49d..d541f78 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.24.5 require ( github.com/BurntSushi/toml v1.5.0 - github.com/lixenwraith/config v0.0.0-20250719015120-e02ee494d440 github.com/mitchellh/mapstructure v1.5.0 github.com/stretchr/testify v1.10.0 ) diff --git a/go.sum b/go.sum index e2ab293..d199406 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-viper/mapstructure v1.6.0 h1:0WdPOF2rmmQDN1xo8qIgxyugvLp71HrZSWyGLxofobw= github.com/go-viper/mapstructure v1.6.0/go.mod h1:FcbLReH7/cjaC0RVQR+LHFIrBhHF3s1e/ud1KMDoBVw= -github.com/lixenwraith/config v0.0.0-20250719015120-e02ee494d440 h1:O6nHnpeDfIYQ1WxCtA2gkm8upQ4RW21DUMlQz5DKJCU= -github.com/lixenwraith/config v0.0.0-20250719015120-e02ee494d440/go.mod h1:y7kgDrWIFROWJJ6ASM/SPTRRAj27FjRGWh2SDLcdQ68= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=