Compare commits

..

1 Commits

Author SHA256 Message Date
00193cf096 v0.1.0 Release 2025-11-08 07:16:48 -05:00
38 changed files with 1167 additions and 802 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
- [LLM Integration Guide](doc/config-llm-guide.md) - Guide for LLM usage without full codebase
## License

View File

@ -5,7 +5,9 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
)
// Builder provides a fluent API for constructing a Config instance. It allows for
@ -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
@ -66,13 +68,13 @@ func (b *Builder) Build() (*Config, error) {
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.
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))
}
}
@ -84,13 +86,13 @@ func (b *Builder) Build() (*Config, error) {
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))
}
}
@ -99,7 +101,7 @@ func (b *Builder) Build() (*Config, error) {
// Populate the target struct first. This unifies 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.
@ -109,14 +111,14 @@ func (b *Builder) Build() (*Config, error) {
// 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.
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 +146,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 +163,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 +228,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 +249,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
@ -269,7 +328,7 @@ func (b *Builder) WithTypedValidator(fn any) *Builder {
// 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

@ -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
@ -79,13 +59,8 @@ type Config struct {
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.
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()
@ -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")

68
constant.go Normal file
View File

@ -0,0 +1,68 @@
// 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"
)
// debounceSettleMultiplier ensures sufficient time for debounce to complete
const debounceSettleMultiplier = 3 // Wait 3x debounce period for value stabilization
// 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

@ -2,14 +2,15 @@
package config
import (
"errors"
"flag"
"fmt"
"github.com/mitchellh/mapstructure"
"os"
"reflect"
"strings"
"github.com/BurntSushi/toml"
"github.com/go-viper/mapstructure/v2"
)
// Quick creates a fully configured Config instance with a single call
@ -20,7 +21,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))
}
}
@ -39,7 +40,7 @@ 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))
}
}
@ -50,7 +51,7 @@ func QuickCustom(structDefaults any, opts LoadOptions, configFile string) (*Conf
// 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 +106,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 +144,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 +184,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
@ -245,7 +249,7 @@ 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.
@ -263,17 +267,49 @@ 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.
// This is a convenience function 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.
func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {

View File

@ -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,48 @@ 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)
})
}

View File

@ -10,7 +10,7 @@ import (
"strings"
"time"
"github.com/mitchellh/mapstructure"
"github.com/go-viper/mapstructure/v2"
)
// unmarshal is the single authoritative function for decoding configuration
@ -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()
@ -63,7 +63,7 @@ func (c *Config) unmarshal(source Source, target any, basePath ...string) error
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)
return wrapError(ErrTypeMismatch, fmt.Errorf("path %q refers to non-map value (type %T)", path, sectionData))
}
}
@ -77,11 +77,11 @@ 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
@ -102,7 +102,7 @@ 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.
@ -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**: `Load()`, `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 (`convenience.go`)
- **Quick Setup**: `Quick()`, `QuickCustom()`, `MustQuick()`, `QuickTyped()`
- **Flag Integration**: `GenerateFlags()`, `BindFlags()`
- **Type-Safe Access**: `GetTyped()`, `GetTypedWithDefault()`, `ScanTyped()`
- **Utilities**: `Validate()`, `Debug()`, `Dump()`, `Clone()`
### 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

157
doc/config-llm-guide.md Normal file
View File

@ -0,0 +1,157 @@
# lixenwraith/config LLM Usage 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**. This provides compile-time type safety and eliminates 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)** Enables type-safe mode. Registers fields from the target struct and allows access via `AsStruct()`. The target's initial values are used as defaults unless `WithDefaults` is also called.
* `WithDefaults(defaults any)`: Explicitly sets a struct containing default values. Overrides any defaults 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 that runs *after* the target struct is populated. The function signature must be `func(c *YourConfigType) error`.
* `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)`).
* `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 and should be **avoided when a type-safe struct can be used**. They require runtime type assertions.
* `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

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

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

@ -31,7 +31,7 @@ func main() {
log.Println("---")
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...")

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=

376
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,7 +59,6 @@ type LoadOptions struct {
func DefaultLoadOptions() LoadOptions {
return LoadOptions{
Sources: []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault},
LoadMode: LoadModeReplace,
}
}
@ -107,20 +91,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 +122,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 +287,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 +295,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 +305,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 +319,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 +328,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 +340,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 +361,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 +498,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 +527,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 +558,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 +575,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 +649,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 +665,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 +683,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

@ -14,14 +14,14 @@ import (
// 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
@ -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))
}
}
@ -100,7 +100,7 @@ func (c *Config) Unregister(path string) error {
// It uses struct tags (`toml:"..."`) to determine the configuration paths.
// The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed.
func (c *Config) RegisterStruct(prefix string, structWithDefaults any) error {
return c.RegisterStructWithTags(prefix, structWithDefaults, "toml")
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,7 +133,7 @@ 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
@ -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

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

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