e5.1.0 Example added, improved builder, doc updated.

This commit is contained in:
2025-07-19 21:43:01 -04:00
parent e9b55063ff
commit 573eef8d78
6 changed files with 304 additions and 11 deletions

3
.gitignore vendored
View File

@ -5,5 +5,4 @@ log
logs
script
*.log
bin
example
bin

View File

@ -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
}

View File

@ -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

232
example/main.go Normal file
View File

@ -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(" --------------------------------------------------")
}

1
go.mod
View File

@ -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
)

2
go.sum
View File

@ -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=