v0.1.0 Release
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,3 +7,4 @@ script
|
|||||||
*.log
|
*.log
|
||||||
*.toml
|
*.toml
|
||||||
bin
|
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,
|
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
|
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.
|
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
|
## 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
|
- [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
|
- [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
|
## License
|
||||||
|
|
||||||
|
|||||||
93
builder.go
93
builder.go
@ -5,7 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Builder provides a fluent API for constructing a Config instance. It allows for
|
// 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
|
return nil, b.err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use tagName if set, default to "toml"
|
// Use tagName if set, default to toml
|
||||||
tagName := b.tagName
|
tagName := b.tagName
|
||||||
if tagName == "" {
|
if tagName == "" {
|
||||||
tagName = "toml"
|
tagName = FormatTOML
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set format and security settings
|
// Set format and security settings
|
||||||
@ -66,13 +68,13 @@ func (b *Builder) Build() (*Config, error) {
|
|||||||
if b.defaults != nil {
|
if b.defaults != nil {
|
||||||
// WithDefaults() was called explicitly.
|
// WithDefaults() was called explicitly.
|
||||||
if err := b.cfg.RegisterStructWithTags(b.prefix, b.defaults, tagName); err != nil {
|
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 {
|
} else if b.cfg.structCache != nil && b.cfg.structCache.target != nil {
|
||||||
// No explicit defaults, so use the target struct as the source of defaults.
|
// No explicit defaults, so use the target struct as the source of defaults.
|
||||||
// This is the behavior the tests rely on.
|
// This is the behavior the tests rely on.
|
||||||
if err := b.cfg.RegisterStructWithTags(b.prefix, b.cfg.structCache.target, tagName); err != nil {
|
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)
|
loadErr := b.cfg.LoadWithOptions(b.file, b.args, b.opts)
|
||||||
if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) {
|
if loadErr != nil && !errors.Is(loadErr, ErrConfigNotFound) {
|
||||||
// Return on fatal load errors. ErrConfigNotFound is not fatal.
|
// Return on fatal load errors. ErrConfigNotFound is not fatal.
|
||||||
return nil, loadErr
|
return nil, wrapError(ErrFileAccess, loadErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Run non-typed validators
|
// 3. Run non-typed validators
|
||||||
for _, validator := range b.validators {
|
for _, validator := range b.validators {
|
||||||
if err := validator(b.cfg); err != nil {
|
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).
|
// Populate the target struct first. This unifies all types (e.g., string "8888" -> int64 8888).
|
||||||
populatedTarget, err := b.cfg.AsStruct()
|
populatedTarget, err := b.cfg.AsStruct()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to populate target struct for validation: %w", err)
|
return nil, wrapError(ErrValidation, fmt.Errorf("failed to populate target struct for validation: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the typed validators against the populated, type-safe struct.
|
// Run the typed validators against the populated, type-safe struct.
|
||||||
@ -109,14 +111,14 @@ func (b *Builder) Build() (*Config, error) {
|
|||||||
|
|
||||||
// Check if the validator's input type matches the target's type.
|
// Check if the validator's input type matches the target's type.
|
||||||
if validatorType.In(0) != reflect.TypeOf(populatedTarget) {
|
if validatorType.In(0) != reflect.TypeOf(populatedTarget) {
|
||||||
return nil, fmt.Errorf("typed validator signature %v does not match target type %T", validatorType, populatedTarget)
|
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("typed validator signature %v does not match target type %T", validatorType, populatedTarget))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the validator.
|
// Call the validator.
|
||||||
results := validatorFunc.Call([]reflect.Value{reflect.ValueOf(populatedTarget)})
|
results := validatorFunc.Call([]reflect.Value{reflect.ValueOf(populatedTarget)})
|
||||||
if !results[0].IsNil() {
|
if !results[0].IsNil() {
|
||||||
err := results[0].Interface().(error)
|
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
|
// 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 {
|
func (b *Builder) WithTagName(tagName string) *Builder {
|
||||||
switch tagName {
|
switch tagName {
|
||||||
case "toml", "json", "yaml":
|
case FormatTOML, FormatJSON, FormatYAML:
|
||||||
b.tagName = tagName
|
b.tagName = tagName
|
||||||
if b.cfg != nil { // Ensure cfg exists
|
if b.cfg != nil { // Ensure cfg exists
|
||||||
b.cfg.tagName = tagName
|
b.cfg.tagName = tagName
|
||||||
}
|
}
|
||||||
default:
|
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
|
return b
|
||||||
}
|
}
|
||||||
@ -161,10 +163,10 @@ func (b *Builder) WithTagName(tagName string) *Builder {
|
|||||||
// WithFileFormat sets the expected file format
|
// WithFileFormat sets the expected file format
|
||||||
func (b *Builder) WithFileFormat(format string) *Builder {
|
func (b *Builder) WithFileFormat(format string) *Builder {
|
||||||
switch format {
|
switch format {
|
||||||
case "toml", "json", "yaml", "auto":
|
case FormatTOML, FormatJSON, FormatYAML, FormatAuto:
|
||||||
b.fileFormat = format
|
b.fileFormat = format
|
||||||
default:
|
default:
|
||||||
b.err = fmt.Errorf("unsupported file format %q", format)
|
b.err = wrapError(ErrTypeMismatch, fmt.Errorf("unsupported file format %q", format))
|
||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
@ -226,13 +228,13 @@ func (b *Builder) WithEnvWhitelist(paths ...string) *Builder {
|
|||||||
func (b *Builder) WithTarget(target any) *Builder {
|
func (b *Builder) WithTarget(target any) *Builder {
|
||||||
rv := reflect.ValueOf(target)
|
rv := reflect.ValueOf(target)
|
||||||
if rv.Kind() != reflect.Ptr || rv.IsNil() {
|
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
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
elem := rv.Elem()
|
elem := rv.Elem()
|
||||||
if elem.Kind() != reflect.Struct {
|
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
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,6 +249,63 @@ func (b *Builder) WithTarget(target any) *Builder {
|
|||||||
return b
|
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
|
// 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
|
// Multiple validators can be added and are executed in the order they are added
|
||||||
// Validation runs after all sources are loaded
|
// 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.
|
// Basic reflection check to ensure it's a function that takes one argument and returns an error.
|
||||||
t := reflect.TypeOf(fn)
|
t := reflect.TypeOf(fn)
|
||||||
if t.Kind() != reflect.Func || t.NumIn() != 1 || t.NumOut() != 1 || t.Out(0) != reflect.TypeOf((*error)(nil)).Elem() {
|
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
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
88
config.go
88
config.go
@ -5,32 +5,12 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"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
|
// configItem holds configuration values from different sources
|
||||||
type configItem struct {
|
type configItem struct {
|
||||||
defaultValue any
|
defaultValue any
|
||||||
@ -60,7 +40,7 @@ type SecurityOptions struct {
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
items map[string]configItem
|
items map[string]configItem
|
||||||
tagName string
|
tagName string
|
||||||
fileFormat string // Separate from tagName: "toml", "json", "yaml", or "auto"
|
fileFormat string // Separate from tagName: toml, json, yaml, or auto
|
||||||
securityOpts *SecurityOptions
|
securityOpts *SecurityOptions
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
options LoadOptions // Current load options
|
options LoadOptions // Current load options
|
||||||
@ -79,13 +59,8 @@ type Config struct {
|
|||||||
func New() *Config {
|
func New() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
items: make(map[string]configItem),
|
items: make(map[string]configItem),
|
||||||
tagName: "toml",
|
tagName: FormatTOML,
|
||||||
fileFormat: "auto",
|
fileFormat: FormatAuto,
|
||||||
// securityOpts: &SecurityOptions{
|
|
||||||
// PreventPathTraversal: false,
|
|
||||||
// EnforceFileOwnership: false,
|
|
||||||
// MaxFileSize: 0,
|
|
||||||
// },
|
|
||||||
options: DefaultLoadOptions(),
|
options: DefaultLoadOptions(),
|
||||||
fileData: make(map[string]any),
|
fileData: make(map[string]any),
|
||||||
envData: 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
|
// 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()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
@ -112,8 +87,6 @@ func (c *Config) SetLoadOptions(opts LoadOptions) error {
|
|||||||
item.currentValue = c.computeValue(item)
|
item.currentValue = c.computeValue(item)
|
||||||
c.items[path] = item
|
c.items[path] = item
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPrecedence updates source precedence with validation
|
// SetPrecedence updates source precedence with validation
|
||||||
@ -128,7 +101,7 @@ func (c *Config) SetPrecedence(sources ...Source) error {
|
|||||||
|
|
||||||
for _, s := range sources {
|
for _, s := range sources {
|
||||||
if _, valid := required[s]; !valid {
|
if _, valid := required[s]; !valid {
|
||||||
return fmt.Errorf("invalid source: %s", s)
|
return wrapError(ErrNotConfigured, fmt.Errorf("invalid source: %s", s))
|
||||||
}
|
}
|
||||||
required[s] = true
|
required[s] = true
|
||||||
}
|
}
|
||||||
@ -141,7 +114,7 @@ func (c *Config) SetPrecedence(sources ...Source) error {
|
|||||||
c.mutex.Lock()
|
c.mutex.Lock()
|
||||||
defer c.mutex.Unlock()
|
defer c.mutex.Unlock()
|
||||||
|
|
||||||
// FIXED: Check if precedence actually changed
|
// Check if precedence actually changed
|
||||||
oldPrecedence := c.options.Sources
|
oldPrecedence := c.options.Sources
|
||||||
if reflect.DeepEqual(oldPrecedence, sources) {
|
if reflect.DeepEqual(oldPrecedence, sources) {
|
||||||
return nil // No change needed
|
return nil // No change needed
|
||||||
@ -169,7 +142,7 @@ func (c *Config) SetPrecedence(sources ...Source) error {
|
|||||||
// Notify watchers of precedence change
|
// Notify watchers of precedence change
|
||||||
if c.watcher != nil && len(changedPaths) > 0 {
|
if c.watcher != nil && len(changedPaths) > 0 {
|
||||||
for _, path := range changedPaths {
|
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
|
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.
|
// SetFileFormat sets the expected format for configuration files.
|
||||||
// Use "auto" to detect based on file extension.
|
// Use "auto" to detect based on file extension.
|
||||||
func (c *Config) SetFileFormat(format string) error {
|
func (c *Config) SetFileFormat(format string) error {
|
||||||
switch format {
|
switch format {
|
||||||
case "toml", "json", "yaml", "auto":
|
case FormatTOML, FormatJSON, FormatYAML, FormatAuto:
|
||||||
// Valid formats
|
// Valid formats
|
||||||
default:
|
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()
|
c.mutex.Lock()
|
||||||
@ -266,7 +226,7 @@ func (c *Config) SetSource(source Source, path string, value any) error {
|
|||||||
|
|
||||||
item, registered := c.items[path]
|
item, registered := c.items[path]
|
||||||
if !registered {
|
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 {
|
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
|
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
|
// AsStruct returns the populated struct if in type-aware mode
|
||||||
func (c *Config) AsStruct() (any, error) {
|
func (c *Config) AsStruct() (any, error) {
|
||||||
if c.structCache == nil || c.structCache.target == nil {
|
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()
|
c.structCache.mu.RLock()
|
||||||
@ -382,9 +337,17 @@ func (c *Config) AsStruct() (any, error) {
|
|||||||
return c.structCache.target, nil
|
return c.structCache.target, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Target populates the provided struct with current configuration
|
// computeValue determines the current value based on precedence
|
||||||
func (c *Config) Target(out any) error {
|
func (c *Config) computeValue(item configItem) any {
|
||||||
return c.Scan(out)
|
// 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
|
// 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 {
|
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.version = currentVersion
|
||||||
c.structCache.populated = true
|
c.structCache.populated = true
|
||||||
return nil
|
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{
|
opts := LoadOptions{
|
||||||
Sources: []Source{SourceEnv, SourceFile, SourceDefault},
|
Sources: []Source{SourceEnv, SourceFile, SourceDefault},
|
||||||
EnvPrefix: "MYAPP_",
|
EnvPrefix: "MYAPP_",
|
||||||
LoadMode: LoadModeReplace,
|
|
||||||
}
|
}
|
||||||
cfg := NewWithOptions(opts)
|
cfg := NewWithOptions(opts)
|
||||||
require.NotNil(t, cfg)
|
require.NotNil(t, cfg)
|
||||||
@ -174,10 +173,9 @@ func TestSourcePrecedence(t *testing.T) {
|
|||||||
assert.Equal(t, "from-env", val)
|
assert.Equal(t, "from-env", val)
|
||||||
|
|
||||||
// Change precedence
|
// Change precedence
|
||||||
err := cfg.SetLoadOptions(LoadOptions{
|
cfg.SetLoadOptions(LoadOptions{
|
||||||
Sources: []Source{SourceFile, SourceEnv, SourceCLI, SourceDefault},
|
Sources: []Source{SourceFile, SourceEnv, SourceCLI, SourceDefault},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
|
||||||
val, _ = cfg.Get("test.value")
|
val, _ = cfg.Get("test.value")
|
||||||
assert.Equal(t, "from-file", val)
|
assert.Equal(t, "from-file", val)
|
||||||
|
|
||||||
@ -249,7 +247,7 @@ func TestSetPrecedence(t *testing.T) {
|
|||||||
cfg := New()
|
cfg := New()
|
||||||
|
|
||||||
// Try to set invalid source
|
// Try to set invalid source
|
||||||
err := cfg.SetPrecedence(Source("invalid"), SourceFile)
|
err := cfg.SetPrecedence("invalid", SourceFile)
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "invalid source")
|
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
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mitchellh/mapstructure"
|
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Quick creates a fully configured Config instance with a single call
|
// 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
|
// Register defaults from struct if provided
|
||||||
if structDefaults != nil {
|
if structDefaults != nil {
|
||||||
if err := cfg.RegisterStruct("", structDefaults); err != 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
|
// Register defaults from struct if provided
|
||||||
if structDefaults != nil {
|
if structDefaults != nil {
|
||||||
if err := cfg.RegisterStruct("", structDefaults); err != 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
|
// MustQuick is like Quick but panics on error
|
||||||
func MustQuick(structDefaults any, envPrefix, configFile string) *Config {
|
func MustQuick(structDefaults any, envPrefix, configFile string) *Config {
|
||||||
cfg, err := Quick(structDefaults, envPrefix, configFile)
|
cfg, err := Quick(structDefaults, envPrefix, configFile)
|
||||||
if err != nil {
|
if err != nil && !errors.Is(err, ErrConfigNotFound) {
|
||||||
panic(fmt.Sprintf("config initialization failed: %v", err))
|
panic(fmt.Sprintf("config initialization failed: %v", err))
|
||||||
}
|
}
|
||||||
return cfg
|
return cfg
|
||||||
@ -105,7 +106,7 @@ func (c *Config) BindFlags(fs *flag.FlagSet) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(errors) > 0 {
|
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
|
return nil
|
||||||
@ -143,7 +144,7 @@ func (c *Config) Validate(required ...string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(missing) > 0 {
|
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
|
return nil
|
||||||
@ -183,7 +184,10 @@ func (c *Config) Dump() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
encoder := toml.NewEncoder(os.Stdout)
|
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
|
// 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)
|
rawValue, exists := c.Get(path)
|
||||||
if !exists {
|
if !exists {
|
||||||
return zero, fmt.Errorf("path %q not found", path)
|
return zero, wrapError(ErrPathNotFound, fmt.Errorf("path %q not found", path))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare the input map and target struct for the decoder.
|
// Prepare the input map and target struct for the decoder.
|
||||||
@ -263,17 +267,49 @@ func GetTyped[T any](c *Config, path string) (T, error) {
|
|||||||
Metadata: nil,
|
Metadata: nil,
|
||||||
})
|
})
|
||||||
if err != 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.
|
// Decode the single value.
|
||||||
if err := decoder.Decode(inputMap); err != nil {
|
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
|
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,
|
// ScanTyped is a generic wrapper around Scan. It allocates a new instance of type T,
|
||||||
// populates it with configuration data from the given base path, and returns a pointer to it.
|
// populates it with configuration data from the given base path, and returns a pointer to it.
|
||||||
func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
|
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])
|
assert.Equal(t, "envvalue", sources[SourceEnv])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGenericHelpers tests generic helper functions
|
||||||
func TestGenericHelpers(t *testing.T) {
|
func TestGenericHelpers(t *testing.T) {
|
||||||
cfg := New()
|
cfg := New()
|
||||||
cfg.Register("server.host", "localhost")
|
cfg.Register("server.host", "localhost")
|
||||||
@ -325,3 +326,48 @@ func TestGenericHelpers(t *testing.T) {
|
|||||||
assert.Equal(t, 8080, serverConf.Port)
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mitchellh/mapstructure"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// unmarshal is the single authoritative function for decoding configuration
|
// 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:
|
case 1:
|
||||||
path = basePath[0]
|
path = basePath[0]
|
||||||
default:
|
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
|
// Validate target
|
||||||
rv := reflect.ValueOf(target)
|
rv := reflect.ValueOf(target)
|
||||||
if rv.Kind() != reflect.Ptr || rv.IsNil() {
|
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()
|
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.
|
sectionMap = make(map[string]any) // Empty section is valid.
|
||||||
} else {
|
} else {
|
||||||
// Path points to a non-map value, which is an error for Scan.
|
// 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,
|
Metadata: nil,
|
||||||
})
|
})
|
||||||
if err != 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 {
|
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
|
return nil
|
||||||
@ -102,7 +102,7 @@ func normalizeMap(data any) (map[string]any, error) {
|
|||||||
v := reflect.ValueOf(data)
|
v := reflect.ValueOf(data)
|
||||||
if v.Kind() == reflect.Map {
|
if v.Kind() == reflect.Map {
|
||||||
if v.Type().Key().Kind() != reflect.String {
|
if v.Type().Key().Kind() != reflect.String {
|
||||||
return nil, fmt.Errorf("map keys must be strings, but got %v", v.Type().Key())
|
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("map keys must be strings, but got %v", v.Type().Key()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new map[string]any and copy the values.
|
// Create a new map[string]any and copy the values.
|
||||||
@ -114,7 +114,7 @@ func normalizeMap(data any) (map[string]any, error) {
|
|||||||
return normalized, nil
|
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
|
// getDecodeHook returns the composite decode hook for all type conversions
|
||||||
@ -151,19 +151,27 @@ func jsonNumberHookFunc() mapstructure.DecodeHookFunc {
|
|||||||
// Convert based on target type
|
// Convert based on target type
|
||||||
switch t.Kind() {
|
switch t.Kind() {
|
||||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
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:
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||||
// Parse as int64 first, then convert
|
// Parse as int64 first, then convert
|
||||||
i, err := num.Int64()
|
i, err := num.Int64()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, wrapError(ErrDecode, err)
|
||||||
}
|
}
|
||||||
if i < 0 {
|
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
|
return uint64(i), nil
|
||||||
case reflect.Float32, reflect.Float64:
|
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:
|
case reflect.String:
|
||||||
return num.String(), nil
|
return num.String(), nil
|
||||||
default:
|
default:
|
||||||
@ -186,7 +194,7 @@ func stringToNetIPHookFunc() mapstructure.DecodeHookFunc {
|
|||||||
|
|
||||||
// SECURITY: Validate IP string format to prevent injection
|
// SECURITY: Validate IP string format to prevent injection
|
||||||
str := data.(string)
|
str := data.(string)
|
||||||
if len(str) > 45 { // Max IPv6 length
|
if len(str) > MaxIPv6Length {
|
||||||
return nil, fmt.Errorf("invalid IP length: %d", len(str))
|
return nil, fmt.Errorf("invalid IP length: %d", len(str))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,12 +223,12 @@ func stringToNetIPNetHookFunc() mapstructure.DecodeHookFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
str := data.(string)
|
str := data.(string)
|
||||||
if len(str) > 49 { // Max IPv6 CIDR length
|
if len(str) > MaxCIDRLength {
|
||||||
return nil, fmt.Errorf("invalid CIDR length: %d", len(str))
|
return nil, wrapError(ErrDecode, fmt.Errorf("invalid CIDR length: %d", len(str)))
|
||||||
}
|
}
|
||||||
_, ipnet, err := net.ParseCIDR(str)
|
_, ipnet, err := net.ParseCIDR(str)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid CIDR: %w", err)
|
return nil, wrapError(ErrDecode, fmt.Errorf("invalid CIDR: %w", err))
|
||||||
}
|
}
|
||||||
if isPtr {
|
if isPtr {
|
||||||
return ipnet, nil
|
return ipnet, nil
|
||||||
@ -245,12 +253,12 @@ func stringToURLHookFunc() mapstructure.DecodeHookFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
str := data.(string)
|
str := data.(string)
|
||||||
if len(str) > 2048 {
|
if len(str) > MaxURLLength {
|
||||||
return nil, fmt.Errorf("URL too long: %d bytes", len(str))
|
return nil, wrapError(ErrDecode, fmt.Errorf("URL too long: %d bytes", len(str)))
|
||||||
}
|
}
|
||||||
u, err := url.Parse(str)
|
u, err := url.Parse(str)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
return nil, wrapError(ErrDecode, fmt.Errorf("invalid URL: %w", err))
|
||||||
}
|
}
|
||||||
if isPtr {
|
if isPtr {
|
||||||
return u, nil
|
return u, nil
|
||||||
@ -262,7 +270,7 @@ func stringToURLHookFunc() mapstructure.DecodeHookFunc {
|
|||||||
// customDecodeHook allows for application-specific type conversions
|
// customDecodeHook allows for application-specific type conversions
|
||||||
func (c *Config) customDecodeHook() mapstructure.DecodeHookFunc {
|
func (c *Config) customDecodeHook() mapstructure.DecodeHookFunc {
|
||||||
return func(f reflect.Type, t reflect.Type, data any) (any, error) {
|
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.
|
// Example: Rate limit parsing, permission validation, etc.
|
||||||
|
|
||||||
// Pass through by default
|
// Pass through by default
|
||||||
|
|||||||
66
discovery.go
66
discovery.go
@ -35,7 +35,7 @@ type FileDiscoveryOptions struct {
|
|||||||
func DefaultDiscoveryOptions(appName string) FileDiscoveryOptions {
|
func DefaultDiscoveryOptions(appName string) FileDiscoveryOptions {
|
||||||
return FileDiscoveryOptions{
|
return FileDiscoveryOptions{
|
||||||
Name: appName,
|
Name: appName,
|
||||||
Extensions: []string{".toml", ".conf", ".config"},
|
Extensions: DefaultConfigExtensions,
|
||||||
EnvVar: strings.ToUpper(appName) + "_CONFIG",
|
EnvVar: strings.ToUpper(appName) + "_CONFIG",
|
||||||
CLIFlag: "--config",
|
CLIFlag: "--config",
|
||||||
UseXDG: true,
|
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
|
// getXDGConfigPaths returns XDG-compliant config search paths
|
||||||
func getXDGConfigPaths(appName string) []string {
|
func getXDGConfigPaths(appName string) []string {
|
||||||
var paths []string
|
var paths []string
|
||||||
@ -118,10 +61,9 @@ func getXDGConfigPaths(appName string) []string {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default system paths
|
// Default system paths
|
||||||
paths = append(paths,
|
for _, dir := range XDGSystemPaths {
|
||||||
filepath.Join("/etc/xdg", appName),
|
paths = append(paths, filepath.Join(dir, appName))
|
||||||
filepath.Join("/etc", appName),
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return paths
|
return paths
|
||||||
|
|||||||
@ -399,9 +399,3 @@ cfg.Dump() // Writes to stdout
|
|||||||
testCfg := cfg.Clone()
|
testCfg := cfg.Clone()
|
||||||
testCfg.Set("server.port", int64(0)) // Random port for tests
|
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()`
|
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")
|
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
|
## Environment Variable Whitelist
|
||||||
|
|
||||||
Limit which paths can be set via environment:
|
Limit which paths can be set via environment:
|
||||||
@ -187,9 +200,3 @@ cfg, _ := config.NewBuilder().
|
|||||||
).
|
).
|
||||||
Build()
|
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
|
4. **Handle Missing Files**: Missing config files often aren't fatal
|
||||||
5. **Use Atomic Saves**: The built-in Save method is atomic
|
5. **Use Atomic Saves**: The built-in Save method is atomic
|
||||||
6. **Document Structure**: Comment your TOML files thoroughly
|
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
|
// Type assertions are safe after registration
|
||||||
port, _ := cfg.Get("port")
|
port, _ := cfg.Get("port")
|
||||||
portNum := port.(int64) // Safe - type is guaranteed
|
portNum := port.(int64) // Type matches registration
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Handling
|
## 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
|
## Common Patterns
|
||||||
|
|
||||||
### Required Fields
|
### 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
|
### Using Different Struct Tags
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Use JSON tags instead of TOML
|
// Use JSON tags instead of default TOML
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server struct {
|
Server struct {
|
||||||
Host string `json:"host"`
|
Host string `json:"host"`
|
||||||
@ -153,7 +193,7 @@ type Config struct {
|
|||||||
cfg, _ := config.NewBuilder().
|
cfg, _ := config.NewBuilder().
|
||||||
WithTarget(&Config{}).
|
WithTarget(&Config{}).
|
||||||
WithTagName("json").
|
WithTagName("json").
|
||||||
WithFile("config.toml").
|
WithFile("config.json").
|
||||||
Build()
|
Build()
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -172,5 +212,4 @@ for source, value := range sources {
|
|||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- [Builder Pattern](builder.md) - Advanced configuration options
|
- [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
|
- [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("---")
|
||||||
log.Println("➡️ PART 1: Creating initial configuration file...")
|
log.Println("➡️ PART 1: Creating initial configuration file...")
|
||||||
|
|
||||||
// Defer cleanup to run at the very end of the program.
|
// Defer cleanup to run at the end of the program.
|
||||||
defer func() {
|
defer func() {
|
||||||
log.Println("---")
|
log.Println("---")
|
||||||
log.Println("🧹 Cleaning up...")
|
log.Println("🧹 Cleaning up...")
|
||||||
|
|||||||
6
go.mod
6
go.mod
@ -1,10 +1,10 @@
|
|||||||
module github.com/lixenwraith/config
|
module github.com/lixenwraith/config
|
||||||
|
|
||||||
go 1.25.1
|
go 1.25.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.5.0
|
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
|
github.com/stretchr/testify v1.10.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
@ -13,5 +13,3 @@ require (
|
|||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure v1.6.0/go.mod h1:FcbLReH7/cjaC0RVQR+LHFIrBhHF3s1e/ud1KMDoBVw=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
|||||||
376
loader.go
376
loader.go
@ -31,18 +31,6 @@ const (
|
|||||||
SourceCLI Source = "cli"
|
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
|
// EnvTransformFunc converts a configuration path to an environment variable name
|
||||||
type EnvTransformFunc func(path string) string
|
type EnvTransformFunc func(path string) string
|
||||||
|
|
||||||
@ -60,9 +48,6 @@ type LoadOptions struct {
|
|||||||
// If nil, uses default transformation (dots to underscores, uppercase)
|
// If nil, uses default transformation (dots to underscores, uppercase)
|
||||||
EnvTransform EnvTransformFunc
|
EnvTransform EnvTransformFunc
|
||||||
|
|
||||||
// LoadMode determines how values are merged
|
|
||||||
LoadMode LoadMode
|
|
||||||
|
|
||||||
// EnvWhitelist limits which paths are checked for env vars (nil = all)
|
// EnvWhitelist limits which paths are checked for env vars (nil = all)
|
||||||
EnvWhitelist map[string]bool
|
EnvWhitelist map[string]bool
|
||||||
|
|
||||||
@ -74,7 +59,6 @@ type LoadOptions struct {
|
|||||||
func DefaultLoadOptions() LoadOptions {
|
func DefaultLoadOptions() LoadOptions {
|
||||||
return LoadOptions{
|
return LoadOptions{
|
||||||
Sources: []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault},
|
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) {
|
if errors.Is(err, ErrConfigNotFound) {
|
||||||
loadErrors = append(loadErrors, err)
|
loadErrors = append(loadErrors, err)
|
||||||
} else {
|
} else {
|
||||||
return err // Fatal error
|
return wrapError(ErrFileAccess, err) // Fatal error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case SourceEnv:
|
case SourceEnv:
|
||||||
if err := c.loadEnv(opts); err != nil {
|
if err := c.loadEnv(opts); err != nil {
|
||||||
loadErrors = append(loadErrors, err)
|
loadErrors = append(loadErrors, wrapError(ErrEnvParse, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
case SourceCLI:
|
case SourceCLI:
|
||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
if err := c.loadCLI(args); err != nil {
|
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
|
// LoadCLI loads configuration values from command-line arguments
|
||||||
func (c *Config) LoadCLI(args []string) error {
|
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
|
// LoadFile loads configuration values from a TOML file
|
||||||
func (c *Config) LoadFile(filePath string) error {
|
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
|
// 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
|
// Check if cleaned path tries to go outside current directory
|
||||||
if strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || cleanPath == ".." {
|
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
|
// 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
|
// Absolute paths are OK if that's what was provided
|
||||||
} else if filepath.IsAbs(cleanPath) && !filepath.IsAbs(path) {
|
} else if filepath.IsAbs(cleanPath) && !filepath.IsAbs(path) {
|
||||||
// Relative path became absolute after cleaning - suspicious
|
// 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) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return ErrConfigNotFound
|
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
|
// Security: File size check
|
||||||
if c.securityOpts != nil && c.securityOpts.MaxFileSize > 0 {
|
if c.securityOpts != nil && c.securityOpts.MaxFileSize > 0 {
|
||||||
if fileInfo.Size() > c.securityOpts.MaxFileSize {
|
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 c.securityOpts != nil && c.securityOpts.EnforceFileOwnership && runtime.GOOS != "windows" {
|
||||||
if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
|
if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
|
||||||
if stat.Uid != uint32(os.Geteuid()) {
|
if stat.Uid != uint32(os.Geteuid()) {
|
||||||
return fmt.Errorf("config file '%s' is not owned by current user (file UID: %d, process UID: %d)",
|
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())
|
path, stat.Uid, os.Geteuid()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -196,7 +328,7 @@ func (c *Config) loadFile(path string) error {
|
|||||||
// 1. Read and parse file data
|
// 1. Read and parse file data
|
||||||
file, err := os.Open(path)
|
file, err := os.Open(path)
|
||||||
if err != nil {
|
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()
|
defer file.Close()
|
||||||
|
|
||||||
@ -208,7 +340,7 @@ func (c *Config) loadFile(path string) error {
|
|||||||
|
|
||||||
fileData, err := io.ReadAll(reader)
|
fileData, err := io.ReadAll(reader)
|
||||||
if err != nil {
|
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
|
// Determine format
|
||||||
@ -229,22 +361,22 @@ func (c *Config) loadFile(path string) error {
|
|||||||
// Parse based on detected/specified format
|
// Parse based on detected/specified format
|
||||||
fileConfig := make(map[string]any)
|
fileConfig := make(map[string]any)
|
||||||
switch format {
|
switch format {
|
||||||
case "toml":
|
case FormatTOML:
|
||||||
if err := toml.Unmarshal(fileData, &fileConfig); err != nil {
|
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 := json.NewDecoder(bytes.NewReader(fileData))
|
||||||
decoder.UseNumber() // Preserve number precision
|
decoder.UseNumber() // Preserve number precision
|
||||||
if err := decoder.Decode(&fileConfig); err != nil {
|
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 {
|
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:
|
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)
|
// 2. Prepare New State (Read-Lock Only)
|
||||||
@ -366,7 +498,7 @@ func (c *Config) loadCLI(args []string) error {
|
|||||||
// -- 1. Prepare data (No Lock)
|
// -- 1. Prepare data (No Lock)
|
||||||
parsedCLI, err := parseArgs(args)
|
parsedCLI, err := parseArgs(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%w: %w", ErrCLIParse, err)
|
return err // Already wrapped with error category in parseArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
flattenedCLI := flattenMap(parsedCLI, "")
|
flattenedCLI := flattenMap(parsedCLI, "")
|
||||||
@ -395,53 +527,6 @@ func (c *Config) loadCLI(args []string) error {
|
|||||||
return nil
|
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
|
// defaultEnvTransform creates the default environment variable transformer
|
||||||
func defaultEnvTransform(prefix string) EnvTransformFunc {
|
func defaultEnvTransform(prefix string) EnvTransformFunc {
|
||||||
return func(path string) string {
|
return func(path string) string {
|
||||||
@ -473,111 +558,16 @@ func parseValue(s string) any {
|
|||||||
return s
|
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
|
// atomicWriteFile performs atomic file write
|
||||||
func atomicWriteFile(path string, data []byte) error {
|
func atomicWriteFile(path string, data []byte) error {
|
||||||
dir := filepath.Dir(path)
|
dir := filepath.Dir(path)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
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")
|
tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
|
||||||
if err != nil {
|
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()
|
tempPath := tempFile.Name()
|
||||||
@ -585,24 +575,24 @@ func atomicWriteFile(path string, data []byte) error {
|
|||||||
|
|
||||||
if _, err := tempFile.Write(data); err != nil {
|
if _, err := tempFile.Write(data); err != nil {
|
||||||
tempFile.Close()
|
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 {
|
if err := tempFile.Sync(); err != nil {
|
||||||
tempFile.Close()
|
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 {
|
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 {
|
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 {
|
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
|
return nil
|
||||||
@ -659,7 +649,7 @@ func parseArgs(args []string) (map[string]any, error) {
|
|||||||
segments := strings.Split(keyPath, ".")
|
segments := strings.Split(keyPath, ".")
|
||||||
for _, segment := range segments {
|
for _, segment := range segments {
|
||||||
if !isValidKeySegment(segment) {
|
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))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".toml", ".tml":
|
case ".toml", ".tml":
|
||||||
return "toml"
|
return FormatTOML
|
||||||
case ".json":
|
case ".json":
|
||||||
return "json"
|
return FormatJSON
|
||||||
case ".yaml", ".yml":
|
case ".yaml", ".yml":
|
||||||
return "yaml"
|
return FormatYAML
|
||||||
case ".conf", ".config":
|
case ".conf", ".config":
|
||||||
// Try to detect from content
|
// Try to detect from content
|
||||||
return ""
|
return ""
|
||||||
@ -693,19 +683,19 @@ func detectFormatFromContent(data []byte) string {
|
|||||||
// Try JSON first (strict format)
|
// Try JSON first (strict format)
|
||||||
var jsonTest any
|
var jsonTest any
|
||||||
if err := json.Unmarshal(data, &jsonTest); err == nil {
|
if err := json.Unmarshal(data, &jsonTest); err == nil {
|
||||||
return "json"
|
return FormatJSON
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try YAML (superset of JSON, so check after JSON)
|
// Try YAML (superset of JSON, so check after JSON)
|
||||||
var yamlTest any
|
var yamlTest any
|
||||||
if err := yaml.Unmarshal(data, &yamlTest); err == nil {
|
if err := yaml.Unmarshal(data, &yamlTest); err == nil {
|
||||||
return "yaml"
|
return FormatYAML
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try TOML last
|
// Try TOML last
|
||||||
var tomlTest any
|
var tomlTest any
|
||||||
if err := toml.Unmarshal(data, &tomlTest); err == nil {
|
if err := toml.Unmarshal(data, &tomlTest); err == nil {
|
||||||
return "toml"
|
return FormatTOML
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
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.
|
// defaultValue is the value returned by Get if no specific value has been set.
|
||||||
func (c *Config) Register(path string, defaultValue any) error {
|
func (c *Config) Register(path string, defaultValue any) error {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return fmt.Errorf("registration path cannot be empty")
|
return wrapError(ErrInvalidPath, fmt.Errorf("registration path cannot be empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate path segments
|
// Validate path segments
|
||||||
segments := strings.Split(path, ".")
|
segments := strings.Split(path, ".")
|
||||||
for _, segment := range segments {
|
for _, segment := range segments {
|
||||||
if !isValidKeySegment(segment) {
|
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
|
// Check if the environment variable exists and load it
|
||||||
if value, exists := os.LookupEnv(envVar); exists {
|
if value, exists := os.LookupEnv(envVar); exists {
|
||||||
parsed := parseValue(value)
|
parsed := parseValue(value)
|
||||||
return c.SetSource(SourceEnv, path, parsed)
|
return c.SetSource(SourceEnv, path, parsed) // Already wrapped with error category in SetSource
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -78,7 +78,7 @@ func (c *Config) Unregister(path string) error {
|
|||||||
}
|
}
|
||||||
// If neither the path nor any children exist, return error
|
// If neither the path nor any children exist, return error
|
||||||
if !hasChildren {
|
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.
|
// 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.
|
// The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed.
|
||||||
func (c *Config) RegisterStruct(prefix string, structWithDefaults any) error {
|
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
|
// 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
|
// Handle pointer or direct struct value
|
||||||
if v.Kind() == reflect.Ptr {
|
if v.Kind() == reflect.Ptr {
|
||||||
if v.IsNil() {
|
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()
|
v = v.Elem()
|
||||||
}
|
}
|
||||||
|
|
||||||
if v.Kind() != reflect.Struct {
|
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
|
// Validate tag name
|
||||||
switch tagName {
|
switch tagName {
|
||||||
case "toml", "json", "yaml":
|
case FormatTOML, FormatJSON, FormatYAML:
|
||||||
// Supported tags
|
// Supported tags
|
||||||
default:
|
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
|
var errors []string
|
||||||
@ -133,7 +133,7 @@ func (c *Config) RegisterStructWithTags(prefix string, structWithDefaults any, t
|
|||||||
c.registerFields(v, prefix, "", &errors, tagName)
|
c.registerFields(v, prefix, "", &errors, tagName)
|
||||||
|
|
||||||
if len(errors) > 0 {
|
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
|
return nil
|
||||||
@ -179,7 +179,6 @@ func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, e
|
|||||||
currentPath = pathPrefix + key
|
currentPath = pathPrefix + key
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: use mapstructure instead of logic with reflection
|
|
||||||
// Handle nested structs recursively
|
// Handle nested structs recursively
|
||||||
fieldType := fieldValue.Type()
|
fieldType := fieldValue.Type()
|
||||||
isStruct := fieldValue.Kind() == reflect.Struct
|
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
|
// Port validates TCP/UDP port range
|
||||||
func Port(p int64) error {
|
func Port(p int64) error {
|
||||||
if p < 1 || p > 65535 {
|
if p < MinPortNumber || p > MaxPortNumber {
|
||||||
return fmt.Errorf("must be 1-65535, got %d", p)
|
return wrapError(ErrValidation, fmt.Errorf("must be 1-65535, got %d", p))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -21,7 +21,7 @@ func Port(p int64) error {
|
|||||||
// Positive validates positive numbers
|
// Positive validates positive numbers
|
||||||
func Positive[T int64 | float64](n T) error {
|
func Positive[T int64 | float64](n T) error {
|
||||||
if n <= 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@ -29,46 +29,46 @@ func Positive[T int64 | float64](n T) error {
|
|||||||
// NonNegative validates non-negative numbers
|
// NonNegative validates non-negative numbers
|
||||||
func NonNegative[T int64 | float64](n T) error {
|
func NonNegative[T int64 | float64](n T) error {
|
||||||
if n < 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPAddress validates IP address format
|
// IPAddress validates IP address format
|
||||||
func IPAddress(s string) error {
|
func IPAddress(s string) error {
|
||||||
if s == "" || s == "0.0.0.0" || s == "::" {
|
if s == "" || s == IPv4Any || s == IPv6Any {
|
||||||
return nil // Allow common defaults
|
return nil // Allow common defaults
|
||||||
}
|
}
|
||||||
if net.ParseIP(s) == nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv4Address validates IPv4 address format
|
// IPv4Address validates IPv4 address format
|
||||||
func IPv4Address(s string) error {
|
func IPv4Address(s string) error {
|
||||||
if s == "" || s == "0.0.0.0" {
|
if s == "" || s == IPv4Any {
|
||||||
return nil // Allow common defaults
|
return nil // Allow common defaults
|
||||||
}
|
}
|
||||||
ip := net.ParseIP(s)
|
ip := net.ParseIP(s)
|
||||||
if ip == nil || ip.To4() == nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IPv6Address validates IPv6 address format
|
// IPv6Address validates IPv6 address format
|
||||||
func IPv6Address(s string) error {
|
func IPv6Address(s string) error {
|
||||||
if s == "" || s == "::" {
|
if s == "" || s == IPv6Any {
|
||||||
return nil // Allow common defaults
|
return nil // Allow common defaults
|
||||||
}
|
}
|
||||||
ip := net.ParseIP(s)
|
ip := net.ParseIP(s)
|
||||||
if ip == nil {
|
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
|
// Valid net.ParseIP with nil ip.To4 indicates IPv6
|
||||||
if ip.To4() != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@ -76,7 +76,7 @@ func IPv6Address(s string) error {
|
|||||||
// URLPath validates URL path format
|
// URLPath validates URL path format
|
||||||
func URLPath(s string) error {
|
func URLPath(s string) error {
|
||||||
if s != "" && !strings.HasPrefix(s, "/") {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@ -89,7 +89,7 @@ func OneOf[T comparable](allowed ...T) func(T) error {
|
|||||||
return nil
|
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 {
|
func Range[T int64 | float64](min, max T) func(T) error {
|
||||||
return func(val T) error {
|
return func(val T) error {
|
||||||
if val < min || val > max {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@ -108,7 +108,7 @@ func Pattern(pattern string) func(string) error {
|
|||||||
re := regexp.MustCompile(pattern)
|
re := regexp.MustCompile(pattern)
|
||||||
return func(s string) error {
|
return func(s string) error {
|
||||||
if !re.MatchString(s) {
|
if !re.MatchString(s) {
|
||||||
return fmt.Errorf("must match pattern %s", pattern)
|
return wrapError(ErrValidation, fmt.Errorf("must match pattern %s", pattern))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -117,7 +117,7 @@ func Pattern(pattern string) func(string) error {
|
|||||||
// NonEmpty validates non-empty strings
|
// NonEmpty validates non-empty strings
|
||||||
func NonEmpty(s string) error {
|
func NonEmpty(s string) error {
|
||||||
if strings.TrimSpace(s) == "" {
|
if strings.TrimSpace(s) == "" {
|
||||||
return fmt.Errorf("must not be empty")
|
return wrapError(ErrValidation, fmt.Errorf("must not be empty"))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
18
watch.go
18
watch.go
@ -11,8 +11,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const DefaultMaxWatchers = 100 // Prevent resource exhaustion
|
|
||||||
|
|
||||||
// WatchOptions configures file watching behavior
|
// WatchOptions configures file watching behavior
|
||||||
type WatchOptions struct {
|
type WatchOptions struct {
|
||||||
// PollInterval for file stat checks (minimum 100ms)
|
// 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
|
// Set format hint if provided
|
||||||
if len(formatHint) > 0 {
|
if len(formatHint) > 0 {
|
||||||
if err := c.SetFileFormat(formatHint[0]); err != nil {
|
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
|
// Load the new file
|
||||||
if err := c.LoadFile(filePath); err != nil {
|
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
|
// Get previous watcher options if available
|
||||||
@ -164,7 +162,7 @@ func (c *Config) WatchFile(filePath string, formatHint ...string) error {
|
|||||||
}
|
}
|
||||||
c.mutex.RUnlock()
|
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)
|
c.AutoUpdateWithOptions(opts)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -253,7 +251,7 @@ func (w *watcher) checkAndReload(c *Config) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
// File was deleted, notify watchers
|
// File was deleted, notify watchers
|
||||||
w.notifyWatchers("file_deleted")
|
w.notifyWatchers(EventFileDeleted)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -272,7 +270,7 @@ func (w *watcher) checkAndReload(c *Config) {
|
|||||||
// Permission change detected
|
// Permission change detected
|
||||||
if (info.Mode() & 0077) != (w.lastMode & 0077) {
|
if (info.Mode() & 0077) != (w.lastMode & 0077) {
|
||||||
// World/group permissions changed - potential security issue
|
// World/group permissions changed - potential security issue
|
||||||
w.notifyWatchers("permissions_changed")
|
w.notifyWatchers(EventPermissionsChanged)
|
||||||
// Don't reload on permission change for security
|
// Don't reload on permission change for security
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -322,7 +320,7 @@ func (w *watcher) performReload(c *Config) {
|
|||||||
case err := <-done:
|
case err := <-done:
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Reload failed, notify error
|
// Reload failed, notify error
|
||||||
w.notifyWatchers(fmt.Sprintf("reload_error:%v", err))
|
w.notifyWatchers(fmt.Sprintf("%s:%v", EventReloadError, err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -343,7 +341,7 @@ func (w *watcher) performReload(c *Config) {
|
|||||||
|
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
// Reload timeout
|
// 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
|
// Create buffered channel to prevent blocking
|
||||||
ch := make(chan string, 10)
|
ch := make(chan string, WatchChannelBuffer)
|
||||||
id := w.watcherID.Add(1)
|
id := w.watcherID.Add(1)
|
||||||
w.watchers[id] = ch
|
w.watchers[id] = ch
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user