Files
config/loader.go
T

394 lines
10 KiB
Go

// FILE: lixenwraith/config/loader.go
package config
import (
"errors"
"fmt"
"maps"
"os"
"reflect"
"slices"
"strings"
)
// Source represents a configuration source, used to define load precedence
type Source string
const (
// SourceDefault represents use of registered default values
SourceDefault Source = "default"
// SourceFile represents values loaded from a configuration file
SourceFile Source = "file"
// SourceEnv represents values loaded from environment variables
SourceEnv Source = "env"
// SourceCLI represents values loaded from command-line arguments
SourceCLI Source = "cli"
)
// EnvTransformFunc converts a configuration path to an environment variable name
type EnvTransformFunc func(path string) string
// LoadOptions configures how configuration is loaded from multiple sources
type LoadOptions struct {
// Sources defines the precedence order (first = highest priority)
// Default: [SourceCLI, SourceEnv, SourceFile, SourceDefault]
Sources []Source
// EnvPrefix is prepended to environment variable names
// Example: "MYAPP_" transforms "server.port" to "MYAPP_SERVER_PORT"
EnvPrefix string
// EnvTransform customizes how paths map to environment variables
// If nil, uses default transformation (dots to underscores, uppercase)
EnvTransform EnvTransformFunc
// EnvWhitelist limits which paths are checked for env vars (nil = all)
EnvWhitelist map[string]bool
// Deprecated: SkipValidation is retained for source compatibility and ignored.
// Path and value checks are always enforced.
SkipValidation bool
}
// DefaultLoadOptions returns the standard load options
func DefaultLoadOptions() LoadOptions {
return LoadOptions{
Sources: []Source{SourceCLI, SourceEnv, SourceFile, SourceDefault},
}
}
func cloneLoadOptions(opts LoadOptions) LoadOptions {
opts.Sources = slices.Clone(opts.Sources)
if len(opts.Sources) == 0 {
opts.Sources = DefaultLoadOptions().Sources
}
opts.EnvWhitelist = maps.Clone(opts.EnvWhitelist)
return opts
}
// loadWithOptions loads configuration from multiple sources with custom options
func (c *Config) loadWithOptions(filePath string, args []string, opts LoadOptions) error {
opts = cloneLoadOptions(opts)
c.SetLoadOptions(opts)
var loadErrors []error
var missingFile error
// Process each source according to precedence (in reverse order for proper layering)
for i := len(opts.Sources) - 1; i >= 0; i-- {
source := opts.Sources[i]
switch source {
case SourceDefault:
// Defaults are already in place from Register calls
continue
case SourceFile:
if filePath != "" {
if err := c.loadFile(filePath); err != nil {
if errors.Is(err, ErrConfigNotFound) {
missingFile = err
} else {
return wrapError(ErrFileAccess, err) // Fatal error
}
}
}
case SourceEnv:
if err := c.loadEnv(opts); err != nil {
loadErrors = append(loadErrors, wrapError(ErrEnvParse, err))
}
case SourceCLI:
if err := c.loadCLI(args); err != nil {
loadErrors = append(loadErrors, wrapError(ErrCLIParse, err))
}
}
}
if len(loadErrors) != 0 {
return errors.Join(loadErrors...)
}
return missingFile
}
// LoadEnv loads configuration values from environment variables
func (c *Config) LoadEnv(prefix string) error {
c.mutex.RLock()
opts := cloneLoadOptions(c.options)
c.mutex.RUnlock()
opts.EnvPrefix = prefix
return c.loadEnv(opts)
}
// LoadCLI loads configuration values from command-line arguments
func (c *Config) LoadCLI(args []string) error {
if err := c.loadCLI(args); err != nil {
return wrapError(ErrCLIParse, err)
}
return nil
}
// LoadFile loads configuration values from a TOML file
func (c *Config) LoadFile(filePath string) error {
if err := c.loadFile(filePath); err != nil {
return wrapError(ErrFileAccess, err)
}
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 {
c.mutex.RLock()
transform := c.options.EnvTransform
paths := make([]string, 0, len(c.items))
for path := range c.items {
paths = append(paths, path)
}
c.mutex.RUnlock()
if transform == nil {
transform = defaultEnvTransform(prefix)
}
discovered := make(map[string]string)
for _, path := range paths {
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 {
c.mutex.RLock()
transform := c.options.EnvTransform
changed := make(map[string]any)
for path, item := range c.items {
if !reflect.DeepEqual(item.currentValue, item.defaultValue) {
changed[path] = item.currentValue
}
}
c.mutex.RUnlock()
if transform == nil {
transform = defaultEnvTransform(prefix)
}
exports := make(map[string]string)
for path, value := range changed {
exports[transform(path)] = fmt.Sprintf("%v", value)
}
return exports
}
// loadEnv replaces the complete environment source, including removed variables.
func (c *Config) loadEnv(opts LoadOptions) error {
transform := opts.EnvTransform
if transform == nil {
transform = defaultEnvTransform(opts.EnvPrefix)
}
c.mutex.RLock()
paths := make([]string, 0, len(c.items))
for path := range c.items {
paths = append(paths, path)
}
c.mutex.RUnlock()
values := make(map[string]any)
byVar := make(map[string]string, len(paths))
for _, path := range paths {
if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] {
continue
}
name := transform(path)
// Empty counts as unset, so a template may leave a variable blank
if value, ok := os.LookupEnv(name); ok && value != "" {
if other, dup := byVar[name]; dup {
return fmt.Errorf("%s would set both %s and %s", name, other, path)
}
byVar[name] = path
values[path] = value
}
}
c.mutex.Lock()
defer c.mutex.Unlock()
return c.replaceSourceLocked(SourceEnv, values)
}
func (c *Config) loadCLI(args []string) error {
parsed, stray, err := parseArgs(args)
if err != nil {
return err
}
c.mutex.Lock()
defer c.mutex.Unlock()
if err := c.replaceSourceLocked(SourceCLI, flattenMap(parsed, "")); err != nil {
return err
}
c.unknownCLIKeys = append(c.unknownCLIKeys, stray...)
return nil
}
// Validate the entire replacement before any mutation. Caller holds c.mutex.
func (c *Config) replaceSourceLocked(source Source, values map[string]any, guard ...func() error) error {
accepted := make(map[string]any, len(values))
var unknown []string
for path, value := range values {
item, ok := c.items[path]
if !ok {
if source == SourceCLI {
unknown = append(unknown, path)
}
continue
}
if source == SourceEnv || source == SourceCLI {
value = textList(item.defaultValue, value)
}
if t := reflect.TypeOf(item.defaultValue); source == SourceCLI && value == true && t != nil && t.Kind() != reflect.Bool {
return fmt.Errorf("--%s needs a value", path)
}
if err := validateValue(item, value); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
owned, err := copyValue(value)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
accepted[path] = owned
}
if len(guard) > 0 {
if err := guard[0](); err != nil {
return err
}
}
for path, item := range c.items {
if value, ok := accepted[path]; ok {
item.values[source] = value
} else {
delete(item.values, source)
}
item.currentValue = c.computeValue(item)
c.items[path] = item
}
switch source {
case SourceFile:
c.fileData = accepted
case SourceEnv:
c.envData = accepted
case SourceCLI:
c.cliData = accepted
slices.Sort(unknown)
c.unknownCLIKeys = unknown
}
c.invalidateCache()
return nil
}
// defaultEnvTransform creates the default environment variable transformer
func defaultEnvTransform(prefix string) EnvTransformFunc {
return func(path string) string {
env := strings.ReplaceAll(path, ".", "_")
env = strings.ToUpper(env)
if prefix != "" {
env = prefix + env
}
return env
}
}
// parseValue attempts to parse a string into appropriate types
// Only basic parse, complex parsing is deferred to typed decoding
func parseValue(s string) any {
if s == "true" {
return true
}
if s == "false" {
return false
}
// Remove quotes if present
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
// Return as string - typed decoding converts as needed
return s
}
// parseArgs processes command-line arguments into a nested map structure. A
// bare flag is the bool true, which only a bool path accepts; arguments that
// are no --flag before "--" come back as stray, for UnknownCLIKeys.
func parseArgs(args []string) (result map[string]any, stray []string, err error) {
result = make(map[string]any)
i := 0
for i < len(args) {
arg := args[i]
if !strings.HasPrefix(arg, "--") {
stray = append(stray, arg)
i++
continue
}
argContent := strings.TrimPrefix(arg, "--")
if argContent == "" {
// Arguments after the terminator are positional.
break
}
var keyPath string
var valueStr string
bare := false
// Check for "--key=value" format
if strings.Contains(argContent, "=") {
parts := strings.SplitN(argContent, "=", 2)
keyPath = parts[0]
valueStr = parts[1]
i++ // Consume only this argument
} else {
// Handle "--key value" or "--booleanflag"
keyPath = argContent
// Check if it's a boolean flag (next arg is another flag or end of args)
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") {
bare = true
i++ // Consume only the flag argument
} else {
// It's a key-value pair with a space
valueStr = args[i+1]
i += 2 // Consume both flag and value arguments
}
}
if keyPath == "" {
// Skip invalid flags like --=value
continue
}
// Validate keyPath segments
segments := strings.Split(keyPath, ".")
for _, segment := range segments {
if !isValidKeySegment(segment) {
return nil, nil, wrapError(ErrInvalidPath, fmt.Errorf("invalid command-line key segment %q in path %q", segment, keyPath))
}
}
// Always store as a string. Let Scan handle final type conversion.
if len(valueStr) > MaxValueSize {
return nil, nil, ErrValueSize
}
if bare {
setNestedValue(result, keyPath, true)
} else {
setNestedValue(result, keyPath, valueStr)
}
}
return result, stray, nil
}