v0.2.0 toml-only, harden concurrency and env export, dep updates
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-09-28 16:55:58 -04:00
1 parent 3c35601179
commit e08467bf38
42 files changed
+2533 -4274

No files matched your search

+37 -401
View File
@@ -1,401 +1,37 @@
# 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(config.SourceEnv, "server.port")
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(&serverConfig, "server"); 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)
```
### GetTyped
Retrieves a single configuration value and decodes it to the specified type.
```go
import "time"
// Returns an int, converting from string "9090" if necessary.
port, err := config.GetTyped[int](cfg, "server.port")
// Returns a time.Duration, converting from string "5m30s".
timeout, err := config.GetTyped[time.Duration](cfg, "server.timeout")
```
### ScanTyped
A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of the specified type.
```go
// Instead of:
// var dbConf DBConfig
// if err := cfg.Scan("database", &dbConf); err != nil { ... }
// You can write:
dbConf, err := config.ScanTyped[DBConfig](cfg, "database")
if err != nil {
// ...
}
// dbConf is a *DBConfig```
```
### 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(config.SourceEnv, "server.port", "8080")
cfg.SetSource(config.SourceCLI, "debug", true)
// File source typically set via LoadFile, but can be manual
cfg.SetSource(config.SourceFile, "feature.enabled", true)
```
### Batch Updates
```go
// Multiple updates
updates := map[string]any{
"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 any) any {
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
```
# Access and conversion
| API | Behavior |
| --- | --- |
| `Get(path)` | Detached raw effective value and registration flag |
| `GetSource(path, source)` | Detached source value; Default returns the registered default |
| `GetSources(path)` | Detached map of explicit source overrides |
| `GetTyped[T](cfg, path)` | Checked conversion to T |
| `GetTypedWithDefault[T]` | Registers a default only when the path is absent; existing conversion errors are returned |
| `Scan(&target, optionalPath)` | Transactional decoding of current values |
| `ScanSource(source, &target, optionalPath)` | Decodes only the selected source |
| `ScanTyped[T]` | Allocates and populates a typed destination |
| `ScanMap(values, &target)` | Uses the same decoder without registration |
| `AsStruct()` | Independent snapshot of the configured target type |
Raw environment/CLI values remain strings. TOML integer literals are int64.
Defaults retain their declared types. Use typed access instead of assuming a raw
source type. Matching uses `toml` tags, then case-sensitive Go field names. Tags
with an empty name use the field name; `-` skips a field. Unknown input fields and
unexported destination fields are ignored. Maps and slices replace existing
containers; fixed arrays require exact lengths. Missing struct fields remain as
provided by the caller. A present nil value clears its destination.
Conversions support signed/unsigned numbers, floats, booleans, strings, slices,
arrays, string-keyed maps, structs and pointers. Durations, time.Time, net.IP,
net.IPNet and url.URL are atomic configuration values, including pointer forms.
They accept their native types or documented text representations. Comma-separated
strings can populate typed numeric slices as well as string slices.
No truncating integer conversions or integer-to-float precision loss is allowed.
Float narrowing may round within range. NaN/Inf and unsupported object graphs are
rejected. All decoding failures leave the destination unchanged. There are no
custom decode hooks or automatic text-unmarshal interfaces.
A Config owns accepted values. Modifying Get results, defaults after registration,
clones or AsStruct snapshots cannot modify stored state. Do not mutate arguments
concurrently while a call is reading them. Builder setup itself is not concurrent.
+24 -169
View File
@@ -1,174 +1,29 @@
# Config Package Architecture
# Architecture
## Overview
Registered items hold a default, source overrides and an effective value selected
by precedence. A Config RWMutex protects these maps and runtime options. Values
are copied on admission and never mutated internally afterward; replacement removes
or swaps references. Public reads return copies. Nested snapshots can therefore be
assembled under a read lock and decoded after releasing it.
The `lixenwraith/config` package provides thread-safe configuration management with support for multiple sources, type-safe struct population, and live reconfiguration.
The decoder handles the limited configuration object model: TOML tags, standard
containers and atomic duration/time/network values. It delegates numeric conversion
to lixenwraith/toml and uses explicit decimal parsing for environment/CLI strings.
No mapstructure or external file parser is used in the runtime dependency graph.
## Logical Architecture
Typed cache metadata uses a second mutex, always acquired after the Config read
lock. Version changes invalidate its immutable decoded snapshot. Every AsStruct call
returns a detached copy, so publication cannot race with existing readers. Build
writes the supplied target once. Clone copies configuration state and options but
does not copy active watchers or subscriptions.
```
┌──────────────────────────────────────────────────────────┐
│ User API Layer │
├────────────────┬────────────────┬────────────────────────┤
│ Builder │ Type-Safe │ Dynamic Access │
│ Pattern │ Struct API │ Key-Value API │
├────────────────┴────────────────┴────────────────────────┤
│ Core Config Engine │
│ • Path Registration • Source Merging • Thread Safety │
├──────────────────────────────────────────────────────────┤
│ Source Loaders │
│ File │ Environment │ CLI Arguments │ Defaults │
├──────────────────────────────────────────────────────────┤
│ Supporting Systems │
│ Validation │ Type Decode │ File Watch │ Error Handling │
└──────────────────────────────────────────────────────────┘
```
File reads and parsing precede source publication. All candidate values are checked
against the current schema, then the complete source is replaced under one lock.
The watcher additionally verifies cancellation, timeout and generation at commit.
One worker owns fingerprint/debounce state. Subscriber send/close operations share a
mutex; stop does not wait for that worker while holding the Config mutex.
## Component Interactions
```
Builder Flow:
NewBuilder()
↓
Configure (WithTarget, WithFile, WithEnvPrefix)
↓
Build() → Register Paths → Load Sources → Merge Values
↓
Config Instance
↓
AsStruct() / Get() / Watch()
Value Resolution:
Path Request
↓
Check Registration
↓
Search Sources (CLI → Env → File → Default)
↓
Type Conversion
↓
Return Value
Live Reload:
File Change Detected
↓
Debounce Timer
↓
Reload File
↓
Merge with Sources
↓
Notify Watchers
```
## File Organization
### Core (`config.go`)
- **Config struct**: Thread-safe state management with atomic versioning
- **Initialization**: `New()`, `NewWithOptions()`
- **State Management**: `Get()`, `Set()`, `GetSource()`, `SetSource()`
- **Precedence Control**: `SetPrecedence()`, `GetPrecedence()`, `computeValue()`
- **Cache Management**: `AsStruct()`, `populateStruct()`, `invalidateCache()`
- **Source Operations**: `Reset()`, `ResetSource()`, `GetSources()`
### Registration (`register.go`)
- **Path Registration**: `Register()`, `RegisterWithEnv()`, `RegisterRequired()`, `Unregister()`
- **Struct Registration**: `RegisterStruct()`, `RegisterStructWithTags()`, `registerFields()`
- **Discovery**: `GetRegisteredPaths()`, `GetRegisteredPathsWithDefaults()`
- **Decoding Bridge**: `Scan()`, `ScanSource()` - delegates to decode.go
### Builder Pattern (`builder.go`)
- **Fluent API**: `NewBuilder()`, `Build()`, `MustBuild()`
- **Configuration**: `WithTarget()`, `WithDefaults()`, `WithFile()`, `WithEnvPrefix()`
- **Discovery Integration**: `WithFileDiscovery()`
- **Validation**: `WithValidator()`, `WithTypedValidator()`
- **Security**: `WithSecurityOptions()`
### Source Loading (`loader.go`)
- **Multi-Source Loading**: `loadWithOptions()`
- **Individual Sources**: `LoadFile()`, `LoadEnv()`, `LoadCLI()`
- **Persistence**: `Save()`, `SaveSource()`, `atomicWriteFile()`
- **Environment Mapping**: `DiscoverEnv()`, `ExportEnv()`, `defaultEnvTransform()`
- **Parsing**: `parseArgs()`, `parseValue()`, `detectFileFormat()`
- **Security Checks**: Path traversal, file ownership, size limits
### Type System (`decode.go`)
- **Unified Decoding**: `unmarshal()` - single authoritative decoder
- **Hook Composition**: `getDecodeHook()`, `customDecodeHook()`
- **Type Converters**: `jsonNumberHookFunc()`, `stringToNetIPHookFunc()`, `stringToURLHookFunc()`
- **Navigation**: `navigateToPath()`, `normalizeMap()`
### File Watching (`watch.go`)
- **Auto-Reload**: `AutoUpdate()`, `AutoUpdateWithOptions()`, `StopAutoUpdate()`
- **Subscriptions**: `Watch()`, `WatchWithOptions()`, `WatchFile()`
- **Watcher State**: `watcher` struct with debouncing, polling, version tracking
- **Change Detection**: `checkAndReload()`, `performReload()`, `notifyWatchers()`
- **Resource Management**: Max watcher limits, graceful shutdown
### Convenience API (`utility.go`)
- **Quick Setup**: `Quick()`, `QuickCustom()`, `MustQuick()`, `QuickTyped()`
- **Flag Integration**: `GenerateFlags()`, `BindFlags()`
- **Type-Safe Access**: `GetTyped()`, `GetTypedWithDefault()`, `ScanTyped()`
- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()`, `ScanMap()`
### File Discovery (`discovery.go`)
- **Options**: `FileDiscoveryOptions` struct with search strategies
- **XDG Compliance**: `getXDGConfigPaths()` for standard config locations
- **Defaults**: `DefaultDiscoveryOptions()` with sensible patterns
### Validation Library (`validator.go`)
- **Network**: `Port()`, `IPAddress()`, `IPv4Address()`, `IPv6Address()`
- **Numeric**: `Positive()`, `NonNegative()`, `Range()`
- **String**: `NonEmpty()`, `Pattern()`, `OneOf()`, `URLPath()`
### Error System (`error.go`)
- **Categories**: Sentinel errors for `errors.Is()` checking
- **Wrapping**: `wrapError()` maintains dual error chains
### Internal Utilities (`helper.go`)
- **Map Operations**: `flattenMap()`, `setNestedValue()`
- **Validation**: `isValidKeySegment()` for path validation
### Constants (`constant.go`)
- **Shared Constants**: Defines shared constants for timing, formats, limits, file watcher and discovery.
## Data Flow Patterns
### Configuration Loading
1. **Registration Phase**: Paths registered with types/defaults
2. **Source Collection**: Each source populates its layer
3. **Merge Phase**: Precedence determines final values
4. **Population Phase**: Struct populated via reflection
### Thread Safety Model
- **Read Operations**: Multiple concurrent readers via RLock
- **Write Operations**: Exclusive access via Lock
- **Atomic Updates**: Prepare → Lock → Swap → Unlock pattern
- **Version Tracking**: Atomic counter for cache invalidation
### Error Propagation
- **Wrapped Errors**: Category + specific error detail
- **Early Return**: Builder accumulates first error
- **Panic Mode**: MustBuild for fail-fast scenarios
## Extension Points
### Custom Types
Implement decode hooks in `customDecodeHook()`:
```go
func(f reflect.Type, t reflect.Type, data any) (any, error)
```
### Source Transformation
Environment variable mapping via `WithEnvTransform()`:
```go
func(path string) string // Return env var name
```
### Validation Layers
- Pre-decode: `WithValidator(func(*Config) error)`
- Post-decode: `WithTypedValidator(func(*YourType) error)`
### File Discovery
Search strategy via `WithFileDiscovery()`:
- CLI flag check → Env var → XDG paths → Current dir
Save normalizes atomic application values to TOML strings, marshals before touching
the target file, then writes/syncs/renames a temporary file. Comment text is captured
by lexer tokens and emitted as a preamble. The codec owns syntax, while Config owns
source precedence, snapshot isolation and live reload publication.
+66
View File
@@ -0,0 +1,66 @@
# Config audit and TOML migration
This branch updates only lixenwraith/config. It uses Go 1.27.1 and the user's merged
TOML main commit `b59d068ff6a48b86365a65e1121343f672f77a67`, pinned as
`v0.0.0-20260928180512-b59d068ff6a4` because no version tag existed at integration.
Logwisp adoption and other TOML consumers are separate follow-up work.
## Confirmed defects and fixes
| Finding | Result | Regression evidence |
| --- | --- | --- |
| Fractions/overflow could pass through weak numeric conversion | Checked TOML conversion and explicit destination-width parsing | `TestCheckedConversionsAndRollback`, `FuzzConfigNumericConversions` |
| Options, input containers and returned values could alias caller state | Owned options/values and detached reads/clones | `TestOptionsAreOwned`, `TestOwnedValuesAndSnapshots` |
| Updating the cached target changed old AsStruct results | Immutable cached result and a fresh snapshot per call | `TestOwnedValuesAndSnapshots` |
| ExportEnv compared uncomparable slices/maps directly | Deep equality, with callbacks outside Config locks | `TestExportEnvContainerValues` |
| Invalid sources could be published before decoding failed | Whole-source validation before mutation | `TestSourceReplacementIsTransactional` |
| Removed env/CLI values survived subsequent loads | Successful loads replace their complete source | `TestSourceReplacementIsTransactional` |
| A timed-out reload kept running and could publish after stop | Context, watcher identity, path and generation checked at commit | `TestReloadCannotPublishAfterStopTimeoutOrNewLoad` |
| A timeout could suppress retries of unchanged content | Retry timeouts while deduplicating their notifications | `TestWatcherRetriesTimedOutUnchangedContent` |
| Debounce did not cover errors from partial in-place writes | Errors and successful updates wait for a quiet interval | `TestWatcherDebouncesErrorsAndRecovers` |
| Metadata-only polling missed some replacements/edits | Content fingerprints on every poll; deletion/recreation recovery | `TestWatcherSameMetadataReplacementAndRecreation` |
| Source saving lost comments and widened permissions | Comment preamble, existing modes retained, new files 0600 | `TestSaveCommentsPermissionsAndFailure` |
| Atomic types were inconsistently registered/serialized | Exact type matching for duration/time/IP/CIDR/URL and pointer forms | `TestAtomicValuesAndFileRoundTrip` |
| A missing file could hide a joined invalid CLI/environment error | Missing-file fallback only when other sources succeed | `TestBuilderDoesNotHideInvalidSourceBehindMissingFile` |
| Required registration was a no-op; overlapping/duplicate fields were ambiguous | Required paths validated at Build; staged registration rejects conflicts | `TestRegistrationAndRequiredContracts` |
| Default source selection and narrow target schemas could be inconsistent | Default precedence honored; explicit defaults must match the typed target | `TestRegistrationAndRequiredContracts` |
Reproduction against original main `ccd280b`: fractional-to-integer conversion
silently produced 1 from 1.5; modifying an input slice changed its registered
default; an out-of-range CLI batch returned success; and requesting a refreshed
AsStruct changed an earlier result under its reader. All four were reproduced with
public-API probes; committed regressions cover the corrected behavior.
## Dependency and scope decision
Mapstructure is replaceable for the package's actual object model. The replacement
supports TOML field names, ordinary containers, pointers and the existing standard
atomic types. It does not reproduce mapstructure's broad weak coercions or add a
custom hook API. Numeric correctness is shared with lixenwraith/toml.
`go list -deps ./...` reports only the standard library, lixenwraith/toml and this
module. BurntSushi TOML, mapstructure and YAML are removed from runtime code.
Testify remains test-only, so its transitive YAML dependency still appears in go.mod
and go.sum. JSON/YAML files and tags are unsupported, with explicit rejection tests.
No global reflection cache or lock was introduced. Known container sizes are used
for allocation. Stored values are immutable after admission. One watcher worker
owns observation/debounce state, and subscription cleanup has no per-subscriber
goroutine. File I/O and environment callbacks execute outside Config locks.
## Verification and limits
The full unit and race suites and go vet pass with Go 1.27.1. Numeric fuzzing ran
for 10 seconds and passed 234,155 executions, covering full uint64 preservation,
signed boundaries and narrowing. The 386 and Windows test binaries cross-compile
locally. This host cannot execute 386 binaries; CI executes that suite and also
runs race tests, vet, fuzzing and Windows compilation. Final checks are on the PR.
TOML's supported subset is unchanged. Native datetime syntax is not added; time.Time
is represented by a quoted RFC3339Nano string. Full uint64 works in memory but values
above MaxInt64 cannot be saved as TOML integers. Comment text survives as a preamble;
locations and formatting do not. Unknown file keys are not saved. Depth is bounded
at 128 for configuration graphs. Live application validators remain an application
responsibility; Builder validators run only during initialization. Notifications
are best effort, and file timeouts prevent late publication without forcibly
interrupting kernel I/O. See the linked topic guides for migration details.
+32 -373
View File
@@ -1,373 +1,32 @@
# 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 *before* the target struct is populated. These validators operate on the raw `*config.Config` object and are suitable for checking required paths or formats before type conversion.
```go
// Validator runs on raw, pre-decoded values.
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()
```
For type-safe validation, see `WithTypedValidator`.
### WithTypedValidator
Add a type-safe validation function that runs *after* the configuration has been fully loaded and decoded into the target struct (set by `WithTarget`). This is the recommended approach for most validation logic.
The validation function must accept a single argument: a pointer to the same struct type that was passed to `WithTarget`.
```go
type AppConfig struct {
Server struct {
Port int64 `toml:"port"`
} `toml:"server"`
}
var target AppConfig
cfg, err := config.NewBuilder().
WithTarget(&target).
WithFile("config.toml").
WithTypedValidator(func(conf *AppConfig) error {
if conf.Server.Port < 1024 || conf.Server.Port > 65535 {
return fmt.Errorf("port %d is outside the valid range", conf.Server.Port)
}
return nil
}).
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/`)
## 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
```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()`
# Builder
`NewBuilder` collects defaults, an optional typed target, file, environment and CLI
settings. Build registers defaults, loads each configured source, checks required
paths, runs Config validators and then typed validators. Missing files are nonfatal
only when other loading and validation succeeds.
| Option | Purpose |
| --- | --- |
| `WithDefaults(value)` | Explicit struct defaults |
| `WithTarget(&value)` | Target type and fallback defaults; filled once by Build |
| `WithPrefix("section")` | Prefix registered paths and the typed snapshot section |
| `WithFile(path)` | TOML file; missing path can be watched for later creation |
| `WithFileDiscovery(options)` | Search CLI, environment and configured directories |
| `WithEnvPrefix`, `WithEnvTransform`, `WithEnvWhitelist` | Environment mapping |
| `WithArgs(args)` | Explicit CLI input; nil disables it |
| `WithSources(...)` | Source precedence; first wins |
| `WithSecurityOptions(options)` | File traversal, owner and size checks |
| `WithValidator(fn)` | Build-time validation using Config accessors |
| `WithTypedValidator(func(*T) error)` | Build-time validation of the target snapshot |
Only TOML tags and file format are supported. WithTagName accepts `toml`;
WithFileFormat accepts `toml` or `auto`. JSON/YAML modes are removed.
Use a builder from one goroutine during initialization. Runtime updates go through
Config methods. `AsStruct` returns independent snapshots and never mutates the
original target after Build. Validators run during Build, not on every later Set
or reload; later changes are always checked against registered value types.
Explicit defaults must use the same struct type as the target. `WithDefaults`
takes precedence over the initial target values. A typed nil validator is ignored;
signatures must match the target pointer and return error.
+15 -184
View File
@@ -1,188 +1,19 @@
# Command Line Arguments
# Command-line arguments
The config package supports command-line argument parsing with flexible formats and automatic type conversion.
`LoadCLI` accepts `--server.port=9000`, `--server.port 9000`, and bare boolean flags
such as `--debug`. Arguments after `--` are positional and are not parsed. Other
positional arguments are ignored. There is no short-flag parser; use Go's flag package
when that interface is needed. Paths must obey registration's dot-separated key syntax.
## Argument Formats
Values remain strings and are validated against registered types before publication.
Each successful call replaces the complete CLI source, including an empty argument
list. Omitted flags lose their previous overrides. Unknown paths are recorded in
`UnknownCLIKeys` in sorted order and are not registered. The returned list is a copy.
### Key-Value Pairs
`GenerateFlags` creates a flag.FlagSet from defaults. `BindFlags` atomically replaces
the CLI source with explicitly visited flags, rejects unregistered flags, and uses
the same checked conversions. Repeated flags resolve to the last value. Invalid
values leave the prior CLI source and unknown-key list intact.
```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)
}
}
```
The Builder reads os.Args by default. Use `WithArgs(nil)` to disable CLI loading for
that build or supply an explicit argument slice for deterministic initialization.
+18 -198
View File
@@ -1,202 +1,22 @@
# Environment Variables
# Environment variables
The config package provides flexible environment variable support with automatic name transformation, custom mappings, and whitelist capabilities.
`LoadEnv("APP_")` maps `server.port` to `APP_SERVER_PORT`. It replaces the complete
environment source: variables removed since the previous load lose their overrides.
Set `LoadOptions.EnvTransform` for custom names and `EnvWhitelist` to limit paths.
Options are copied. Custom transforms run outside Config locks and may inspect Config.
## Basic Usage
Environment values are stored as strings and validated against registered types
before any part of the replacement is published. Numbers use decimal text; durations
use Go duration syntax; timestamps use RFC3339Nano-compatible text. Slices and arrays
use comma-separated elements without an escaping convention. Empty text gives an
empty slice. Values larger than MaxValueSize are rejected.
Environment variables are automatically mapped from configuration paths:
`RegisterWithEnv` and a struct field's `env` tag load an explicitly named variable
during registration. Later `LoadEnv` calls use the configured transform/whitelist,
so explicit registration-time mappings are not automatically repeated on reload.
```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")
```
## Using the `env` Tag
Structs can specify explicit environment variable names using the `env` tag:
```go
type Config struct {
Server struct {
Port int `toml:"port" env:"PORT"` // Uses $PORT
Host string `toml:"host" env:"SERVER_HOST"` // Uses $SERVER_HOST
} `toml:"server"`
}
```
## 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()
```
`DiscoverEnv(prefix)` returns registered paths with present variable names.
`ExportEnv(prefix)` returns formatted effective values that differ from defaults;
it safely handles containers. It is diagnostic output, not a general lossless
serialization format for maps, slices or strings containing delimiters. Use Save
for round-trip persistence.
+29 -299
View File
@@ -1,304 +1,34 @@
# Configuration Files
# TOML files and saves
The config package supports TOML configuration files with automatic loading, discovery, and atomic saving.
`LoadFile(path)` parses TOML regardless of the usual TOML/conf/config extension;
JSON/YAML extensions are rejected. `SetFileFormat` and `WithFileFormat` accept
`toml` and the compatibility alias `auto`; neither enables format detection.
Only registered paths are applied. Registered map fields accept whole tables.
Replacing a registered parent table with a scalar is an error. A missing key
removes that source override, exposing the next source or registered default.
## TOML Format
Loads parse and validate before publication. Syntax errors, incompatible values,
overflow and read failures leave prior values, comments and the loaded path intact.
Readers must be regular files. Security options check ownership of the opened file
on Unix and enforce size limits both before and during reading. Ownership checks
fail explicitly on unsupported platforms. PreventPathTraversal rejects relative
paths escaping through `..`; it is not a symlink sandbox. Absolute paths remain
allowed. A zero MaxFileSize means no configured byte limit.
TOML (Tom's Obvious, Minimal Language) is the supported configuration format:
`Save(path)` writes effective values; `SaveSource(path, source)` writes one source.
Default selects registered defaults. Both use a temporary file in the destination
directory, sync it, and rename it over the destination. They preserve an existing
file's permission bits and use 0600 for a new file. They do not promise portable
power-loss durability of the directory entry or platform-independent replacement
of open files. Encoding failures occur before the destination is changed.
```toml
# Basic values
host = "localhost"
port = 8080
debug = false
Loaded comment text, including inline comments, is preserved as a preamble. Hashes
inside strings are not comments. Formatting and comment positions are standardized,
and unknown/unregistered keys are omitted. Saves do not merge concurrent edits made
outside Config after the last successful load. Save does not switch the watched path.
# 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
Durations and atomic time/network values are saved as strings and decoded back to
their registered types. time.Time uses RFC3339Nano; native TOML datetime syntax is
unsupported. Integers in files have signed 64-bit range. Full uint64 values can be
held in memory but cannot be saved above MaxInt64. Unsupported TOML syntax and
numeric key restrictions follow the pinned [toml package](https://github.com/lixenwraith/toml).
-159
View File
@@ -1,159 +0,0 @@
# lixenwraith/config Quick Reference Guide
This guide details the `lixenwraith/config` package for thread-safe Go configuration.
It supports multiple sources (files, environment, CLI), type-safe struct population, and live reconfiguration.
## Quick Start: Recommended Usage
The recommended pattern uses the **Builder** with a **target struct** providing compile-time type safety and eliminating the need for runtime type assertions
```go
package main
import (
"fmt"
"log"
"os"
"github.com/lixenwraith/config"
)
// 1. Define your application's configuration struct
type AppConfig struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
Debug bool `toml:"debug"`
}
func main() {
// 2. Create a target instance with defaults
// Method A: Direct initialization (cleaner for simple defaults)
target := &AppConfig{}
target.Server.Host = "localhost"
target.Server.Port = 8080
// Method B: Use WithDefaults() for explicit separation (shown below)
// 3. Use the builder to configure and load all sources
cfg, err := config.NewBuilder().
WithTarget(target). // Enable type-safe mode and register struct fields
// WithDefaults(&AppConfig{...}), // Optional: Override defaults from WithTarget
WithFile("config.toml"). // Load from file (supports .toml, .json, .yaml)
WithEnvPrefix("APP_"). // Load from environment (e.g., APP_SERVER_PORT)
WithArgs(os.Args[1:]). // Load from command-line flags (e.g., --server.port=9090)
Build() // Build the final config object
if err != nil {
log.Fatalf("Config build failed: %v", err)
}
// 4. Access the fully populated, type-safe struct
// The `target` variable is now populated with the final merged values
fmt.Printf("Running on %s:%d\n", target.Server.Host, target.Server.Port)
// Or, retrieve the updated struct at any time (e.g., after a live reload)
latest, _ := cfg.AsStruct()
latestConfig := latest.(*AppConfig)
fmt.Printf("Debug mode: %v\n", latestConfig.Debug)
}
```
## Supported Formats: TOML, JSON, YAML
The package supports multiple file formats. The format is auto-detected from the file extension (`.toml`, `.json`, `.yaml`, `.yml`) or file content
* To specify a format explicitly, use `Builder.WithFileFormat("json")`
* The default struct tag for field mapping is `toml`, but can be changed with `Builder.WithTagName("json")`
## Builder Pattern
The `Builder` is the primary way to construct a `Config` instance
```go
// NewBuilder creates a new configuration builder
func NewBuilder() *Builder
// Build finalizes configuration; returns the first of any accumulated errors
func (b *Builder) Build() (*Config, error)
// MustBuild is like Build but panics on fatal errors
func (b *Builder) MustBuild() *Config
```
### Builder Methods
* `WithTarget(target any)`: **(Recommended)** Registers fields from the target struct and allows access via `AsStruct()`
* `WithDefaults(defaults any)`: Explicitly sets a struct containing default values, overriding any default values already in struct from `WithTarget`
* `WithFile(path string)`: Sets the configuration file path
* `WithFileDiscovery(opts FileDiscoveryOptions)`: Enables automatic config file discovery (searches CLI flags, env vars, XDG paths, and current directory)
* `WithArgs(args []string)`: Sets the command-line arguments to parse (e.g., `--server.port=9090`)
* `WithEnvPrefix(prefix string)`: Sets the global environment variable prefix (e.g., `MYAPP_`)
* `WithSources(sources ...Source)`: Overrides the default source precedence order
* `WithTypedValidator(fn any)`: **(Recommended for validation)** Adds a type-safe validation function with signature `func(c *YourConfigType) error` that runs *after* the target struct is populated
* `WithValidator(fn ValidatorFunc)`: Adds a validation function that runs *before* type-safe population, operating on the raw `*Config` object
* `WithTagName(tagName string)`: Sets the primary struct tag for field mapping (`"toml"`, `"json"`, `"yaml"`)
* `WithPrefix(prefix string)`: Adds a prefix to all registered paths from a struct
* `WithFileFormat(format string)`: Explicitly sets the file format (`"toml"`, `"json"`, `"yaml"`, `"auto"`)
* `WithSecurityOptions(opts SecurityOptions)`: Sets security options for file loading (path traversal, file size limits)
* `WithEnvTransform(fn EnvTransformFunc)`: Sets a custom environment variable mapping function
* `WithEnvWhitelist(paths ...string)`: Limits environment variable loading to a specific set of paths
## Type-Safe Access & Population
These are the **preferred methods** for accessing configuration data
* `AsStruct() (any, error)`: After using `Builder.WithTarget()`, this method returns the populated, type-safe target struct - This is the primary way to access config after initialization or live reload
* `Scan(basePath string, target any)`: Populates a struct with values from a specific config path (e.g., `cfg.Scan("server", &serverConf)`)
* `ScanMap(configMap map[string]any, target any, tagName ...string)`: Decodes a map[string]any directly into a target struct, useful for plugins or custom config sources
* `GetTyped[T](c *Config, path string) (T, error)`: Retrieves a single value and decodes it to type `T`, handling type conversion automatically
* `ScanTyped[T](c *Config, basePath ...string) (*T, error)`: A generic wrapper around `Scan` that allocates, populates, and returns a pointer to a struct of type `T`
## Live Reconfiguration
Enable automatic reloading of configuration when the source file changes
* `AutoUpdate()`: Enables file watching and automatic reloading with default options
* `AutoUpdateWithOptions(opts WatchOptions)`: Enables reloading with custom options (e.g., poll interval, debounce)
* `StopAutoUpdate()`: Stops the file watcher
* `Watch() <-chan string`: Returns a channel that receives the paths of changed values
* `WatchFile(filePath string, formatHint ...string)`: Switches the watcher to a new file at runtime
* `IsWatching() bool`: Returns `true` if the file watcher is active
The watch channel also receives special notifications: `"file_deleted"`, `"permissions_changed"`, `"reload_error:..."`
## Source Precedence
The default order of precedence (highest to lowest) is:
1. **CLI**: Command-line arguments (`--server.port=9090`)
2. **Env**: Environment variables (`MYAPP_SERVER_PORT=8888`)
3. **File**: Configuration file (`config.toml`)
4. **Default**: Values registered from a struct
This order can be changed via `Builder.WithSources()` or `Config.SetPrecedence()`
## Dynamic / Legacy Value Access
These methods are for dynamic key-value access that require type assertion and should be **avoided when a type-safe struct can be used**
* `Get(path string) (any, bool)`: Retrieves the final merged value. The `bool` indicates if the path was registered. Requires a type assertion, e.g., `port := val.(int64)`
* `Set(path string, value any)`: Updates a value in the highest priority source. The path must be registered first
* `GetSource(path string, source Source) (any, bool)`: Retrieves a value from a specific source layer
* `SetSource(path string, source Source, value any)`: Sets a value for a specific source layer
## API Reference Summary
### Core Types
* `Config`: The primary thread-safe configuration manager
* `Source`: A configuration source (`SourceCLI`, `SourceEnv`, `SourceFile`, `SourceDefault`)
* `LoadOptions`: Options for loading configuration from multiple sources
* `Builder`: Fluent API for constructing a `Config` instance
### Core Methods
* `New() *Config`: Creates a new `Config` instance.
* `Register(path string, defaultValue any)`: Registers a path with a default value
* `RegisterStruct(prefix string, structWithDefaults any)`: Recursively registers fields from a struct using `toml` tags
* `Validate(required ...string)`: Checks that all specified required paths have been set from a non-default source
* `Save(path string)`: Atomically saves the current merged configuration state to a file
* `Clone() *Config`: Creates a deep copy of the configuration state
* `Debug() string`: Returns a formatted string of all values for debugging
+18 -203
View File
@@ -1,215 +1,30 @@
# Quick Start Guide
# Quick start
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:
Use Go 1.27.1 and TOML tags. Register defaults before loading sources:
```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))
}
cfg := config.New()
if err := cfg.Register("server.port", uint16(8080)); err != nil { return err }
if err := cfg.LoadFile("config.toml"); err != nil { return err }
if err := cfg.LoadEnv("APP_"); err != nil { return err }
if err := cfg.LoadCLI(os.Args[1:]); err != nil { return err }
port, err := config.GetTyped[uint16](cfg, "server.port")
```
## 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)
`APP_SERVER_PORT=9000` and `--server.port=9001` address the same registered path.
CLI has highest default priority. TOML values can be configured in a table:
```toml
[server]
host = "0.0.0.0"
port = 8080
[database]
url = "postgres://localhost/mydb"
max_conns = 25
debug = false
```
## Type Safety
For a typed application, use `NewBuilder().WithTarget(&settings)` as shown in the
[README](../README.md). Build fills the supplied target once. Use `AsStruct` to
obtain new snapshots after updates; retaining an old snapshot is safe.
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) // Type matches registration
```
## 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)
}
```
### Error Categories
The package uses structured error categories for better error handling. Check errors using `errors.Is()`:
```go
if err := cfg.LoadFile("config.toml"); err != nil {
switch {
case errors.Is(err, config.ErrConfigNotFound):
// Config file doesn't exist, use defaults
case errors.Is(err, config.ErrFileAccess):
// Permission denied or file access issues
case errors.Is(err, config.ErrFileFormat):
// Invalid TOML/JSON/YAML syntax
case errors.Is(err, config.ErrTypeMismatch):
// Value type doesn't match registered type
case errors.Is(err, config.ErrValidation):
// Validation failed
default:
log.Fatal(err)
}
}
```
See [Errors](./error.go) for the complete list of error categories and description.
## 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)
}
```
### Type-Safe Validation
```go
cfg, _ := config.NewBuilder().
WithTarget(&Config{}).
WithTypedValidator(func(c *Config) error {
if c.Server.Port < 1024 {
return fmt.Errorf("port must be >= 1024")
}
return nil
}).
Build()
```
See [Validation](validator.md) for more validation options.
### Using Different Struct Tags
```go
// Use JSON tags instead of default 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.json").
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
- [Access Patterns](access.md) - All ways to get and set values
A missing file returns `ErrConfigNotFound` along with a usable Config when all
other initialization succeeds. Check that category explicitly if a file is
optional. Invalid environment or CLI values remain fatal even when the file is
missing. `MustBuild` and `MustQuick` ignore only a missing file and panic on other
initialization errors. All source loads replace the previous values of that source.
+30 -343
View File
@@ -1,349 +1,36 @@
# Live Reconfiguration
# Live reconfiguration
The config package supports automatic configuration reloading when files change, enabling zero-downtime reconfiguration.
`AutoUpdate` starts polling the loaded file. `Watch` starts it if necessary and
subscribes to notifications. Option variants configure the polling interval
(minimum 100ms), debounce, subscriber limit, reload timeout and permission checks.
Options for an already-running watcher stay unchanged; stop/restart to change them.
## Basic File Watching
One worker reads and fingerprints the regular file per poll, including same-size,
same-timestamp edits and atomic replacements. Both successful and erroneous reads
must settle for the debounce interval. Stable errors are reported once per observed
state. Deletion preserves the last valid state, and recreation can reload normally.
This trades one file read per poll for reliable detection of metadata-preserving edits.
### Enable Auto-Update
Before publishing, the worker checks its context, current watcher identity, watched
path and source generation under the Config lock. Type checks and allocation finish
before the final timeout check. Expired, stopped or superseded operations cannot
publish. ReloadTimeout limits publication eligibility; it cannot forcibly interrupt
a kernel filesystem operation. Reads never hold the Config lock.
Timed-out operations retry on later polls even if the content is unchanged; repeated
timeouts for the same observation emit only one notification until recovery.
```go
cfg, _ := config.NewBuilder().
WithDefaults(&Config{}).
WithFile("config.toml").
Build()
Events are changed registered paths, or `file_deleted`, `permissions_changed`,
`reload_timeout`, `reload_error:<detail>`, and `precedence:<path>`. A path event means
the effective value changed; a file value masked by CLI need not notify. Group/world
permission changes are blocked when VerifyPermissions is enabled; correcting the
permissions allows loading again. Default options enable that check.
// Enable automatic reloading
cfg.AutoUpdate()
Notifications are buffered (10 entries) and best effort; a slow subscriber may miss
events and should read the latest full state. MaxWatchers bounds retained channels.
StopAutoUpdate cancels publication and closes channels synchronously without waiting
under the Config lock. There is no goroutine per subscriber or per timed-out reload.
A new Watch call after stopping creates a new channel and worker.
// 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
}
}
}()
```
`WatchFile` loads a replacement path before replacing the watcher and preserves its
options. Failure leaves the prior watcher and values active. Loading a different
file directly stops the previous watcher; call Watch to watch the new path.
+18 -170
View File
@@ -1,175 +1,23 @@
# Validation
The config package provides flexible validation through function injection and bundled validators for common scenarios.
Registration defaults define conversion types. Set, SetSource and complete source
loads reject incompatible values before publication. Overflow, fractional integer
conversion, non-finite values and invalid textual duration/time/network values error.
Untyped nil defaults leave the value type open within the supported value graph.
## Validation Stages
`Validate(paths...)` requires a registered path to have a supplied value, even if it
equals the default. `RegisterRequired` and `required:"true"` fields are collected by
`Validate()` with no arguments and by Build. Defaults alone do not satisfy required
inputs. Registration of a struct is staged, so a duplicate/invalid field or invalid
explicit environment value cannot leave a partially registered schema.
The package supports two validation stages when using the Builder pattern:
Build supports `WithValidator(func(*Config) error)` and a matching
`WithTypedValidator(func(*T) error)`. They run at initialization. They are not rerun
on runtime changes: apply application-level constraints before adopting a new
snapshot when those constraints must hold after reconfiguration.
### Pre-Population Validation (`WithValidator`)
Runs on raw configuration values before struct population:
```go
cfg, _ := config.NewBuilder().
WithDefaults(defaults).
WithValidator(func(c *config.Config) error {
// Check required paths exist
return c.Validate("api.key", "database.url")
}).
Build()
```
### Post-Population Validation (`WithTypedValidator`)
Type-safe validation after struct is populated:
```go
type AppConfig struct {
Server struct {
Port int64 `toml:"port"`
} `toml:"server"`
}
cfg, _ := config.NewBuilder().
WithTarget(&AppConfig{}).
WithTypedValidator(func(cfg *AppConfig) error {
// No type assertion needed - cfg.Server.Port is int64
if cfg.Server.Port < 1024 || cfg.Server.Port > 65535 {
return fmt.Errorf("port %d outside valid range", cfg.Server.Port)
}
return nil
}).
Build()
```
## Bundled Validators
The package includes common validation functions in the `config` package:
### Numeric Validators
```go
// Port validates TCP/UDP port range (1-65535)
config.Port(8080) // returns nil
// Positive validates positive numbers
config.Positive(42) // returns nil
config.Positive(-1) // returns error
// NonNegative validates non-negative numbers
config.NonNegative(0) // returns nil
config.NonNegative(-5) // returns error
// Range creates min/max validators
portValidator := config.Range(1024, 65535)
err := portValidator(8080) // returns nil
```
### String Validators
```go
// NonEmpty validates non-empty strings
config.NonEmpty("hello") // returns nil
config.NonEmpty("") // returns error
// Pattern creates regex validators
emailPattern := config.Pattern(`^[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}$`)
err := emailPattern("user@example.com") // returns nil
// URLPath validates URL path format
config.URLPath("/api/v1") // returns nil
config.URLPath("api/v1") // returns error (must start with /)
```
### Network Validators
```go
// IPAddress validates any IP address
config.IPAddress("192.168.1.1") // returns nil
config.IPAddress("::1") // returns nil
// IPv4Address validates IPv4 only
config.IPv4Address("192.168.1.1") // returns nil
config.IPv4Address("::1") // returns error
// IPv6Address validates IPv6 only
config.IPv6Address("2001:db8::1") // returns nil
config.IPv6Address("192.168.1.1") // returns error
```
### Enum Validators
```go
// OneOf creates allowed values validator
logLevel := config.OneOf("debug", "info", "warn", "error")
err := logLevel("info") // returns nil
err = logLevel("trace") // returns error
```
## Integration Example
```go
type ServerConfig struct {
Host string `toml:"host"`
Port int64 `toml:"port"`
Mode string `toml:"mode"`
}
cfg, err := config.NewBuilder().
WithTarget(&ServerConfig{}).
WithFile("config.toml").
WithTypedValidator(func(c *ServerConfig) error {
// Use bundled validators
if err := config.IPAddress(c.Host); err != nil {
return fmt.Errorf("invalid host: %w", err)
}
if err := config.Port(c.Port); err != nil {
return fmt.Errorf("invalid port: %w", err)
}
modeValidator := config.OneOf("development", "production")
if err := modeValidator(c.Mode); err != nil {
return fmt.Errorf("invalid mode: %w", err)
}
return nil
}).
Build()
```
## Integration with go-playground/validator
The package works seamlessly with `go-playground/validator`:
```go
import "github.com/go-playground/validator/v10"
type ServerConfig struct {
Host string `toml:"host" validate:"required,hostname"`
Port int `toml:"port" validate:"required,min=1024,max=65535"`
TLS struct {
Enabled bool `toml:"enabled"`
Cert string `toml:"cert" validate:"required_if=Enabled true"`
Key string `toml:"key" validate:"required_if=Enabled true"`
} `toml:"tls"`
}
// Load configuration
cfg, _ := config.NewBuilder().
WithDefaults(&ServerConfig{}).
Build()
// Get populated struct
serverCfg, _ := cfg.AsStruct()
// Validate with go-playground/validator
validate := validator.New()
if err := validate.Struct(serverCfg); err != nil {
return err // Validation errors
}
```
Tags don't conflict since each package uses different tag names (`toml`/`json`/`yaml` for config, `validate` for validator).
Helpers include Port, Positive, NonNegative, IPAddress, IPv4Address, IPv6Address,
URLPath, OneOf, Range, Pattern and NonEmpty. Pattern compiles its expression when the
validator is created and panics on an invalid expression. Network validators allow
the documented empty/unspecified defaults. Use errors.Is with ErrValidation,
ErrTypeMismatch and ErrDecode to handle failures.