Compare commits
1 Commits
a66b684330
...
v0.1.0
| Author | SHA256 | Date | |
|---|---|---|---|
|
00193cf096
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,3 +7,4 @@ script
|
||||
*.log
|
||||
*.toml
|
||||
bin
|
||||
build.sh
|
||||
|
||||
1
LICENSE
1
LICENSE
@ -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.
|
||||
|
||||
|
||||
12
README.md
12
README.md
@ -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
|
||||
|
||||
|
||||
93
builder.go
93
builder.go
@ -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
|
||||
}
|
||||
|
||||
|
||||
88
config.go
88
config.go
@ -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)
|
||||
}
|
||||
@ -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
68
constant.go
Normal 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"}
|
||||
)
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
48
decode.go
48
decode.go
@ -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
|
||||
|
||||
66
discovery.go
66
discovery.go
@ -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
|
||||
|
||||
@ -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
174
doc/architecture.md
Normal 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
|
||||
@ -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
|
||||
@ -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
157
doc/config-llm-guide.md
Normal 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.
|
||||
19
doc/env.md
19
doc/env.md
@ -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
|
||||
@ -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
|
||||
302
doc/llm-guide.md
302
doc/llm-guide.md
@ -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`.
|
||||
@ -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
|
||||
@ -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
175
doc/validator.md
Normal 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
55
error.go
Normal 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)
|
||||
}
|
||||
@ -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
6
go.mod
@ -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
4
go.sum
@ -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
376
loader.go
@ -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 ""
|
||||
|
||||
21
register.go
21
register.go
@ -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
|
||||
|
||||
26
timing.go
26
timing.go
@ -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
|
||||
)
|
||||
32
validator.go
32
validator.go
@ -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
|
||||
}
|
||||
18
watch.go
18
watch.go
@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user