305 lines
8.7 KiB
Go
305 lines
8.7 KiB
Go
// FILE: lixenwraith/config/utility.go
|
|
package config
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
)
|
|
|
|
// Quick creates a fully configured Config instance with a single call
|
|
// This is the recommended way to initialize configuration for most applications
|
|
func Quick(structDefaults any, envPrefix, configFile string) (*Config, error) {
|
|
cfg := New()
|
|
|
|
// Register defaults from struct if provided
|
|
if structDefaults != nil {
|
|
if err := cfg.RegisterStruct("", structDefaults); err != nil {
|
|
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("failed to register defaults: %w", err))
|
|
}
|
|
}
|
|
|
|
// Load with standard precedence: CLI > Env > File > Default
|
|
opts := DefaultLoadOptions()
|
|
opts.EnvPrefix = envPrefix
|
|
|
|
err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
|
|
return cfg, err
|
|
}
|
|
|
|
// QuickCustom creates a Config with custom options
|
|
func QuickCustom(structDefaults any, opts LoadOptions, configFile string) (*Config, error) {
|
|
cfg := NewWithOptions(opts)
|
|
|
|
// Register defaults from struct if provided
|
|
if structDefaults != nil {
|
|
if err := cfg.RegisterStruct("", structDefaults); err != nil {
|
|
return nil, wrapError(ErrTypeMismatch, fmt.Errorf("failed to register defaults: %w", err))
|
|
}
|
|
}
|
|
|
|
err := cfg.loadWithOptions(configFile, os.Args[1:], opts)
|
|
return cfg, err
|
|
}
|
|
|
|
// MustQuick is like Quick but panics on error
|
|
func MustQuick(structDefaults any, envPrefix, configFile string) *Config {
|
|
cfg, err := Quick(structDefaults, envPrefix, configFile)
|
|
if err != nil && !errors.Is(err, ErrConfigNotFound) {
|
|
panic(fmt.Sprintf("config initialization failed: %v", err))
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// GenerateFlags creates flag.FlagSet entries for all registered paths
|
|
func (c *Config) GenerateFlags() *flag.FlagSet {
|
|
fs := flag.NewFlagSet("config", flag.ContinueOnError)
|
|
|
|
c.mutex.RLock()
|
|
defer c.mutex.RUnlock()
|
|
|
|
for path, item := range c.items {
|
|
// Create flag based on default value type
|
|
switch v := item.defaultValue.(type) {
|
|
case bool:
|
|
fs.Bool(path, v, fmt.Sprintf("Config: %s", path))
|
|
case int64:
|
|
fs.Int64(path, v, fmt.Sprintf("Config: %s", path))
|
|
case int:
|
|
fs.Int(path, v, fmt.Sprintf("Config: %s", path))
|
|
case float64:
|
|
fs.Float64(path, v, fmt.Sprintf("Config: %s", path))
|
|
case string:
|
|
fs.String(path, v, fmt.Sprintf("Config: %s", path))
|
|
default:
|
|
// For other types, use string flag
|
|
fs.String(path, fmt.Sprintf("%v", v), fmt.Sprintf("Config: %s", path))
|
|
}
|
|
}
|
|
|
|
return fs
|
|
}
|
|
|
|
// BindFlags updates configuration from parsed flag.FlagSet
|
|
func (c *Config) BindFlags(fs *flag.FlagSet) error {
|
|
if fs == nil {
|
|
return wrapError(ErrCLIParse, fmt.Errorf("nil flag set"))
|
|
}
|
|
values := make(map[string]any)
|
|
fs.Visit(func(f *flag.Flag) { values[f.Name] = f.Value.String() })
|
|
c.mutex.Lock()
|
|
defer c.mutex.Unlock()
|
|
for path := range values {
|
|
if _, ok := c.items[path]; !ok {
|
|
return wrapError(ErrCLIParse, fmt.Errorf("failed to bind unregistered flag %q: %w", path, ErrPathNotRegistered))
|
|
}
|
|
}
|
|
if err := c.replaceSourceLocked(SourceCLI, values); err != nil {
|
|
return wrapError(ErrCLIParse, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Validate checks that all required configuration values are set
|
|
// A value is considered "set" if it differs from its default value
|
|
func (c *Config) Validate(required ...string) error {
|
|
c.mutex.RLock()
|
|
defer c.mutex.RUnlock()
|
|
|
|
var missing []string
|
|
if len(required) == 0 {
|
|
for path, item := range c.items {
|
|
if item.required {
|
|
required = append(required, path)
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, path := range required {
|
|
item, exists := c.items[path]
|
|
if !exists {
|
|
missing = append(missing, path+" (not registered)")
|
|
continue
|
|
}
|
|
|
|
// Check if value equals default (indicating not set)
|
|
if reflect.DeepEqual(item.currentValue, item.defaultValue) {
|
|
// Check if any source provided a value
|
|
hasValue := false
|
|
for _, source := range c.options.Sources {
|
|
if source == SourceDefault {
|
|
break
|
|
}
|
|
val := item.values[source]
|
|
if val != nil {
|
|
hasValue = true
|
|
break
|
|
}
|
|
}
|
|
if !hasValue {
|
|
missing = append(missing, path)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(missing) > 0 {
|
|
return wrapError(ErrValidation, fmt.Errorf("missing required configuration: %s", strings.Join(missing, ", ")))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Debug returns a formatted string showing all configuration values and their sources
|
|
func (c *Config) Debug() string {
|
|
c.mutex.RLock()
|
|
defer c.mutex.RUnlock()
|
|
|
|
var b strings.Builder
|
|
b.WriteString("Configuration Debug Info:\n")
|
|
b.WriteString(fmt.Sprintf("Precedence: %v\n", c.options.Sources))
|
|
b.WriteString("Current values:\n")
|
|
|
|
for path, item := range c.items {
|
|
b.WriteString(fmt.Sprintf(" %s:\n", path))
|
|
b.WriteString(fmt.Sprintf(" Current: %v\n", item.currentValue))
|
|
b.WriteString(fmt.Sprintf(" Default: %v\n", item.defaultValue))
|
|
|
|
for source, value := range item.values {
|
|
b.WriteString(fmt.Sprintf(" %s: %v\n", source, value))
|
|
}
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// Dump writes the current configuration to stdout in TOML format
|
|
func (c *Config) Dump() error {
|
|
c.mutex.RLock()
|
|
nestedData := c.nestedLocked("")
|
|
c.mutex.RUnlock()
|
|
|
|
data, err := marshalConfig(nestedData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = os.Stdout.Write(data)
|
|
return err
|
|
}
|
|
|
|
// Clone creates a deep copy of the configuration
|
|
func (c *Config) Clone() *Config {
|
|
c.mutex.RLock()
|
|
defer c.mutex.RUnlock()
|
|
clone := NewWithOptions(c.options)
|
|
clone.tagName, clone.fileFormat = c.tagName, c.fileFormat
|
|
clone.configFilePath = c.configFilePath
|
|
clone.fileComments = append([]string(nil), c.fileComments...)
|
|
clone.unknownCLIKeys = append([]string(nil), c.unknownCLIKeys...)
|
|
if c.securityOpts != nil {
|
|
opts := *c.securityOpts
|
|
clone.securityOpts = &opts
|
|
}
|
|
for path, item := range c.items {
|
|
next := configItem{defaultValue: cloneOwned(item.defaultValue), currentValue: cloneOwned(item.currentValue), values: make(map[Source]any)}
|
|
for source, value := range item.values {
|
|
next.values[source] = cloneOwned(value)
|
|
}
|
|
next.required = item.required
|
|
clone.items[path] = next
|
|
}
|
|
for k, v := range c.fileData {
|
|
clone.fileData[k] = cloneOwned(v)
|
|
}
|
|
for k, v := range c.envData {
|
|
clone.envData[k] = cloneOwned(v)
|
|
}
|
|
for k, v := range c.cliData {
|
|
clone.cliData[k] = cloneOwned(v)
|
|
}
|
|
if c.structCache != nil {
|
|
clone.structCache = &structCache{targetType: c.structCache.targetType, prefix: c.structCache.prefix}
|
|
}
|
|
return clone
|
|
}
|
|
|
|
// QuickTyped creates a fully configured Config with a typed target
|
|
func QuickTyped[T any](target *T, envPrefix, configFile string) (*Config, error) {
|
|
return NewBuilder().
|
|
WithTarget(target).
|
|
WithEnvPrefix(envPrefix).
|
|
WithFile(configFile).
|
|
Build()
|
|
}
|
|
|
|
// GetTyped retrieves a configuration value and decodes it into the specified type T
|
|
// It leverages the same decoding hooks as the Scan and AsStruct methods,
|
|
// providing type conversion from strings, numbers, etc.
|
|
func GetTyped[T any](c *Config, path string) (T, error) {
|
|
var zero T
|
|
|
|
rawValue, exists := c.Get(path)
|
|
if !exists {
|
|
return zero, wrapError(ErrPathNotFound, fmt.Errorf("path %q not found", path))
|
|
}
|
|
|
|
var target T
|
|
if err := decodeConfig(rawValue, &target); err != nil {
|
|
return zero, err
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
// GetTypedWithDefault retrieves a configuration value with a default fallback
|
|
// If the path doesn't exist or isn't set, it sets and returns the default value
|
|
// For simple cases where explicit defaults aren't pre-registered
|
|
func GetTypedWithDefault[T any](c *Config, path string, defaultValue T) (T, error) {
|
|
c.mutex.Lock()
|
|
if item, exists := c.items[path]; exists {
|
|
raw := item.currentValue
|
|
c.mutex.Unlock()
|
|
var result T
|
|
if err := decodeConfig(raw, &result); err != nil {
|
|
return result, err
|
|
}
|
|
return result, nil
|
|
}
|
|
if err := validatePath(path); err != nil {
|
|
c.mutex.Unlock()
|
|
return defaultValue, err
|
|
}
|
|
owned, err := copyValue(defaultValue)
|
|
if err == nil {
|
|
err = c.registerLocked(path, owned)
|
|
}
|
|
c.mutex.Unlock()
|
|
if err != nil {
|
|
return defaultValue, err
|
|
}
|
|
var result T
|
|
err = decodeConfig(owned, &result)
|
|
return result, err
|
|
}
|
|
|
|
// ScanTyped is a generic wrapper around Scan. It allocates a new instance of type T,
|
|
// populates it with configuration data from the given base path, and returns a pointer to it
|
|
func ScanTyped[T any](c *Config, basePath ...string) (*T, error) {
|
|
var target T
|
|
if err := c.Scan(&target, basePath...); err != nil {
|
|
return nil, err
|
|
}
|
|
return &target, nil
|
|
}
|
|
|
|
// ScanMap decodes a configuration map directly into a target struct
|
|
// without requiring a full Config instance. This is useful for plugin
|
|
// initialization where config data arrives as a map[string]any.
|
|
func ScanMap(configMap map[string]any, target any, tagName ...string) error {
|
|
if len(tagName) > 1 || len(tagName) == 1 && tagName[0] != "" && tagName[0] != FormatTOML {
|
|
return wrapError(ErrTypeMismatch, fmt.Errorf("only toml tags are supported"))
|
|
}
|
|
return decodeConfig(configMap, target)
|
|
}
|