e0.1.0 Project restructure and feature add.

This commit is contained in:
2025-04-21 00:39:33 -04:00
parent 2f7aafcbb8
commit 522b01e73e
7 changed files with 579 additions and 200 deletions

View File

@ -1,6 +1,6 @@
BSD 3-Clause License
Copyright (c) 2024, Lixen Wraith
Copyright (c) 2025, Lixen Wraith
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

151
README.md
View File

@ -1,70 +1,143 @@
# Config
A simple configuration management package for Go applications that supports TOML files and CLI arguments.
A simple, thread-safe configuration management package for Go applications that supports TOML files, command-line argument overrides, and registered default values.
## Features
- TOML configuration with [tinytoml](https://github.com/LixenWraith/tinytoml)
- Command line argument overrides with dot notation
- Default config handling
- Atomic file operations
- No external dependencies beyond tinytoml
- **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.
- **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`.
## Installation
```bash
go get github.com/example/config
go get github.com/LixenWraith/config
```
Dependencies will be automatically fetched:
```bash
github.com/LixenWraith/tinytoml
github.com/google/uuid
```
## Usage
### Basic Usage Pattern
```go
type AppConfig struct {
Server struct {
Host string `toml:"host"`
Port int `toml:"port"`
} `toml:"server"`
}
// 1. Initialize a new Config instance
cfg := config.New()
func main() {
cfg := AppConfig{
Host: "localhost",
Port: 8080,
} // default config
exists, err := config.LoadConfig("config.toml", &cfg, os.Args[1:])
if err != nil {
log.Fatal(err)
}
// 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)
if !exists {
if err := config.SaveConfig("config.toml", &cfg); err != nil {
log.Fatal(err)
}
}
}
// 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)
// 5. Save configuration (creates the file if it doesn't exist)
err = cfg.Save("app_config.toml")
```
### CLI Arguments
Override config values using dot notation:
Command-line arguments override file configuration using dot notation:
```bash
./app --server.host localhost --server.port 8080
# Override server port and enable debug mode
./your_app --server.port 9090 --debug
# Override nested database setting
./your_app --database.connection.pool_size 50
```
Flags without values are treated as boolean `true`.
## API
### `LoadConfig(path string, config interface{}, args []string) (bool, error)`
Loads configuration from TOML file and CLI args. Returns true if config file exists.
### `New() *Config`
### `SaveConfig(path string, config interface{}) error`
Saves configuration to TOML file atomically.
Creates and returns a new, initialized `*Config` instance ready for use.
## Limitations
### `(*Config) Register(path string, defaultValue any) (string, error)`
- Supports only basic Go types and structures supported by tinytoml
- CLI arguments must use `--key value` format
- Indirect dependency on [mapstructure](https://github.com/mitchellh/mapstructure) through tinytoml
Registers a configuration path with a default value and returns a unique UUID 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.
- **Returns**: UUID string (key) for use with `Get` and error (nil on success)
### `(*Config) Get(key string) (any, bool)`
Retrieves a configuration value using the UUID key from `Register`.
- **key**: The UUID string returned by `Register`.
- **Returns**: The configuration value and a boolean indicating if the key was registered.
- **Value precedence**: CLI Argument > Config File Value > Registered Default Value
### `(*Config) Load(filePath string, args []string) (bool, error)`
Loads configuration from a TOML file and merges overrides from command-line arguments.
- **filePath**: Path to the TOML configuration file.
- **args**: Command-line arguments (e.g., `os.Args[1:]`).
- **Returns**: Boolean indicating if the file existed and a nil error on success.
### `(*Config) Save(filePath string) error`
Saves the current configuration to the specified TOML file path, performing an atomic write.
- **filePath**: Path where the TOML configuration file will be written.
- **Returns**: Error if marshaling or file operations fail, nil on success.
## Implementation Details
### Key Design Choices
- **Thread Safety**: All operations are protected by a `sync.RWMutex` to support concurrent access.
- **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`.
- **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.
### Naming Conventions
- **Paths**: Configuration paths provided to `Register` (e.g., `"server.port"`) are dot-separated strings.
- **Segments**: Each part of the path between dots (a "segment") must adhere to TOML key naming rules:
- Must start with a letter (a-z, A-Z) or an underscore (`_`).
- Subsequent characters can be letters, numbers (0-9), underscores (`_`), or hyphens (`-`).
- Segments *cannot* contain dots (`.`).
### Type Handling Note
- Values loaded from TOML files or parsed from CLI arguments often result in specific types (e.g., `int64` for integers, `float64` for floats) due to the underlying `tinytoml` and `strconv` packages.
- This might differ from the type of a default value provided during `Register` (e.g., default `int(8080)` vs. loaded `int64(8080)`).
- When retrieving values using `Get`, be mindful of this potential difference and use appropriate type assertions or checks. Consider using `int64` or `float64` for default values where applicable to maintain consistency.
### Merge Behavior Note
- The internal merging logic (used during `Load`) performs a deep merge for nested maps.
- However, non-map types like slices are assigned by reference during the merge. If the source map containing the slice is modified after merging, the change might be reflected in the config data. This is generally not an issue with the standard Load workflow but should be noted if using merge logic independently.
### Limitations
- Supports only basic Go types and structures compatible with the tinytoml package.
- CLI arguments must use `--key value` or `--booleanflag` format.
- Path segments must start with letter/underscore, followed by letters/numbers/dashes/underscores.
## Examples
Complete example programs demonstrating the config package are available in the `cmd` directory.
## License
BSD-3
BSD-3-Clause

183
cmd/main.go Normal file
View File

@ -0,0 +1,183 @@
package main
import (
"fmt"
"log"
"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
`
func main() {
// --- Setup: Create a temporary directory for config files ---
tempDir, err := os.MkdirTemp("", "config_example")
if err != nil {
log.Fatalf("Failed to create temp dir: %v", err)
}
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 handleErr(err error) {
if err != nil {
log.Fatalf("Error: %v", err)
}
}

353
config.go
View File

@ -1,149 +1,340 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"sync" // Added sync import
"github.com/LixenWraith/tinytoml"
"github.com/google/uuid" // Added uuid import
)
type cliArg struct {
key string
value string
// registeredItem holds metadata for a configuration value registered for access.
type registeredItem struct {
path string // Dot-separated path (e.g., "server.port")
defaultValue any
}
func Load(path string, config interface{}, args []string) (bool, error) {
if config == nil {
return false, fmt.Errorf("config cannot be nil")
// 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
}
// 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
}
}
// Register makes a configuration path known to the Config instance and returns a unique key (UUID) for accessing it.
// 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) {
if path == "" {
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)
}
}
c.mutex.Lock()
defer c.mutex.Unlock()
newUUID := uuid.NewString()
item := registeredItem{
path: path,
defaultValue: defaultValue,
}
c.registry[newUUID] = item
return newUUID, nil
}
// 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) {
c.mutex.Lock()
defer c.mutex.Unlock()
delete(c.registry, key)
}
// 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
}
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) {
c.mutex.RLock()
item, registered := c.registry[key]
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
}
// Key was registered, but value not found in data, return default
return item.defaultValue, true
}
// 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) {
c.mutex.Lock()
defer c.mutex.Unlock()
configExists := false
loadedData := make(map[string]any) // Load into a temporary map first
if stat, err := os.Stat(path); err == nil && !stat.IsDir() {
configExists = true
data, err := os.ReadFile(path)
fileData, err := os.ReadFile(path)
if err != nil {
return false, fmt.Errorf("failed to read config file: %w", err)
return false, fmt.Errorf("failed to read config file '%s': %w", path, err)
}
if err := tinytoml.Unmarshal(data, config); err != nil {
return false, fmt.Errorf("failed to parse config file: %w", 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)
}
} 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)
// Parse and merge CLI arguments if any
if len(args) > 0 {
overrides, err := parseArgs(args)
if err != nil {
return configExists, fmt.Errorf("failed to parse CLI args: %w", err)
}
if err := mergeConfig(config, overrides); err != nil {
return configExists, fmt.Errorf("failed to merge CLI args: %w", err)
}
// Merge overrides into the potentially file-loaded data
mergeMaps(c.data, overrides)
}
return configExists, nil
}
func Save(path string, config interface{}) error {
v := reflect.ValueOf(config)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
// Save writes the current configuration stored in the Config instance 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
if v.Kind() != reflect.Struct {
return fmt.Errorf("config must be a struct or pointer to struct")
}
data, err := tinytoml.Marshal(v.Interface())
tomlData, err := tinytoml.Marshal(dataToMarshal)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
// Atomic write logic
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
return fmt.Errorf("failed to create config directory '%s': %w", dir, err)
}
tempFile := path + ".tmp"
if err := os.WriteFile(tempFile, data, 0644); err != nil {
return fmt.Errorf("failed to write temp config file: %w", err)
tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("failed to create temporary config file: %w", err)
}
defer os.Remove(tempFile.Name()) // Clean up temp file if rename fails
if _, err := tempFile.Write(tomlData); err != nil {
tempFile.Close() // Close file before attempting remove on error path
return fmt.Errorf("failed to write temp config file '%s': %w", tempFile.Name(), err)
}
if err := tempFile.Sync(); err != nil {
tempFile.Close()
return fmt.Errorf("failed to sync temp config file '%s': %w", tempFile.Name(), err)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("failed to close temp config file '%s': %w", tempFile.Name(), err)
}
if err := os.Rename(tempFile, path); err != nil {
os.Remove(tempFile)
return fmt.Errorf("failed to save config file: %w", 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
}
func parseArgs(args []string) (map[string]interface{}, error) {
parsed := make([]cliArg, 0, len(args))
for i := 0; i < len(args); i++ {
// 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)
i := 0
for i < len(args) {
arg := args[i]
if !strings.HasPrefix(arg, "--") {
i++ // Skip non-flag arguments
continue
}
key := strings.TrimPrefix(arg, "--")
keyPath := strings.TrimPrefix(arg, "--")
if keyPath == "" {
i++ // Skip "--" argument
continue
}
var valueStr string
// Check if it's a boolean flag (next arg starts with -- or end of args)
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") {
parsed = append(parsed, cliArg{key: key, value: "true"})
continue
}
parsed = append(parsed, cliArg{key: key, value: args[i+1]})
i++
}
result := make(map[string]interface{})
for _, arg := range parsed {
keys := strings.Split(arg.key, ".")
current := result
for i, k := range keys[:len(keys)-1] {
if _, exists := current[k]; !exists {
current[k] = make(map[string]interface{})
}
if nested, ok := current[k].(map[string]interface{}); ok {
current = nested
} else {
return nil, fmt.Errorf("invalid nested key at %s", strings.Join(keys[:i+1], "."))
}
}
lastKey := keys[len(keys)-1]
if val, err := strconv.ParseBool(arg.value); err == nil {
current[lastKey] = val
} else if val, err := strconv.ParseInt(arg.value, 10, 64); err == nil {
current[lastKey] = val
} else if val, err := strconv.ParseFloat(arg.value, 64); err == nil {
current[lastKey] = val
valueStr = "true" // Assume boolean flag if no value provided
i++ // Consume only the flag
} else {
current[lastKey] = arg.value
valueStr = args[i+1]
i += 2 // Consume flag and value
}
// Try to parse the value into bool, int, float, otherwise keep as string
var value any
if v, err := strconv.ParseBool(valueStr); err == nil {
value = v
} else if v, err := strconv.ParseInt(valueStr, 10, 64); err == nil {
value = v // Store as int64
} else if v, err := strconv.ParseFloat(valueStr, 64); err == nil {
value = v
} else {
value = valueStr // Keep as string if parsing fails
}
// 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])
}
} 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 result, nil
return overrides, nil
}
func mergeConfig(base interface{}, override map[string]interface{}) error {
baseValue := reflect.ValueOf(base)
if baseValue.Kind() != reflect.Ptr || baseValue.IsNil() {
return fmt.Errorf("base config must be a non-nil pointer")
// 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
}
}
data, err := json.Marshal(override)
if err != nil {
return fmt.Errorf("failed to marshal override values: %w", err)
}
// Successfully traversed the entire path
return current, true
}
if err := json.Unmarshal(data, base); err != nil {
return fmt.Errorf("failed to merge override values: %w", err)
}
// 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')
}
return nil
// isNumeric checks if a character is a digit (0-9)
func isNumeric(c rune) bool {
return c >= '0' && c <= '9'
}

View File

@ -1,77 +0,0 @@
package main
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/LixenWraith/config"
)
type SMTPConfig struct {
Host string `toml:"host"`
Port string `toml:"port"`
FromAddr string `toml:"from_addr"`
AuthUser string `toml:"auth_user"`
AuthPass string `toml:"auth_pass"`
}
type ServerConfig struct {
Host string `toml:"host"`
Port int `toml:"port"`
ReadTimeout time.Duration `toml:"read_timeout"`
WriteTimeout time.Duration `toml:"write_timeout"`
MaxConns int `toml:"max_conns"`
}
type AppConfig struct {
SMTP SMTPConfig `toml:"smtp"`
Server ServerConfig `toml:"server"`
Debug bool `toml:"debug"`
}
func main() {
defaultConfig := AppConfig{
SMTP: SMTPConfig{
Host: "smtp.example.com",
Port: "587",
FromAddr: "noreply@example.com",
AuthUser: "admin",
AuthPass: "default123",
},
Server: ServerConfig{
Host: "localhost",
Port: 8080,
ReadTimeout: time.Second * 30,
WriteTimeout: time.Second * 30,
MaxConns: 1000,
},
Debug: true,
}
configPath := filepath.Join(".", "test.toml")
cfg := defaultConfig
// CLI argument usage example to override SMTP host and server port of existing and default config:
// ./main --smtp.host mail.example.com --server.port 9090
exists, err := config.Load(configPath, &cfg, os.Args[1:])
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err)
os.Exit(1)
}
if !exists {
if err := config.Save(configPath, &cfg); err != nil {
fmt.Fprintf(os.Stderr, "Failed to save default config: %v\n", err)
os.Exit(1)
}
fmt.Println("Created default config at:", configPath)
}
fmt.Printf("Running with config:\n")
fmt.Printf("Server: %s:%d\n", cfg.Server.Host, cfg.Server.Port)
fmt.Printf("SMTP: %s:%s\n", cfg.SMTP.Host, cfg.SMTP.Port)
fmt.Printf("Debug: %v\n", cfg.Debug)
}

9
go.mod
View File

@ -1,7 +1,12 @@
module github.com/LixenWraith/config
go 1.23.4
go 1.24.0
require github.com/LixenWraith/tinytoml v0.0.0-20241125164826-37e61dcbf33b
toolchain go1.24.2
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

4
go.sum
View File

@ -1,4 +1,8 @@
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=