e2.0.0 Changed dependency from tinytoml to burntsushi/toml, code divided into multple files, better cli arg handling.
This commit is contained in:
107
README.md
107
README.md
@ -5,14 +5,12 @@ A simple, thread-safe configuration management package for Go applications that
|
||||
## Features
|
||||
|
||||
- **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`).
|
||||
- **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.
|
||||
- **Atomic File Operations:** Ensures configuration files are written atomically to prevent corruption.
|
||||
- **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.
|
||||
- **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:
|
||||
```bash
|
||||
github.com/LixenWraith/tinytoml
|
||||
```
|
||||
github.com/BurntSushi/toml
|
||||
github.com/mitchellh/mapstructure
|
||||
```
|
||||
|
||||
@ -37,11 +35,18 @@ github.com/mitchellh/mapstructure
|
||||
cfg := config.New()
|
||||
|
||||
// 2. Register configuration paths with default values
|
||||
err := cfg.Register("server.host", "127.0.0.1")
|
||||
err = cfg.Register("server.port", 8080)
|
||||
cfg.Register("server.host", "127.0.0.1")
|
||||
cfg.Register("server.port", 8080)
|
||||
|
||||
// 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
|
||||
serverHost, err := cfg.String("server.host")
|
||||
@ -84,31 +89,30 @@ err := cfg.RegisterStruct("server.", defaults)
|
||||
### Accessing Typed Values
|
||||
|
||||
```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
|
||||
port, err := cfg.Int64("server.port")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting port: %v", err)
|
||||
}
|
||||
|
||||
debug, err := cfg.Bool("debug")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting debug flag: %v", err)
|
||||
}
|
||||
|
||||
rate, err := cfg.Float64("rate.limit")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting rate limit: %v", err)
|
||||
name, err := cfg.String("server.name")
|
||||
```
|
||||
|
||||
### 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 {
|
||||
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
|
||||
- **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.
|
||||
|
||||
@ -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.
|
||||
- **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.
|
||||
|
||||
- **filePath**: Path to the TOML configuration file.
|
||||
- **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`
|
||||
|
||||
@ -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.
|
||||
- **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
|
||||
|
||||
BSD-3-Clause
|
||||
363
cmd/main.go
363
cmd/main.go
@ -2,10 +2,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors" // Import errors package
|
||||
"fmt"
|
||||
"github.com/LixenWraith/config"
|
||||
"log" // Using standard log for simplicity
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/LixenWraith/config" // Assuming this is the correct import path after potential renaming/moving
|
||||
)
|
||||
|
||||
// LogConfig represents logging configuration parameters
|
||||
@ -36,220 +40,271 @@ type LogConfig struct {
|
||||
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() {
|
||||
// Create a temporary file path for our test
|
||||
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
|
||||
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)
|
||||
|
||||
// Initialize the Config instance
|
||||
// 1. Initialize the Config instance
|
||||
cfg := config.New()
|
||||
|
||||
// Register default values for all LogConfig fields
|
||||
registerLogConfigDefaults(cfg)
|
||||
|
||||
// Load the configuration (will use defaults since file doesn't exist yet)
|
||||
exists, err := cfg.Load(configPath, nil)
|
||||
// 2. Register default values using RegisterStruct
|
||||
fmt.Println("Registering default values using RegisterStruct...")
|
||||
err := cfg.RegisterStruct("log.", defaultLogConfig) // Note the "log." prefix
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
log.Fatalf("FATAL: Error registering defaults: %v\n", err)
|
||||
}
|
||||
fmt.Printf("Config file exists: %v (expected: false)\n", exists)
|
||||
fmt.Println("Defaults registered.")
|
||||
|
||||
// Unmarshal into LogConfig struct
|
||||
var logConfig LogConfig
|
||||
err = cfg.UnmarshalSubtree("log", &logConfig)
|
||||
// 3. Load configuration (file doesn't exist yet)
|
||||
fmt.Println("\nAttempting initial load (expecting file not found)...")
|
||||
err = cfg.Load(configPath, nil) // No CLI args yet
|
||||
if err != nil {
|
||||
fmt.Printf("Error unmarshaling config: %v\n", err)
|
||||
os.Exit(1)
|
||||
// Check specifically for ErrConfigNotFound
|
||||
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
|
||||
fmt.Println("\n=== Default Configuration Values ===")
|
||||
printLogConfig(logConfig)
|
||||
// 4. Unmarshal defaults into LogConfig struct
|
||||
var currentConfig LogConfig
|
||||
fmt.Println("\nUnmarshaling current config (should be defaults)...")
|
||||
err = cfg.Scan("log", ¤tConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("FATAL: Error unmarshaling default config: %v\n", err)
|
||||
}
|
||||
|
||||
// Modify some values
|
||||
fmt.Println("\n=== Modifying Configuration Values ===")
|
||||
// Print default 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(" - level: 1 → 2")
|
||||
fmt.Println(" - name: default_logger → modified_logger")
|
||||
fmt.Println(" - format: txt → json")
|
||||
fmt.Println(" - max_size_mb: 10 → 50")
|
||||
fmt.Println(" - retention_period_hrs: 24.0 → 72.0")
|
||||
fmt.Println(" - enable_adaptive_interval: false → true")
|
||||
fmt.Println(" - log.name: default_logger → saved_logger")
|
||||
fmt.Println(" - log.max_size_mb: 10 → 50")
|
||||
fmt.Println(" - log.retention_period_hrs: 24.0 → 48.0") // Different from CLI override later
|
||||
|
||||
cfg.Set("log.level", int64(2))
|
||||
cfg.Set("log.name", "modified_logger")
|
||||
cfg.Set("log.format", "json")
|
||||
cfg.Set("log.name", "saved_logger") // This will be saved to file
|
||||
cfg.Set("log.max_size_mb", int64(50))
|
||||
cfg.Set("log.retention_period_hrs", 72.0)
|
||||
cfg.Set("log.enable_adaptive_interval", true)
|
||||
cfg.Set("log.retention_period_hrs", 48.0)
|
||||
|
||||
// Save the configuration
|
||||
// 6. Save the configuration
|
||||
fmt.Println("\nSaving configuration to file...")
|
||||
err = cfg.Save(configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error saving config: %v\n", err)
|
||||
os.Exit(1)
|
||||
log.Fatalf("FATAL: Error saving config: %v\n", err)
|
||||
}
|
||||
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
|
||||
fileBytes, err := os.ReadFile(configPath)
|
||||
// Optional: Read and print file contents
|
||||
// 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 {
|
||||
fmt.Printf("Error reading config file: %v\n", err)
|
||||
os.Exit(1)
|
||||
log.Fatalf("FATAL: Error registering defaults for cfg2: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Generated TOML File Contents ===")
|
||||
fmt.Println(string(fileBytes))
|
||||
|
||||
// Load the config again to verify it can be read back correctly
|
||||
exists, err = cfg.Load(configPath, nil)
|
||||
fmt.Println("Loading config with file and CLI...")
|
||||
err = cfg2.Load(configPath, cliArgs)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reloading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
// Note: If "--invalid-key" is included above, Load should return ErrCLIParse.
|
||||
// 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.Println("Retrying load with filtered CLI args...")
|
||||
err = cfg2.Load(configPath, validArgs)
|
||||
if err != nil {
|
||||
log.Fatalf("FATAL: Error loading config even after filtering CLI args: %v\n", err)
|
||||
}
|
||||
} else {
|
||||
log.Fatalf("FATAL: Unexpected error loading config with file and CLI: %v\n", err)
|
||||
}
|
||||
}
|
||||
fmt.Printf("\nConfig file exists: %v (expected: true)\n", exists)
|
||||
fmt.Println("Load successful.")
|
||||
|
||||
// Unmarshal into a new LogConfig to verify loaded values
|
||||
var loadedConfig LogConfig
|
||||
err = cfg.UnmarshalSubtree("log", &loadedConfig)
|
||||
// 9. Unmarshal the final configuration state
|
||||
var finalConfig LogConfig
|
||||
fmt.Println("\nUnmarshaling final config state...")
|
||||
err = cfg2.Scan("log", &finalConfig)
|
||||
if err != nil {
|
||||
fmt.Printf("Error unmarshaling reloaded config: %v\n", err)
|
||||
os.Exit(1)
|
||||
log.Fatalf("FATAL: Error unmarshaling final config: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Loaded Configuration Values ===")
|
||||
printLogConfig(loadedConfig)
|
||||
fmt.Println("\n=== Final Configuration (Defaults + File + CLI) ===")
|
||||
printLogConfig(finalConfig)
|
||||
|
||||
// Verify specific values were changed correctly
|
||||
fmt.Println("\n=== Verification ===")
|
||||
verifyConfig(loadedConfig)
|
||||
// 10. Verify final values (Defaults < File < CLI)
|
||||
fmt.Println("\n=== Final Verification ===")
|
||||
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 ===")
|
||||
}
|
||||
|
||||
// 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
|
||||
func printLogConfig(cfg LogConfig) {
|
||||
fmt.Println("Basic settings:")
|
||||
fmt.Printf(" - Level: %d\n", cfg.Level)
|
||||
fmt.Printf(" - Name: %s\n", cfg.Name)
|
||||
fmt.Printf(" - Directory: %s\n", cfg.Directory)
|
||||
fmt.Printf(" - Format: %s\n", cfg.Format)
|
||||
fmt.Printf(" - Extension: %s\n", cfg.Extension)
|
||||
|
||||
fmt.Println("Formatting:")
|
||||
fmt.Printf(" - ShowTimestamp: %t\n", cfg.ShowTimestamp)
|
||||
fmt.Printf(" - ShowLevel: %t\n", cfg.ShowLevel)
|
||||
|
||||
fmt.Println("Buffer and size limits:")
|
||||
fmt.Printf(" - BufferSize: %d\n", cfg.BufferSize)
|
||||
fmt.Printf(" - MaxSizeMB: %d\n", cfg.MaxSizeMB)
|
||||
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)
|
||||
fmt.Println(" Basic:")
|
||||
fmt.Printf(" Level: %d, Name: %s, Dir: %s, Format: %s, Ext: %s\n",
|
||||
cfg.Level, cfg.Name, cfg.Directory, cfg.Format, cfg.Extension)
|
||||
fmt.Println(" Formatting:")
|
||||
fmt.Printf(" ShowTimestamp: %t, ShowLevel: %t\n", cfg.ShowTimestamp, cfg.ShowLevel)
|
||||
fmt.Println(" Limits:")
|
||||
fmt.Printf(" BufferSize: %d, MaxSizeMB: %d, MaxTotalSizeMB: %d, MinDiskFreeMB: %d\n",
|
||||
cfg.BufferSize, cfg.MaxSizeMB, cfg.MaxTotalSizeMB, cfg.MinDiskFreeMB)
|
||||
fmt.Println(" Timers:")
|
||||
fmt.Printf(" FlushIntervalMs: %d, TraceDepth: %d, RetentionPeriodHrs: %.1f, RetentionCheckMins: %.1f\n",
|
||||
cfg.FlushIntervalMs, cfg.TraceDepth, cfg.RetentionPeriodHrs, cfg.RetentionCheckMins)
|
||||
fmt.Println(" Disk Check:")
|
||||
fmt.Printf(" DiskCheckIntervalMs: %d, EnableAdaptive: %t, MinCheckMs: %d, MaxCheckMs: %d\n",
|
||||
cfg.DiskCheckIntervalMs, cfg.EnableAdaptiveInterval, cfg.MinCheckIntervalMs, cfg.MaxCheckIntervalMs)
|
||||
}
|
||||
|
||||
// verifyConfig checks if the modified values were set correctly
|
||||
func verifyConfig(cfg LogConfig) {
|
||||
// verifyFinalConfig checks if the final values reflect the merge order: Default < File < CLI
|
||||
func verifyFinalConfig(cfg LogConfig) {
|
||||
allCorrect := true
|
||||
fmt.Println("Verifying values reflect merge order (Default < File < CLI)...")
|
||||
|
||||
if cfg.Level != 2 {
|
||||
fmt.Printf("ERROR: Level is %d, expected 2\n", cfg.Level)
|
||||
allCorrect = false
|
||||
}
|
||||
|
||||
if cfg.Name != "modified_logger" {
|
||||
fmt.Printf("ERROR: Name is %s, expected 'modified_logger'\n", cfg.Name)
|
||||
allCorrect = false
|
||||
}
|
||||
|
||||
if cfg.Format != "json" {
|
||||
fmt.Printf("ERROR: Format is %s, expected 'json'\n", cfg.Format)
|
||||
// Value overridden by CLI
|
||||
if cfg.Level != 3 {
|
||||
fmt.Printf(" ERROR: Level is %d, expected 3 (from CLI)\n", cfg.Level)
|
||||
allCorrect = false
|
||||
}
|
||||
// Value overridden by CLI (overriding file value)
|
||||
if cfg.Name != "cli_logger" {
|
||||
fmt.Printf(" ERROR: Name is %s, expected 'cli_logger' (from CLI)\n", cfg.Name)
|
||||
allCorrect = false
|
||||
}
|
||||
// Value overridden by CLI
|
||||
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
|
||||
}
|
||||
|
||||
// Value overridden by File (not present in CLI)
|
||||
if cfg.MaxSizeMB != 50 {
|
||||
fmt.Printf("ERROR: MaxSizeMB is %d, expected 50\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)
|
||||
fmt.Printf(" ERROR: MaxSizeMB is %d, expected 50 (from File)\n", cfg.MaxSizeMB)
|
||||
allCorrect = false
|
||||
}
|
||||
|
||||
// Value from Default (not in File or CLI)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if allCorrect {
|
||||
fmt.Println("SUCCESS: All configuration values match expected values!")
|
||||
fmt.Println(" SUCCESS: All verified configuration values match expected final state!")
|
||||
} 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
587
config.go
@ -3,18 +3,17 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors" // Import errors package
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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
|
||||
type configItem struct {
|
||||
defaultValue any
|
||||
@ -23,8 +22,8 @@ type configItem struct {
|
||||
|
||||
// Config manages application configuration loaded from files and CLI arguments.
|
||||
type Config struct {
|
||||
items map[string]configItem // Maps paths to config items (default and current values)
|
||||
mutex sync.RWMutex // Protects concurrent access
|
||||
items map[string]configItem
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// 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.
|
||||
// It returns the current value (or default if not explicitly set).
|
||||
// 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.
|
||||
// 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 {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
@ -202,431 +64,4 @@ func (c *Config) Set(path string, value any) error {
|
||||
item.currentValue = value
|
||||
c.items[path] = item
|
||||
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
7
go.mod
@ -2,6 +2,7 @@ module github.com/LixenWraith/config
|
||||
|
||||
go 1.24.2
|
||||
|
||||
require github.com/LixenWraith/tinytoml v0.0.0-20250305012228-6862ba843264
|
||||
|
||||
require github.com/mitchellh/mapstructure v1.5.0
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.5.0
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
)
|
||||
|
||||
4
go.sum
4
go.sum
@ -1,4 +1,4 @@
|
||||
github.com/LixenWraith/tinytoml v0.0.0-20250305012228-6862ba843264 h1:p2hpE672qTRuhR9FAt7SIHp8aP0pJbBKushCiIRNRpo=
|
||||
github.com/LixenWraith/tinytoml v0.0.0-20250305012228-6862ba843264/go.mod h1:pm+BQlZ/VQC30uaB5Vfeih2b77QkGIiMvu+QgG/XOTk=
|
||||
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
|
||||
101
helpers.go
Normal file
101
helpers.go
Normal 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
244
io.go
Normal 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
278
register.go
Normal 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
161
types.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user