e3.0.0 Added env variable support, improved cli arg, added tests, updated documentation.

This commit is contained in:
2025-07-01 13:06:07 -04:00
parent b1e241149e
commit 4053c463d6
17 changed files with 2290 additions and 615 deletions

422
io.go
View File

@ -1,3 +1,4 @@
// File: lixenwraith/config/io.go
package config
import (
@ -13,72 +14,275 @@ import (
)
// Load reads configuration from a TOML file and merges overrides from command-line arguments.
// 'args' should be the command-line arguments (e.g., os.Args[1:]).
// It returns an error if loading or parsing fails.
// Specific errors ErrConfigNotFound and ErrCLIParse can be checked using errors.Is.
func (c *Config) Load(path string, args []string) error {
// This is a convenience method that maintains backward compatibility.
func (c *Config) Load(filePath string, args []string) error {
return c.LoadWithOptions(filePath, args, c.options)
}
// LoadWithOptions loads configuration from multiple sources with custom options
func (c *Config) LoadWithOptions(filePath string, args []string, opts LoadOptions) error {
c.mutex.Lock()
defer c.mutex.Unlock()
c.options = opts
c.mutex.Unlock()
var errNotFound error
var errCLI error
var loadErrors []error
fileConfig := make(map[string]any) // Holds only file data
// 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]
// --- Load from file ---
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) {
loadErrors = append(loadErrors, err)
} else {
return err // Fatal error
}
}
}
case SourceEnv:
if err := c.loadEnv(opts); err != nil {
loadErrors = append(loadErrors, err)
}
case SourceCLI:
if len(args) > 0 {
if err := c.loadCLI(args); err != nil {
loadErrors = append(loadErrors, err)
}
}
}
}
return errors.Join(loadErrors...)
}
// LoadEnv loads configuration values from environment variables
func (c *Config) LoadEnv(prefix string) error {
opts := c.options
opts.EnvPrefix = prefix
return c.loadEnv(opts)
}
// LoadCLI loads configuration values from command-line arguments
func (c *Config) LoadCLI(args []string) error {
return c.loadCLI(args)
}
// LoadFile loads configuration values from a TOML file
func (c *Config) LoadFile(filePath string) error {
return c.loadFile(filePath)
}
// loadFile reads and parses a TOML configuration file
func (c *Config) loadFile(path string) error {
fileData, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
errNotFound = ErrConfigNotFound
// fileData is nil, proceed to CLI args
} else {
return fmt.Errorf("failed to read config file '%s': %w", path, err)
return ErrConfigNotFound
}
} else if err := toml.Unmarshal(fileData, &fileConfig); err != nil {
return fmt.Errorf("failed to read config file '%s': %w", path, err)
}
fileConfig := make(map[string]any)
if err := toml.Unmarshal(fileData, &fileConfig); err != nil {
return fmt.Errorf("failed to parse TOML config file '%s': %w", path, err)
}
// --- Flatten file data ---
// Flatten and apply file data
flattenedFileConfig := flattenMap(fileConfig, "")
// --- Parse CLI arguments ---
cliOverrides := make(map[string]any) // Holds only CLI args data
if len(args) > 0 {
parsedCliMap, parseErr := parseArgs(args) // parseArgs returns a nested map
if parseErr != nil {
// Wrap the CLI parsing error with our specific error type
errCLI = fmt.Errorf("%w: %w", ErrCLIParse, parseErr)
// Do not return yet, proceed to merge what we have
} else {
// Flatten the nested map from CLI args only if parsing succeeded
cliOverrides = flattenMap(parsedCliMap, "")
}
}
c.mutex.Lock()
defer c.mutex.Unlock()
// --- Merge and Update Internal State ---
// Iterate through registered paths to apply loaded/default values correctly.
// The order of precedence is: CLI > File > Registered Default
for regPath, item := range c.items {
// 1. Check CLI overrides (only if CLI parsing succeeded)
if errCLI == nil {
if cliVal, cliExists := cliOverrides[regPath]; cliExists {
item.currentValue = cliVal
c.items[regPath] = item
continue
// Store in cache
c.fileData = flattenedFileConfig
// Apply to registered paths
for path, value := range flattenedFileConfig {
if item, exists := c.items[path]; exists {
if item.values == nil {
item.values = make(map[Source]any)
}
item.values[SourceFile] = value
item.currentValue = c.computeValue(path, item)
c.items[path] = item
}
// 2. Check File config (if no CLI override or CLI parsing failed)
if fileVal, fileExists := flattenedFileConfig[regPath]; fileExists {
item.currentValue = fileVal
} else {
// 3. Use Default (if not in CLI or File)
item.currentValue = item.defaultValue
}
c.items[regPath] = item
// Ignore unregistered paths from file
}
return errors.Join(errNotFound, errCLI)
return nil
}
// loadEnv loads configuration from environment variables
func (c *Config) loadEnv(opts LoadOptions) error {
// Default transform function
transform := opts.EnvTransform
if transform == nil {
transform = func(path string) string {
// Convert dots to underscores and uppercase
env := strings.ReplaceAll(path, ".", "_")
env = strings.ToUpper(env)
if opts.EnvPrefix != "" {
env = opts.EnvPrefix + env
}
return env
}
}
c.mutex.Lock()
defer c.mutex.Unlock()
// Clear previous env data
c.envData = make(map[string]any)
// Check each registered path for corresponding env var
for path, item := range c.items {
// Skip if whitelisted and not in whitelist
if opts.EnvWhitelist != nil && !opts.EnvWhitelist[path] {
continue
}
envVar := transform(path)
if value, exists := os.LookupEnv(envVar); exists {
// Parse the string value
parsedValue := parseValue(value)
if item.values == nil {
item.values = make(map[Source]any)
}
item.values[SourceEnv] = parsedValue
item.currentValue = c.computeValue(path, item)
c.items[path] = item
c.envData[path] = parsedValue
}
}
return nil
}
// loadCLI loads configuration from command-line arguments
func (c *Config) loadCLI(args []string) error {
parsedCLI, err := parseArgs(args)
if err != nil {
return fmt.Errorf("%w: %w", ErrCLIParse, err)
}
// Flatten CLI data
flattenedCLI := flattenMap(parsedCLI, "")
c.mutex.Lock()
defer c.mutex.Unlock()
// Store in cache
c.cliData = flattenedCLI
// Apply to registered paths
for path, value := range flattenedCLI {
if item, exists := c.items[path]; exists {
if item.values == nil {
item.values = make(map[Source]any)
}
item.values[SourceCLI] = value
item.currentValue = c.computeValue(path, item)
c.items[path] = item
}
// Ignore unregistered paths from CLI
}
return nil
}
// DiscoverEnv finds all environment variables matching registered paths
// and returns a map of path -> env var name for found variables
func (c *Config) DiscoverEnv(prefix string) map[string]string {
transform := c.options.EnvTransform
if transform == nil {
transform = defaultEnvTransform(prefix)
}
c.mutex.RLock()
defer c.mutex.RUnlock()
discovered := make(map[string]string)
for path := range c.items {
envVar := transform(path)
if _, exists := os.LookupEnv(envVar); exists {
discovered[path] = envVar
}
}
return discovered
}
// ExportEnv exports the current configuration as environment variables
// Only exports paths that have non-default values
func (c *Config) ExportEnv(prefix string) map[string]string {
transform := c.options.EnvTransform
if transform == nil {
transform = defaultEnvTransform(prefix)
}
c.mutex.RLock()
defer c.mutex.RUnlock()
exports := make(map[string]string)
for path, item := range c.items {
// Only export if value differs from default
if item.currentValue != item.defaultValue {
envVar := transform(path)
exports[envVar] = fmt.Sprintf("%v", item.currentValue)
}
}
return exports
}
// defaultEnvTransform creates the default environment variable transformer
func defaultEnvTransform(prefix string) EnvTransformFunc {
return func(path string) string {
env := strings.ReplaceAll(path, ".", "_")
env = strings.ToUpper(env)
if prefix != "" {
env = prefix + env
}
return env
}
}
// parseValue attempts to parse a string into appropriate types
func parseValue(s string) any {
// Try boolean
if v, err := strconv.ParseBool(s); err == nil {
return v
}
// Try int64
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
return v
}
// Try float64
if v, err := strconv.ParseFloat(s, 64); err == nil {
return v
}
// Remove quotes if present
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
// Return as string
return s
}
// Save writes the current configuration to a TOML file atomically.
@ -93,20 +297,18 @@ func (c *Config) Save(path string) error {
c.mutex.RUnlock()
// --- Marshal using BurntSushi/toml ---
// Marshal using BurntSushi/toml
var buf bytes.Buffer
encoder := toml.NewEncoder(&buf)
// encoder.Indent = " " // Optional use of 2 spaces for indentation
if err := encoder.Encode(nestedData); err != nil {
return fmt.Errorf("failed to marshal config data to TOML: %w", err)
}
tomlData := buf.Bytes()
// --- End Marshal ---
// --- Atomic write logic ---
// Atomic write logic
dir := filepath.Dir(path)
// Ensure the directory exists
if err := os.MkdirAll(dir, 0755); err != nil { // 0755 allows owner rwx, group rx, other rx
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory '%s': %w", dir, err)
}
@ -115,56 +317,111 @@ func (c *Config) Save(path string) error {
if err != nil {
return fmt.Errorf("failed to create temporary config file in '%s': %w", dir, err)
}
// Defer cleanup in case of errors during write/rename
tempFilePath := tempFile.Name()
removed := false
defer func() {
if !removed {
os.Remove(tempFilePath) // Clean up temp file if rename fails or we panic
os.Remove(tempFilePath)
}
}()
// Write data to the temporary file
if _, err := tempFile.Write(tomlData); err != nil {
tempFile.Close() // Close file before returning error
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 *before* renaming (safer)
// Use 0644: owner rw, group r, other r
// 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 with the temporary file
// 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 // Mark temp file as successfully renamed
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()
// Use the same atomic save logic
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)
}
// ... (rest of atomic save logic same as Save method)
return atomicWriteFile(path, buf.Bytes())
}
// atomicWriteFile performs atomic file write
func atomicWriteFile(path string, data []byte) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory '%s': %w", dir, err)
}
tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
tempPath := tempFile.Name()
defer os.Remove(tempPath) // Clean up on any error
if _, err := tempFile.Write(data); err != nil {
tempFile.Close()
return fmt.Errorf("failed to write temporary file: %w", err)
}
if err := tempFile.Sync(); err != nil {
tempFile.Close()
return fmt.Errorf("failed to sync temporary file: %w", err)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("failed to close temporary file: %w", err)
}
if err := os.Chmod(tempPath, 0644); err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
}
if err := os.Rename(tempPath, path); err != nil {
return fmt.Errorf("failed to rename temporary file: %w", err)
}
return nil
}
// parseArgs processes command-line arguments into a nested map structure.
// Expects arguments in the format:
//
// --key.subkey=value
// --key.subkey value
// --booleanflag (implicitly true)
// --booleanflag=true
// --booleanflag=false
//
// Values are parsed into bool, int64, float64, or string.
// Returns an error if a key segment is invalid.
func parseArgs(args []string) (map[string]any, error) {
result := make(map[string]any)
i := 0
@ -196,7 +453,7 @@ func parseArgs(args []string) (map[string]any, error) {
} else {
// Handle "--key value" or "--booleanflag"
keyPath = argContent
// Check if it's potentially a boolean flag (next arg starts with -- or end of args)
// Check if it's potentially a boolean flag
isBoolFlag := i+1 >= len(args) || strings.HasPrefix(args[i+1], "--")
if isBoolFlag {
@ -210,33 +467,16 @@ func parseArgs(args []string) (map[string]any, error) {
}
}
// Validate keyPath segments *after* extracting the key
// Validate keyPath segments
segments := strings.Split(keyPath, ".")
for _, segment := range segments {
if !isValidKeySegment(segment) {
// Return a specific error indicating the problem
return nil, fmt.Errorf("invalid command-line key segment %q in path %q", segment, keyPath)
}
}
// Attempt to parse the value string into richer types
var value any
if v, err := strconv.ParseBool(valueStr); err == nil {
value = v
} else if v, err := strconv.ParseInt(valueStr, 10, 64); err == nil {
value = v
} else if v, err := strconv.ParseFloat(valueStr, 64); err == nil {
value = v
} else {
// Keep as string if no other parsing succeeded
// Remove surrounding quotes if present
if len(valueStr) >= 2 && valueStr[0] == '"' && valueStr[len(valueStr)-1] == '"' {
value = valueStr[1 : len(valueStr)-1]
} else {
value = valueStr
}
}
// Parse the value
value := parseValue(valueStr)
setNestedValue(result, keyPath, value)
}