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

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
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,
currentValue: defaultValue, // Initially set to default
}
c.registry[newUUID] = item
return newUUID, nil
return 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) {
// 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)
}
// 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
if _, exists := c.items[path]; !exists {
return fmt.Errorf("path not registered: %s", path)
}
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
// Remove the path itself
delete(c.items, path)
// Remove any child paths
prefix := path + "."
for childPath := range c.items {
if strings.HasPrefix(childPath, prefix) {
delete(c.items, childPath)
}
}
return true
return nil
}
// 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) {
// 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
return item.currentValue, true
}
if found {
return value, 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)
}
// 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.
// 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])
}
} else {
// Create intermediate map
newMap := make(map[string]any)
currentMap[key] = newMap
currentMap = newMap
// 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
}
}
// 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')