Compare commits

..

3 Commits

38 changed files with 1414 additions and 919 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ script
*.log
*.toml
bin
build.sh

View File

@ -26,3 +26,4 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

@ -53,13 +53,15 @@ func main() {
## Documentation
- [Quick Start Guide](doc/quick-start.md) - Get up and running quickly
- [Quick Start](doc/quick-start.md) - Get started
- [Builder Pattern](doc/builder.md) - Advanced configuration with the builder
- [Command Line](doc/cli.md) - CLI argument handling
- [Environment Variables](doc/env.md) - Environment variable configuration
- [Configuration Files](doc/file.md) - File loading and formats
- [Access Patterns](doc/access.md) - Getting and setting values
- [Live Reconfiguration](doc/reconfiguration.md) - File watching and updates
- [Command Line](doc/cli.md) - CLI argument handling
- [Environment Variables](doc/env.md) - Environment variable handling
- [File Configuration](doc/file.md) - File formats and loading
- [Validation](doc/validator.md) - Validation functions and integration
- [Live Reconfiguration](doc/reconfiguration.md) - File watching and auto-update on change
- [Quick Guide](doc/quick-guide_lixenwraith_config.md) - Quick reference guide
## License

View File

@ -5,11 +5,13 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
)
// Builder provides a fluent API for constructing a Config instance. It allows for
// chaining configuration options before final build of the config object.
// Builder provides a fluent API for constructing a Config instance
// Allows chaining configuration options before final build of the config object
type Builder struct {
cfg *Config
opts LoadOptions
@ -25,8 +27,8 @@ type Builder struct {
typedValidators []any
}
// ValidatorFunc defines the signature for a function that can validate a Config instance.
// It receives the fully loaded *Config object and should return an error if validation fails.
// ValidatorFunc defines the signature for a function that can validate a Config instance
// It receives the fully loaded *Config object and returns error if validation fails
type ValidatorFunc func(c *Config) error
// NewBuilder creates a new configuration builder
@ -46,10 +48,10 @@ func (b *Builder) Build() (*Config, error) {
return nil, b.err
}
// Use tagName if set, default to "toml"
// Use tagName if set, default to toml
tagName := b.tagName
if tagName == "" {
tagName = "toml"
tagName = FormatTOML
}
// Set format and security settings
@ -61,62 +63,61 @@ func (b *Builder) Build() (*Config, error) {
}
// 1. Register defaults
// If WithDefaults() was called, it takes precedence.
// If not, but WithTarget() was called, use the target struct for defaults.
// If WithDefaults() was called, it takes precedence
// 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)
return nil, wrapError(ErrTypeMismatch, 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.
// No explicit defaults, so use the target struct as the source of defaults
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)
return nil, wrapError(ErrTypeMismatch, 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,
// even if the initial load fails with a non-fatal error (file not found).
// even if the initial load fails with a non-fatal error (file not found)
b.cfg.configFilePath = b.file
// 2. Load configuration
loadErr := b.cfg.LoadWithOptions(b.file, b.args, b.opts)
loadErr := b.cfg.loadWithOptions(b.file, b.args, b.opts)
if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) {
// Return on fatal load errors. ErrConfigNotFound is not fatal.
return nil, loadErr
return nil, wrapError(ErrFileAccess, loadErr)
}
// 3. Run non-typed validators
for _, validator := range b.validators {
if err := validator(b.cfg); err != nil {
return nil, fmt.Errorf("configuration validation failed: %w", err)
return nil, wrapError(ErrValidation, fmt.Errorf("configuration validation failed: %w", err))
}
}
// 4. Populate target and run typed validators
if b.cfg.structCache != nil && b.cfg.structCache.target != nil && len(b.typedValidators) > 0 {
// Populate the target struct first. This unifies all types (e.g., string "8888" -> int64 8888).
// Populate the target struct first, unifying all types (e.g., string "8888" -> int64 8888)
populatedTarget, err := b.cfg.AsStruct()
if err != nil {
return nil, fmt.Errorf("failed to populate target struct for validation: %w", err)
return nil, wrapError(ErrValidation, fmt.Errorf("failed to populate target struct for validation: %w", err))
}
// Run the typed validators against the populated, type-safe struct.
// Run the typed validators against the populated, type-safe struct
for _, validator := range b.typedValidators {
validatorFunc := reflect.ValueOf(validator)
validatorType := validatorFunc.Type()
// Check if the validator's input type matches the target's type.
// Check if the validator's input type matches the target's type
if validatorType.In(0) != reflect.TypeOf(populatedTarget) {
return nil, fmt.Errorf("typed validator signature %v does not match target type %T", validatorType, populatedTarget)
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("typed validator signature %v does not match target type %T", validatorType, populatedTarget))
}
// Call the validator.
// Call the validator
results := validatorFunc.Call([]reflect.Value{reflect.ValueOf(populatedTarget)})
if !results[0].IsNil() {
err := results[0].Interface().(error)
return nil, fmt.Errorf("typed configuration validation failed: %w", err)
return nil, wrapError(ErrValidation, fmt.Errorf("typed configuration validation failed: %w", err))
}
}
}
@ -144,16 +145,16 @@ func (b *Builder) WithDefaults(defaults any) *Builder {
}
// WithTagName sets the struct tag name to use for field mapping
// Supported values: "toml" (default), "json", "yaml"
// Supported values: toml (default), json, yaml
func (b *Builder) WithTagName(tagName string) *Builder {
switch tagName {
case "toml", "json", "yaml":
case FormatTOML, FormatJSON, FormatYAML:
b.tagName = tagName
if b.cfg != nil { // Ensure cfg exists
b.cfg.tagName = tagName
}
default:
b.err = fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName)
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName))
}
return b
}
@ -161,10 +162,10 @@ func (b *Builder) WithTagName(tagName string) *Builder {
// WithFileFormat sets the expected file format
func (b *Builder) WithFileFormat(format string) *Builder {
switch format {
case "toml", "json", "yaml", "auto":
case FormatTOML, FormatJSON, FormatYAML, FormatAuto:
b.fileFormat = format
default:
b.err = fmt.Errorf("unsupported file format %q", format)
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("unsupported file format %q", format))
}
return b
}
@ -226,13 +227,13 @@ func (b *Builder) WithEnvWhitelist(paths ...string) *Builder {
func (b *Builder) WithTarget(target any) *Builder {
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
b.err = fmt.Errorf("WithTarget requires non-nil pointer to struct, got %T", target)
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("WithTarget requires non-nil pointer to struct, got %T", target))
return b
}
elem := rv.Elem()
if elem.Kind() != reflect.Struct {
b.err = fmt.Errorf("WithTarget requires pointer to struct, got pointer to %v", elem.Kind())
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("WithTarget requires pointer to struct, got pointer to %v", elem.Kind()))
return b
}
@ -247,6 +248,63 @@ func (b *Builder) WithTarget(target any) *Builder {
return b
}
// WithFileDiscovery enables automatic config file discovery
func (b *Builder) WithFileDiscovery(opts FileDiscoveryOptions) *Builder {
// Check CLI args first (highest priority)
if opts.CLIFlag != "" && len(b.args) > 0 {
for i, arg := range b.args {
if arg == opts.CLIFlag && i+1 < len(b.args) {
b.file = b.args[i+1]
return b
}
if strings.HasPrefix(arg, opts.CLIFlag+"=") {
b.file = strings.TrimPrefix(arg, opts.CLIFlag+"=")
return b
}
}
}
// Check environment variable
if opts.EnvVar != "" {
if path := os.Getenv(opts.EnvVar); path != "" {
b.file = path
return b
}
}
// Build search paths
var searchPaths []string
// Custom paths first
searchPaths = append(searchPaths, opts.Paths...)
// Current directory
if opts.UseCurrentDir {
if cwd, err := os.Getwd(); err == nil {
searchPaths = append(searchPaths, cwd)
}
}
// XDG paths
if opts.UseXDG {
searchPaths = append(searchPaths, getXDGConfigPaths(opts.Name)...)
}
// Search for config file
for _, dir := range searchPaths {
for _, ext := range opts.Extensions {
path := filepath.Join(dir, opts.Name+ext)
if _, err := os.Stat(path); err == nil {
b.file = path
return b
}
}
}
// No file found is not an error - app can run with defaults/env
return b
}
// WithValidator adds a validation function that runs at the end of the build process
// Multiple validators can be added and are executed in the order they are added
// Validation runs after all sources are loaded
@ -260,16 +318,16 @@ func (b *Builder) WithValidator(fn ValidatorFunc) *Builder {
// WithTypedValidator adds a type-safe validation function that runs at the end of the build process,
// after the target struct has been populated. The provided function must accept a single argument
// that is a pointer to the same type as the one provided to WithTarget, and must return an error.
// that is a pointer to the same type as the one provided to WithTarget, and must return an error
func (b *Builder) WithTypedValidator(fn any) *Builder {
if fn == nil {
return b
}
// Basic reflection check to ensure it's a function that takes one argument and returns an error.
// Basic reflection check to ensure it's a function that takes one argument and returns an error
t := reflect.TypeOf(fn)
if t.Kind() != reflect.Func || t.NumIn() != 1 || t.NumOut() != 1 || t.Out(0) != reflect.TypeOf((*error)(nil)).Elem() {
b.err = fmt.Errorf("WithTypedValidator requires a function with signature func(*T) error")
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("WithTypedValidator requires a function with signature func(*T) error"))
return b
}

View File

@ -224,7 +224,6 @@ func TestFileDiscovery(t *testing.T) {
assert.Equal(t, "value", val)
})
// Rest of test cases remain the same...
t.Run("DiscoveryWithEnvVar", func(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "env.toml")

102
config.go
View File

@ -5,32 +5,12 @@
package config
import (
"errors"
"fmt"
"reflect"
"sync"
"sync/atomic"
)
// Max config item value size to prevent misuse
const MaxValueSize = 1024 * 1024 // 1MB
// Errors
var (
// ErrConfigNotFound indicates the specified configuration file was not found.
ErrConfigNotFound = errors.New("configuration file not found")
// ErrCLIParse indicates that parsing command-line arguments failed.
ErrCLIParse = errors.New("failed to parse command-line arguments")
// ErrEnvParse indicates that parsing environment variables failed.
// TODO: use in loader:loadEnv or remove
ErrEnvParse = errors.New("failed to parse environment variables")
// ErrValueSize indicates a value larger than MaxValueSize
ErrValueSize = fmt.Errorf("value size exceeds maximum %d bytes", MaxValueSize)
)
// configItem holds configuration values from different sources
type configItem struct {
defaultValue any
@ -60,7 +40,7 @@ type SecurityOptions struct {
type Config struct {
items map[string]configItem
tagName string
fileFormat string // Separate from tagName: "toml", "json", "yaml", or "auto"
fileFormat string // Separate from tagName: toml, json, yaml, or auto
securityOpts *SecurityOptions
mutex sync.RWMutex
options LoadOptions // Current load options
@ -75,17 +55,12 @@ type Config struct {
configFilePath string // Track loaded file path
}
// New creates and initializes a new Config instance.
// New creates and initializes a new Config instance
func New() *Config {
return &Config{
items: make(map[string]configItem),
tagName: "toml",
fileFormat: "auto",
// securityOpts: &SecurityOptions{
// PreventPathTraversal: false,
// EnforceFileOwnership: false,
// MaxFileSize: 0,
// },
tagName: FormatTOML,
fileFormat: FormatAuto,
options: DefaultLoadOptions(),
fileData: make(map[string]any),
envData: make(map[string]any),
@ -101,7 +76,7 @@ func NewWithOptions(opts LoadOptions) *Config {
}
// SetLoadOptions updates the load options and recomputes current values
func (c *Config) SetLoadOptions(opts LoadOptions) error {
func (c *Config) SetLoadOptions(opts LoadOptions) {
c.mutex.Lock()
defer c.mutex.Unlock()
@ -112,8 +87,6 @@ func (c *Config) SetLoadOptions(opts LoadOptions) error {
item.currentValue = c.computeValue(item)
c.items[path] = item
}
return nil
}
// SetPrecedence updates source precedence with validation
@ -128,7 +101,7 @@ func (c *Config) SetPrecedence(sources ...Source) error {
for _, s := range sources {
if _, valid := required[s]; !valid {
return fmt.Errorf("invalid source: %s", s)
return wrapError(ErrNotConfigured, fmt.Errorf("invalid source: %s", s))
}
required[s] = true
}
@ -141,7 +114,7 @@ func (c *Config) SetPrecedence(sources ...Source) error {
c.mutex.Lock()
defer c.mutex.Unlock()
// FIXED: Check if precedence actually changed
// Check if precedence actually changed
oldPrecedence := c.options.Sources
if reflect.DeepEqual(oldPrecedence, sources) {
return nil // No change needed
@ -169,7 +142,7 @@ func (c *Config) SetPrecedence(sources ...Source) error {
// Notify watchers of precedence change
if c.watcher != nil && len(changedPaths) > 0 {
for _, path := range changedPaths {
c.watcher.notifyWatchers("precedence:" + path)
c.watcher.notifyWatchers(fmt.Sprintf("%s:%s", EventPrecedenceChanged, path))
}
}
@ -187,27 +160,14 @@ func (c *Config) GetPrecedence() []Source {
return result
}
// computeValue determines the current value based on precedence
func (c *Config) computeValue(item configItem) any {
// Check sources in precedence order
for _, source := range c.options.Sources {
if val, exists := item.values[source]; exists && val != nil {
return val
}
}
// No source had a value, use default
return item.defaultValue
}
// SetFileFormat sets the expected format for configuration files.
// Use "auto" to detect based on file extension.
// SetFileFormat sets the expected format for configuration files
// Use "auto" to detect based on file extension
func (c *Config) SetFileFormat(format string) error {
switch format {
case "toml", "json", "yaml", "auto":
case FormatTOML, FormatJSON, FormatYAML, FormatAuto:
// Valid formats
default:
return fmt.Errorf("unsupported file format %q, must be one of: toml, json, yaml, auto", format)
return wrapError(ErrFileFormat, fmt.Errorf("unsupported file format %q, must be one of: toml, json, yaml, auto", format))
}
c.mutex.Lock()
@ -251,10 +211,10 @@ func (c *Config) GetSource(path string, source Source) (any, bool) {
return val, exists
}
// Set updates a configuration value for the given path.
// It sets the value in the highest priority source from the configured Sources.
// By default, this is SourceCLI. Returns an error if the path is not registered.
// To set a value in a specific source, use SetSource instead.
// Set updates a configuration value for the given path
// It sets the value in the highest priority source from the configured Sources
// By default, this is SourceCLI. Returns an error if the path is not registered
// To set a value in a specific source, use SetSource instead
func (c *Config) Set(path string, value any) error {
return c.SetSource(c.options.Sources[0], path, value)
}
@ -266,7 +226,7 @@ func (c *Config) SetSource(source Source, path string, value any) error {
item, registered := c.items[path]
if !registered {
return fmt.Errorf("path %s is not registered", path)
return wrapError(ErrPathNotRegistered, fmt.Errorf("path %s is not registered", path))
}
if str, ok := value.(string); ok && len(str) > MaxValueSize {
@ -357,15 +317,10 @@ func (c *Config) ResetSource(source Source) {
c.invalidateCache() // Invalidate cache after changes
}
// Override Set methods to invalidate cache
func (c *Config) invalidateCache() {
c.version.Add(1)
}
// AsStruct returns the populated struct if in type-aware mode
func (c *Config) AsStruct() (any, error) {
if c.structCache == nil || c.structCache.target == nil {
return nil, fmt.Errorf("no target struct configured")
return nil, wrapError(ErrNotConfigured, fmt.Errorf("no target struct configured"))
}
c.structCache.mu.RLock()
@ -382,9 +337,17 @@ func (c *Config) AsStruct() (any, error) {
return c.structCache.target, nil
}
// Target populates the provided struct with current configuration
func (c *Config) Target(out any) error {
return c.Scan(out)
// computeValue determines the current value based on precedence
func (c *Config) computeValue(item configItem) any {
// Check sources in precedence order
for _, source := range c.options.Sources {
if val, exists := item.values[source]; exists && val != nil {
return val
}
}
// No source had a value, use default
return item.defaultValue
}
// populateStruct updates the cached struct representation using unified unmarshal
@ -398,10 +361,15 @@ func (c *Config) populateStruct() error {
}
if err := c.unmarshal("", c.structCache.target); err != nil {
return fmt.Errorf("failed to populate struct cache: %w", err)
return wrapError(ErrDecode, fmt.Errorf("failed to populate struct cache: %w", err))
}
c.structCache.version = currentVersion
c.structCache.populated = true
return nil
}
// invalidateCache override Set methods to invalidate cache
func (c *Config) invalidateCache() {
c.version.Add(1)
}

View File

@ -28,7 +28,6 @@ func TestConfigCreation(t *testing.T) {
opts := LoadOptions{
Sources: []Source{SourceEnv, SourceFile, SourceDefault},
EnvPrefix: "MYAPP_",
LoadMode: LoadModeReplace,
}
cfg := NewWithOptions(opts)
require.NotNil(t, cfg)
@ -174,10 +173,9 @@ func TestSourcePrecedence(t *testing.T) {
assert.Equal(t, "from-env", val)
// Change precedence
err := cfg.SetLoadOptions(LoadOptions{
cfg.SetLoadOptions(LoadOptions{
Sources: []Source{SourceFile, SourceEnv, SourceCLI, SourceDefault},
})
require.NoError(t, err)
val, _ = cfg.Get("test.value")
assert.Equal(t, "from-file", val)
@ -249,7 +247,7 @@ func TestSetPrecedence(t *testing.T) {
cfg := New()
// Try to set invalid source
err := cfg.SetPrecedence(Source("invalid"), SourceFile)
err := cfg.SetPrecedence("invalid", SourceFile)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid source")

65
constant.go Normal file
View File

@ -0,0 +1,65 @@
// FILE: lixenwraith/config/constant.go
package config
import "time"
// Timing constants for production use
const (
SpinWaitInterval = 5 * time.Millisecond
MinPollInterval = 100 * time.Millisecond
ShutdownTimeout = 100 * time.Millisecond
DefaultDebounce = 500 * time.Millisecond
DefaultPollInterval = time.Second
DefaultReloadTimeout = 5 * time.Second
)
// Network validation limits
const (
MaxIPv6Length = 45 // Maximum IPv6 address string length
MaxCIDRLength = 49 // Maximum IPv6 CIDR string length
MaxURLLength = 2048 // Maximum URL string length
MinPortNumber = 1
MaxPortNumber = 65535
)
// File system permissions
const (
DirPermissions = 0755
FilePermissions = 0644
)
// Format identifiers
const (
FormatTOML = "toml"
FormatJSON = "json"
FormatYAML = "yaml"
FormatAuto = "auto"
)
// Watch event types
const (
EventFileDeleted = "file_deleted"
EventPermissionsChanged = "permissions_changed"
EventReloadError = "reload_error"
EventReloadTimeout = "reload_timeout"
EventPrecedenceChanged = "precedence"
)
// Channel and resource limits
const (
DefaultMaxWatchers = 100
WatchChannelBuffer = 10
MaxValueSize = 1024 * 1024 // 1MB
)
// Network defaults
const (
IPv4Any = "0.0.0.0"
IPv6Any = "::"
)
// File discovery defaults
var (
DefaultConfigExtensions = []string{".toml", ".conf", ".config"}
XDGSystemPaths = []string{"/etc/xdg", "/etc"}
)

View File

@ -10,11 +10,11 @@ import (
"strings"
"time"
"github.com/mitchellh/mapstructure"
"github.com/go-viper/mapstructure/v2"
)
// unmarshal is the single authoritative function for decoding configuration
// into target structures. All public decoding methods delegate to this.
// into target structures. All public decoding methods delegate to this
func (c *Config) unmarshal(source Source, target any, basePath ...string) error {
// Parse variadic basePath
path := ""
@ -24,13 +24,13 @@ func (c *Config) unmarshal(source Source, target any, basePath ...string) error
case 1:
path = basePath[0]
default:
return fmt.Errorf("too many basePath arguments: expected 0 or 1, got %d", len(basePath))
return wrapError(ErrInvalidPath, fmt.Errorf("too many basePath arguments: expected 0 or 1, got %d", len(basePath)))
}
// Validate target
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return fmt.Errorf("unmarshal target must be non-nil pointer, got %T", target)
return wrapError(ErrTypeMismatch, fmt.Errorf("unmarshal target must be non-nil pointer, got %T", target))
}
c.mutex.RLock()
@ -56,14 +56,14 @@ func (c *Config) unmarshal(source Source, target any, basePath ...string) error
// Navigate to basePath section
sectionData := navigateToPath(nestedMap, path)
// Ensure we have a map to decode, normalizing if necessary.
// Ensure we have a map to decode, normalizing if necessary
sectionMap, err := normalizeMap(sectionData)
if err != nil {
if sectionData == nil {
sectionMap = make(map[string]any) // Empty section is valid.
sectionMap = make(map[string]any) // Empty section is valid
} else {
// Path points to a non-map value, which is an error for Scan.
return fmt.Errorf("path %q refers to non-map value (type %T)", path, sectionData)
// Path points to a non-map value, which is an error for Scan
return wrapError(ErrTypeMismatch, fmt.Errorf("path %q refers to non-map value (type %T)", path, sectionData))
}
}
@ -77,17 +77,17 @@ func (c *Config) unmarshal(source Source, target any, basePath ...string) error
Metadata: nil,
})
if err != nil {
return fmt.Errorf("decoder creation failed: %w", err)
return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err))
}
if err := decoder.Decode(sectionMap); err != nil {
return fmt.Errorf("decode failed for path %q: %w", path, err)
return wrapError(ErrDecode, fmt.Errorf("decode failed for path %q: %w", path, err))
}
return nil
}
// normalizeMap ensures that the input data is a map[string]any for the decoder.
// normalizeMap ensures that the input data is a map[string]any for the decoder
func normalizeMap(data any) (map[string]any, error) {
if data == nil {
return make(map[string]any), nil
@ -102,10 +102,10 @@ func normalizeMap(data any) (map[string]any, error) {
v := reflect.ValueOf(data)
if v.Kind() == reflect.Map {
if v.Type().Key().Kind() != reflect.String {
return nil, fmt.Errorf("map keys must be strings, but got %v", v.Type().Key())
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("map keys must be strings, but got %v", v.Type().Key()))
}
// Create a new map[string]any and copy the values.
// Create a new map[string]any and copy the values
normalized := make(map[string]any, v.Len())
iter := v.MapRange()
for iter.Next() {
@ -114,7 +114,7 @@ func normalizeMap(data any) (map[string]any, error) {
return normalized, nil
}
return nil, fmt.Errorf("expected a map but got %T", data)
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("expected a map but got %T", data))
}
// getDecodeHook returns the composite decode hook for all type conversions
@ -151,19 +151,27 @@ func jsonNumberHookFunc() mapstructure.DecodeHookFunc {
// Convert based on target type
switch t.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return num.Int64()
val, err := num.Int64()
if err != nil {
return nil, wrapError(ErrDecode, err)
}
return val, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
// Parse as int64 first, then convert
i, err := num.Int64()
if err != nil {
return nil, err
return nil, wrapError(ErrDecode, err)
}
if i < 0 {
return nil, fmt.Errorf("cannot convert negative number to unsigned type")
return nil, wrapError(ErrDecode, fmt.Errorf("cannot convert negative number to unsigned type"))
}
return uint64(i), nil
case reflect.Float32, reflect.Float64:
return num.Float64()
val, err := num.Float64()
if err != nil {
return nil, wrapError(ErrDecode, err)
}
return val, nil
case reflect.String:
return num.String(), nil
default:
@ -186,7 +194,7 @@ func stringToNetIPHookFunc() mapstructure.DecodeHookFunc {
// SECURITY: Validate IP string format to prevent injection
str := data.(string)
if len(str) > 45 { // Max IPv6 length
if len(str) > MaxIPv6Length {
return nil, fmt.Errorf("invalid IP length: %d", len(str))
}
@ -215,12 +223,12 @@ func stringToNetIPNetHookFunc() mapstructure.DecodeHookFunc {
}
str := data.(string)
if len(str) > 49 { // Max IPv6 CIDR length
return nil, fmt.Errorf("invalid CIDR length: %d", len(str))
if len(str) > MaxCIDRLength {
return nil, wrapError(ErrDecode, fmt.Errorf("invalid CIDR length: %d", len(str)))
}
_, ipnet, err := net.ParseCIDR(str)
if err != nil {
return nil, fmt.Errorf("invalid CIDR: %w", err)
return nil, wrapError(ErrDecode, fmt.Errorf("invalid CIDR: %w", err))
}
if isPtr {
return ipnet, nil
@ -245,12 +253,12 @@ func stringToURLHookFunc() mapstructure.DecodeHookFunc {
}
str := data.(string)
if len(str) > 2048 {
return nil, fmt.Errorf("URL too long: %d bytes", len(str))
if len(str) > MaxURLLength {
return nil, wrapError(ErrDecode, fmt.Errorf("URL too long: %d bytes", len(str)))
}
u, err := url.Parse(str)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
return nil, wrapError(ErrDecode, fmt.Errorf("invalid URL: %w", err))
}
if isPtr {
return u, nil
@ -262,7 +270,7 @@ func stringToURLHookFunc() mapstructure.DecodeHookFunc {
// customDecodeHook allows for application-specific type conversions
func (c *Config) customDecodeHook() mapstructure.DecodeHookFunc {
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
// SECURITY: Add custom validation for application types here
// TODO: Add support of custom validation for application types here
// Example: Rate limit parsing, permission validation, etc.
// Pass through by default

View File

@ -35,7 +35,7 @@ type FileDiscoveryOptions struct {
func DefaultDiscoveryOptions(appName string) FileDiscoveryOptions {
return FileDiscoveryOptions{
Name: appName,
Extensions: []string{".toml", ".conf", ".config"},
Extensions: DefaultConfigExtensions,
EnvVar: strings.ToUpper(appName) + "_CONFIG",
CLIFlag: "--config",
UseXDG: true,
@ -43,63 +43,6 @@ func DefaultDiscoveryOptions(appName string) FileDiscoveryOptions {
}
}
// WithFileDiscovery enables automatic config file discovery
func (b *Builder) WithFileDiscovery(opts FileDiscoveryOptions) *Builder {
// Check CLI args first (highest priority)
if opts.CLIFlag != "" && len(b.args) > 0 {
for i, arg := range b.args {
if arg == opts.CLIFlag && i+1 < len(b.args) {
b.file = b.args[i+1]
return b
}
if strings.HasPrefix(arg, opts.CLIFlag+"=") {
b.file = strings.TrimPrefix(arg, opts.CLIFlag+"=")
return b
}
}
}
// Check environment variable
if opts.EnvVar != "" {
if path := os.Getenv(opts.EnvVar); path != "" {
b.file = path
return b
}
}
// Build search paths
var searchPaths []string
// Custom paths first
searchPaths = append(searchPaths, opts.Paths...)
// Current directory
if opts.UseCurrentDir {
if cwd, err := os.Getwd(); err == nil {
searchPaths = append(searchPaths, cwd)
}
}
// XDG paths
if opts.UseXDG {
searchPaths = append(searchPaths, getXDGConfigPaths(opts.Name)...)
}
// Search for config file
for _, dir := range searchPaths {
for _, ext := range opts.Extensions {
path := filepath.Join(dir, opts.Name+ext)
if _, err := os.Stat(path); err == nil {
b.file = path
return b
}
}
}
// No file found is not an error - app can run with defaults/env
return b
}
// getXDGConfigPaths returns XDG-compliant config search paths
func getXDGConfigPaths(appName string) []string {
var paths []string
@ -118,10 +61,9 @@ func getXDGConfigPaths(appName string) []string {
}
} else {
// Default system paths
paths = append(paths,
filepath.Join("/etc/xdg", appName),
filepath.Join("/etc", appName),
)
for _, dir := range XDGSystemPaths {
paths = append(paths, filepath.Join(dir, appName))
}
}
return paths

View File

@ -399,9 +399,3 @@ cfg.Dump() // Writes to stdout
testCfg := cfg.Clone()
testCfg.Set("server.port", int64(0)) // Random port for tests
```
## See Also
- [Live Reconfiguration](reconfiguration.md) - Reacting to changes
- [Builder Pattern](builder.md) - Type-aware configuration
- [Environment Variables](env.md) - Environment value access

174
doc/architecture.md Normal file
View File

@ -0,0 +1,174 @@
# Config Package Architecture
## Overview
The `lixenwraith/config` package provides thread-safe configuration management with support for multiple sources, type-safe struct population, and live reconfiguration.
## Logical Architecture
```
┌──────────────────────────────────────────────────────────┐
│ 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 │
└──────────────────────────────────────────────────────────┘
```
## 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

View File

@ -371,8 +371,3 @@ if err != nil {
```
For panic on error use `MustBuild()`
## See Also
- [Environment Variables](env.md) - Environment configuration details
- [Live Reconfiguration](reconfiguration.md) - File watching with builder

View File

@ -186,8 +186,3 @@ if err != nil {
}
}
```
## See Also
- [Environment Variables](env.md) - Environment variable handling
- [Access Patterns](access.md) - Retrieving parsed values

View File

@ -99,6 +99,19 @@ 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:
@ -187,9 +200,3 @@ cfg, _ := config.NewBuilder().
).
Build()
```
## See Also
- [Command Line](cli.md) - CLI argument handling
- [File Configuration](file.md) - Configuration file formats
- [Access Patterns](access.md) - Retrieving values

View File

@ -302,9 +302,3 @@ if err := cfg.Scan("database", &dbCfg); err != nil {
4. **Handle Missing Files**: Missing config files often aren't fatal
5. **Use Atomic Saves**: The built-in Save method is atomic
6. **Document Structure**: Comment your TOML files thoroughly
## See Also
- [Live Reconfiguration](reconfiguration.md) - Automatic file reloading
- [Builder Pattern](builder.md) - File discovery options
- [Access Patterns](access.md) - Working with loaded values

View File

@ -1,302 +0,0 @@
# lixenwraith/config LLM Usage Guide
Thread-safe configuration management for Go applications with multi-source support, type safety, and live reconfiguration.
Use default configuration and behavior if applicable, unless explicitly required.
## Core Types
### Config
```go
// Primary configuration manager. All operations are thread-safe.
type Config struct {
// Internal fields - thread-safe configuration store
}
```
### Source
```go
// Represents a configuration source, used to define load precedence.
type Source string
const (
SourceDefault Source = "default"
SourceFile Source = "file"
SourceEnv Source = "env"
SourceCLI Source = "cli"
)
```
### LoadOptions
```go
type LoadOptions struct {
Sources []Source // Precedence order (first = highest)
EnvPrefix string // Prepended to env var names
EnvTransform EnvTransformFunc // Custom path→env mapping
LoadMode LoadMode // Uses default behavior, do not configure
EnvWhitelist map[string]bool // Limit env paths (nil = all)
SkipValidation bool // Skip path validation
}
type EnvTransformFunc func(path string) string
type LoadMode int // LoadModeReplace (default) or LoadModeMerge
```
## Error Types
```go
var (
ErrConfigNotFound = errors.New("configuration file not found")
ErrCLIParse = errors.New("failed to parse command-line arguments")
ErrEnvParse = errors.New("failed to parse environment variables")
ErrValueSize = fmt.Errorf("value size exceeds maximum %d bytes", MaxValueSize)
)
const MaxValueSize = 1024 * 1024 // 1MB
```
## Core Methods
### Creation
```go
// New creates a new Config instance with default options.
func New() *Config
// NewWithOptions creates a new Config instance with custom load options.
func NewWithOptions(opts LoadOptions) *Config
func DefaultLoadOptions() LoadOptions
```
### Registration
```go
// Register makes a configuration path known with a default value; required before use.
func (c *Config) Register(path string, defaultValue any) error
// RegisterStruct recursively registers fields from a struct using `toml` tags by default.
func (c *Config) RegisterStruct(prefix string, structWithDefaults any) error
// RegisterStructWithTags is like RegisterStruct but allows custom tag names ("json", "yaml").
func (c *Config) RegisterStructWithTags(prefix string, structWithDefaults any, tagName string) error
// RegisterWithEnv registers a path with an explicit environment variable mapping.
func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) error
// Unregister removes a configuration path and all its children.
func (c *Config) Unregister(path string) error
```
Only default `toml` tags must be used unless support of other types are explicitly requested.
Path registration is required before setting values. Paths use dot notation (e.g., "server.port").
### Value Access
```go
// Get retrieves the final merged value; the bool indicates if the path was registered.
func (c *Config) Get(path string) (any, bool)
// GetSource retrieves a value from a specific source layer.
func (c *Config) GetSource(path string, source Source) (any, bool)
// GetSources returns all sources that have a value for the given path.
func (c *Config) GetSources(path string) map[Source]any
```
The returned `any` type requires type assertion, e.g., `port := val.(int64)`.
### Value Modification
```go
// Set updates a value in the highest priority source (default: CLI). Path must be registered.
func (c *Config) Set(path string, value any) error
// SetSource sets a value for a specific source layer.
func (c *Config) SetSource(path string, source Source, value any) error
// SetLoadOptions updates the load options, recomputing all current values.
func (c *Config) SetLoadOptions(opts LoadOptions) error
```
### Loading
```go
// Load reads configuration from a TOML file and merges overrides from command-line arguments.
func (c *Config) Load(filePath string, args []string) error
// LoadWithOptions loads configuration from multiple sources with custom options.
func (c *Config) LoadWithOptions(filePath string, args []string, opts LoadOptions) error
// LoadFile loads configuration values from a TOML file into the File source.
func (c *Config) LoadFile(path string) error
// LoadEnv loads values from environment variables into the Env source.
func (c *Config) LoadEnv(prefix string) error
// LoadCLI loads values from command-line arguments into the CLI source.
func (c *Config) LoadCLI(args []string) error
```
### Scanning & Population
```go
// Scan populates a struct from a specific config path (e.g., "server").
func (c *Config) Scan(basePath string, target any) error
// ScanSource decodes configuration from specific source
func (c *Config) ScanSource(basePath string, source Source, target any) error
// Target populates a struct from the root of the config; alias for Scan("", target).
func (c *Config) Target(out any) error
// AsStruct retrieves the pre-configured target struct (see Builder.WithTarget).
func (c *Config) AsStruct() (any, error)
```
Populates structs using mapstructure with automatic type conversion.
### Persistence
```go
// Save atomically saves the current merged configuration state to a TOML file.
func (c *Config) Save(path string) error
// SaveSource atomically saves values from only a specific source to a TOML file.
func (c *Config) SaveSource(path string, source Source) error
```
Atomic file writes in TOML format.
### State Management
```go
// Reset clears all non-default values from all sources.
func (c *Config) Reset()
// ResetSource clears all values from a specific source.
func (c *Config) ResetSource(source Source)
// Clone creates a deep copy of the configuration state.
func (c *Config) Clone() *Config
```
### Inspection
```go
// GetRegisteredPaths returns all registered paths matching a prefix.
func (c *Config) GetRegisteredPaths(prefix string) map[string]bool
// Validate checks that all specified required paths have been set.
func (c *Config) Validate(required ...string) error
// Debug returns a formatted string of all values and their sources for debugging.
func (c *Config) Debug() string
```
### Environment
```go
// DiscoverEnv discovers environment variables matching a prefix.
func (c *Config) DiscoverEnv(prefix string) map[string]string
// ExportEnv exports the current configuration as environment variables
func (c *Config) ExportEnv(prefix string) map[string]string
```
## Builder Pattern
### Builder
```go
type Builder struct {
// Internal builder state
}
type ValidatorFunc func(c *Config) error
```
### Builder Methods
```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)
// WithDefaults sets the struct containing default values.
func (b *Builder) WithDefaults(defaults any) *Builder
// WithTarget enables type-aware mode for AsStruct() and registers struct fields.
func (b *Builder) WithTarget(target any) *Builder
// WithTagName sets the primary struct tag for field mapping: "toml", "json", "yaml".
func (b *Builder) WithTagName(tagName string) *Builder
// WithSources sets the precedence order for configuration sources.
func (b *Builder) WithSources(sources ...Source) *Builder
// WithPrefix adds a prefix to all registered paths from a struct.
func (b *Builder) WithPrefix(prefix string) *Builder
// WithEnvPrefix sets the global environment variable prefix.
func (b *Builder) WithEnvPrefix(prefix string) *Builder
// WithFile sets the configuration file path to be loaded.
func (b *Builder) WithFile(path string) *Builder
// WithArgs sets the command-line arguments to be parsed.
func (b *Builder) WithArgs(args []string) *Builder
// WithValidator adds a validation function that runs after loading.
func (b *Builder) WithValidator(fn ValidatorFunc) *Builder
// WithEnvTransform sets a custom environment variable mapping function.
func (b *Builder) WithSources(sources ...Source) *Builder
// WithEnvTransform sets a custom environment variable mapping function.
func (b *Builder) WithEnvTransform(fn EnvTransformFunc) *Builder
// WithFileDiscovery enables automatic config file discovery
func (b *Builder) WithFileDiscovery(opts FileDiscoveryOptions) *Builder
```
### FileDiscoveryOptions
```go
type FileDiscoveryOptions struct {
Name string // Base name without extension
Extensions []string // Extensions to try in order
Paths []string // Custom search paths
EnvVar string // Environment variable for path
CLIFlag string // CLI flag for path
UseXDG bool // Search XDG directories
UseCurrentDir bool // Search current directory
}
func DefaultDiscoveryOptions(appName string) FileDiscoveryOptions
```
## Live Reconfiguration
### AutoUpdate
```go
// AutoUpdate enables automatic configuration reloading on file changes with default options.
func (c *Config) AutoUpdate()
// AutoUpdateWithOptions enables reloading with custom options.
func (c *Config) AutoUpdateWithOptions(opts WatchOptions)
// StopAutoUpdate stops the file watcher and cleans up resources.
func (c *Config) StopAutoUpdate()
// IsWatching returns true if the file watcher is active.
func (c *Config) IsWatching() bool
```
### Watch
```go
// Watch returns a channel that receives paths of changed values.
func (c *Config) Watch() <-chan string
// WatcherCount returns the number of active watch subscribers.
func (c *Config) WatcherCount() int
```
Channel receives paths of changed values or special notifications: `"file_deleted"`, `"permissions_changed"`, `"reload_error:*"`.
### WatchOptions
```go
type WatchOptions struct {
PollInterval time.Duration // File check interval (min 100ms)
Debounce time.Duration // Delay after changes
MaxWatchers int // Concurrent watch limit
ReloadTimeout time.Duration // Reload operation timeout
VerifyPermissions bool // Check permission changes
}
func DefaultWatchOptions() WatchOptions
```
## Type System
### Supported Types
- Basic: `bool`, `int64`, `float64`, `string`
- Time: `time.Duration`, `time.Time`
- Network: `net.IP`, `net.IPNet`, `url.URL`
- Slices: Any slice type with comma-separated parsing
- Complex: Any type via mapstructure decode hooks
### Type Conversion
All integer types are stored as `int64`, and floats as `float64`. String inputs from sources like environment variables or CLI arguments are automatically parsed to the target registered type. Custom types supported via decode hooks.
### Struct Tags
The `WithTagName` builder method sets the primary tag used for mapping paths.
```go
type Config struct {
// Uses the tag set by WithTagName (default "toml") for path name.
// The `env` tag provides an explicit environment variable override.
Port int64 `toml:"port" env:"PORT"`
Timeout time.Duration `toml:"timeout"`
// Slices are populated from comma-separated strings (env/CLI) or arrays (file).
Tags []string `toml:"tags"`
}
```
## Thread Safety
All methods are thread-safe. Concurrent reads and writes are synchronized internally.
## Path Validation
- Paths use dot notation: "server.port", "database.connections.max"
- Segments must be valid identifiers: `[A-Za-z0-9_-]+`
- No leading/trailing dots or empty segments
## Source Precedence
Default order (highest to lowest):
1. CLI arguments
2. Environment variables
3. Configuration file
4. Default values
Precedence is configurable via `Builder.WithSources()` or `LoadOptions.Sources`.

View File

@ -0,0 +1,159 @@
# 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

View File

@ -106,7 +106,7 @@ type Config struct {
// Type assertions are safe after registration
port, _ := cfg.Get("port")
portNum := port.(int64) // Safe - type is guaranteed
portNum := port.(int64) // Type matches registration
```
## Error Handling
@ -124,6 +124,30 @@ if err != nil {
}
```
### 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
@ -139,10 +163,26 @@ if err := cfg.Validate("api.key", "database.url"); err != nil {
}
```
### 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 TOML
// Use JSON tags instead of default TOML
type Config struct {
Server struct {
Host string `json:"host"`
@ -153,7 +193,7 @@ type Config struct {
cfg, _ := config.NewBuilder().
WithTarget(&Config{}).
WithTagName("json").
WithFile("config.toml").
WithFile("config.json").
Build()
```
@ -172,5 +212,4 @@ for source, value := range sources {
## Next Steps
- [Builder Pattern](builder.md) - Advanced configuration options
- [Environment Variables](env.md) - Detailed environment variable handling
- [Access Patterns](access.md) - All ways to get and set values

View File

@ -347,9 +347,3 @@ go func() {
}
}()
```
## See Also
- [File Configuration](file.md) - File format and loading
- [Access Patterns](access.md) - Reacting to changed values
- [Builder Pattern](builder.md) - Setting up watching with builder

175
doc/validator.md Normal file
View File

@ -0,0 +1,175 @@
# Validation
The config package provides flexible validation through function injection and bundled validators for common scenarios.
## Validation Stages
The package supports two validation stages when using the Builder pattern:
### 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).

55
error.go Normal file
View File

@ -0,0 +1,55 @@
// FILE: lixenwraith/config/error.go
package config
import (
"errors"
)
// Error categories - major error types for client code to check with errors.Is()
var (
// ErrNotConfigured indicates an operation was attempted on a Config instance
// that was not properly prepared for it
ErrNotConfigured = errors.New("operation requires additional configuration")
// ErrConfigNotFound indicates the specified configuration file was not found
ErrConfigNotFound = errors.New("configuration file not found")
// ErrCLIParse indicates that parsing command-line arguments failed
ErrCLIParse = errors.New("failed to parse command-line arguments")
// ErrEnvParse indicates that parsing environment variables failed
ErrEnvParse = errors.New("failed to parse environment variables")
// ErrValueSize indicates a value larger than MaxValueSize
ErrValueSize = errors.New("value size exceeds maximum")
// ErrPathNotFound indicates the configuration path doesn't exist.
ErrPathNotFound = errors.New("configuration path not found")
// ErrPathNotRegistered indicates trying to operate on unregistered path.
ErrPathNotRegistered = errors.New("configuration path not registered")
// ErrInvalidPath indicates malformed configuration path.
ErrInvalidPath = errors.New("invalid configuration path")
// ErrTypeMismatch indicates type conversion/assertion failure.
ErrTypeMismatch = errors.New("type mismatch")
// ErrValidation indicates configuration validation failure.
ErrValidation = errors.New("configuration validation failed")
// ErrFileFormat indicates unsupported or undetectable file format.
ErrFileFormat = errors.New("unsupported file format")
// ErrDecode indicates failure during value decoding.
ErrDecode = errors.New("failed to decode value")
// ErrFileAccess indicates file permission or access issues.
ErrFileAccess = errors.New("file access denied")
)
// wrapError joins a base error type with a more specific error,
// allowing errors.Is() to work on both.
func wrapError(base error, detail error) error {
return errors.Join(base, detail)
}

View File

@ -11,7 +11,7 @@ import (
"github.com/lixenwraith/config"
)
// AppConfig defines a richer configuration structure to showcase more features.
// AppConfig defines a richer configuration structure to showcase more features
type AppConfig struct {
Server struct {
Host string `toml:"host"`
@ -26,17 +26,17 @@ const configFilePath = "config.toml"
func main() {
// =========================================================================
// PART 1: INITIAL SETUP
// Create a clean config.toml file on disk for our program to read.
// Create a clean config.toml file on disk for our program to read
// =========================================================================
log.Println("---")
log.Println("➡️ PART 1: Creating initial configuration file...")
log.Println("PART 1: Creating initial configuration file...")
// Defer cleanup to run at the very end of the program.
// Defer cleanup to run at the end of the program
defer func() {
log.Println("---")
log.Println("🧹 Cleaning up...")
log.Println("Cleaning up...")
os.Remove(configFilePath)
// Unset the environment variable we use for testing.
// Unset the environment variable we use for testing
os.Unsetenv("APP_SERVER_PORT")
log.Printf("Removed %s and unset APP_SERVER_PORT.", configFilePath)
}()
@ -48,67 +48,67 @@ func main() {
initialData.FeatureFlags = map[string]bool{"enable_metrics": true}
if err := createInitialConfigFile(initialData); err != nil {
log.Fatalf(" Failed during initial file creation: %v", err)
log.Fatalf("FAIL - Failed during initial file creation: %v", err)
}
log.Printf(" Initial configuration saved to %s.", configFilePath)
log.Printf("PASS - Initial configuration saved to %s.", configFilePath)
// =========================================================================
// PART 2: RECOMMENDED CONFIGURATION USING THE BUILDER
// This demonstrates source precedence, validation, and type-safe targets.
// This demonstrates source precedence, validation, and type-safe targets
// =========================================================================
log.Println("---")
log.Println("➡️ PART 2: Configuring manager with the Builder...")
log.Println("PART 2: Configuring manager with the Builder...")
// Set an environment variable to demonstrate source precedence (Env > File).
// 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()`.
// and keep it updated when using `AsStruct()`
target := &AppConfig{}
// Use the builder to chain multiple configuration options.
// 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).
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)
WithTypedValidator(func(cfg *AppConfig) error { // <-- NEW METHOD
// No type assertion needed! `cfg.Server.Port` is guaranteed to be an int64
// because the validator runs *after* the target struct is populated.
// because the validator runs *after* the target struct is populated
if cfg.Server.Port < 1024 || cfg.Server.Port > 65535 {
return fmt.Errorf("port %d is outside the recommended range (1024-65535)", cfg.Server.Port)
}
return nil
})
// Build the final config object.
// Build the final config object
cfg, err := builder.Build()
if err != nil {
log.Fatalf(" Builder failed: %v", err)
log.Fatalf("FAIL - Builder failed: %v", err)
}
log.Println(" Builder finished successfully. Initial values loaded.")
log.Println("PASS - 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.
// We'll now modify the file and verify the watcher updates the config
// =========================================================================
log.Println("---")
log.Println("➡️ PART 3: Testing the file watcher...")
log.Println("PART 3: Testing the file watcher...")
// Use WithOptions to demonstrate customizing the 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.")
log.Println("PASS - Watcher is now active with custom options.")
// Start a goroutine to modify the file after a short delay.
// Start a goroutine to modify the file after a short delay
var wg sync.WaitGroup
wg.Add(1)
go modifyFileOnDiskStructurally(&wg)
@ -117,33 +117,33 @@ func main() {
log.Println(" (Waiting for watcher notification...)")
select {
case path := <-changes:
log.Printf(" Watcher detected a change for path: '%s'", path)
log.Printf("PASS - Watcher detected a change for path: '%s'", path)
log.Println(" Verifying in-memory config using AsStruct()...")
// Retrieve the updated, type-safe struct.
// Retrieve the updated, type-safe struct
updatedTarget, err := cfg.AsStruct()
if err != nil {
log.Fatalf(" AsStruct() failed after update: %v", err)
log.Fatalf("FAIL - AsStruct() failed after update: %v", err)
}
// Type-assert and verify the new values.
// 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.Fatalf("FAIL - 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.")
log.Println("PASS - 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.")
log.Fatalf("FAIL - TEST FAILED: Timed out waiting for watcher notification.")
}
wg.Wait()
}
// createInitialConfigFile is a helper to set up the initial file state.
// 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 {
@ -152,44 +152,44 @@ func createInitialConfigFile(data *AppConfig) error {
return cfg.Save(configFilePath)
}
// modifyFileOnDiskStructurally simulates an external program that changes the config file.
// modifyFileOnDiskStructurally simulates an external program that changes 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...)")
// Create a new, independent config instance to simulate an external process.
// Create a new, independent config instance to simulate an external process
modifierCfg := config.New()
// Register the struct shape so the loader knows what paths are valid.
// Register the struct shape so the loader knows what paths are valid
if err := modifierCfg.RegisterStruct("", &AppConfig{}); err != nil {
log.Fatalf(" Modifier failed to register struct: %v", err)
log.Fatalf("FAIL - Modifier failed to register struct: %v", err)
}
// Load the current state from disk.
if err := modifierCfg.LoadFile(configFilePath); err != nil {
log.Fatalf(" Modifier failed to load file: %v", err)
log.Fatalf("FAIL - Modifier failed to load file: %v", err)
}
// Change the log level.
modifierCfg.Set("server.log_level", "debug")
// Use the generic GetTyped function. This is safe because modifierCfg has loaded the file.
// Use the generic GetTyped function. This is safe because modifierCfg has loaded the file
featureFlags, err := config.GetTyped[map[string]bool](modifierCfg, "feature_flags")
if err != nil {
log.Fatalf(" Modifier failed to get typed feature_flags: %v", err)
log.Fatalf("FAIL - Modifier failed to get typed feature_flags: %v", err)
}
// Modify the typed map and set it back.
featureFlags["enable_metrics"] = false
modifierCfg.Set("feature_flags", featureFlags)
// Save the changes back to disk, which will trigger the watcher in the main goroutine.
// Save the changes back to disk, which will trigger the watcher in the main goroutine
if err := modifierCfg.Save(configFilePath); err != nil {
log.Fatalf(" Modifier failed to save file: %v", err)
log.Fatalf("FAIL - Modifier failed to save file: %v", err)
}
log.Println(" (Modifier goroutine: finished.)")
}
// printCurrentState is a helper to display the typed config state.
// printCurrentState is a helper to display the typed config state
func printCurrentState(cfg *AppConfig, title string) {
fmt.Println(" --------------------------------------------------")
fmt.Printf(" %s\n", title)

6
go.mod
View File

@ -1,10 +1,10 @@
module github.com/lixenwraith/config
go 1.25.1
go 1.25.4
require (
github.com/BurntSushi/toml v1.5.0
github.com/mitchellh/mapstructure v1.5.0
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/stretchr/testify v1.10.0
gopkg.in/yaml.v3 v3.0.1
)
@ -13,5 +13,3 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
)
replace github.com/mitchellh/mapstructure => github.com/go-viper/mapstructure v1.6.0

4
go.sum
View File

@ -2,8 +2,8 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
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/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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=

View File

@ -3,20 +3,20 @@ package config
import "strings"
// flattenMap converts a nested map[string]any to a flat map[string]any with dot-notation paths.
func flattenMap(nested map[string]any, prefix string) map[string]any {
// flattenMap converts a nested map[string]any to a flat map[string]any with dot-notation paths
func flattenMap(nestedMap map[string]any, prefix string) map[string]any {
flat := make(map[string]any)
for key, value := range nested {
for key, value := range nestedMap {
newPath := key
if prefix != "" {
newPath = prefix + "." + key
}
// Check if the value is a map that can be further flattened
if nestedMap, isMap := value.(map[string]any); isMap {
if nested, isMap := value.(map[string]any); isMap {
// Recursively flatten the nested map
flattenedSubMap := flattenMap(nestedMap, newPath)
flattenedSubMap := flattenMap(nested, newPath)
// Merge the flattened sub-map into the main flat map
for subPath, subValue := range flattenedSubMap {
flat[subPath] = subValue
@ -30,9 +30,9 @@ func flattenMap(nested map[string]any, prefix string) map[string]any {
return flat
}
// setNestedValue sets a value in a nested map using a dot-notation path.
// It creates intermediate maps if they don't exist.
// If a segment exists but is not a map, it will be overwritten by a new map.
// setNestedValue sets a value in a nested map using a dot-notation path
// It creates intermediate maps if they don't exist
// If a segment exists but is not a map, it will be overwritten by a new map
func setNestedValue(nested map[string]any, path string, value any) {
segments := strings.Split(path, ".")
current := nested
@ -64,12 +64,12 @@ func setNestedValue(nested map[string]any, path string, value any) {
current[lastSegment] = value
}
// isValidKeySegment checks if a single path segment is a valid TOML key part.
// isValidKeySegment checks if a single path segment is a valid TOML key part
func isValidKeySegment(s string) bool {
if len(s) == 0 {
return false
}
// TOML bare keys are sequences of ASCII letters, ASCII digits, underscores, and dashes (A-Za-z0-9_-).
// TOML bare keys are sequences of ASCII letters, ASCII digits, underscores, and dashes (A-Za-z0-9_-)
if strings.ContainsRune(s, '.') {
return false // Segments themselves cannot contain dots
}

386
loader.go
View File

@ -31,18 +31,6 @@ const (
SourceCLI Source = "cli"
)
// LoadMode defines how configuration sources are processed
type LoadMode int
const (
// LoadModeReplace completely replaces values (default behavior)
LoadModeReplace LoadMode = iota
// LoadModeMerge merges maps/structs instead of replacing
// TODO: future implementation
LoadModeMerge
)
// EnvTransformFunc converts a configuration path to an environment variable name
type EnvTransformFunc func(path string) string
@ -60,9 +48,6 @@ type LoadOptions struct {
// If nil, uses default transformation (dots to underscores, uppercase)
EnvTransform EnvTransformFunc
// LoadMode determines how values are merged
LoadMode LoadMode
// EnvWhitelist limits which paths are checked for env vars (nil = all)
EnvWhitelist map[string]bool
@ -74,18 +59,11 @@ type LoadOptions struct {
func DefaultLoadOptions() LoadOptions {
return LoadOptions{
Sources: []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault},
LoadMode: LoadModeReplace,
}
}
// Load reads configuration from a TOML file and merges overrides from command-line arguments.
// This is a convenience method that maintains backward compatibility.
func (c *Config) Load(filePath string, args []string) error {
return c.LoadWithOptions(filePath, args, c.options)
}
// LoadWithOptions loads configuration from multiple sources with custom options
func (c *Config) LoadWithOptions(filePath string, args []string, opts LoadOptions) error {
// loadWithOptions loads configuration from multiple sources with custom options
func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOptions) error {
c.mutex.Lock()
c.options = opts
c.mutex.Unlock()
@ -107,20 +85,20 @@ func (c *Config) LoadWithOptions(filePath string, args []string, opts LoadOption
if errors.Is(err, ErrConfigNotFound) {
loadErrors = append(loadErrors, err)
} else {
return err // Fatal error
return wrapError(ErrFileAccess, err) // Fatal error
}
}
}
case SourceEnv:
if err := c.loadEnv(opts); err != nil {
loadErrors = append(loadErrors, err)
loadErrors = append(loadErrors, wrapError(ErrEnvParse, err))
}
case SourceCLI:
if len(args) > 0 {
if err := c.loadCLI(args); err != nil {
loadErrors = append(loadErrors, err)
loadErrors = append(loadErrors, wrapError(ErrCLIParse, err))
}
}
}
@ -138,12 +116,160 @@ func (c *Config) LoadEnv(prefix string) error {
// LoadCLI loads configuration values from command-line arguments
func (c *Config) LoadCLI(args []string) error {
return c.loadCLI(args)
if err := c.loadCLI(args); err != nil {
return wrapError(ErrCLIParse, err)
}
return nil
}
// LoadFile loads configuration values from a TOML file
func (c *Config) LoadFile(filePath string) error {
return c.loadFile(filePath)
if err := c.loadFile(filePath); err != nil {
return wrapError(ErrFileAccess, err)
}
return nil
}
// Save writes the current configuration to a TOML file atomically.
// Only registered paths are saved.
func (c *Config) Save(path string) error {
c.mutex.RLock()
nestedData := make(map[string]any)
for itemPath, item := range c.items {
setNestedValue(nestedData, itemPath, item.currentValue)
}
c.mutex.RUnlock()
// Marshal using BurntSushi/toml
var buf bytes.Buffer
encoder := toml.NewEncoder(&buf)
if err := encoder.Encode(nestedData); err != nil {
return wrapError(ErrFileFormat, fmt.Errorf("failed to marshal config data to TOML: %w", err))
}
tomlData := buf.Bytes()
// Atomic write logic
dir := filepath.Dir(path)
// Ensure the directory exists
if err := os.MkdirAll(dir, DirPermissions); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to create config directory '%s': %w", dir, err))
}
// Create a temporary file in the same directory
tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to create temporary config file in '%s': %w", dir, err))
}
tempFilePath := tempFile.Name()
removed := false
defer func() {
if !removed {
os.Remove(tempFilePath)
}
}()
// Write data to the temporary file
if _, err := tempFile.Write(tomlData); err != nil {
tempFile.Close()
return wrapError(ErrFileAccess, fmt.Errorf("failed to write temp config file '%s': %w", tempFilePath, err))
}
// Sync data to disk
if err := tempFile.Sync(); err != nil {
tempFile.Close()
return wrapError(ErrFileAccess, fmt.Errorf("failed to sync temp config file '%s': %w", tempFilePath, err))
}
// Close the temporary file
if err := tempFile.Close(); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to close temp config file '%s': %w", tempFilePath, err))
}
// Set permissions on the temporary file
if err := os.Chmod(tempFilePath, FilePermissions); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to set permissions on temporary config file '%s': %w", tempFilePath, err))
}
// Atomically replace the original file
if err := os.Rename(tempFilePath, path); err != nil {
return wrapError(ErrFileAccess, fmt.Errorf("failed to rename temp file '%s' to '%s': %w", tempFilePath, path, err))
}
removed = true
return nil
}
// SaveSource writes values from a specific source to a TOML file
func (c *Config) SaveSource(path string, source Source) error {
c.mutex.RLock()
nestedData := make(map[string]any)
for itemPath, item := range c.items {
if val, exists := item.values[source]; exists {
setNestedValue(nestedData, itemPath, val)
}
}
c.mutex.RUnlock()
// Marshal using BurntSushi/toml
var buf bytes.Buffer
encoder := toml.NewEncoder(&buf)
if err := encoder.Encode(nestedData); err != nil {
return wrapError(ErrFileFormat, fmt.Errorf("failed to marshal %s source data to TOML: %w", source, err))
}
return atomicWriteFile(path, buf.Bytes())
}
// DiscoverEnv finds all environment variables matching registered paths
// and returns a map of path -> env var name for found variables
func (c *Config) DiscoverEnv(prefix string) map[string]string {
transform := c.options.EnvTransform
if transform == nil {
transform = defaultEnvTransform(prefix)
}
c.mutex.RLock()
defer c.mutex.RUnlock()
discovered := make(map[string]string)
for path := range c.items {
envVar := transform(path)
if _, exists := os.LookupEnv(envVar); exists {
discovered[path] = envVar
}
}
return discovered
}
// ExportEnv exports the current configuration as environment variables
// Only exports paths that have non-default values
func (c *Config) ExportEnv(prefix string) map[string]string {
transform := c.options.EnvTransform
if transform == nil {
transform = defaultEnvTransform(prefix)
}
c.mutex.RLock()
defer c.mutex.RUnlock()
exports := make(map[string]string)
for path, item := range c.items {
// Only export if value differs from default
if item.currentValue != item.defaultValue {
envVar := transform(path)
exports[envVar] = fmt.Sprintf("%v", item.currentValue)
}
}
return exports
}
// loadFile reads and parses a TOML configuration file
@ -155,7 +281,7 @@ func (c *Config) loadFile(path string) error {
// Check if cleaned path tries to go outside current directory
if strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || cleanPath == ".." {
return fmt.Errorf("potential path traversal detected in config path: %s", path)
return wrapError(ErrFileAccess, fmt.Errorf("potential path traversal detected in config path: %s", path))
}
// Also check for absolute paths that might escape jail
@ -163,7 +289,7 @@ func (c *Config) loadFile(path string) error {
// Absolute paths are OK if that's what was provided
} else if filepath.IsAbs(cleanPath) && !filepath.IsAbs(path) {
// Relative path became absolute after cleaning - suspicious
return fmt.Errorf("potential path traversal detected in config path: %s", path)
return wrapError(ErrFileAccess, fmt.Errorf("potential path traversal detected in config path: %s", path))
}
}
@ -173,13 +299,13 @@ func (c *Config) loadFile(path string) error {
if errors.Is(err, os.ErrNotExist) {
return ErrConfigNotFound
}
return fmt.Errorf("failed to stat config file '%s': %w", path, err)
return wrapError(ErrFileAccess, fmt.Errorf("failed to stat config file '%s': %w", path, err))
}
// Security: File size check
if c.securityOpts != nil && c.securityOpts.MaxFileSize > 0 {
if fileInfo.Size() > c.securityOpts.MaxFileSize {
return fmt.Errorf("config file '%s' exceeds maximum size %d bytes", path, c.securityOpts.MaxFileSize)
return wrapError(ErrFileAccess, fmt.Errorf("config file '%s' exceeds maximum size %d bytes", path, c.securityOpts.MaxFileSize))
}
}
@ -187,8 +313,8 @@ func (c *Config) loadFile(path string) error {
if c.securityOpts != nil && c.securityOpts.EnforceFileOwnership && runtime.GOOS != "windows" {
if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
if stat.Uid != uint32(os.Geteuid()) {
return fmt.Errorf("config file '%s' is not owned by current user (file UID: %d, process UID: %d)",
path, stat.Uid, os.Geteuid())
return wrapError(ErrFileAccess, fmt.Errorf("config file '%s' is not owned by current user (file UID: %d, process UID: %d)",
path, stat.Uid, os.Geteuid()))
}
}
}
@ -196,7 +322,7 @@ func (c *Config) loadFile(path string) error {
// 1. Read and parse file data
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open config file '%s': %w", path, err)
return wrapError(ErrFileAccess, fmt.Errorf("failed to open config file '%s': %w", path, err))
}
defer file.Close()
@ -208,7 +334,7 @@ func (c *Config) loadFile(path string) error {
fileData, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("failed to read config file '%s': %w", path, err)
return wrapError(ErrFileAccess, fmt.Errorf("failed to read config file '%s': %w", path, err))
}
// Determine format
@ -229,22 +355,22 @@ func (c *Config) loadFile(path string) error {
// Parse based on detected/specified format
fileConfig := make(map[string]any)
switch format {
case "toml":
case FormatTOML:
if err := toml.Unmarshal(fileData, &fileConfig); err != nil {
return fmt.Errorf("failed to parse TOML config file '%s': %w", path, err)
return wrapError(ErrDecode, fmt.Errorf("failed to parse TOML config file '%s': %w", path, err))
}
case "json":
case FormatJSON:
decoder := json.NewDecoder(bytes.NewReader(fileData))
decoder.UseNumber() // Preserve number precision
if err := decoder.Decode(&fileConfig); err != nil {
return fmt.Errorf("failed to parse JSON config file '%s': %w", path, err)
return wrapError(ErrDecode, fmt.Errorf("failed to parse JSON config file '%s': %w", path, err))
}
case "yaml":
case FormatYAML:
if err := yaml.Unmarshal(fileData, &fileConfig); err != nil {
return fmt.Errorf("failed to parse YAML config file '%s': %w", path, err)
return wrapError(ErrDecode, fmt.Errorf("failed to parse YAML config file '%s': %w", path, err))
}
default:
return fmt.Errorf("unable to determine config format for file '%s'", path)
return wrapError(ErrFileFormat, fmt.Errorf("unable to determine config format for file '%s'", path))
}
// 2. Prepare New State (Read-Lock Only)
@ -366,7 +492,7 @@ func (c *Config) loadCLI(args []string) error {
// -- 1. Prepare data (No Lock)
parsedCLI, err := parseArgs(args)
if err != nil {
return fmt.Errorf("%w: %w", ErrCLIParse, err)
return err // Already wrapped with error category in parseArgs
}
flattenedCLI := flattenMap(parsedCLI, "")
@ -395,53 +521,6 @@ func (c *Config) loadCLI(args []string) error {
return nil
}
// DiscoverEnv finds all environment variables matching registered paths
// and returns a map of path -> env var name for found variables
func (c *Config) DiscoverEnv(prefix string) map[string]string {
transform := c.options.EnvTransform
if transform == nil {
transform = defaultEnvTransform(prefix)
}
c.mutex.RLock()
defer c.mutex.RUnlock()
discovered := make(map[string]string)
for path := range c.items {
envVar := transform(path)
if _, exists := os.LookupEnv(envVar); exists {
discovered[path] = envVar
}
}
return discovered
}
// ExportEnv exports the current configuration as environment variables
// Only exports paths that have non-default values
func (c *Config) ExportEnv(prefix string) map[string]string {
transform := c.options.EnvTransform
if transform == nil {
transform = defaultEnvTransform(prefix)
}
c.mutex.RLock()
defer c.mutex.RUnlock()
exports := make(map[string]string)
for path, item := range c.items {
// Only export if value differs from default
if item.currentValue != item.defaultValue {
envVar := transform(path)
exports[envVar] = fmt.Sprintf("%v", item.currentValue)
}
}
return exports
}
// defaultEnvTransform creates the default environment variable transformer
func defaultEnvTransform(prefix string) EnvTransformFunc {
return func(path string) string {
@ -473,111 +552,16 @@ func parseValue(s string) any {
return s
}
// Save writes the current configuration to a TOML file atomically.
// Only registered paths are saved.
func (c *Config) Save(path string) error {
c.mutex.RLock()
nestedData := make(map[string]any)
for itemPath, item := range c.items {
setNestedValue(nestedData, itemPath, item.currentValue)
}
c.mutex.RUnlock()
// Marshal using BurntSushi/toml
var buf bytes.Buffer
encoder := toml.NewEncoder(&buf)
if err := encoder.Encode(nestedData); err != nil {
return fmt.Errorf("failed to marshal config data to TOML: %w", err)
}
tomlData := buf.Bytes()
// Atomic write logic
dir := filepath.Dir(path)
// Ensure the directory exists
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory '%s': %w", dir, err)
}
// Create a temporary file in the same directory
tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("failed to create temporary config file in '%s': %w", dir, err)
}
tempFilePath := tempFile.Name()
removed := false
defer func() {
if !removed {
os.Remove(tempFilePath)
}
}()
// Write data to the temporary file
if _, err := tempFile.Write(tomlData); err != nil {
tempFile.Close()
return fmt.Errorf("failed to write temp config file '%s': %w", tempFilePath, err)
}
// Sync data to disk
if err := tempFile.Sync(); err != nil {
tempFile.Close()
return fmt.Errorf("failed to sync temp config file '%s': %w", tempFilePath, err)
}
// Close the temporary file
if err := tempFile.Close(); err != nil {
return fmt.Errorf("failed to close temp config file '%s': %w", tempFilePath, err)
}
// Set permissions on the temporary file
if err := os.Chmod(tempFilePath, 0644); err != nil {
return fmt.Errorf("failed to set permissions on temporary config file '%s': %w", tempFilePath, err)
}
// Atomically replace the original file
if err := os.Rename(tempFilePath, path); err != nil {
return fmt.Errorf("failed to rename temp file '%s' to '%s': %w", tempFilePath, path, err)
}
removed = true
return nil
}
// SaveSource writes values from a specific source to a TOML file
func (c *Config) SaveSource(path string, source Source) error {
c.mutex.RLock()
nestedData := make(map[string]any)
for itemPath, item := range c.items {
if val, exists := item.values[source]; exists {
setNestedValue(nestedData, itemPath, val)
}
}
c.mutex.RUnlock()
// Marshal using BurntSushi/toml
var buf bytes.Buffer
encoder := toml.NewEncoder(&buf)
if err := encoder.Encode(nestedData); err != nil {
return fmt.Errorf("failed to marshal %s source data to TOML: %w", source, err)
}
return atomicWriteFile(path, buf.Bytes())
}
// atomicWriteFile performs atomic file write
func atomicWriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory '%s': %w", dir, err)
return wrapError(ErrFileAccess, fmt.Errorf("failed to create directory '%s': %w", dir, err))
}
tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
return wrapError(ErrFileAccess, fmt.Errorf("failed to create temporary file: %w", err))
}
tempPath := tempFile.Name()
@ -585,24 +569,24 @@ func atomicWriteFile(path string, data []byte) error {
if _, err := tempFile.Write(data); err != nil {
tempFile.Close()
return fmt.Errorf("failed to write temporary file: %w", err)
return wrapError(ErrFileAccess, fmt.Errorf("failed to write temporary file: %w", err))
}
if err := tempFile.Sync(); err != nil {
tempFile.Close()
return fmt.Errorf("failed to sync temporary file: %w", err)
return wrapError(ErrFileAccess, fmt.Errorf("failed to sync temporary file: %w", err))
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("failed to close temporary file: %w", err)
return wrapError(ErrFileAccess, fmt.Errorf("failed to close temporary file: %w", err))
}
if err := os.Chmod(tempPath, 0644); err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
return wrapError(ErrFileAccess, fmt.Errorf("failed to set permissions: %w", err))
}
if err := os.Rename(tempPath, path); err != nil {
return fmt.Errorf("failed to rename temporary file: %w", err)
return wrapError(ErrFileAccess, fmt.Errorf("failed to rename temporary file: %w", err))
}
return nil
@ -659,7 +643,7 @@ func parseArgs(args []string) (map[string]any, error) {
segments := strings.Split(keyPath, ".")
for _, segment := range segments {
if !isValidKeySegment(segment) {
return nil, fmt.Errorf("invalid command-line key segment %q in path %q", segment, keyPath)
return nil, wrapError(ErrInvalidPath, fmt.Errorf("invalid command-line key segment %q in path %q", segment, keyPath))
}
}
@ -675,11 +659,11 @@ func detectFileFormat(path string) string {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".toml", ".tml":
return "toml"
return FormatTOML
case ".json":
return "json"
return FormatJSON
case ".yaml", ".yml":
return "yaml"
return FormatYAML
case ".conf", ".config":
// Try to detect from content
return ""
@ -693,19 +677,19 @@ func detectFormatFromContent(data []byte) string {
// Try JSON first (strict format)
var jsonTest any
if err := json.Unmarshal(data, &jsonTest); err == nil {
return "json"
return FormatJSON
}
// Try YAML (superset of JSON, so check after JSON)
var yamlTest any
if err := yaml.Unmarshal(data, &yamlTest); err == nil {
return "yaml"
return FormatYAML
}
// Try TOML last
var tomlTest any
if err := toml.Unmarshal(data, &tomlTest); err == nil {
return "toml"
return FormatTOML
}
return ""

View File

@ -156,7 +156,7 @@ func TestEnvironmentLoading(t *testing.T) {
},
}
err := cfg.LoadWithOptions("", nil, opts)
err := cfg.loadWithOptions("", nil, opts)
require.NoError(t, err)
host, _ := cfg.Get("db.host")
@ -176,7 +176,7 @@ func TestEnvironmentLoading(t *testing.T) {
EnvWhitelist: map[string]bool{"allowed.path": true},
}
err := cfg.LoadWithOptions("", nil, opts)
err := cfg.loadWithOptions("", nil, opts)
require.NoError(t, err)
allowed, _ := cfg.Get("allowed.path")
@ -321,7 +321,7 @@ port = 8080
EnvPrefix: "TEST_",
}
err := cfg.LoadWithOptions(configFile, args, opts)
err := cfg.loadWithOptions(configFile, args, opts)
require.NoError(t, err)
// CLI should win

View File

@ -8,20 +8,20 @@ import (
"strings"
)
// Register makes a configuration path known to the Config instance.
// The path should be dot-separated (e.g., "server.port", "debug").
// Each segment of the path must be a valid TOML key identifier.
// defaultValue is the value returned by Get if no specific value has been set.
// Register makes a configuration path known to the Config instance
// The path should be dot-separated (e.g., "server.port", "debug")
// Each segment of the path must be a valid TOML key identifier
// defaultValue is the value returned by Get if no specific value has been set
func (c *Config) Register(path string, defaultValue any) error {
if path == "" {
return fmt.Errorf("registration path cannot be empty")
return wrapError(ErrInvalidPath, fmt.Errorf("registration path cannot be empty"))
}
// Validate path segments
segments := strings.Split(path, ".")
for _, segment := range segments {
if !isValidKeySegment(segment) {
return fmt.Errorf("invalid path segment %q in path %q", segment, path)
return wrapError(ErrInvalidPath, fmt.Errorf("invalid path segment %q in path %q", segment, path))
}
}
@ -46,7 +46,7 @@ func (c *Config) RegisterWithEnv(path string, defaultValue any, envVar string) e
// Check if the environment variable exists and load it
if value, exists := os.LookupEnv(envVar); exists {
parsed := parseValue(value)
return c.SetSource(SourceEnv, path, parsed)
return c.SetSource(SourceEnv, path, parsed) // Already wrapped with error category in SetSource
}
return nil
@ -60,7 +60,7 @@ func (c *Config) RegisterRequired(path string, defaultValue any) error {
return c.Register(path, defaultValue)
}
// Unregister removes a configuration path and all its children.
// Unregister removes a configuration path and all its children
func (c *Config) Unregister(path string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
@ -78,7 +78,7 @@ func (c *Config) Unregister(path string) error {
}
// If neither the path nor any children exist, return error
if !hasChildren {
return fmt.Errorf("path not registered: %s", path)
return wrapError(ErrPathNotRegistered, fmt.Errorf("path not registered: %s", path))
}
}
@ -96,11 +96,11 @@ func (c *Config) Unregister(path string) error {
return nil
}
// RegisterStruct registers configuration values derived from a struct.
// It uses struct tags (`toml:"..."`) to determine the configuration paths.
// The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed.
// RegisterStruct registers configuration values derived from a struct
// It uses struct tags (`toml:"..."`) to determine the configuration paths
// The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed
func (c *Config) RegisterStruct(prefix string, structWithDefaults any) error {
return c.RegisterStructWithTags(prefix, structWithDefaults, "toml")
return c.RegisterStructWithTags(prefix, structWithDefaults, FormatTOML)
}
// RegisterStructWithTags is like RegisterStruct but allows custom tag names
@ -110,21 +110,21 @@ func (c *Config) RegisterStructWithTags(prefix string, structWithDefaults any, t
// Handle pointer or direct struct value
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return fmt.Errorf("RegisterStructWithTags requires a non-nil struct pointer or value")
return wrapError(ErrTypeMismatch, fmt.Errorf("RegisterStructWithTags requires a non-nil struct pointer or value"))
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return fmt.Errorf("RegisterStructWithTags requires a struct or struct pointer, got %T", structWithDefaults)
return wrapError(ErrTypeMismatch, fmt.Errorf("RegisterStructWithTags requires a struct or struct pointer, got %T", structWithDefaults))
}
// Validate tag name
switch tagName {
case "toml", "json", "yaml":
case FormatTOML, FormatJSON, FormatYAML:
// Supported tags
default:
return fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName)
return wrapError(ErrTypeMismatch, fmt.Errorf("unsupported tag name %q, must be one of: toml, json, yaml", tagName))
}
var errors []string
@ -133,13 +133,13 @@ func (c *Config) RegisterStructWithTags(prefix string, structWithDefaults any, t
c.registerFields(v, prefix, "", &errors, tagName)
if len(errors) > 0 {
return fmt.Errorf("failed to register %d field(s): %s", len(errors), strings.Join(errors, "; "))
return wrapError(ErrTypeMismatch, fmt.Errorf("failed to register %d field(s): %s", len(errors), strings.Join(errors, "; ")))
}
return nil
}
// registerFields is a helper function that handles the recursive field registration.
// registerFields is a helper function that handles the recursive field registration
func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, errors *[]string, tagName string) {
t := v.Type()
@ -179,7 +179,6 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
currentPath = pathPrefix + key
}
// TODO: use mapstructure instead of logic with reflection
// Handle nested structs recursively
fieldType := fieldValue.Type()
isStruct := fieldValue.Kind() == reflect.Struct
@ -187,7 +186,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
if isStruct || isPtrToStruct {
// Check if the field's TYPE is one that should be treated as a single value,
// even though it's a struct. These types have custom decode hooks.
// even though it's a struct. These types have custom decode hooks
fieldType := fieldValue.Type()
isAtomicStruct := false
switch fieldType.String() {
@ -195,7 +194,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
isAtomicStruct = true
}
// Only recurse if it's a "normal" struct, not an atomic one.
// Only recurse if it's a "normal" struct, not an atomic one
if !isAtomicStruct {
nestedValue := fieldValue
if isPtrToStruct {
@ -209,7 +208,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors, tagName)
continue
}
// If it is an atomic struct, we fall through and register it as a single value.
// If it is an atomic struct, we fall through and register it as a single value
}
// Register non-struct fields
@ -238,7 +237,7 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
}
}
// GetRegisteredPaths returns all registered configuration paths with the specified prefix.
// GetRegisteredPaths returns all registered configuration paths with the specified prefix
func (c *Config) GetRegisteredPaths(prefix ...string) map[string]bool {
p := ""
if len(prefix) > 0 {

View File

@ -1,26 +0,0 @@
// FILE: lixenwraith/config/timing.go
package config
import "time"
// Core timing constants for production use.
// These define the fundamental timing behavior of the config package.
const (
// File watching intervals (ordered by frequency)
SpinWaitInterval = 5 * time.Millisecond // CPU-friendly busy-wait quantum
MinPollInterval = 100 * time.Millisecond // Hard floor for file stat polling
ShutdownTimeout = 100 * time.Millisecond // Graceful watcher termination window
DefaultDebounce = 500 * time.Millisecond // File change coalescence period
DefaultPollInterval = time.Second // Standard file monitoring frequency
DefaultReloadTimeout = 5 * time.Second // Maximum duration for reload operations
)
// Derived timing relationships for internal use.
// These maintain consistent ratios between related timers.
const (
// shutdownPollCycles defines how many spin-wait cycles comprise a shutdown timeout
shutdownPollCycles = ShutdownTimeout / SpinWaitInterval // = 20 cycles
// debounceSettleMultiplier ensures sufficient time for debounce to complete
debounceSettleMultiplier = 3 // Wait 3x debounce period for value stabilization
)

View File

@ -1,15 +1,17 @@
// FILE: lixenwraith/config/convenience.go
// FILE: lixenwraith/config/utility.go
package config
import (
"errors"
"flag"
"fmt"
"github.com/mitchellh/mapstructure"
"os"
"reflect"
"strings"
"time"
"github.com/BurntSushi/toml"
"github.com/go-viper/mapstructure/v2"
)
// Quick creates a fully configured Config instance with a single call
@ -20,7 +22,7 @@ func Quick(structDefaults any, envPrefix, configFile string) (*Config, error) {
// Register defaults from struct if provided
if structDefaults != nil {
if err := cfg.RegisterStruct("", structDefaults); err != nil {
return nil, fmt.Errorf("failed to register defaults: %w", err)
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("failed to register defaults: %w", err))
}
}
@ -28,7 +30,7 @@ func Quick(structDefaults any, envPrefix, configFile string) (*Config, error) {
opts := DefaultLoadOptions()
opts.EnvPrefix = envPrefix
err := cfg.LoadWithOptions(configFile, os.Args[1:], opts)
err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
return cfg, err
}
@ -39,18 +41,18 @@ func QuickCustom(structDefaults any, opts LoadOptions, configFile string) (*Conf
// Register defaults from struct if provided
if structDefaults != nil {
if err := cfg.RegisterStruct("", structDefaults); err != nil {
return nil, fmt.Errorf("failed to register defaults: %w", err)
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("failed to register defaults: %w", err))
}
}
err := cfg.LoadWithOptions(configFile, os.Args[1:], opts)
err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
return cfg, err
}
// MustQuick is like Quick but panics on error
func MustQuick(structDefaults any, envPrefix, configFile string) *Config {
cfg, err := Quick(structDefaults, envPrefix, configFile)
if err != nil {
if err != nil && !errors.Is(err, ErrConfigNotFound) {
panic(fmt.Sprintf("config initialization failed: %v", err))
}
return cfg
@ -105,7 +107,7 @@ func (c *Config) BindFlags(fs *flag.FlagSet) error {
}
if len(errors) > 0 {
return fmt.Errorf("failed to bind %d flags: %w", len(errors), errors[0])
return wrapError(ErrCLIParse, fmt.Errorf("failed to bind %d flags: %w", len(errors), errors[0]))
}
return nil
@ -143,7 +145,7 @@ func (c *Config) Validate(required ...string) error {
}
if len(missing) > 0 {
return fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", "))
return wrapError(ErrValidation, fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", ")))
}
return nil
@ -183,7 +185,10 @@ func (c *Config) Dump() error {
}
encoder := toml.NewEncoder(os.Stdout)
return encoder.Encode(nestedData)
if err := encoder.Encode(nestedData); err != nil {
return wrapError(ErrDecode, err)
}
return nil
}
// Clone creates a deep copy of the configuration
@ -237,7 +242,7 @@ func QuickTyped[T any](target *T, envPrefix, configFile string) (*Config, error)
Build()
}
// GetTyped retrieves a configuration value and decodes it into the specified type T.
// GetTyped retrieves a configuration value and decodes it into the specified type T
// It leverages the same decoding hooks as the Scan and AsStruct methods,
// providing type conversion from strings, numbers, etc.
func GetTyped[T any](c *Config, path string) (T, error) {
@ -245,16 +250,16 @@ func GetTyped[T any](c *Config, path string) (T, error) {
rawValue, exists := c.Get(path)
if !exists {
return zero, fmt.Errorf("path %q not found", path)
return zero, wrapError(ErrPathNotFound, fmt.Errorf("path %q not found", path))
}
// Prepare the input map and target struct for the decoder.
// Prepare the input map and target struct for the decoder
inputMap := map[string]any{"value": rawValue}
var target struct {
Value T `mapstructure:"value"`
}
// Create a new decoder configured with the same hooks as the main config.
// Create a new decoder configured with the same hooks as the main config
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: &target,
TagName: c.tagName,
@ -263,19 +268,51 @@ func GetTyped[T any](c *Config, path string) (T, error) {
Metadata: nil,
})
if err != nil {
return zero, fmt.Errorf("failed to create decoder for path %q: %w", path, err)
return zero, wrapError(ErrDecode, fmt.Errorf("failed to create decoder for path %q: %w", path, err))
}
// Decode the single value.
if err := decoder.Decode(inputMap); err != nil {
return zero, fmt.Errorf("failed to decode value for path %q into type %T: %w", path, zero, err)
return zero, wrapError(ErrDecode, fmt.Errorf("failed to decode value for path %q into type %T: %w", path, zero, err))
}
return target.Value, nil
}
// GetTypedWithDefault retrieves a configuration value with a default fallback
// If the path doesn't exist or isn't set, it sets and returns the default value
// For simple cases where explicit defaults aren't pre-registered
func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) {
// Check if path exists and has a value
if _, exists := c.Get(path); exists {
// Path exists, try to decode the current value
result, err := GetTyped[T](c, path)
if err == nil {
return result, nil
}
// Type conversion failed, fall through to set default
}
// Path doesn't exist or value not set - register and set default
// This handles the case where the path wasn't pre-registered
if err := c.Register(path, defaultValue); err != nil {
// Path might already be registered with incompatible type
// Try to just set the value
if setErr := c.Set(path, defaultValue); setErr != nil {
return defaultValue, wrapError(ErrPathNotRegistered, fmt.Errorf("%w : failed to register or set default for path %q", ErrPathNotRegistered, path))
}
}
// Set the default value
if err := c.Set(path, defaultValue); err != nil {
return defaultValue, wrapError(ErrTypeMismatch, fmt.Errorf("%w : failed to set default value for path %q", ErrTypeMismatch, path))
}
return defaultValue, nil
}
// ScanTyped is a generic wrapper around Scan. It allocates a new instance of type T,
// populates it with configuration data from the given base path, and returns a pointer to it.
// populates it with configuration data from the given base path, and returns a pointer to it
func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
var target T
if err := c.Scan(&target, basePath...); err != nil {
@ -283,3 +320,46 @@ func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
}
return &target, nil
}
// ScanMap decodes a configuration map directly into a target struct
// without requiring a full Config instance. This is useful for plugin
// initialization where config data arrives as a map[string]any.
func ScanMap(configMap map[string]any, target any, tagName ...string) error {
// Handle nil map
if configMap == nil {
configMap = make(map[string]any)
}
// Determine tag name
tag := "toml" // default
if len(tagName) > 0 && tagName[0] != "" {
tag = tagName[0]
}
// Create decoder with standard hooks
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: target,
TagName: tag,
WeaklyTypedInput: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
jsonNumberHookFunc(),
stringToNetIPHookFunc(),
stringToNetIPNetHookFunc(),
stringToURLHookFunc(),
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToTimeHookFunc(time.RFC3339),
mapstructure.StringToSliceHookFunc(","),
),
ZeroFields: true,
})
if err != nil {
return wrapError(ErrDecode, fmt.Errorf("decoder creation failed: %w", err))
}
// Decode directly
if err := decoder.Decode(configMap); err != nil {
return wrapError(ErrDecode, fmt.Errorf("decode failed: %w", err))
}
return nil
}

View File

@ -1,4 +1,4 @@
// FILE: lixenwraith/config/convenience_test.go
// FILE: lixenwraith/config/utility_test.go
package config
import (
@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/require"
)
// TestQuickFunctions tests the convenience Quick* functions
// TestQuickFunctions tests the utility Quick* functions
func TestQuickFunctions(t *testing.T) {
tmpDir := t.TempDir()
configFile := filepath.Join(tmpDir, "quick.toml")
@ -287,6 +287,7 @@ func TestClone(t *testing.T) {
assert.Equal(t, "envvalue", sources[SourceEnv])
}
// TestGenericHelpers tests generic helper functions
func TestGenericHelpers(t *testing.T) {
cfg := New()
cfg.Register("server.host", "localhost")
@ -325,3 +326,144 @@ func TestGenericHelpers(t *testing.T) {
assert.Equal(t, 8080, serverConf.Port)
})
}
// TestGetTypedWithDefault tests generic helper function with default value fallback
func TestGetTypedWithDefault(t *testing.T) {
t.Run("PathNotSet", func(t *testing.T) {
cfg := New()
// Get with default when path doesn't exist
port, err := GetTypedWithDefault(cfg, "server.port", int64(8080))
require.NoError(t, err)
assert.Equal(t, int64(8080), port)
// Verify it was actually set
val, exists := cfg.Get("server.port")
assert.True(t, exists)
assert.Equal(t, int64(8080), val)
})
t.Run("PathAlreadySet", func(t *testing.T) {
cfg := New()
cfg.Register("server.host", "localhost")
cfg.Set("server.host", "example.com")
// Should return existing value, not default
host, err := GetTypedWithDefault(cfg, "server.host", "default.com")
require.NoError(t, err)
assert.Equal(t, "example.com", host)
})
t.Run("DifferentTypes", func(t *testing.T) {
cfg := New()
// Test with various types
timeout, err := GetTypedWithDefault(cfg, "timeouts.read", 30*time.Second)
require.NoError(t, err)
assert.Equal(t, 30*time.Second, timeout)
enabled, err := GetTypedWithDefault(cfg, "features.enabled", true)
require.NoError(t, err)
assert.True(t, enabled)
tags, err := GetTypedWithDefault(cfg, "app.tags", []string{"default", "tag"})
require.NoError(t, err)
assert.Equal(t, []string{"default", "tag"}, tags)
})
}
// TestScanMap tests the ScanMap utility function
func TestScanMap(t *testing.T) {
type Config struct {
Server struct {
Host string `toml:"host" json:"hostname"`
Port int `toml:"port" json:"port"`
Timeout time.Duration `toml:"timeout" json:"timeout"`
} `toml:"server" json:"server"`
LogLevel string `toml:"log_level" json:"logLevel"`
}
t.Run("BasicScanWithTOMLTags", func(t *testing.T) {
configMap := map[string]any{
"server": map[string]any{
"host": "localhost",
"port": 8080,
"timeout": "15s",
},
"log_level": "info",
}
var target Config
err := ScanMap(configMap, &target)
require.NoError(t, err)
assert.Equal(t, "localhost", target.Server.Host)
assert.Equal(t, 8080, target.Server.Port)
assert.Equal(t, 15*time.Second, target.Server.Timeout)
assert.Equal(t, "info", target.LogLevel)
})
t.Run("ScanWithJSONTags", func(t *testing.T) {
configMap := map[string]any{
"server": map[string]any{
"hostname": "json-host",
"port": 9090,
"timeout": "1m",
},
"logLevel": "debug",
}
var target Config
err := ScanMap(configMap, &target, "json")
require.NoError(t, err)
assert.Equal(t, "json-host", target.Server.Host)
assert.Equal(t, 9090, target.Server.Port)
assert.Equal(t, 1*time.Minute, target.Server.Timeout)
assert.Equal(t, "debug", target.LogLevel)
})
t.Run("NilMapInput", func(t *testing.T) {
var target Config
target.LogLevel = "initial"
target.Server.Port = 1234
err := ScanMap(nil, &target)
require.NoError(t, err)
// Verify that fields are NOT changed when the map is empty,
// reflecting the observed behavior.
assert.Equal(t, "initial", target.LogLevel)
assert.Equal(t, 1234, target.Server.Port)
assert.Empty(t, target.Server.Host)
})
t.Run("PartialMapBehavior", func(t *testing.T) {
configMap := map[string]any{
"log_level": "warn",
}
var target Config
target.Server.Host = "initial_host"
target.Server.Port = 1234
target.LogLevel = "initial_log"
err := ScanMap(configMap, &target)
require.NoError(t, err)
// Mapped field should be updated
assert.Equal(t, "warn", target.LogLevel)
// Unmapped fields should be untouched
assert.Equal(t, "initial_host", target.Server.Host, "Unmapped field should be untouched")
assert.Equal(t, 1234, target.Server.Port, "Unmapped field should be untouched")
})
t.Run("InvalidTarget", func(t *testing.T) {
configMap := map[string]any{"log_level": "info"}
var target Config // Not a pointer
err := ScanMap(configMap, target)
assert.Error(t, err)
// The underlying mapstructure error is "result must be a pointer"
assert.Contains(t, err.Error(), "must be a pointer")
})
}

View File

@ -12,8 +12,8 @@ import (
// Port validates TCP/UDP port range
func Port(p int64) error {
if p < 1 || p > 65535 {
return fmt.Errorf("must be 1-65535, got %d", p)
if p < MinPortNumber || p > MaxPortNumber {
return wrapError(ErrValidation, fmt.Errorf("must be 1-65535, got %d", p))
}
return nil
}
@ -21,7 +21,7 @@ func Port(p int64) error {
// Positive validates positive numbers
func Positive[T int64 | float64](n T) error {
if n <= 0 {
return fmt.Errorf("must be positive, got %v", n)
return wrapError(ErrValidation, fmt.Errorf("must be positive, got %v", n))
}
return nil
}
@ -29,46 +29,46 @@ func Positive[T int64 | float64](n T) error {
// NonNegative validates non-negative numbers
func NonNegative[T int64 | float64](n T) error {
if n < 0 {
return fmt.Errorf("must be non-negative, got %v", n)
return wrapError(ErrValidation, fmt.Errorf("must be non-negative, got %v", n))
}
return nil
}
// IPAddress validates IP address format
func IPAddress(s string) error {
if s == "" || s == "0.0.0.0" || s == "::" {
if s == "" || s == IPv4Any || s == IPv6Any {
return nil // Allow common defaults
}
if net.ParseIP(s) == nil {
return fmt.Errorf("invalid IP address: %s", s)
return wrapError(ErrValidation, fmt.Errorf("invalid IP address: %s", s))
}
return nil
}
// IPv4Address validates IPv4 address format
func IPv4Address(s string) error {
if s == "" || s == "0.0.0.0" {
if s == "" || s == IPv4Any {
return nil // Allow common defaults
}
ip := net.ParseIP(s)
if ip == nil || ip.To4() == nil {
return fmt.Errorf("invalid IPv4 address: %s", s)
return wrapError(ErrValidation, fmt.Errorf("invalid IPv4 address: %s", s))
}
return nil
}
// IPv6Address validates IPv6 address format
func IPv6Address(s string) error {
if s == "" || s == "::" {
if s == "" || s == IPv6Any {
return nil // Allow common defaults
}
ip := net.ParseIP(s)
if ip == nil {
return fmt.Errorf("invalid IPv6 address: %s", s)
return wrapError(ErrValidation, fmt.Errorf("invalid IPv6 address: %s", s))
}
// Valid net.ParseIP with nil ip.To4 indicates IPv6
if ip.To4() != nil {
return fmt.Errorf("invalid IPv6 address (is an IPv4 address): %s", s)
return wrapError(ErrValidation, fmt.Errorf("invalid IPv6 address (is an IPv4 address): %s", s))
}
return nil
}
@ -76,7 +76,7 @@ func IPv6Address(s string) error {
// URLPath validates URL path format
func URLPath(s string) error {
if s != "" && !strings.HasPrefix(s, "/") {
return fmt.Errorf("must start with /: %s", s)
return wrapError(ErrValidation, fmt.Errorf("must start with /: %s", s))
}
return nil
}
@ -89,7 +89,7 @@ func OneOf[T comparable](allowed ...T) func(T) error {
return nil
}
}
return fmt.Errorf("must be one of %v, got %v", allowed, val)
return wrapError(ErrValidation, fmt.Errorf("must be one of %v, got %v", allowed, val))
}
}
@ -97,7 +97,7 @@ func OneOf[T comparable](allowed ...T) func(T) error {
func Range[T int64 | float64](min, max T) func(T) error {
return func(val T) error {
if val < min || val > max {
return fmt.Errorf("must be %v-%v, got %v", min, max, val)
return wrapError(ErrValidation, fmt.Errorf("must be %v-%v, got %v", min, max, val))
}
return nil
}
@ -108,7 +108,7 @@ func Pattern(pattern string) func(string) error {
re := regexp.MustCompile(pattern)
return func(s string) error {
if !re.MatchString(s) {
return fmt.Errorf("must match pattern %s", pattern)
return wrapError(ErrValidation, fmt.Errorf("must match pattern %s", pattern))
}
return nil
}
@ -117,7 +117,7 @@ func Pattern(pattern string) func(string) error {
// NonEmpty validates non-empty strings
func NonEmpty(s string) error {
if strings.TrimSpace(s) == "" {
return fmt.Errorf("must not be empty")
return wrapError(ErrValidation, fmt.Errorf("must not be empty"))
}
return nil
}

View File

@ -11,8 +11,6 @@ import (
"time"
)
const DefaultMaxWatchers = 100 // Prevent resource exhaustion
// WatchOptions configures file watching behavior
type WatchOptions struct {
// PollInterval for file stat checks (minimum 100ms)
@ -139,7 +137,7 @@ func (c *Config) Watch() <-chan string {
}
// WatchFile stops any existing file watcher, loads a new configuration file,
// and starts a new watcher on that file path. Optionally accepts format hint.
// and starts a new watcher on that file path. Optionally accepts format hint
func (c *Config) WatchFile(filePath string, formatHint ...string) error {
// Stop any currently running watcher
c.StopAutoUpdate()
@ -147,13 +145,13 @@ func (c *Config) WatchFile(filePath string, formatHint ...string) error {
// Set format hint if provided
if len(formatHint) > 0 {
if err := c.SetFileFormat(formatHint[0]); err != nil {
return fmt.Errorf("invalid format hint: %w", err)
return wrapError(ErrFileFormat, fmt.Errorf("invalid format hint: %w", err))
}
}
// Load the new file
if err := c.LoadFile(filePath); err != nil {
return fmt.Errorf("failed to load new file for watching: %w", err)
return fmt.Errorf("failed to load new file for watching: %w", err) // Already wrapped with error category in LoadFile
}
// Get previous watcher options if available
@ -164,7 +162,7 @@ func (c *Config) WatchFile(filePath string, formatHint ...string) error {
}
c.mutex.RUnlock()
// Start new watcher (AutoUpdateWithOptions will create a new watcher with the new file path)
// Create and start a new watcher with the new file path
c.AutoUpdateWithOptions(opts)
return nil
}
@ -253,7 +251,7 @@ func (w *watcher) checkAndReload(c *Config) {
if err != nil {
if os.IsNotExist(err) {
// File was deleted, notify watchers
w.notifyWatchers("file_deleted")
w.notifyWatchers(EventFileDeleted)
}
return
}
@ -272,7 +270,7 @@ func (w *watcher) checkAndReload(c *Config) {
// Permission change detected
if (info.Mode() & 0077) != (w.lastMode & 0077) {
// World/group permissions changed - potential security issue
w.notifyWatchers("permissions_changed")
w.notifyWatchers(EventPermissionsChanged)
// Don't reload on permission change for security
return
}
@ -322,7 +320,7 @@ func (w *watcher) performReload(c *Config) {
case err := <-done:
if err != nil {
// Reload failed, notify error
w.notifyWatchers(fmt.Sprintf("reload_error:%v", err))
w.notifyWatchers(fmt.Sprintf("%s:%v", EventReloadError, err))
return
}
@ -343,7 +341,7 @@ func (w *watcher) performReload(c *Config) {
case <-ctx.Done():
// Reload timeout
w.notifyWatchers("reload_timeout")
w.notifyWatchers(EventReloadTimeout)
}
}
@ -361,7 +359,7 @@ func (w *watcher) subscribe() <-chan string {
}
// Create buffered channel to prevent blocking
ch := make(chan string, 10)
ch := make(chan string, WatchChannelBuffer)
id := w.watcherID.Add(1)
w.watchers[id] = ch

View File

@ -14,8 +14,8 @@ import (
"github.com/stretchr/testify/require"
)
// Test-specific timing constants derived from production values.
// These accelerate test execution while maintaining timing relationships.
// Test-specific timing constants derived from production values
// These accelerate test execution while maintaining timing relationships
const (
// testAcceleration reduces all intervals by this factor for faster tests
testAcceleration = 10
@ -32,7 +32,6 @@ const (
testWatchTimeout = 2 * DefaultPollInterval // 2s for change propagation
// Derived test multipliers with clear purpose
testDebounceSettle = debounceSettleMultiplier * testDebounce // 150ms for debounce verification
testPollWindow = 3 * testPollInterval // 300ms change detection window
testStateStabilize = 4 * testDebounce // 200ms for state convergence
)