e1.0.0 More restructuring and feature add, full scope for this stage.

This commit is contained in:
2025-04-22 02:58:42 -04:00
parent 522b01e73e
commit e8519145c7
7 changed files with 761 additions and 368 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
data data
dev dev
logs logs
*.log

183
README.md
View File

@ -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. - **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 [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`). - **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. - **Atomic File Operations:** Ensures configuration files are written atomically to prevent corruption.
- **Path Validation:** Validates configuration path segments against TOML key requirements. - **Path Validation:** Validates configuration path segments against TOML key requirements.
- **Minimal Dependencies:** Relies only on `tinytoml` and `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 ## Installation
@ -21,7 +24,7 @@ go get github.com/LixenWraith/config
Dependencies will be automatically fetched: Dependencies will be automatically fetched:
```bash ```bash
github.com/LixenWraith/tinytoml github.com/LixenWraith/tinytoml
github.com/google/uuid github.com/mitchellh/mapstructure
``` ```
## Usage ## Usage
@ -32,21 +35,111 @@ github.com/google/uuid
// 1. Initialize a new Config instance // 1. Initialize a new Config instance
cfg := config.New() cfg := config.New()
// 2. Register configuration keys with paths and default values // 2. Register configuration paths with default values
keyServerHost, err := cfg.Register("server.host", "127.0.0.1") err := cfg.Register("server.host", "127.0.0.1")
keyServerPort, err := cfg.Register("server.port", 8080) err = cfg.Register("server.port", 8080)
// 3. Load configuration from file with CLI argument overrides // 3. Load configuration from file with CLI argument overrides
fileExists, err := cfg.Load("app_config.toml", os.Args[1:]) fileExists, err := cfg.Load("app_config.toml", os.Args[1:])
// 4. Access configuration values using the registered keys // 4. Access configuration values using the registered paths
serverHost, _ := cfg.Get(keyServerHost) serverHost, err := cfg.String("server.host")
serverPort, _ := cfg.Get(keyServerPort) 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) // 5. Save configuration (creates the file if it doesn't exist)
err = cfg.Save("app_config.toml") 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 ### CLI Arguments
Command-line arguments override file configuration using dot notation: 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. 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. - **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. - **defaultValue**: The value returned if no other value has been set through Load or Set.
- **Returns**: UUID string (key) for use with `Get` and error (nil on success) - **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`. - **path**: The dot-separated path string used during registration.
- **Returns**: The configuration value and a boolean indicating if the key was registered. - **Returns**: The configuration value and a boolean indicating if the path was registered.
- **Value precedence**: CLI Argument > Config File Value > Registered Default Value - **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)` ### `(*Config) Load(filePath string, args []string) (bool, error)`
Loads configuration from a TOML file and merges overrides from command-line arguments. Loads configuration from a TOML file and merges overrides from command-line arguments.
@ -103,25 +237,28 @@ Saves the current configuration to the specified TOML file path, performing an a
### Key Design Choices ### Key Design Choices
- **Thread Safety**: All operations are protected by a `sync.RWMutex` to support concurrent access. - **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. - **Unified Storage Model**: Uses a `configItem` struct to store both default values and current values for each path.
- **Map Structure**: Configuration is stored in nested maps of type `map[string]any`. - **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. - **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. - **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 ### Naming Conventions
- **Paths**: Configuration paths provided to `Register` (e.g., `"server.port"`) are dot-separated strings. - **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: - **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 (`_`). - Must start with a letter (a-z, A-Z) or an underscore (`_`).
- Subsequent characters can be letters, numbers (0-9), underscores (`_`), or hyphens (`-`). - Subsequent characters can be letters, numbers (0-9), underscores (`_`), or hyphens (`-`).
- Segments *cannot* contain dots (`.`). - Segments *cannot* contain dots (`.`).
### Type Handling Note ### 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. - 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)`). - 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. - 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 ### Merge Behavior Note

View File

@ -1,183 +1,257 @@
// Test program for the config package
package main package main
import ( import (
"fmt" "fmt"
"log" "github.com/LixenWraith/config"
"os" "os"
"path/filepath" "path/filepath"
"github.com/LixenWraith/config"
) )
const ( // LogConfig represents logging configuration parameters
initialConfigFile = "config_initial.toml" type LogConfig struct {
finalConfigFile = "config_final.toml" // Basic settings
) Level int64 `toml:"level"`
Name string `toml:"name"`
// Sample TOML content for the initial configuration file Directory string `toml:"directory"`
var initialTomlContent = ` Format string `toml:"format"` // "txt" or "json"
debug = true Extension string `toml:"extension"`
log_level = "info" # This will be overridden by default // Formatting
ShowTimestamp bool `toml:"show_timestamp"`
[server] ShowLevel bool `toml:"show_level"`
host = "localhost" // Buffer and size limits
port = 8080 # This will be overridden by CLI BufferSize int64 `toml:"buffer_size"` // Channel buffer size
MaxSizeMB int64 `toml:"max_size_mb"` // Max size per log file
[smtp] MaxTotalSizeMB int64 `toml:"max_total_size_mb"` // Max total size of all logs in dir
host = "mail.example.com" # This will be overridden by CLI MinDiskFreeMB int64 `toml:"min_disk_free_mb"` // Minimum free disk space required
port = 587 // Timers
auth_user = "file_user" FlushIntervalMs int64 `toml:"flush_interval_ms"` // Interval for flushing file buffer
# auth_pass is missing, will use default 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
func main() { // Disk check settings
// --- Setup: Create a temporary directory for config files --- DiskCheckIntervalMs int64 `toml:"disk_check_interval_ms"` // Base interval for disk checks
tempDir, err := os.MkdirTemp("", "config_example") EnableAdaptiveInterval bool `toml:"enable_adaptive_interval"` // Adjust interval based on log rate
if err != nil { MinCheckIntervalMs int64 `toml:"min_check_interval_ms"` // Minimum adaptive interval
log.Fatalf("Failed to create temp dir: %v", err) MaxCheckIntervalMs int64 `toml:"max_check_interval_ms"` // Maximum adaptive interval
}
defer os.RemoveAll(tempDir) // Clean up temp directory
initialPath := filepath.Join(tempDir, initialConfigFile)
finalPath := filepath.Join(tempDir, finalConfigFile)
// Write the initial TOML config file
err = os.WriteFile(initialPath, []byte(initialTomlContent), 0644)
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 ---")
} }
// Simple error handler func main() {
func handleErr(err error) { // 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 { if err != nil {
log.Fatalf("Error: %v", err) fmt.Printf("Error loading config: %v\n", err)
os.Exit(1)
}
fmt.Printf("Config file exists: %v (expected: false)\n", exists)
// Unmarshal into LogConfig struct
var logConfig LogConfig
err = cfg.UnmarshalSubtree("log", &logConfig)
if err != nil {
fmt.Printf("Error unmarshaling config: %v\n", err)
os.Exit(1)
}
// 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 {
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!")
} }
} }

503
config.go
View File

@ -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 package config
import ( import (
@ -6,124 +8,228 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" // Added sync import "sync"
"github.com/LixenWraith/tinytoml" "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. // configItem holds both the default and current value for a configuration path
type registeredItem struct { type configItem struct {
path string // Dot-separated path (e.g., "server.port")
defaultValue any defaultValue any
currentValue any
} }
// Config manages application configuration loaded from files and CLI arguments. // Config manages application configuration loaded from files and CLI arguments.
// It provides thread-safe access to configuration values.
type Config struct { type Config struct {
data map[string]any // Stores the actual configuration data (nested map) items map[string]configItem // Maps paths to config items (default and current values)
registry map[string]registeredItem // Maps generated UUIDs to registered items mutex sync.RWMutex // Protects concurrent access
mutex sync.RWMutex // Protects concurrent access to data and registry
} }
// New creates and initializes a new Config instance. // New creates and initializes a new Config instance.
func New() *Config { func New() *Config {
return &Config{ return &Config{
data: make(map[string]any), items: make(map[string]configItem),
registry: make(map[string]registeredItem),
// mutex is implicitly initialized
} }
} }
// 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"). // The path should be dot-separated (e.g., "server.port", "debug").
// Each segment of the path must be a valid TOML key identifier. // 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. // defaultValue is the value returned by Get if no specific value has been set.
func (c *Config) Register(path string, defaultValue any) (string, error) { func (c *Config) Register(path string, defaultValue any) error {
if path == "" { if path == "" {
return "", fmt.Errorf("registration path cannot be empty") return fmt.Errorf("registration path cannot be empty")
} }
// Validate path segments // Validate path segments
segments := strings.Split(path, ".") segments := strings.Split(path, ".")
for _, segment := range segments { 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) { 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() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
newUUID := uuid.NewString() c.items[path] = configItem{
item := registeredItem{
path: path,
defaultValue: defaultValue, defaultValue: defaultValue,
currentValue: defaultValue, // Initially set to default
} }
c.registry[newUUID] = item
return newUUID, nil return nil
} }
// Unregister removes a configuration key from the registry. // Unregister removes a configuration path and all its children.
// Subsequent calls to Get with this key will return (nil, false). func (c *Config) Unregister(path string) error {
// 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) {
c.mutex.Lock() c.mutex.Lock()
defer c.mutex.Unlock() defer c.mutex.Unlock()
delete(c.registry, key)
}
// isValidKeySegment checks if a single path segment is valid. if _, exists := c.items[path]; !exists {
// Adapts the logic described for tinytoml's isValidKey. return fmt.Errorf("path not registered: %s", path)
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 // Remove the path itself
if strings.ContainsRune(s, '.') { delete(c.items, path)
return false // Segments themselves cannot contain dots
} // Remove any child paths
if !isAlpha(firstChar) && firstChar != '_' { prefix := path + "."
return false for childPath := range c.items {
} if strings.HasPrefix(childPath, prefix) {
for _, r := range s[1:] { delete(c.items, childPath)
if !isAlpha(r) && !isNumeric(r) && r != '-' && r != '_' {
return false
} }
} }
return true
return nil
} }
// Get retrieves a configuration value using the unique key (UUID) obtained from Register. // Get retrieves a configuration value using the path.
// It returns the value found in the loaded configuration or the registered default value. // It returns the current value (or default if not explicitly set).
// The second return value (bool) indicates if the key was successfully registered (true) or not (false). // The second return value indicates if the path was registered.
func (c *Config) Get(key string) (any, bool) { func (c *Config) Get(path string) (any, bool) {
c.mutex.RLock() c.mutex.RLock()
item, registered := c.registry[key] defer c.mutex.RUnlock()
item, registered := c.items[path]
if !registered { if !registered {
c.mutex.RUnlock()
return nil, false return nil, false
} }
// Lookup value in the data map using the item's path return item.currentValue, true
value, found := getValueFromMap(c.data, item.path) }
c.mutex.RUnlock() // Unlock after accessing both registry and data
if found { // Set updates a configuration value for the given path.
return value, true // 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)
} }
// Key was registered, but value not found in data, return default
return item.defaultValue, true 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. // 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:]). // 'args' should be the command-line arguments (e.g., os.Args[1:]).
// Returns true if the configuration file was found and loaded, false otherwise. // Returns true if the configuration file was found and loaded, false otherwise.
func (c *Config) Load(path string, args []string) (bool, error) { 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() defer c.mutex.Unlock()
configExists := false 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() { if stat, err := os.Stat(path); err == nil && !stat.IsDir() {
configExists = true 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) return false, fmt.Errorf("failed to read config file '%s': %w", path, err)
} }
// Use tinytoml to unmarshal directly into the map if err := tinytoml.Unmarshal(fileData, &nestedData); err != nil {
// Pass a pointer to the map for Unmarshal return false, fmt.Errorf("failed to parse TOML config file '%s': %w", path, err)
if err := tinytoml.Unmarshal(fileData, &loadedData); err != nil {
return false, fmt.Errorf("failed to parse config file '%s': %w", path, err)
} }
} else if !os.IsNotExist(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) return false, fmt.Errorf("failed to check config file '%s': %w", path, err)
} }
// Merge loaded data into the main config data // Flatten the nested map into path->value pairs
// This ensures existing data (e.g. from defaults set programmatically before load) isn't wiped out flattenedData := flattenMap(nestedData, "")
mergeMaps(c.data, loadedData)
// Parse and merge CLI arguments if any // Parse CLI arguments if any
if len(args) > 0 { if len(args) > 0 {
overrides, err := parseArgs(args) cliOverrides, err := parseArgs(args)
if err != nil { if err != nil {
return configExists, fmt.Errorf("failed to parse CLI args: %w", err) 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 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. // It performs an atomic write using a temporary file.
func (c *Config) Save(path string) error { func (c *Config) Save(path string) error {
c.mutex.RLock() 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 { if err != nil {
return fmt.Errorf("failed to marshal config: %w", err) 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 defer os.Remove(tempFile.Name()) // Clean up temp file if rename fails
if _, err := tempFile.Write(tomlData); err != nil { 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) return fmt.Errorf("failed to write temp config file '%s': %w", tempFile.Name(), err)
} }
if err := tempFile.Sync(); err != nil { 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) 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 { if err := os.Rename(tempFile.Name(), path); err != nil {
return fmt.Errorf("failed to rename temp file to '%s': %w", path, err) 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 { 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 fmt.Errorf("failed to set permissions on config file '%s': %w", path, err)
} }
return nil 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. // parseArgs processes command-line arguments into a nested map structure.
// Expects arguments in the format "--key.subkey value" or "--booleanflag". // Expects arguments in the format "--key.subkey value" or "--booleanflag".
func parseArgs(args []string) (map[string]any, error) { func parseArgs(args []string) (map[string]any, error) {
overrides := make(map[string]any) result := make(map[string]any)
i := 0 i := 0
for i < len(args) { for i < len(args) {
arg := args[i] arg := args[i]
@ -258,77 +453,85 @@ func parseArgs(args []string) (map[string]any, error) {
value = valueStr // Keep as string if parsing fails value = valueStr // Keep as string if parsing fails
} }
// Build nested map structure based on dots in the keyPath // Set the value in the result map
keys := strings.Split(keyPath, ".") setNestedValue(result, keyPath, value)
currentMap := overrides }
for j, key := range keys[:len(keys)-1] {
// Ensure intermediate paths are maps return result, nil
if existingVal, ok := currentMap[key]; ok { }
if nestedMap, isMap := existingVal.(map[string]any); isMap {
currentMap = nestedMap // Navigate deeper // flattenMap converts a nested map to a flat map with dot-notation paths.
} else { func flattenMap(nested map[string]any, prefix string) map[string]any {
// Error: trying to overwrite a non-map value with a nested structure flat := make(map[string]any)
return nil, fmt.Errorf("conflicting CLI key: %q is not a table but has subkey %q", strings.Join(keys[:j+1], "."), keys[j+1])
} for key, value := range nested {
} else { path := key
// Create intermediate map if prefix != "" {
newMap := make(map[string]any) path = prefix + "." + key
currentMap[key] = newMap }
currentMap = newMap
if nestedMap, isMap := value.(map[string]any); isMap {
// Recursively flatten nested maps
for subPath, subValue := range flattenMap(nestedMap, path) {
flat[subPath] = subValue
} }
}
// 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 { } else {
// Override value (or add if key doesn't exist in base) // Add leaf value
base[key] = overrideVal flat[path] = value
}
}
}
// 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
} }
} }
// Successfully traversed the entire path return flat
return current, true }
// 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) // isAlpha checks if a character is a letter (A-Z, a-z)
func isAlpha(c rune) bool { func isAlpha(c rune) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')

11
go.mod
View File

@ -1,12 +1,7 @@
module github.com/LixenWraith/config 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 ( require github.com/mitchellh/mapstructure v1.5.0
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

4
go.sum
View File

@ -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 h1:p2hpE672qTRuhR9FAt7SIHp8aP0pJbBKushCiIRNRpo=
github.com/LixenWraith/tinytoml v0.0.0-20250305012228-6862ba843264/go.mod h1:pm+BQlZ/VQC30uaB5Vfeih2b77QkGIiMvu+QgG/XOTk= 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 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=

View File

@ -1,13 +0,0 @@
debug = true
[server]
host = "localhost"
max_conns = 1000
port = 9090
read_timeout = 30000000000
write_timeout = 30000000000
[smtp]
auth_pass = "default123"
auth_user = "admin"
from_addr = "noreply@example.com"
host = "mail.example.com"
port = "587"