e2.0.0 Changed dependency from tinytoml to burntsushi/toml, code divided into multple files, better cli arg handling.

This commit is contained in:
2025-04-26 02:15:18 -04:00
parent 62d947156a
commit b1e241149e
9 changed files with 1045 additions and 807 deletions

107
README.md
View File

@ -5,14 +5,12 @@ A simple, thread-safe configuration management package for Go applications that
## Features ## Features
- **Thread-Safe Operations:** Uses `sync.RWMutex` to protect concurrent access during all configuration operations. - **Thread-Safe Operations:** Uses `sync.RWMutex` to protect concurrent access during all configuration operations.
- **TOML Configuration:** Uses [tinytoml](https://github.com/LixenWraith/tinytoml) for loading and saving configuration files. - **TOML Configuration:** Uses [BurntSushi/toml](https://github.com/BurntSushi/toml) for loading and saving configuration files.
- **Command-Line Overrides:** Allows overriding configuration values using dot notation in CLI arguments (e.g., `--server.port 9090`). - **Command-Line Overrides:** Allows overriding configuration values using dot notation in CLI arguments (e.g., `--server.port 9090`).
- **Path-Based Access:** Register configuration paths with default values for direct, consistent access with clear error messages. - **Path-Based Access:** Register configuration paths with default values for direct, consistent access with clear error messages.
- **Struct Registration:** Register an entire struct as configuration defaults, using struct tags to determine paths. - **Struct Registration:** Register an entire struct as configuration defaults, using struct tags to determine paths.
- **Atomic File Operations:** Ensures configuration files are written atomically to prevent corruption. - **Atomic File Operations:** Ensures configuration files are written atomically to prevent corruption.
- **Path Validation:** Validates configuration path segments against TOML key requirements. - **Path Validation:** Validates configuration path segments against TOML key requirements.
- **Minimal Dependencies:** Relies only on `tinytoml` and `mitchellh/mapstructure`.
- **Struct Unmarshaling:** Supports decoding configuration subtrees into Go structs with the `UnmarshalSubtree` method.
- **Type Conversions:** Helper methods for converting configuration values to common Go types with detailed error messages. - **Type Conversions:** Helper methods for converting configuration values to common Go types with detailed error messages.
- **Hierarchical Data Management:** Automatically handles nested structures through dot notation. - **Hierarchical Data Management:** Automatically handles nested structures through dot notation.
@ -23,8 +21,8 @@ go get github.com/LixenWraith/config
``` ```
Dependencies will be automatically fetched: Dependencies will be automatically fetched:
```bash ```
github.com/LixenWraith/tinytoml github.com/BurntSushi/toml
github.com/mitchellh/mapstructure github.com/mitchellh/mapstructure
``` ```
@ -37,11 +35,18 @@ github.com/mitchellh/mapstructure
cfg := config.New() cfg := config.New()
// 2. Register configuration paths with default values // 2. Register configuration paths with default values
err := cfg.Register("server.host", "127.0.0.1") cfg.Register("server.host", "127.0.0.1")
err = cfg.Register("server.port", 8080) cfg.Register("server.port", 8080)
// 3. Load configuration from file with CLI argument overrides // 3. Load configuration from file with CLI argument overrides
fileExists, err := cfg.Load("app_config.toml", os.Args[1:]) err := cfg.Load("app_config.toml", os.Args[1:])
if err != nil {
if errors.Is(err, config.ErrConfigNotFound) {
log.Println("Config file not found, using defaults")
} else {
log.Fatal(err)
}
}
// 4. Access configuration values using the registered paths // 4. Access configuration values using the registered paths
serverHost, err := cfg.String("server.host") serverHost, err := cfg.String("server.host")
@ -84,31 +89,30 @@ err := cfg.RegisterStruct("server.", defaults)
### Accessing Typed Values ### Accessing Typed Values
```go ```go
// Register configuration paths
cfg.Register("server.port", 8080)
cfg.Register("debug", false)
cfg.Register("rate.limit", 1.5)
cfg.Register("server.name", "default-server")
// Use type-specific accessor methods // Use type-specific accessor methods
port, err := cfg.Int64("server.port") port, err := cfg.Int64("server.port")
if err != nil {
log.Fatalf("Error getting port: %v", err)
}
debug, err := cfg.Bool("debug") debug, err := cfg.Bool("debug")
if err != nil {
log.Fatalf("Error getting debug flag: %v", err)
}
rate, err := cfg.Float64("rate.limit") rate, err := cfg.Float64("rate.limit")
if err != nil { name, err := cfg.String("server.name")
log.Fatalf("Error getting rate limit: %v", err) ```
### Using Scan to Populate Structs
```go
// Define a struct matching your configuration
type AppConfig struct {
ServerName string `toml:"name"`
ServerPort int64 `toml:"port"`
Debug bool `toml:"debug"`
} }
name, err := cfg.String("server.name") // Create an instance to receive the configuration
var appConfig AppConfig
// Scan the configuration into the struct
err := cfg.Scan("server", &appConfig)
if err != nil { if err != nil {
log.Fatalf("Error getting server name: %v", err) log.Fatal(err)
} }
``` ```
@ -182,7 +186,7 @@ Removes a configuration path and all its children from the configuration.
- Completely removes both registration and data - Completely removes both registration and data
- **Returns**: Error if the path wasn't registered. - **Returns**: Error if the path wasn't registered.
### `(*Config) UnmarshalSubtree(basePath string, target any) error` ### `(*Config) Scan(basePath string, target any) error`
Decodes a section of the configuration into a struct or map. Decodes a section of the configuration into a struct or map.
@ -190,13 +194,15 @@ Decodes a section of the configuration into a struct or map.
- **target**: Pointer to a struct or map where the configuration should be unmarshaled. - **target**: Pointer to a struct or map where the configuration should be unmarshaled.
- **Returns**: Error if unmarshaling fails. - **Returns**: Error if unmarshaling fails.
### `(*Config) Load(filePath string, args []string) (bool, error)` ### `(*Config) Load(filePath string, args []string) error`
Loads configuration from a TOML file and merges overrides from command-line arguments. Loads configuration from a TOML file and merges overrides from command-line arguments.
- **filePath**: Path to the TOML configuration file. - **filePath**: Path to the TOML configuration file.
- **args**: Command-line arguments (e.g., `os.Args[1:]`). - **args**: Command-line arguments (e.g., `os.Args[1:]`).
- **Returns**: Boolean indicating if the file existed and a nil error on success. - **Returns**: Error on failure, which can be checked with:
- `errors.Is(err, config.ErrConfigNotFound)` to detect missing file
- `errors.Is(err, config.ErrCLIParse)` to detect CLI parsing errors
### `(*Config) Save(filePath string) error` ### `(*Config) Save(filePath string) error`
@ -205,49 +211,6 @@ Saves the current configuration to the specified TOML file path, performing an a
- **filePath**: Path where the TOML configuration file will be written. - **filePath**: Path where the TOML configuration file will be written.
- **Returns**: Error if marshaling or file operations fail, nil on success. - **Returns**: Error if marshaling or file operations fail, nil on success.
## Implementation Details
### Key Design Choices
- **Thread Safety**: All operations are protected by a `sync.RWMutex` to support concurrent access.
- **Unified Storage Model**: Uses a `configItem` struct to store both default values and current values for each path.
- **Path-Based Access**: Using path strings directly as configuration keys provides a simple, intuitive API while maintaining the path as the persistent identifier in the config file.
- **Hierarchical Management**: Automatically handles conversion between flat storage and nested TOML structure.
- **Path Validation**: Configuration paths are validated to ensure they contain only valid TOML key segments.
- **Atomic Saving**: Configuration is written to a temporary file first, then atomically renamed.
- **CLI Argument Types**: Command-line values are automatically parsed into bool, int64, float64, or string.
- **Struct Unmarshaling**: The `UnmarshalSubtree` method uses `mapstructure` to decode configuration subtrees into Go structs.
### Naming Conventions
- **Paths**: Configuration paths provided to `Register` (e.g., `"server.port"`) are dot-separated strings.
- **Segments**: Each part of the path between dots (a "segment") must adhere to TOML key naming rules:
- Must start with a letter (a-z, A-Z) or an underscore (`_`).
- Subsequent characters can be letters, numbers (0-9), underscores (`_`), or hyphens (`-`).
- Segments *cannot* contain dots (`.`).
### Type Handling Note
- Values loaded from TOML files or parsed from CLI arguments often result in specific types (e.g., `int64` for integers, `float64` for floats) due to the underlying `tinytoml` and `strconv` packages.
- This might differ from the type of a default value provided during `Register` (e.g., default `int(8080)` vs. loaded `int64(8080)`).
- When retrieving values using `Get`, be mindful of this potential difference and use appropriate type assertions or checks. Consider using `int64` or `float64` for default values where applicable to maintain consistency.
- Alternatively, use the type-specific accessors (`Int64`, `Bool`, `Float64`, `String`) which attempt to convert values to the desired type and provide detailed error messages if conversion fails.
### Merge Behavior Note
- The internal merging logic (used during `Load`) performs a deep merge for nested maps.
- However, non-map types like slices are assigned by reference during the merge. If the source map containing the slice is modified after merging, the change might be reflected in the config data. This is generally not an issue with the standard Load workflow but should be noted if using merge logic independently.
### Limitations
- Supports only basic Go types and structures compatible with the tinytoml package.
- CLI arguments must use `--key value` or `--booleanflag` format.
- Path segments must start with letter/underscore, followed by letters/numbers/dashes/underscores.
## Examples
Complete example programs demonstrating the config package are available in the `cmd` directory.
## License ## License
BSD-3-Clause BSD-3-Clause

View File

@ -2,10 +2,14 @@
package main package main
import ( import (
"errors" // Import errors package
"fmt" "fmt"
"github.com/LixenWraith/config" "log" // Using standard log for simplicity
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/LixenWraith/config" // Assuming this is the correct import path after potential renaming/moving
) )
// LogConfig represents logging configuration parameters // LogConfig represents logging configuration parameters
@ -36,220 +40,271 @@ type LogConfig struct {
MaxCheckIntervalMs int64 `toml:"max_check_interval_ms"` // Maximum adaptive interval MaxCheckIntervalMs int64 `toml:"max_check_interval_ms"` // Maximum adaptive interval
} }
// Define default configuration values
var defaultLogConfig = LogConfig{
// Basic settings
Level: 1,
Name: "default_logger",
Directory: "./logs",
Format: "txt",
Extension: ".log",
// Formatting
ShowTimestamp: true,
ShowLevel: true,
// Buffer and size limits
BufferSize: 1000,
MaxSizeMB: 10,
MaxTotalSizeMB: 100,
MinDiskFreeMB: 500,
// Timers
FlushIntervalMs: 1000,
TraceDepth: 3,
RetentionPeriodHrs: 24.0,
RetentionCheckMins: 15.0,
// Disk check settings
DiskCheckIntervalMs: 60000,
EnableAdaptiveInterval: false,
MinCheckIntervalMs: 5000,
MaxCheckIntervalMs: 300000,
}
func main() { func main() {
// Create a temporary file path for our test // Create a temporary file path for our test
tempDir := os.TempDir() tempDir := os.TempDir()
configPath := filepath.Join(tempDir, "logconfig_test.toml") configPath := filepath.Join(tempDir, "logconfig_test_enhanced.toml")
// Clean up any existing file from previous runs // Clean up any existing file from previous runs
os.Remove(configPath) os.Remove(configPath)
defer os.Remove(configPath) // Ensure cleanup even on error exit
fmt.Println("=== LogConfig Test Program ===") fmt.Println("=== Enhanced LogConfig Test Program ===")
fmt.Printf("Using temporary config file: %s\n\n", configPath) fmt.Printf("Using temporary config file: %s\n\n", configPath)
// Initialize the Config instance // 1. Initialize the Config instance
cfg := config.New() cfg := config.New()
// Register default values for all LogConfig fields // 2. Register default values using RegisterStruct
registerLogConfigDefaults(cfg) fmt.Println("Registering default values using RegisterStruct...")
err := cfg.RegisterStruct("log.", defaultLogConfig) // Note the "log." prefix
// Load the configuration (will use defaults since file doesn't exist yet)
exists, err := cfg.Load(configPath, nil)
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) log.Fatalf("FATAL: Error registering defaults: %v\n", err)
os.Exit(1)
} }
fmt.Printf("Config file exists: %v (expected: false)\n", exists) fmt.Println("Defaults registered.")
// Unmarshal into LogConfig struct // 3. Load configuration (file doesn't exist yet)
var logConfig LogConfig fmt.Println("\nAttempting initial load (expecting file not found)...")
err = cfg.UnmarshalSubtree("log", &logConfig) err = cfg.Load(configPath, nil) // No CLI args yet
if err != nil { if err != nil {
fmt.Printf("Error unmarshaling config: %v\n", err) // Check specifically for ErrConfigNotFound
os.Exit(1) if errors.Is(err, config.ErrConfigNotFound) {
fmt.Println("SUCCESS: Correctly detected config file not found.")
} else {
// Any other error during initial load is unexpected here
log.Fatalf("FATAL: Unexpected error loading initial config: %v\n", err)
}
} else {
log.Fatalf("FATAL: Expected an error (ErrConfigNotFound) during initial load, but got nil")
} }
// Print current values // 4. Unmarshal defaults into LogConfig struct
fmt.Println("\n=== Default Configuration Values ===") var currentConfig LogConfig
printLogConfig(logConfig) fmt.Println("\nUnmarshaling current config (should be defaults)...")
err = cfg.Scan("log", &currentConfig)
if err != nil {
log.Fatalf("FATAL: Error unmarshaling default config: %v\n", err)
}
// Modify some values // Print default values
fmt.Println("\n=== Modifying Configuration Values ===") fmt.Println("\n=== Current Configuration (Defaults) ===")
printLogConfig(currentConfig)
// 5. Modify some values using Set
fmt.Println("\n=== Modifying Configuration Values via Set ===")
fmt.Println("Changing:") fmt.Println("Changing:")
fmt.Println(" - level: 1 → 2") fmt.Println(" - log.name: default_logger → saved_logger")
fmt.Println(" - name: default_logger → modified_logger") fmt.Println(" - log.max_size_mb: 10 → 50")
fmt.Println(" - format: txt → json") fmt.Println(" - log.retention_period_hrs: 24.0 → 48.0") // Different from CLI override later
fmt.Println(" - max_size_mb: 10 → 50")
fmt.Println(" - retention_period_hrs: 24.0 → 72.0")
fmt.Println(" - enable_adaptive_interval: false → true")
cfg.Set("log.level", int64(2)) cfg.Set("log.name", "saved_logger") // This will be saved to file
cfg.Set("log.name", "modified_logger")
cfg.Set("log.format", "json")
cfg.Set("log.max_size_mb", int64(50)) cfg.Set("log.max_size_mb", int64(50))
cfg.Set("log.retention_period_hrs", 72.0) cfg.Set("log.retention_period_hrs", 48.0)
cfg.Set("log.enable_adaptive_interval", true)
// Save the configuration // 6. Save the configuration
fmt.Println("\nSaving configuration to file...")
err = cfg.Save(configPath) err = cfg.Save(configPath)
if err != nil { if err != nil {
fmt.Printf("Error saving config: %v\n", err) log.Fatalf("FATAL: Error saving config: %v\n", err)
os.Exit(1)
} }
fmt.Printf("\nSaved configuration to: %s\n", configPath) fmt.Printf("Saved configuration to: %s\n", configPath)
// Read the file to verify it contains the expected values // Optional: Read and print file contents
fileBytes, err := os.ReadFile(configPath) // fileBytes, _ := os.ReadFile(configPath)
// fmt.Println("\n=== Saved TOML File Contents ===")
// fmt.Println(string(fileBytes))
// 7. Define some command-line arguments for override testing
fmt.Println("\n=== Preparing Command-Line Overrides ===")
// Simulate os.Args[1:]
cliArgs := []string{
"--log.level", "3", // Override default 1
"--log.name", "cli_logger", // Override value set before save ("saved_logger")
"--log.show_timestamp=false", // Override default true
"--log.retention_period_hrs", "72.5", // Override value set before save (48.0)
"--other.value", "test", // An unregistered key (should be ignored by Load logic)
"--invalid-key", // Invalid key format (test error handling if desired)
}
fmt.Printf("Simulated CLI Args: %v\n", cliArgs)
// 8. Load again, now with file and CLI overrides
// Create a *new* config instance to simulate a fresh application start
// that loads existing file + CLI args over defaults.
fmt.Println("\nCreating NEW config instance and loading with file and CLI args...")
cfg2 := config.New()
fmt.Println("Registering defaults for new instance...")
err = cfg2.RegisterStruct("log.", defaultLogConfig)
if err != nil { if err != nil {
fmt.Printf("Error reading config file: %v\n", err) log.Fatalf("FATAL: Error registering defaults for cfg2: %v\n", err)
os.Exit(1)
} }
fmt.Println("\n=== Generated TOML File Contents ===") fmt.Println("Loading config with file and CLI...")
fmt.Println(string(fileBytes)) err = cfg2.Load(configPath, cliArgs)
// Load the config again to verify it can be read back correctly
exists, err = cfg.Load(configPath, nil)
if err != nil { if err != nil {
fmt.Printf("Error reloading config: %v\n", err) // Note: If "--invalid-key" is included above, Load should return ErrCLIParse.
os.Exit(1) // Handle or remove the invalid key for a successful load test.
// Example check:
if errors.Is(err, config.ErrCLIParse) {
fmt.Printf("INFO: Expected CLI parsing error detected: %v\n", err)
// Decide how to proceed - maybe exit or remove the offending arg and retry
// For this example, we'll filter the bad arg and try again
var validArgs []string
for _, arg := range cliArgs {
if !strings.HasPrefix(arg, "--invalid") {
validArgs = append(validArgs, arg)
} }
fmt.Printf("\nConfig file exists: %v (expected: true)\n", exists) }
fmt.Println("Retrying load with filtered CLI args...")
// Unmarshal into a new LogConfig to verify loaded values err = cfg2.Load(configPath, validArgs)
var loadedConfig LogConfig
err = cfg.UnmarshalSubtree("log", &loadedConfig)
if err != nil { if err != nil {
fmt.Printf("Error unmarshaling reloaded config: %v\n", err) log.Fatalf("FATAL: Error loading config even after filtering CLI args: %v\n", err)
os.Exit(1) }
} else {
log.Fatalf("FATAL: Unexpected error loading config with file and CLI: %v\n", err)
}
}
fmt.Println("Load successful.")
// 9. Unmarshal the final configuration state
var finalConfig LogConfig
fmt.Println("\nUnmarshaling final config state...")
err = cfg2.Scan("log", &finalConfig)
if err != nil {
log.Fatalf("FATAL: Error unmarshaling final config: %v\n", err)
} }
fmt.Println("\n=== Loaded Configuration Values ===") fmt.Println("\n=== Final Configuration (Defaults + File + CLI) ===")
printLogConfig(loadedConfig) printLogConfig(finalConfig)
// Verify specific values were changed correctly // 10. Verify final values (Defaults < File < CLI)
fmt.Println("\n=== Verification ===") fmt.Println("\n=== Final Verification ===")
verifyConfig(loadedConfig) verifyFinalConfig(finalConfig)
// 11. Demonstrate typed accessors on the final state
fmt.Println("\n=== Demonstrating Typed Accessors ===")
level, err := cfg2.Int64("log.level")
if err != nil {
fmt.Printf("ERROR getting log.level via Int64(): %v\n", err)
} else {
fmt.Printf("SUCCESS: cfg2.Int64(\"log.level\") = %d (matches expected CLI override)\n", level)
}
name, err := cfg2.String("log.name")
if err != nil {
fmt.Printf("ERROR getting log.name via String(): %v\n", err)
} else {
fmt.Printf("SUCCESS: cfg2.String(\"log.name\") = %q (matches expected CLI override)\n", name)
}
showTS, err := cfg2.Bool("log.show_timestamp")
if err != nil {
fmt.Printf("ERROR getting log.show_timestamp via Bool(): %v\n", err)
} else {
fmt.Printf("SUCCESS: cfg2.Bool(\"log.show_timestamp\") = %t (matches expected CLI override)\n", showTS)
}
// Try getting an unregistered value (should fail)
_, err = cfg2.String("other.value")
if err == nil {
fmt.Println("ERROR: Expected error when getting unregistered key 'other.value', but got nil")
} else {
fmt.Printf("SUCCESS: Correctly got error for unregistered key 'other.value': %v\n", err)
}
// Clean up
os.Remove(configPath)
fmt.Println("\nCleanup: Temporary file removed.")
fmt.Println("\n=== Test Complete ===") fmt.Println("\n=== Test Complete ===")
} }
// registerLogConfigDefaults registers all default values for the LogConfig struct
func registerLogConfigDefaults(cfg *config.Config) {
fmt.Println("Registering default values...")
// Basic settings
cfg.Register("log.level", int64(1))
cfg.Register("log.name", "default_logger")
cfg.Register("log.directory", "./logs")
cfg.Register("log.format", "txt")
cfg.Register("log.extension", ".log")
// Formatting
cfg.Register("log.show_timestamp", true)
cfg.Register("log.show_level", true)
// Buffer and size limits
cfg.Register("log.buffer_size", int64(1000))
cfg.Register("log.max_size_mb", int64(10))
cfg.Register("log.max_total_size_mb", int64(100))
cfg.Register("log.min_disk_free_mb", int64(500))
// Timers
cfg.Register("log.flush_interval_ms", int64(1000))
cfg.Register("log.trace_depth", int64(3))
cfg.Register("log.retention_period_hrs", 24.0)
cfg.Register("log.retention_check_mins", 15.0)
// Disk check settings
cfg.Register("log.disk_check_interval_ms", int64(60000))
cfg.Register("log.enable_adaptive_interval", false)
cfg.Register("log.min_check_interval_ms", int64(5000))
cfg.Register("log.max_check_interval_ms", int64(300000))
}
// printLogConfig prints the values of a LogConfig struct // printLogConfig prints the values of a LogConfig struct
func printLogConfig(cfg LogConfig) { func printLogConfig(cfg LogConfig) {
fmt.Println("Basic settings:") fmt.Println(" Basic:")
fmt.Printf(" - Level: %d\n", cfg.Level) fmt.Printf(" Level: %d, Name: %s, Dir: %s, Format: %s, Ext: %s\n",
fmt.Printf(" - Name: %s\n", cfg.Name) cfg.Level, cfg.Name, cfg.Directory, cfg.Format, cfg.Extension)
fmt.Printf(" - Directory: %s\n", cfg.Directory) fmt.Println(" Formatting:")
fmt.Printf(" - Format: %s\n", cfg.Format) fmt.Printf(" ShowTimestamp: %t, ShowLevel: %t\n", cfg.ShowTimestamp, cfg.ShowLevel)
fmt.Printf(" - Extension: %s\n", cfg.Extension) fmt.Println(" Limits:")
fmt.Printf(" BufferSize: %d, MaxSizeMB: %d, MaxTotalSizeMB: %d, MinDiskFreeMB: %d\n",
fmt.Println("Formatting:") cfg.BufferSize, cfg.MaxSizeMB, cfg.MaxTotalSizeMB, cfg.MinDiskFreeMB)
fmt.Printf(" - ShowTimestamp: %t\n", cfg.ShowTimestamp) fmt.Println(" Timers:")
fmt.Printf(" - ShowLevel: %t\n", cfg.ShowLevel) fmt.Printf(" FlushIntervalMs: %d, TraceDepth: %d, RetentionPeriodHrs: %.1f, RetentionCheckMins: %.1f\n",
cfg.FlushIntervalMs, cfg.TraceDepth, cfg.RetentionPeriodHrs, cfg.RetentionCheckMins)
fmt.Println("Buffer and size limits:") fmt.Println(" Disk Check:")
fmt.Printf(" - BufferSize: %d\n", cfg.BufferSize) fmt.Printf(" DiskCheckIntervalMs: %d, EnableAdaptive: %t, MinCheckMs: %d, MaxCheckMs: %d\n",
fmt.Printf(" - MaxSizeMB: %d\n", cfg.MaxSizeMB) cfg.DiskCheckIntervalMs, cfg.EnableAdaptiveInterval, cfg.MinCheckIntervalMs, cfg.MaxCheckIntervalMs)
fmt.Printf(" - MaxTotalSizeMB: %d\n", cfg.MaxTotalSizeMB)
fmt.Printf(" - MinDiskFreeMB: %d\n", cfg.MinDiskFreeMB)
fmt.Println("Timers:")
fmt.Printf(" - FlushIntervalMs: %d\n", cfg.FlushIntervalMs)
fmt.Printf(" - TraceDepth: %d\n", cfg.TraceDepth)
fmt.Printf(" - RetentionPeriodHrs: %.1f\n", cfg.RetentionPeriodHrs)
fmt.Printf(" - RetentionCheckMins: %.1f\n", cfg.RetentionCheckMins)
fmt.Println("Disk check settings:")
fmt.Printf(" - DiskCheckIntervalMs: %d\n", cfg.DiskCheckIntervalMs)
fmt.Printf(" - EnableAdaptiveInterval: %t\n", cfg.EnableAdaptiveInterval)
fmt.Printf(" - MinCheckIntervalMs: %d\n", cfg.MinCheckIntervalMs)
fmt.Printf(" - MaxCheckIntervalMs: %d\n", cfg.MaxCheckIntervalMs)
} }
// verifyConfig checks if the modified values were set correctly // verifyFinalConfig checks if the final values reflect the merge order: Default < File < CLI
func verifyConfig(cfg LogConfig) { func verifyFinalConfig(cfg LogConfig) {
allCorrect := true allCorrect := true
fmt.Println("Verifying values reflect merge order (Default < File < CLI)...")
if cfg.Level != 2 { // Value overridden by CLI
fmt.Printf("ERROR: Level is %d, expected 2\n", cfg.Level) if cfg.Level != 3 {
allCorrect = false fmt.Printf(" ERROR: Level is %d, expected 3 (from CLI)\n", cfg.Level)
} allCorrect = false
}
if cfg.Name != "modified_logger" { // Value overridden by CLI (overriding file value)
fmt.Printf("ERROR: Name is %s, expected 'modified_logger'\n", cfg.Name) if cfg.Name != "cli_logger" {
allCorrect = false fmt.Printf(" ERROR: Name is %s, expected 'cli_logger' (from CLI)\n", cfg.Name)
} allCorrect = false
}
if cfg.Format != "json" { // Value overridden by CLI
fmt.Printf("ERROR: Format is %s, expected 'json'\n", cfg.Format) if cfg.ShowTimestamp != false {
fmt.Printf(" ERROR: ShowTimestamp is %t, expected false (from CLI)\n", cfg.ShowTimestamp)
allCorrect = false
}
// Value overridden by CLI (float)
if cfg.RetentionPeriodHrs != 72.5 {
fmt.Printf(" ERROR: RetentionPeriodHrs is %.1f, expected 72.5 (from CLI)\n", cfg.RetentionPeriodHrs)
allCorrect = false allCorrect = false
} }
// Value overridden by File (not present in CLI)
if cfg.MaxSizeMB != 50 { if cfg.MaxSizeMB != 50 {
fmt.Printf("ERROR: MaxSizeMB is %d, expected 50\n", cfg.MaxSizeMB) fmt.Printf(" ERROR: MaxSizeMB is %d, expected 50 (from File)\n", cfg.MaxSizeMB)
allCorrect = false
}
if cfg.RetentionPeriodHrs != 72.0 {
fmt.Printf("ERROR: RetentionPeriodHrs is %.1f, expected 72.0\n", cfg.RetentionPeriodHrs)
allCorrect = false
}
if !cfg.EnableAdaptiveInterval {
fmt.Printf("ERROR: EnableAdaptiveInterval is %t, expected true\n", cfg.EnableAdaptiveInterval)
allCorrect = false allCorrect = false
} }
// Value from Default (not in File or CLI)
if cfg.Directory != "./logs" { if cfg.Directory != "./logs" {
fmt.Printf("ERROR: Directory changed to %s, expected './logs'\n", cfg.Directory) fmt.Printf(" ERROR: Directory is %s, expected './logs' (from Default)\n", cfg.Directory)
allCorrect = false allCorrect = false
} }
if cfg.BufferSize != 1000 { if cfg.BufferSize != 1000 {
fmt.Printf("ERROR: BufferSize changed to %d, expected 1000\n", cfg.BufferSize) fmt.Printf(" ERROR: BufferSize is %d, expected 1000 (from Default)\n", cfg.BufferSize)
allCorrect = false allCorrect = false
} }
if allCorrect { if allCorrect {
fmt.Println("SUCCESS: All configuration values match expected values!") fmt.Println(" SUCCESS: All verified configuration values match expected final state!")
} else { } else {
fmt.Println("FAILURE: Some configuration values don't match expected values!") fmt.Println(" FAILURE: Some configuration values don't match expected final state!")
} }
} }

587
config.go
View File

@ -3,18 +3,17 @@
package config package config
import ( import (
"errors" // Import errors package
"fmt" "fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"sync" "sync"
"github.com/LixenWraith/tinytoml"
"github.com/mitchellh/mapstructure"
) )
// ErrConfigNotFound indicates the specified configuration file was not found.
var ErrConfigNotFound = errors.New("configuration file not found")
// ErrCLIParse indicates that parsing command-line arguments failed.
var ErrCLIParse = errors.New("failed to parse command-line arguments")
// configItem holds both the default and current value for a configuration path // configItem holds both the default and current value for a configuration path
type configItem struct { type configItem struct {
defaultValue any defaultValue any
@ -23,8 +22,8 @@ type configItem struct {
// Config manages application configuration loaded from files and CLI arguments. // Config manages application configuration loaded from files and CLI arguments.
type Config struct { type Config struct {
items map[string]configItem // Maps paths to config items (default and current values) items map[string]configItem
mutex sync.RWMutex // Protects concurrent access mutex sync.RWMutex
} }
// New creates and initializes a new Config instance. // New creates and initializes a new Config instance.
@ -34,145 +33,6 @@ func New() *Config {
} }
} }
// Register makes a configuration path known to the Config instance.
// The path should be dot-separated (e.g., "server.port", "debug").
// Each segment of the path must be a valid TOML key identifier.
// defaultValue is the value returned by Get if no specific value has been set.
func (c *Config) Register(path string, defaultValue any) error {
if path == "" {
return fmt.Errorf("registration path cannot be empty")
}
// Validate path segments
segments := strings.Split(path, ".")
for _, segment := range segments {
if !isValidKeySegment(segment) {
return fmt.Errorf("invalid path segment %q in path %q", segment, path)
}
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.items[path] = configItem{
defaultValue: defaultValue,
currentValue: defaultValue, // Initially set to default
}
return nil
}
// Unregister removes a configuration path and all its children.
func (c *Config) Unregister(path string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
if _, exists := c.items[path]; !exists {
return fmt.Errorf("path not registered: %s", path)
}
// Remove the path itself
delete(c.items, path)
// Remove any child paths
prefix := path + "."
for childPath := range c.items {
if strings.HasPrefix(childPath, prefix) {
delete(c.items, childPath)
}
}
return nil
}
// RegisterStruct registers configuration values derived from a struct.
// It uses struct tags to determine the configuration paths.
// The prefix is prepended to all paths (e.g., "log.").
func (c *Config) RegisterStruct(prefix string, structWithDefaults interface{}) error {
v := reflect.ValueOf(structWithDefaults)
// Handle pointer or direct struct value
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return fmt.Errorf("RegisterStruct requires a struct, got %T", structWithDefaults)
}
var errors []string
// Use a helper function for recursive registration
registerFields(c, v, prefix, "", &errors)
if len(errors) > 0 {
return fmt.Errorf("failed to register %d field(s): %s", len(errors), strings.Join(errors, "; "))
}
return nil
}
// Helper function that handles the recursive field registration
func registerFields(c *Config, v reflect.Value, pathPrefix, fieldPath string, errors *[]string) {
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
fieldValue := v.Field(i)
// Skip unexported fields
if !field.IsExported() {
continue
}
// Get tag value or use field name
tag := field.Tag.Get("toml")
if tag == "-" {
continue // Skip this field
}
// Extract tag name or use field name
key := field.Name
if tag != "" {
parts := strings.Split(tag, ",")
if parts[0] != "" {
key = parts[0]
}
}
// Build full path
path := pathPrefix + key
// Handle nested structs recursively
if fieldValue.Kind() == reflect.Struct {
// For nested structs, append a dot and continue recursion
nestedPrefix := path + "."
registerFields(c, fieldValue, nestedPrefix, fieldPath+field.Name+".", errors)
continue
}
// Register non-struct fields
if err := c.Register(path, fieldValue.Interface()); err != nil {
*errors = append(*errors, fmt.Sprintf("field %s%s: %v", fieldPath, field.Name, err))
}
}
}
// GetRegisteredPaths returns all registered configuration paths with the specified prefix.
func (c *Config) GetRegisteredPaths(prefix string) map[string]bool {
c.mutex.RLock()
defer c.mutex.RUnlock()
result := make(map[string]bool)
for path := range c.items {
if strings.HasPrefix(path, prefix) {
result[path] = true
}
}
return result
}
// Get retrieves a configuration value using the path. // Get retrieves a configuration value using the path.
// It returns the current value (or default if not explicitly set). // It returns the current value (or default if not explicitly set).
// The second return value indicates if the path was registered. // The second return value indicates if the path was registered.
@ -190,6 +50,8 @@ func (c *Config) Get(path string) (any, bool) {
// Set updates a configuration value for the given path. // Set updates a configuration value for the given path.
// It returns an error if the path is not registered. // It returns an error if the path is not registered.
// Note: This allows setting a value of a different type than the default.
// Type-specific getters will handle conversion attempts.
func (c *Config) Set(path string, value any) error { func (c *Config) Set(path string, value any) error {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
@ -203,430 +65,3 @@ func (c *Config) Set(path string, value any) error {
c.items[path] = item c.items[path] = item
return nil return nil
} }
// String retrieves a string configuration value using the path.
func (c *Config) String(path string) (string, error) {
val, found := c.Get(path)
if !found {
return "", fmt.Errorf("path not registered: %s", path)
}
if strVal, ok := val.(string); ok {
return strVal, nil
}
// Try to convert other types to string
switch v := val.(type) {
case fmt.Stringer:
return v.String(), nil
case error:
return v.Error(), nil
default:
return fmt.Sprintf("%v", val), nil
}
}
// Int64 retrieves an int64 configuration value using the path.
func (c *Config) Int64(path string) (int64, error) {
val, found := c.Get(path)
if !found {
return 0, fmt.Errorf("path not registered: %s", path)
}
// Type assertion
if intVal, ok := val.(int64); ok {
return intVal, nil
}
// Try to convert other numeric types
switch v := val.(type) {
case int:
return int64(v), nil
case float64:
return int64(v), nil
case string:
if i, err := strconv.ParseInt(v, 10, 64); err == nil {
return i, nil
} else {
return 0, fmt.Errorf("cannot convert string '%s' to int64: %w", v, err)
}
}
return 0, fmt.Errorf("cannot convert %T to int64", val)
}
// Bool retrieves a boolean configuration value using the path.
func (c *Config) Bool(path string) (bool, error) {
val, found := c.Get(path)
if !found {
return false, fmt.Errorf("path not registered: %s", path)
}
// Type assertion
if boolVal, ok := val.(bool); ok {
return boolVal, nil
}
// Try to convert string to bool
if strVal, ok := val.(string); ok {
if b, err := strconv.ParseBool(strVal); err == nil {
return b, nil
} else {
return false, fmt.Errorf("cannot convert string '%s' to bool: %w", strVal, err)
}
}
// Try to interpret numbers
switch v := val.(type) {
case int:
return v != 0, nil
case int64:
return v != 0, nil
case float64:
return v != 0, nil
}
return false, fmt.Errorf("cannot convert %T to bool", val)
}
// Float64 retrieves a float64 configuration value using the path.
func (c *Config) Float64(path string) (float64, error) {
val, found := c.Get(path)
if !found {
return 0.0, fmt.Errorf("path not registered: %s", path)
}
// Type assertion
if floatVal, ok := val.(float64); ok {
return floatVal, nil
}
// Try to convert other numeric types
switch v := val.(type) {
case int:
return float64(v), nil
case int64:
return float64(v), nil
case string:
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f, nil
} else {
return 0.0, fmt.Errorf("cannot convert string '%s' to float64: %w", v, err)
}
}
return 0.0, fmt.Errorf("cannot convert %T to float64", val)
}
// 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:]).
// Returns true if the configuration file was found and loaded, false otherwise.
func (c *Config) Load(path string, args []string) (bool, error) {
c.mutex.Lock()
defer c.mutex.Unlock()
configExists := false
// First, build a nested map for file data (if it exists)
nestedData := make(map[string]any)
if stat, err := os.Stat(path); err == nil && !stat.IsDir() {
configExists = true
fileData, err := os.ReadFile(path)
if err != nil {
return false, fmt.Errorf("failed to read config file '%s': %w", path, err)
}
if err := tinytoml.Unmarshal(fileData, &nestedData); err != nil {
return false, fmt.Errorf("failed to parse TOML config file '%s': %w", path, err)
}
} else if !os.IsNotExist(err) {
return false, fmt.Errorf("failed to check config file '%s': %w", path, err)
}
// Flatten the nested map into path->value pairs
flattenedData := flattenMap(nestedData, "")
// Parse CLI arguments if any
if len(args) > 0 {
cliOverrides, err := parseArgs(args)
if err != nil {
return configExists, fmt.Errorf("failed to parse CLI args: %w", err)
}
// Merge CLI overrides into flattened data (CLI takes precedence)
for path, value := range flattenMap(cliOverrides, "") {
flattenedData[path] = value
}
}
// Update configItems with loaded values
for path, value := range flattenedData {
if item, registered := c.items[path]; registered {
// Update existing item
item.currentValue = value
c.items[path] = item
} else {
// Create new item with default = current = loaded value
c.items[path] = configItem{
defaultValue: value,
currentValue: value,
}
}
}
return configExists, nil
}
// Save writes the current configuration to a TOML file.
// It performs an atomic write using a temporary file.
func (c *Config) Save(path string) error {
c.mutex.RLock()
// Build a nested map from our flat structure
nestedData := make(map[string]any)
for path, item := range c.items {
setNestedValue(nestedData, path, item.currentValue)
}
c.mutex.RUnlock() // Release lock before I/O operations
tomlData, err := tinytoml.Marshal(nestedData)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Atomic write logic
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory '%s': %w", dir, err)
}
tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("failed to create temporary config file: %w", err)
}
defer os.Remove(tempFile.Name()) // Clean up temp file if rename fails
if _, err := tempFile.Write(tomlData); err != nil {
tempFile.Close()
return fmt.Errorf("failed to write temp config file '%s': %w", tempFile.Name(), err)
}
if err := tempFile.Sync(); err != nil {
tempFile.Close()
return fmt.Errorf("failed to sync temp config file '%s': %w", tempFile.Name(), err)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("failed to close temp config file '%s': %w", tempFile.Name(), err)
}
if err := os.Rename(tempFile.Name(), path); err != nil {
return fmt.Errorf("failed to rename temp file to '%s': %w", path, err)
}
if err := os.Chmod(path, 0644); err != nil {
return fmt.Errorf("failed to set permissions on config file '%s': %w", path, err)
}
return nil
}
// UnmarshalSubtree decodes the configuration data under a specific base path into the target struct or map.
func (c *Config) UnmarshalSubtree(basePath string, target any) error {
c.mutex.RLock()
defer c.mutex.RUnlock()
// Build the nested map from our flat structure
fullNestedMap := make(map[string]any)
for path, item := range c.items {
setNestedValue(fullNestedMap, path, item.currentValue)
}
var subtreeData any
if basePath == "" {
// Use the entire data structure
subtreeData = fullNestedMap
} else {
// Navigate to the specific subtree
segments := strings.Split(basePath, ".")
current := any(fullNestedMap)
for _, segment := range segments {
currentMap, ok := current.(map[string]any)
if !ok {
// Path segment is not a map
return fmt.Errorf("configuration path segment %q is not a table (map)", segment)
}
value, exists := currentMap[segment]
if !exists {
// If the path doesn't exist, return an empty map
subtreeData = make(map[string]any)
break
}
current = value
}
if subtreeData == nil {
subtreeData = current
}
}
// Ensure we have a map for decoding
subtreeMap, ok := subtreeData.(map[string]any)
if !ok {
return fmt.Errorf("configuration path %q does not refer to a table (map)", basePath)
}
// Use mapstructure to decode
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Result: target,
TagName: "toml",
WeaklyTypedInput: true,
DecodeHook: mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
),
})
if err != nil {
return fmt.Errorf("failed to create mapstructure decoder: %w", err)
}
err = decoder.Decode(subtreeMap)
if err != nil {
return fmt.Errorf("failed to decode subtree %q: %w", basePath, err)
}
return nil
}
// parseArgs processes command-line arguments into a nested map structure.
// Expects arguments in the format "--key.subkey value" or "--booleanflag".
func parseArgs(args []string) (map[string]any, error) {
result := make(map[string]any)
i := 0
for i < len(args) {
arg := args[i]
if !strings.HasPrefix(arg, "--") {
i++ // Skip non-flag arguments
continue
}
keyPath := strings.TrimPrefix(arg, "--")
if keyPath == "" {
i++ // Skip "--" argument
continue
}
var valueStr string
// Check if it's a boolean flag (next arg starts with -- or end of args)
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") {
valueStr = "true" // Assume boolean flag if no value provided
i++ // Consume only the flag
} else {
valueStr = args[i+1]
i += 2 // Consume flag and value
}
// Try to parse the value into bool, int, float, otherwise keep as string
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 // Store as int64
} else if v, err := strconv.ParseFloat(valueStr, 64); err == nil {
value = v
} else {
value = valueStr // Keep as string if parsing fails
}
// Set the value in the result map
setNestedValue(result, keyPath, value)
}
return result, nil
}
// flattenMap converts a nested map to a flat map with dot-notation paths.
func flattenMap(nested map[string]any, prefix string) map[string]any {
flat := make(map[string]any)
for key, value := range nested {
path := key
if prefix != "" {
path = prefix + "." + key
}
if nestedMap, isMap := value.(map[string]any); isMap {
// Recursively flatten nested maps
for subPath, subValue := range flattenMap(nestedMap, path) {
flat[subPath] = subValue
}
} else {
// Add leaf value
flat[path] = value
}
}
return flat
}
// setNestedValue sets a value in a nested map using a dot-notation path.
func setNestedValue(nested map[string]any, path string, value any) {
segments := strings.Split(path, ".")
if len(segments) == 1 {
// Base case: set the value directly
nested[segments[0]] = value
return
}
// Ensure parent map exists
if _, exists := nested[segments[0]]; !exists {
nested[segments[0]] = make(map[string]any)
}
// Ensure the existing value is a map, or replace it
current := nested[segments[0]]
currentMap, isMap := current.(map[string]any)
if !isMap {
currentMap = make(map[string]any)
nested[segments[0]] = currentMap
}
// Recurse with remaining path
setNestedValue(currentMap, strings.Join(segments[1:], "."), value)
}
// isValidKeySegment checks if a single path segment is valid.
func isValidKeySegment(s string) bool {
if len(s) == 0 {
return false
}
firstChar := rune(s[0])
// Using simplified check: must not contain dots and must be valid TOML key part
if strings.ContainsRune(s, '.') {
return false // Segments themselves cannot contain dots
}
if !isAlpha(firstChar) && firstChar != '_' {
return false
}
for _, r := range s[1:] {
if !isAlpha(r) && !isNumeric(r) && r != '-' && r != '_' {
return false
}
}
return true
}
// isAlpha checks if a character is a letter (A-Z, a-z)
func isAlpha(c rune) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
// isNumeric checks if a character is a digit (0-9)
func isNumeric(c rune) bool {
return c >= '0' && c <= '9'
}

7
go.mod
View File

@ -2,6 +2,7 @@ module github.com/LixenWraith/config
go 1.24.2 go 1.24.2
require github.com/LixenWraith/tinytoml v0.0.0-20250305012228-6862ba843264 require (
github.com/BurntSushi/toml v1.5.0
require github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
)

4
go.sum
View File

@ -1,4 +1,4 @@
github.com/LixenWraith/tinytoml v0.0.0-20250305012228-6862ba843264 h1:p2hpE672qTRuhR9FAt7SIHp8aP0pJbBKushCiIRNRpo= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/LixenWraith/tinytoml v0.0.0-20250305012228-6862ba843264/go.mod h1:pm+BQlZ/VQC30uaB5Vfeih2b77QkGIiMvu+QgG/XOTk= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=

101
helpers.go Normal file
View File

@ -0,0 +1,101 @@
package config
import (
"strings"
)
// flattenMap converts a nested map[string]any to a flat map[string]any with dot-notation paths.
func flattenMap(nested map[string]any, prefix string) map[string]any {
flat := make(map[string]any)
for key, value := range nested {
newPath := key
if prefix != "" {
newPath = prefix + "." + key
}
// Check if the value is a map that can be further flattened
if nestedMap, isMap := value.(map[string]any); isMap {
// Recursively flatten the nested map
flattenedSubMap := flattenMap(nestedMap, newPath)
// Merge the flattened sub-map into the main flat map
for subPath, subValue := range flattenedSubMap {
flat[subPath] = subValue
}
} else {
// If it's not a map, add the value directly to the flat map
flat[newPath] = value
}
}
return flat
}
// setNestedValue sets a value in a nested map using a dot-notation path.
// It creates intermediate maps if they don't exist.
// If a segment exists but is not a map, it will be overwritten by a new map.
func setNestedValue(nested map[string]any, path string, value any) {
segments := strings.Split(path, ".")
current := nested
// Iterate through segments up to the second-to-last one
for i := 0; i < len(segments)-1; i++ {
segment := segments[i]
// Check if the next level exists
next, exists := current[segment]
if !exists {
newMap := make(map[string]any)
current[segment] = newMap
current = newMap
} else {
// If the segment exists, check if it's already a map
if nextMap, isMap := next.(map[string]any); isMap {
current = nextMap
} else {
newMap := make(map[string]any)
current[segment] = newMap
current = newMap
}
}
}
lastSegment := segments[len(segments)-1]
current[lastSegment] = value
}
// isValidKeySegment checks if a single path segment is a valid TOML key part.
func isValidKeySegment(s string) bool {
if len(s) == 0 {
return false
}
// TOML bare keys are sequences of ASCII letters, ASCII digits, underscores, and dashes (A-Za-z0-9_-).
if strings.ContainsRune(s, '.') {
return false // Segments themselves cannot contain dots
}
for _, r := range s {
isLetter := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
isDigit := r >= '0' && r <= '9'
isUnderscore := r == '_'
isDash := r == '-'
if !(isLetter || isDigit || isUnderscore || isDash) {
return false
}
}
return true
}
// isAlpha checks if a character is a letter (A-Z, a-z)
// Note: not used, potential future use.
func isAlpha(c rune) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
}
// isNumeric checks if a character is a digit (0-9)
// Note: not used, potential future use.
func isNumeric(c rune) bool {
return c >= '0' && c <= '9'
}

244
io.go Normal file
View File

@ -0,0 +1,244 @@
package config
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/BurntSushi/toml"
)
// 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 {
c.mutex.Lock()
defer c.mutex.Unlock()
var errNotFound error
var errCLI error
fileConfig := make(map[string]any) // Holds only file data
// --- Load from file ---
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)
}
} else if err := toml.Unmarshal(fileData, &fileConfig); err != nil {
return fmt.Errorf("failed to parse TOML config file '%s': %w", path, err)
}
// --- Flatten 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, "")
}
}
// --- 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
}
}
// 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
}
return errors.Join(errNotFound, errCLI)
}
// 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)
// 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 ---
dir := filepath.Dir(path)
// Ensure the directory exists
if err := os.MkdirAll(dir, 0755); err != nil { // 0755 allows owner rwx, group rx, other rx
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)
}
// 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
}
}()
// Write data to the temporary file
if _, err := tempFile.Write(tomlData); err != nil {
tempFile.Close() // Close file before returning error
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
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
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
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
for i < len(args) {
arg := args[i]
if !strings.HasPrefix(arg, "--") {
// Skip non-flag arguments
i++
continue
}
// Remove the leading "--"
argContent := strings.TrimPrefix(arg, "--")
if argContent == "" {
// Skip "--" argument if used as a separator
i++
continue
}
var keyPath string
var valueStr string
// 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 potentially a boolean flag (next arg starts with -- or end of args)
isBoolFlag := i+1 >= len(args) || strings.HasPrefix(args[i+1], "--")
if isBoolFlag {
// Assume boolean flag is true if no value follows
valueStr = "true"
i++ // Consume only the flag argument
} else {
// Potential key-value pair with space separation
valueStr = args[i+1]
i += 2 // Consume flag and value arguments
}
}
// Validate keyPath segments *after* extracting the key
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
}
}
setNestedValue(result, keyPath, value)
}
return result, nil
}

278
register.go Normal file
View File

@ -0,0 +1,278 @@
package config
import (
"fmt"
"github.com/mitchellh/mapstructure"
"reflect"
"strings"
)
// Register makes a configuration path known to the Config instance.
// The path should be dot-separated (e.g., "server.port", "debug").
// Each segment of the path must be a valid TOML key identifier.
// defaultValue is the value returned by Get if no specific value has been set.
func (c *Config) Register(path string, defaultValue any) error {
if path == "" {
return fmt.Errorf("registration path cannot be empty")
}
// Validate path segments
segments := strings.Split(path, ".")
for _, segment := range segments {
if !isValidKeySegment(segment) {
return fmt.Errorf("invalid path segment %q in path %q", segment, path)
}
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.items[path] = configItem{
defaultValue: defaultValue,
currentValue: defaultValue, // Initially set to default
}
return nil
}
// Unregister removes a configuration path and all its children.
func (c *Config) Unregister(path string) error {
c.mutex.Lock()
defer c.mutex.Unlock()
// Check if the exact path exists before proceeding
if _, exists := c.items[path]; !exists {
// Check if it's a prefix for other registered paths
hasChildren := false
prefix := path + "."
for childPath := range c.items {
if strings.HasPrefix(childPath, prefix) {
hasChildren = true
break
}
}
// If neither the path nor any children exist, return error
if !hasChildren {
return fmt.Errorf("path not registered: %s", path)
}
}
// Remove the path itself if it exists
delete(c.items, path)
// Remove any child paths
prefix := path + "."
for childPath := range c.items {
if strings.HasPrefix(childPath, prefix) {
delete(c.items, childPath)
}
}
return nil
}
// RegisterStruct registers configuration values derived from a struct.
// It uses struct tags (`toml:"..."`) to determine the configuration paths.
// The prefix is prepended to all paths (e.g., "log."). An empty prefix is allowed.
func (c *Config) RegisterStruct(prefix string, structWithDefaults interface{}) error {
v := reflect.ValueOf(structWithDefaults)
// Handle pointer or direct struct value
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return fmt.Errorf("RegisterStruct requires a non-nil struct pointer or value")
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return fmt.Errorf("RegisterStruct requires a struct or struct pointer, got %T", structWithDefaults)
}
var errors []string
// Use a helper function for recursive registration
c.registerFields(v, prefix, "", &errors) // Pass receiver `c`
if len(errors) > 0 {
return fmt.Errorf("failed to register %d field(s): %s", len(errors), strings.Join(errors, "; "))
}
return nil
}
// registerFields is a helper function that handles the recursive field registration.
// It's now a method on *Config to simplify calling c.Register.
func (c *Config) registerFields(v reflect.Value, pathPrefix, fieldPath string, errors *[]string) {
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
fieldValue := v.Field(i)
if !field.IsExported() {
continue
}
// Get tag value or use field name
tag := field.Tag.Get("toml")
if tag == "-" {
continue // Skip this field
}
key := field.Name
if tag != "" {
parts := strings.Split(tag, ",")
if parts[0] != "" {
key = parts[0]
}
// Note: We are ignoring other tag options like 'omitempty' here,
// as RegisterStruct is about setting defaults.
}
// Build full path
currentPath := key
if pathPrefix != "" {
// Ensure trailing dot on prefix if needed
if !strings.HasSuffix(pathPrefix, ".") {
pathPrefix += "."
}
currentPath = pathPrefix + key
}
// Handle nested structs recursively
// Check for pointer to struct as well
fieldType := fieldValue.Type()
isStruct := fieldValue.Kind() == reflect.Struct
isPtrToStruct := fieldValue.Kind() == reflect.Ptr && fieldType.Elem().Kind() == reflect.Struct
if isStruct || isPtrToStruct {
// Dereference pointer if necessary
nestedValue := fieldValue
if isPtrToStruct {
if fieldValue.IsNil() {
// Skip nil pointers, as their paths aren't well-defined defaults.
continue
}
nestedValue = fieldValue.Elem()
}
// For nested structs, append a dot and continue recursion
nestedPrefix := currentPath + "."
c.registerFields(nestedValue, nestedPrefix, fieldPath+field.Name+".", errors) // Call recursively on `c`
continue
}
// Register non-struct fields
// Use fieldValue.Interface() to get the actual default value
if err := c.Register(currentPath, fieldValue.Interface()); err != nil {
*errors = append(*errors, fmt.Sprintf("field %s%s (path %s): %v", fieldPath, field.Name, currentPath, err))
}
}
}
// GetRegisteredPaths returns all registered configuration paths with the specified prefix.
func (c *Config) GetRegisteredPaths(prefix string) map[string]bool {
c.mutex.RLock()
defer c.mutex.RUnlock()
result := make(map[string]bool)
for path := range c.items {
if strings.HasPrefix(path, prefix) {
result[path] = true
}
}
return result
}
// Scan decodes the configuration data under a specific base path
// into the target struct or map. It operates on the current, merged configuration state.
// The target must be a non-nil pointer to a struct or map.
// It uses the "toml" struct tag for mapping fields.
func (c *Config) Scan(basePath string, target any) error {
// Validate target
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return fmt.Errorf("target of Scan must be a non-nil pointer, got %T", target)
}
c.mutex.RLock() // Read lock is sufficient
// Build the full nested map from the current state of registered items
fullNestedMap := make(map[string]any)
for path, item := range c.items {
setNestedValue(fullNestedMap, path, item.currentValue)
}
c.mutex.RUnlock() // Unlock before decoding
var sectionData any = fullNestedMap
// Navigate to the specific section if basePath is provided
if basePath != "" {
// Allow trailing dot for convenience
basePath = strings.TrimSuffix(basePath, ".")
if basePath == "" { // Handle case where input was just "."
// Use the full map
} else {
segments := strings.Split(basePath, ".")
current := any(fullNestedMap)
found := true
for _, segment := range segments {
currentMap, ok := current.(map[string]any)
if !ok {
// Path segment does not lead to a map/table
found = false
break
}
value, exists := currentMap[segment]
if !exists {
// The requested path segment does not exist in the current config
found = false
break
}
current = value
}
if !found {
// If the path doesn't fully exist, decode an empty map into the target.
sectionData = make(map[string]any)
} else {
sectionData = current
}
}
}
// Ensure the final data we are decoding from is actually a map
sectionMap, ok := sectionData.(map[string]any)
if !ok {
// This can happen if the basePath points to a non-map value (e.g., a string, int)
return fmt.Errorf("configuration path %q does not refer to a scannable section (map), but to type %T", basePath, sectionData) // Updated error message
}
// Use mapstructure to decode the relevant section map into the target
decoderConfig := &mapstructure.DecoderConfig{
Result: target,
TagName: "toml", // Use the same tag name for consistency
WeaklyTypedInput: true, // Allow conversions (e.g., int to string if needed by target)
DecodeHook: mapstructure.ComposeDecodeHookFunc(
mapstructure.StringToTimeDurationHookFunc(),
mapstructure.StringToSliceHookFunc(","),
),
}
decoder, err := mapstructure.NewDecoder(decoderConfig)
if err != nil {
return fmt.Errorf("failed to create mapstructure decoder: %w", err)
}
err = decoder.Decode(sectionMap) // Use sectionMap
if err != nil {
return fmt.Errorf("failed to scan section %q into %T: %w", basePath, target, err) // Updated error message
}
return nil
}

161
types.go Normal file
View File

@ -0,0 +1,161 @@
package config
import (
"fmt"
"reflect"
"strconv"
)
// String retrieves a string configuration value using the path.
// Attempts conversion from common types if the stored value isn't already a string.
func (c *Config) String(path string) (string, error) {
val, found := c.Get(path)
if !found {
return "", fmt.Errorf("path not registered: %s", path)
}
if val == nil {
return "", nil // Treat nil as empty string for convenience
}
if strVal, ok := val.(string); ok {
return strVal, nil
}
// Attempt conversion for common types
switch v := val.(type) {
case fmt.Stringer:
return v.String(), nil
case []byte:
return string(v), nil
case int, int8, int16, int32, int64:
return strconv.FormatInt(reflect.ValueOf(val).Int(), 10), nil
case uint, uint8, uint16, uint32, uint64:
return strconv.FormatUint(reflect.ValueOf(val).Uint(), 10), nil
case float32, float64:
return strconv.FormatFloat(reflect.ValueOf(val).Float(), 'f', -1, 64), nil
case bool:
return strconv.FormatBool(v), nil
case error:
return v.Error(), nil
default:
return "", fmt.Errorf("cannot convert type %T to string for path %s", val, path)
}
}
// Int64 retrieves an int64 configuration value using the path.
// Attempts conversion from numeric types, parsable strings, and booleans.
func (c *Config) Int64(path string) (int64, error) {
val, found := c.Get(path)
if !found {
return 0, fmt.Errorf("path not registered: %s", path)
}
if val == nil {
return 0, fmt.Errorf("value for path %s is nil, cannot convert to int64", path)
}
// Use reflection for broader compatibility with numeric types
v := reflect.ValueOf(val)
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int(), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
u := v.Uint()
// Check for potential overflow converting uint64 to int64
maxInt64 := int64(^uint64(0) >> 1)
if u > uint64(maxInt64) {
return 0, fmt.Errorf("cannot convert unsigned integer %d (type %T) to int64 for path %s: overflow", u, val, path)
}
return int64(u), nil
case reflect.Float32, reflect.Float64:
// Truncate float to int
return int64(v.Float()), nil
case reflect.String:
s := v.String()
if i, err := strconv.ParseInt(s, 0, 64); err == nil { // Use base 0 for auto-detection (e.g., "0xFF")
return i, nil
} else {
if f, ferr := strconv.ParseFloat(s, 64); ferr == nil {
return int64(f), nil // Truncate
}
// Return the original integer parsing error if float also fails
return 0, fmt.Errorf("cannot convert string %q to int64 for path %s: %w", s, path, err)
}
case reflect.Bool:
if v.Bool() {
return 1, nil
}
return 0, nil
}
return 0, fmt.Errorf("cannot convert type %T to int64 for path %s", val, path)
}
// Bool retrieves a boolean configuration value using the path.
// Attempts conversion from numeric types (0=false, non-zero=true) and parsable strings.
func (c *Config) Bool(path string) (bool, error) {
val, found := c.Get(path)
if !found {
return false, fmt.Errorf("path not registered: %s", path)
}
if val == nil {
return false, fmt.Errorf("value for path %s is nil, cannot convert to bool", path)
}
v := reflect.ValueOf(val)
switch v.Kind() {
case reflect.Bool:
return v.Bool(), nil
case reflect.String:
s := v.String()
if b, err := strconv.ParseBool(s); err == nil {
return b, nil
} else {
return false, fmt.Errorf("cannot convert string %q to bool for path %s: %w", s, path, err)
}
// Numeric interpretation: 0 is false, non-zero is true
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return v.Int() != 0, nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return v.Uint() != 0, nil
case reflect.Float32, reflect.Float64:
return v.Float() != 0, nil
}
return false, fmt.Errorf("cannot convert type %T to bool for path %s", val, path)
}
// Float64 retrieves a float64 configuration value using the path.
// Attempts conversion from numeric types, parsable strings, and booleans.
func (c *Config) Float64(path string) (float64, error) {
val, found := c.Get(path)
if !found {
return 0.0, fmt.Errorf("path not registered: %s", path)
}
if val == nil {
return 0.0, fmt.Errorf("value for path %s is nil, cannot convert to float64", path)
}
v := reflect.ValueOf(val)
switch v.Kind() {
case reflect.Float32, reflect.Float64:
return v.Float(), nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return float64(v.Int()), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return float64(v.Uint()), nil
case reflect.String:
s := v.String()
if f, err := strconv.ParseFloat(s, 64); err == nil {
return f, nil
} else {
return 0.0, fmt.Errorf("cannot convert string %q to float64 for path %s: %w", s, path, err)
}
case reflect.Bool:
if v.Bool() {
return 1.0, nil
}
return 0.0, nil
}
return 0.0, fmt.Errorf("cannot convert type %T to float64 for path %s", val, path)
}