e1.0.0 More restructuring and feature add, full scope for this stage.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
||||
data
|
||||
dev
|
||||
logs
|
||||
*.log
|
||||
|
||||
177
README.md
177
README.md
@ -7,10 +7,13 @@ A simple, thread-safe configuration management package for Go applications that
|
||||
- **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.
|
||||
- **Command-Line Overrides:** Allows overriding configuration values using dot notation in CLI arguments (e.g., `--server.port 9090`).
|
||||
- **Type-Safe Access:** Register configuration paths with default values and receive unique keys (UUIDs) for consistent access.
|
||||
- **Path-Based Access:** Register configuration paths with default values for direct, consistent access with clear error messages.
|
||||
- **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 `google/uuid`.
|
||||
- **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.
|
||||
|
||||
## Installation
|
||||
|
||||
@ -21,7 +24,7 @@ go get github.com/LixenWraith/config
|
||||
Dependencies will be automatically fetched:
|
||||
```bash
|
||||
github.com/LixenWraith/tinytoml
|
||||
github.com/google/uuid
|
||||
github.com/mitchellh/mapstructure
|
||||
```
|
||||
|
||||
## Usage
|
||||
@ -32,21 +35,111 @@ github.com/google/uuid
|
||||
// 1. Initialize a new Config instance
|
||||
cfg := config.New()
|
||||
|
||||
// 2. Register configuration keys with paths and default values
|
||||
keyServerHost, err := cfg.Register("server.host", "127.0.0.1")
|
||||
keyServerPort, err := cfg.Register("server.port", 8080)
|
||||
// 2. Register configuration paths with default values
|
||||
err := cfg.Register("server.host", "127.0.0.1")
|
||||
err = cfg.Register("server.port", 8080)
|
||||
|
||||
// 3. Load configuration from file with CLI argument overrides
|
||||
fileExists, err := cfg.Load("app_config.toml", os.Args[1:])
|
||||
|
||||
// 4. Access configuration values using the registered keys
|
||||
serverHost, _ := cfg.Get(keyServerHost)
|
||||
serverPort, _ := cfg.Get(keyServerPort)
|
||||
// 4. Access configuration values using the registered paths
|
||||
serverHost, err := cfg.String("server.host")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
serverPort, err := cfg.Int64("server.port")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 5. Save configuration (creates the file if it doesn't exist)
|
||||
err = cfg.Save("app_config.toml")
|
||||
```
|
||||
|
||||
### 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")
|
||||
if err != nil {
|
||||
log.Fatalf("Error getting server name: %v", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Unmarshal Into Structs
|
||||
|
||||
```go
|
||||
// Define a struct to hold configuration
|
||||
type ServerConfig struct {
|
||||
Host string `toml:"host"`
|
||||
Port int `toml:"port"`
|
||||
Timeout int `toml:"timeout"`
|
||||
}
|
||||
|
||||
// Register default values if needed
|
||||
cfg.Register("server.host", "localhost")
|
||||
cfg.Register("server.port", 8080)
|
||||
cfg.Register("server.timeout", 30)
|
||||
|
||||
// Load from file
|
||||
cfg.Load("config.toml", nil)
|
||||
|
||||
// Unmarshal the "server" subtree into a struct
|
||||
var serverCfg ServerConfig
|
||||
err := cfg.UnmarshalSubtree("server", &serverCfg)
|
||||
```
|
||||
|
||||
### Updating Configuration Values
|
||||
|
||||
```go
|
||||
// Register a configuration path
|
||||
cfg.Register("server.port", 8080)
|
||||
|
||||
// Update the value
|
||||
err := cfg.Set("server.port", 9090)
|
||||
|
||||
// Save to persist the changes
|
||||
cfg.Save("config.toml")
|
||||
```
|
||||
|
||||
### Removing Configuration Values
|
||||
|
||||
```go
|
||||
// Register paths
|
||||
cfg.Register("server.host", "localhost")
|
||||
cfg.Register("server.port", 8080)
|
||||
cfg.Register("server.debug", true)
|
||||
|
||||
// Unregister a single path
|
||||
err := cfg.Unregister("server.port")
|
||||
|
||||
// Unregister a parent path and all its children
|
||||
// This would remove server.host, server.port, and server.debug
|
||||
err = cfg.Unregister("server")
|
||||
```
|
||||
|
||||
### CLI Arguments
|
||||
|
||||
Command-line arguments override file configuration using dot notation:
|
||||
@ -67,22 +160,63 @@ Flags without values are treated as boolean `true`.
|
||||
|
||||
Creates and returns a new, initialized `*Config` instance ready for use.
|
||||
|
||||
### `(*Config) Register(path string, defaultValue any) (string, error)`
|
||||
### `(*Config) Register(path string, defaultValue any) error`
|
||||
|
||||
Registers a configuration path with a default value and returns a unique UUID key.
|
||||
Registers a configuration path with a default value.
|
||||
|
||||
- **path**: Dot-separated path corresponding to the TOML structure. Each segment must be a valid TOML key.
|
||||
- **defaultValue**: The value returned by `Get` if not found in configuration or CLI.
|
||||
- **Returns**: UUID string (key) for use with `Get` and error (nil on success)
|
||||
- **defaultValue**: The value returned if no other value has been set through Load or Set.
|
||||
- **Returns**: Error (nil on success)
|
||||
|
||||
### `(*Config) Get(key string) (any, bool)`
|
||||
### `(*Config) Get(path string) (any, bool)`
|
||||
|
||||
Retrieves a configuration value using the UUID key from `Register`.
|
||||
Retrieves a configuration value using the registered path.
|
||||
|
||||
- **key**: The UUID string returned by `Register`.
|
||||
- **Returns**: The configuration value and a boolean indicating if the key was registered.
|
||||
- **path**: The dot-separated path string used during registration.
|
||||
- **Returns**: The configuration value and a boolean indicating if the path was registered.
|
||||
- **Value precedence**: CLI Argument > Config File Value > Registered Default Value
|
||||
|
||||
### `(*Config) String(path string) (string, error)`
|
||||
### `(*Config) Int64(path string) (int64, error)`
|
||||
### `(*Config) Bool(path string) (bool, error)`
|
||||
### `(*Config) Float64(path string) (float64, error)`
|
||||
|
||||
Type-specific accessor methods that retrieve and attempt to convert configuration values to the desired type.
|
||||
|
||||
- **path**: The dot-separated path string used during registration.
|
||||
- **Returns**: The typed value and an error (nil on success).
|
||||
- **Errors**: Detailed error messages when:
|
||||
- The path is not registered
|
||||
- The value cannot be converted to the requested type
|
||||
- Type conversion fails (with the specific reason)
|
||||
|
||||
### `(*Config) Set(path string, value any) error`
|
||||
|
||||
Updates a configuration value using the registered path.
|
||||
|
||||
- **path**: The dot-separated path string used during registration.
|
||||
- **value**: The new value to set.
|
||||
- **Returns**: Error if the path wasn't registered or if setting the value fails.
|
||||
|
||||
### `(*Config) Unregister(path string) error`
|
||||
|
||||
Removes a configuration path and all its children from the configuration.
|
||||
|
||||
- **path**: The dot-separated path string used during registration.
|
||||
- **Effects**:
|
||||
- Removes the specified path
|
||||
- Recursively removes all child paths (e.g., unregistering "server" also removes "server.host", "server.port", etc.)
|
||||
- Completely removes both registration and data
|
||||
- **Returns**: Error if the path wasn't registered.
|
||||
|
||||
### `(*Config) UnmarshalSubtree(basePath string, target any) error`
|
||||
|
||||
Decodes a section of the configuration into a struct or map.
|
||||
|
||||
- **basePath**: Dot-separated path to the configuration subtree.
|
||||
- **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)`
|
||||
|
||||
Loads configuration from a TOML file and merges overrides from command-line arguments.
|
||||
@ -103,11 +237,13 @@ Saves the current configuration to the specified TOML file path, performing an a
|
||||
### Key Design Choices
|
||||
|
||||
- **Thread Safety**: All operations are protected by a `sync.RWMutex` to support concurrent access.
|
||||
- **UUID-based Access**: Using UUIDs for configuration keys ensures type safety and prevents string typos during runtime access via `Get`. The path remains the persistent identifier in the config file.
|
||||
- **Map Structure**: Configuration is stored in nested maps of type `map[string]any`.
|
||||
- **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. See note below on Type Handling.
|
||||
- **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
|
||||
|
||||
@ -122,6 +258,7 @@ Saves the current configuration to the specified TOML file path, performing an a
|
||||
- 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
|
||||
|
||||
|
||||
402
cmd/main.go
402
cmd/main.go
@ -1,183 +1,257 @@
|
||||
// Test program for the config package
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"github.com/LixenWraith/config"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/LixenWraith/config"
|
||||
)
|
||||
|
||||
const (
|
||||
initialConfigFile = "config_initial.toml"
|
||||
finalConfigFile = "config_final.toml"
|
||||
)
|
||||
|
||||
// Sample TOML content for the initial configuration file
|
||||
var initialTomlContent = `
|
||||
debug = true
|
||||
log_level = "info" # This will be overridden by default
|
||||
|
||||
[server]
|
||||
host = "localhost"
|
||||
port = 8080 # This will be overridden by CLI
|
||||
|
||||
[smtp]
|
||||
host = "mail.example.com" # This will be overridden by CLI
|
||||
port = 587
|
||||
auth_user = "file_user"
|
||||
# auth_pass is missing, will use default
|
||||
`
|
||||
// LogConfig represents logging configuration parameters
|
||||
type LogConfig struct {
|
||||
// Basic settings
|
||||
Level int64 `toml:"level"`
|
||||
Name string `toml:"name"`
|
||||
Directory string `toml:"directory"`
|
||||
Format string `toml:"format"` // "txt" or "json"
|
||||
Extension string `toml:"extension"`
|
||||
// Formatting
|
||||
ShowTimestamp bool `toml:"show_timestamp"`
|
||||
ShowLevel bool `toml:"show_level"`
|
||||
// Buffer and size limits
|
||||
BufferSize int64 `toml:"buffer_size"` // Channel buffer size
|
||||
MaxSizeMB int64 `toml:"max_size_mb"` // Max size per log file
|
||||
MaxTotalSizeMB int64 `toml:"max_total_size_mb"` // Max total size of all logs in dir
|
||||
MinDiskFreeMB int64 `toml:"min_disk_free_mb"` // Minimum free disk space required
|
||||
// Timers
|
||||
FlushIntervalMs int64 `toml:"flush_interval_ms"` // Interval for flushing file buffer
|
||||
TraceDepth int64 `toml:"trace_depth"` // Default trace depth (0-10)
|
||||
RetentionPeriodHrs float64 `toml:"retention_period_hrs"` // Hours to keep logs (0=disabled)
|
||||
RetentionCheckMins float64 `toml:"retention_check_mins"` // How often to check retention
|
||||
// Disk check settings
|
||||
DiskCheckIntervalMs int64 `toml:"disk_check_interval_ms"` // Base interval for disk checks
|
||||
EnableAdaptiveInterval bool `toml:"enable_adaptive_interval"` // Adjust interval based on log rate
|
||||
MinCheckIntervalMs int64 `toml:"min_check_interval_ms"` // Minimum adaptive interval
|
||||
MaxCheckIntervalMs int64 `toml:"max_check_interval_ms"` // Maximum adaptive interval
|
||||
}
|
||||
|
||||
func main() {
|
||||
// --- Setup: Create a temporary directory for config files ---
|
||||
tempDir, err := os.MkdirTemp("", "config_example")
|
||||
// Create a temporary file path for our test
|
||||
tempDir := os.TempDir()
|
||||
configPath := filepath.Join(tempDir, "logconfig_test.toml")
|
||||
|
||||
// Clean up any existing file from previous runs
|
||||
os.Remove(configPath)
|
||||
|
||||
fmt.Println("=== LogConfig Test Program ===")
|
||||
fmt.Printf("Using temporary config file: %s\n\n", configPath)
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create temp dir: %v", err)
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer os.RemoveAll(tempDir) // Clean up temp directory
|
||||
fmt.Printf("Config file exists: %v (expected: false)\n", exists)
|
||||
|
||||
initialPath := filepath.Join(tempDir, initialConfigFile)
|
||||
finalPath := filepath.Join(tempDir, finalConfigFile)
|
||||
|
||||
// Write the initial TOML config file
|
||||
err = os.WriteFile(initialPath, []byte(initialTomlContent), 0644)
|
||||
// Unmarshal into LogConfig struct
|
||||
var logConfig LogConfig
|
||||
err = cfg.UnmarshalSubtree("log", &logConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to write initial config file: %v", err)
|
||||
}
|
||||
fmt.Printf("Wrote initial config to: %s\n", initialPath)
|
||||
|
||||
// --- Step 1: Create and Register Configuration ---
|
||||
fmt.Println("\n--- Step 1: Initialize Config and Register Keys ---")
|
||||
c := config.New()
|
||||
|
||||
// Register keys and store their UUIDs
|
||||
// Provide default values that might be overridden by file or CLI
|
||||
keyDebug, err := c.Register("debug", false) // Default false, overridden by file
|
||||
handleErr(err)
|
||||
keyLogLevel, err := c.Register("log_level", "warn") // Default warn, file has "info"
|
||||
handleErr(err)
|
||||
keyServerHost, err := c.Register("server.host", "127.0.0.1") // Default 127.0.0.1, overridden by file
|
||||
handleErr(err)
|
||||
keyServerPort, err := c.Register("server.port", 9090) // Default 9090, file 8080, CLI 9999
|
||||
handleErr(err)
|
||||
keySmtpHost, err := c.Register("smtp.host", "default.mail.com") // Default, file mail.example.com, CLI override.mail.com
|
||||
handleErr(err)
|
||||
keySmtpPort, err := c.Register("smtp.port", 25) // Default 25, overridden by file
|
||||
handleErr(err)
|
||||
keySmtpUser, err := c.Register("smtp.auth_user", "default_user") // Default, overridden by file
|
||||
handleErr(err)
|
||||
keySmtpPass, err := c.Register("smtp.auth_pass", "default_pass") // Default, not in file or CLI
|
||||
handleErr(err)
|
||||
keyNewCliFlag, err := c.Register("new_cli_flag", false) // Default false, set true by CLI
|
||||
handleErr(err)
|
||||
keyOnlyDefault, err := c.Register("only_default", "this_is_the_default") // Only has a default value
|
||||
handleErr(err)
|
||||
|
||||
fmt.Println("Registered configuration keys with defaults.")
|
||||
fmt.Printf(" - debug (default: false): %s\n", keyDebug)
|
||||
fmt.Printf(" - server.port (default: 9090): %s\n", keyServerPort)
|
||||
fmt.Printf(" - smtp.auth_pass (default: 'default_pass'): %s\n", keySmtpPass)
|
||||
fmt.Printf(" - only_default (default: 'this_is_the_default'): %s\n", keyOnlyDefault)
|
||||
|
||||
// --- Step 2: Load Configuration from File and CLI Args ---
|
||||
fmt.Println("\n--- Step 2: Load from File and CLI ---")
|
||||
// Simulate command-line arguments
|
||||
cliArgs := []string{
|
||||
"--server.port", "9999", // Override file value
|
||||
"--smtp.host", "override.mail.com", // Override file value
|
||||
"--new_cli_flag", // Set boolean flag to true
|
||||
"--unregistered.cli.arg", "some_value", // This will be loaded but not accessible via registered Get
|
||||
}
|
||||
fmt.Printf("Simulated CLI Args: %v\n", cliArgs)
|
||||
|
||||
foundFile, err := c.Load(initialPath, cliArgs)
|
||||
handleErr(err)
|
||||
fmt.Printf("Config file loaded: %t\n", foundFile)
|
||||
|
||||
// --- Step 3: Access Merged Configuration Values ---
|
||||
fmt.Println("\n--- Step 3: Access Merged Values via Get() ---")
|
||||
|
||||
// Retrieve values using the UUID keys. Observe the precedence: Default -> File -> CLI
|
||||
debugVal, _ := c.Get(keyDebug) // Expect: true (from file)
|
||||
logLevelVal, _ := c.Get(keyLogLevel) // Expect: "info" (from file)
|
||||
serverHostVal, _ := c.Get(keyServerHost) // Expect: "localhost" (from file)
|
||||
serverPortVal, _ := c.Get(keyServerPort) // Expect: 9999 (int64 from CLI)
|
||||
smtpHostVal, _ := c.Get(keySmtpHost) // Expect: "override.mail.com" (from CLI)
|
||||
smtpPortVal, _ := c.Get(keySmtpPort) // Expect: 587 (int64 from file - parseArgs converts numbers)
|
||||
smtpUserVal, _ := c.Get(keySmtpUser) // Expect: "file_user" (from file)
|
||||
smtpPassVal, _ := c.Get(keySmtpPass) // Expect: "default_pass" (only default exists)
|
||||
newCliFlagVal, _ := c.Get(keyNewCliFlag) // Expect: true (from CLI flag)
|
||||
onlyDefaultVal, _ := c.Get(keyOnlyDefault) // Expect: "this_is_the_default" (only default exists)
|
||||
_, registered := c.Get("nonexistent-uuid") // Expect: registered = false
|
||||
|
||||
fmt.Printf("Debug (File): %v (%T)\n", debugVal, debugVal)
|
||||
fmt.Printf("LogLevel (File): %v (%T)\n", logLevelVal, logLevelVal)
|
||||
fmt.Printf("Server Host (File): %v (%T)\n", serverHostVal, serverHostVal)
|
||||
fmt.Printf("Server Port (CLI) : %v (%T)\n", serverPortVal, serverPortVal)
|
||||
fmt.Printf("SMTP Host (CLI) : %v (%T)\n", smtpHostVal, smtpHostVal)
|
||||
fmt.Printf("SMTP Port (File): %v (%T)\n", smtpPortVal, smtpPortVal) // Note: parseArgs converts to int64
|
||||
fmt.Printf("SMTP User (File): %v (%T)\n", smtpUserVal, smtpUserVal)
|
||||
fmt.Printf("SMTP Pass (Default): %v (%T)\n", smtpPassVal, smtpPassVal)
|
||||
fmt.Printf("New CLI Flag (CLI) : %v (%T)\n", newCliFlagVal, newCliFlagVal)
|
||||
fmt.Printf("Only Default (Default): %v (%T)\n", onlyDefaultVal, onlyDefaultVal)
|
||||
fmt.Printf("Unregistered UUID : Found=%t\n", registered)
|
||||
|
||||
// --- Step 4: Save the Final Configuration ---
|
||||
fmt.Println("\n--- Step 4: Save Final Configuration ---")
|
||||
err = c.Save(finalPath)
|
||||
handleErr(err)
|
||||
fmt.Printf("Saved final merged configuration to: %s\n", finalPath)
|
||||
|
||||
// Optional: Print the content of the saved file
|
||||
savedContent, _ := os.ReadFile(finalPath)
|
||||
fmt.Println("--- Content of saved file (config_final.toml): ---")
|
||||
fmt.Println(string(savedContent))
|
||||
fmt.Println("-------------------------------------------------")
|
||||
|
||||
// --- Step 5: Load Saved Config into a New Instance and Compare ---
|
||||
fmt.Println("\n--- Step 5: Reload Saved Config and Verify ---")
|
||||
c2 := config.New()
|
||||
|
||||
// NOTE: For c2 to use Get(), keys would need to be registered again.
|
||||
// For simple verification here, we load and access the underlying map.
|
||||
// In a real app, you'd likely register keys consistently at startup.
|
||||
foundSavedFile, err := c2.Load(finalPath, nil) // Load without CLI args this time
|
||||
handleErr(err)
|
||||
if !foundSavedFile {
|
||||
log.Fatalf("Failed to find the saved config file '%s' for reloading", finalPath)
|
||||
}
|
||||
fmt.Println("Reloaded final config into a new instance.")
|
||||
|
||||
// Directly compare some values from the internal data map for verification
|
||||
// This requires accessing the unexported 'data' field via a helper or reflection,
|
||||
// OR rely on saving/loading being correct (which is what we test here).
|
||||
// Let's assume Save/Load worked and verify the expected final values are present after load.
|
||||
|
||||
// We need to register keys again in c2 to use Get() for comparison
|
||||
key2ServerPort, _ := c2.Register("server.port", 0) // Default doesn't matter now
|
||||
key2SmtpHost, _ := c2.Register("smtp.host", "")
|
||||
key2NewCliFlag, _ := c2.Register("new_cli_flag", false)
|
||||
key2Unregistered, _ := c2.Register("unregistered.cli.arg", "") // Register the CLI-only arg
|
||||
|
||||
reloadedPort, _ := c2.Get(key2ServerPort)
|
||||
reloadedSmtpHost, _ := c2.Get(key2SmtpHost)
|
||||
reloadedCliFlag, _ := c2.Get(key2NewCliFlag)
|
||||
reloadedUnregistered, _ := c2.Get(key2Unregistered) // Get the value added only via CLI initially
|
||||
|
||||
fmt.Println("Comparing reloaded values:")
|
||||
fmt.Printf(" - Reloaded Server Port: %v (Expected: 9999) - Match: %t\n", reloadedPort, reloadedPort == int64(9999))
|
||||
fmt.Printf(" - Reloaded SMTP Host : %v (Expected: override.mail.com) - Match: %t\n", reloadedSmtpHost, reloadedSmtpHost == "override.mail.com")
|
||||
fmt.Printf(" - Reloaded CLI Flag : %v (Expected: true) - Match: %t\n", reloadedCliFlag, reloadedCliFlag == true)
|
||||
fmt.Printf(" - Reloaded Unreg Arg : %v (Expected: some_value) - Match: %t\n", reloadedUnregistered, reloadedUnregistered == "some_value")
|
||||
|
||||
fmt.Println("\n--- Example Finished ---")
|
||||
fmt.Printf("Error unmarshaling config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Simple error handler
|
||||
func handleErr(err error) {
|
||||
// Print current values
|
||||
fmt.Println("\n=== Default Configuration Values ===")
|
||||
printLogConfig(logConfig)
|
||||
|
||||
// Modify some values
|
||||
fmt.Println("\n=== Modifying Configuration Values ===")
|
||||
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")
|
||||
|
||||
cfg.Set("log.level", int64(2))
|
||||
cfg.Set("log.name", "modified_logger")
|
||||
cfg.Set("log.format", "json")
|
||||
cfg.Set("log.max_size_mb", int64(50))
|
||||
cfg.Set("log.retention_period_hrs", 72.0)
|
||||
cfg.Set("log.enable_adaptive_interval", true)
|
||||
|
||||
// Save the configuration
|
||||
err = cfg.Save(configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Error: %v", err)
|
||||
fmt.Printf("Error saving config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("\nSaved configuration to: %s\n", configPath)
|
||||
|
||||
// Read the file to verify it contains the expected values
|
||||
fileBytes, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading config file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
fmt.Printf("Error reloading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("\nConfig file exists: %v (expected: true)\n", exists)
|
||||
|
||||
// Unmarshal into a new LogConfig to verify loaded values
|
||||
var loadedConfig LogConfig
|
||||
err = cfg.UnmarshalSubtree("log", &loadedConfig)
|
||||
if err != nil {
|
||||
fmt.Printf("Error unmarshaling reloaded config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Loaded Configuration Values ===")
|
||||
printLogConfig(loadedConfig)
|
||||
|
||||
// Verify specific values were changed correctly
|
||||
fmt.Println("\n=== Verification ===")
|
||||
verifyConfig(loadedConfig)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// verifyConfig checks if the modified values were set correctly
|
||||
func verifyConfig(cfg LogConfig) {
|
||||
allCorrect := true
|
||||
|
||||
// Check each modified value
|
||||
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)
|
||||
allCorrect = false
|
||||
}
|
||||
|
||||
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)
|
||||
allCorrect = false
|
||||
}
|
||||
|
||||
// Check that unmodified values retained their defaults
|
||||
if cfg.Directory != "./logs" {
|
||||
fmt.Printf("ERROR: Directory changed to %s, expected './logs'\n", cfg.Directory)
|
||||
allCorrect = false
|
||||
}
|
||||
|
||||
if cfg.BufferSize != 1000 {
|
||||
fmt.Printf("ERROR: BufferSize changed to %d, expected 1000\n", cfg.BufferSize)
|
||||
allCorrect = false
|
||||
}
|
||||
|
||||
if allCorrect {
|
||||
fmt.Println("SUCCESS: All configuration values match expected values!")
|
||||
} else {
|
||||
fmt.Println("FAILURE: Some configuration values don't match expected values!")
|
||||
}
|
||||
}
|
||||
511
config.go
511
config.go
@ -1,3 +1,5 @@
|
||||
// Package config provides thread-safe configuration management for Go applications
|
||||
// with support for TOML files, command-line overrides, and default values.
|
||||
package config
|
||||
|
||||
import (
|
||||
@ -6,124 +8,228 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync" // Added sync import
|
||||
"sync"
|
||||
|
||||
"github.com/LixenWraith/tinytoml"
|
||||
"github.com/google/uuid" // Added uuid import
|
||||
"github.com/mitchellh/mapstructure"
|
||||
)
|
||||
|
||||
// registeredItem holds metadata for a configuration value registered for access.
|
||||
type registeredItem struct {
|
||||
path string // Dot-separated path (e.g., "server.port")
|
||||
// configItem holds both the default and current value for a configuration path
|
||||
type configItem struct {
|
||||
defaultValue any
|
||||
currentValue any
|
||||
}
|
||||
|
||||
// Config manages application configuration loaded from files and CLI arguments.
|
||||
// It provides thread-safe access to configuration values.
|
||||
type Config struct {
|
||||
data map[string]any // Stores the actual configuration data (nested map)
|
||||
registry map[string]registeredItem // Maps generated UUIDs to registered items
|
||||
mutex sync.RWMutex // Protects concurrent access to data and registry
|
||||
items map[string]configItem // Maps paths to config items (default and current values)
|
||||
mutex sync.RWMutex // Protects concurrent access
|
||||
}
|
||||
|
||||
// New creates and initializes a new Config instance.
|
||||
func New() *Config {
|
||||
return &Config{
|
||||
data: make(map[string]any),
|
||||
registry: make(map[string]registeredItem),
|
||||
// mutex is implicitly initialized
|
||||
items: make(map[string]configItem),
|
||||
}
|
||||
}
|
||||
|
||||
// Register makes a configuration path known to the Config instance and returns a unique key (UUID) for accessing it.
|
||||
// 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 returned by Get if the value is not found in the loaded configuration.
|
||||
func (c *Config) Register(path string, defaultValue any) (string, error) {
|
||||
// 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")
|
||||
return fmt.Errorf("registration path cannot be empty")
|
||||
}
|
||||
|
||||
// Validate path segments
|
||||
segments := strings.Split(path, ".")
|
||||
for _, segment := range segments {
|
||||
// tinytoml.isValidKey doesn't exist, but we can use its logic criteria.
|
||||
// Assuming isValidKey checks for alphanumeric, underscore, dash, starting with letter/underscore.
|
||||
// We adapt the validation logic here based on tinytoml's description.
|
||||
if !isValidKeySegment(segment) {
|
||||
return "", fmt.Errorf("invalid path segment %q in path %q", segment, path)
|
||||
return fmt.Errorf("invalid path segment %q in path %q", segment, path)
|
||||
}
|
||||
}
|
||||
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
newUUID := uuid.NewString()
|
||||
item := registeredItem{
|
||||
path: path,
|
||||
c.items[path] = configItem{
|
||||
defaultValue: defaultValue,
|
||||
}
|
||||
c.registry[newUUID] = item
|
||||
|
||||
return newUUID, nil
|
||||
currentValue: defaultValue, // Initially set to default
|
||||
}
|
||||
|
||||
// Unregister removes a configuration key from the registry.
|
||||
// Subsequent calls to Get with this key will return (nil, false).
|
||||
// This does not remove the value from the underlying configuration data map,
|
||||
// only the ability to access it via this specific registration key.
|
||||
func (c *Config) Unregister(key string) {
|
||||
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()
|
||||
delete(c.registry, key)
|
||||
|
||||
if _, exists := c.items[path]; !exists {
|
||||
return fmt.Errorf("path not registered: %s", path)
|
||||
}
|
||||
|
||||
// isValidKeySegment checks if a single path segment is valid.
|
||||
// Adapts the logic described for tinytoml's isValidKey.
|
||||
func isValidKeySegment(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return false
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Get retrieves a configuration value using the unique key (UUID) obtained from Register.
|
||||
// It returns the value found in the loaded configuration or the registered default value.
|
||||
// The second return value (bool) indicates if the key was successfully registered (true) or not (false).
|
||||
func (c *Config) Get(key string) (any, bool) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Config) Get(path string) (any, bool) {
|
||||
c.mutex.RLock()
|
||||
item, registered := c.registry[key]
|
||||
defer c.mutex.RUnlock()
|
||||
|
||||
item, registered := c.items[path]
|
||||
if !registered {
|
||||
c.mutex.RUnlock()
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Lookup value in the data map using the item's path
|
||||
value, found := getValueFromMap(c.data, item.path)
|
||||
c.mutex.RUnlock() // Unlock after accessing both registry and data
|
||||
|
||||
if found {
|
||||
return value, true
|
||||
return item.currentValue, true
|
||||
}
|
||||
// Key was registered, but value not found in data, return default
|
||||
return item.defaultValue, true
|
||||
|
||||
// Set updates a configuration value for the given path.
|
||||
// It returns an error if the path is not registered.
|
||||
func (c *Config) Set(path string, value any) error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
item, registered := c.items[path]
|
||||
if !registered {
|
||||
return fmt.Errorf("path %s is not registered", path)
|
||||
}
|
||||
|
||||
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.
|
||||
// It populates the Config instance's internal data map.
|
||||
// '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) {
|
||||
@ -131,7 +237,9 @@ func (c *Config) Load(path string, args []string) (bool, error) {
|
||||
defer c.mutex.Unlock()
|
||||
|
||||
configExists := false
|
||||
loadedData := make(map[string]any) // Load into a temporary map first
|
||||
|
||||
// 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
|
||||
@ -140,42 +248,61 @@ func (c *Config) Load(path string, args []string) (bool, error) {
|
||||
return false, fmt.Errorf("failed to read config file '%s': %w", path, err)
|
||||
}
|
||||
|
||||
// Use tinytoml to unmarshal directly into the map
|
||||
// Pass a pointer to the map for Unmarshal
|
||||
if err := tinytoml.Unmarshal(fileData, &loadedData); err != nil {
|
||||
return false, fmt.Errorf("failed to parse 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) {
|
||||
// Handle potential errors from os.Stat other than file not existing
|
||||
return false, fmt.Errorf("failed to check config file '%s': %w", path, err)
|
||||
}
|
||||
|
||||
// Merge loaded data into the main config data
|
||||
// This ensures existing data (e.g. from defaults set programmatically before load) isn't wiped out
|
||||
mergeMaps(c.data, loadedData)
|
||||
// Flatten the nested map into path->value pairs
|
||||
flattenedData := flattenMap(nestedData, "")
|
||||
|
||||
// Parse and merge CLI arguments if any
|
||||
// Parse CLI arguments if any
|
||||
if len(args) > 0 {
|
||||
overrides, err := parseArgs(args)
|
||||
cliOverrides, err := parseArgs(args)
|
||||
if err != nil {
|
||||
return configExists, fmt.Errorf("failed to parse CLI args: %w", err)
|
||||
}
|
||||
// Merge overrides into the potentially file-loaded data
|
||||
mergeMaps(c.data, overrides)
|
||||
|
||||
// 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 stored in the Config instance to a TOML file.
|
||||
// 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()
|
||||
// Marshal requires the actual value, not a pointer if data is already a map
|
||||
dataToMarshal := c.data
|
||||
c.mutex.RUnlock() // Unlock before potentially long I/O
|
||||
|
||||
tomlData, err := tinytoml.Marshal(dataToMarshal)
|
||||
// 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)
|
||||
}
|
||||
@ -193,7 +320,7 @@ func (c *Config) Save(path string) error {
|
||||
defer os.Remove(tempFile.Name()) // Clean up temp file if rename fails
|
||||
|
||||
if _, err := tempFile.Write(tomlData); err != nil {
|
||||
tempFile.Close() // Close file before attempting remove on error path
|
||||
tempFile.Close()
|
||||
return fmt.Errorf("failed to write temp config file '%s': %w", tempFile.Name(), err)
|
||||
}
|
||||
if err := tempFile.Sync(); err != nil {
|
||||
@ -204,24 +331,92 @@ func (c *Config) Save(path string) error {
|
||||
return fmt.Errorf("failed to close temp config file '%s': %w", tempFile.Name(), err)
|
||||
}
|
||||
|
||||
// Use Rename for atomic replace
|
||||
if err := os.Rename(tempFile.Name(), path); err != nil {
|
||||
return fmt.Errorf("failed to rename temp file to '%s': %w", path, err)
|
||||
}
|
||||
|
||||
// Set permissions after successful rename
|
||||
if err := os.Chmod(path, 0644); err != nil {
|
||||
// Log or handle this non-critical error? For now, return it.
|
||||
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) {
|
||||
overrides := make(map[string]any)
|
||||
result := make(map[string]any)
|
||||
i := 0
|
||||
for i < len(args) {
|
||||
arg := args[i]
|
||||
@ -258,77 +453,85 @@ func parseArgs(args []string) (map[string]any, error) {
|
||||
value = valueStr // Keep as string if parsing fails
|
||||
}
|
||||
|
||||
// Build nested map structure based on dots in the keyPath
|
||||
keys := strings.Split(keyPath, ".")
|
||||
currentMap := overrides
|
||||
for j, key := range keys[:len(keys)-1] {
|
||||
// Ensure intermediate paths are maps
|
||||
if existingVal, ok := currentMap[key]; ok {
|
||||
if nestedMap, isMap := existingVal.(map[string]any); isMap {
|
||||
currentMap = nestedMap // Navigate deeper
|
||||
} else {
|
||||
// Error: trying to overwrite a non-map value with a nested structure
|
||||
return nil, fmt.Errorf("conflicting CLI key: %q is not a table but has subkey %q", strings.Join(keys[:j+1], "."), keys[j+1])
|
||||
// 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 {
|
||||
// Create intermediate map
|
||||
newMap := make(map[string]any)
|
||||
currentMap[key] = newMap
|
||||
currentMap = newMap
|
||||
}
|
||||
}
|
||||
// Set the final value
|
||||
lastKey := keys[len(keys)-1]
|
||||
currentMap[lastKey] = value
|
||||
}
|
||||
|
||||
return overrides, nil
|
||||
}
|
||||
|
||||
// mergeMaps recursively merges the 'override' map into the 'base' map.
|
||||
// Values in 'override' take precedence. If both values are maps, they are merged recursively.
|
||||
func mergeMaps(base map[string]any, override map[string]any) {
|
||||
if base == nil || override == nil {
|
||||
return // Avoid panic on nil maps, though caller should initialize
|
||||
}
|
||||
for key, overrideVal := range override {
|
||||
baseVal, _ := base[key]
|
||||
// Check if both values are maps for recursive merge
|
||||
baseMap, baseIsMap := baseVal.(map[string]any)
|
||||
overrideMap, overrideIsMap := overrideVal.(map[string]any)
|
||||
|
||||
if baseIsMap && overrideIsMap {
|
||||
// Recursively merge nested maps
|
||||
mergeMaps(baseMap, overrideMap)
|
||||
} else {
|
||||
// Override value (or add if key doesn't exist in base)
|
||||
base[key] = overrideVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getValueFromMap retrieves a value from a nested map using a dot-separated path.
|
||||
func getValueFromMap(data map[string]any, path string) (any, bool) {
|
||||
keys := strings.Split(path, ".")
|
||||
current := any(data) // Start with the top-level map
|
||||
|
||||
for _, key := range keys {
|
||||
if currentMap, ok := current.(map[string]any); ok {
|
||||
value, exists := currentMap[key]
|
||||
if !exists {
|
||||
return nil, false // Key not found at this level
|
||||
}
|
||||
current = value // Move to the next level
|
||||
} else {
|
||||
return nil, false // Path segment is not a map, cannot traverse further
|
||||
// Add leaf value
|
||||
flat[path] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Successfully traversed the entire path
|
||||
return current, true
|
||||
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
|
||||
}
|
||||
|
||||
// Helper functions adapted from tinytoml internal logic (as it's not exported)
|
||||
// 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')
|
||||
|
||||
11
go.mod
11
go.mod
@ -1,12 +1,7 @@
|
||||
module github.com/LixenWraith/config
|
||||
|
||||
go 1.24.0
|
||||
go 1.24.2
|
||||
|
||||
toolchain go1.24.2
|
||||
require github.com/LixenWraith/tinytoml v0.0.0-20250305012228-6862ba843264
|
||||
|
||||
require (
|
||||
github.com/LixenWraith/tinytoml v0.0.0-20250305012228-6862ba843264
|
||||
github.com/google/uuid v1.6.0
|
||||
)
|
||||
|
||||
require github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
require github.com/mitchellh/mapstructure v1.5.0
|
||||
|
||||
4
go.sum
4
go.sum
@ -1,8 +1,4 @@
|
||||
github.com/LixenWraith/tinytoml v0.0.0-20241125164826-37e61dcbf33b h1:zjNL89uvvL9xB65qKXQGrzVOAH0CWkxRmcbU2uyyUk4=
|
||||
github.com/LixenWraith/tinytoml v0.0.0-20241125164826-37e61dcbf33b/go.mod h1:27w5bMp6NIrEuelM/8a+htswf0Dohs/AZ9tSsQ+lnN0=
|
||||
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
|
||||
Reference in New Issue
Block a user