e0.1.0 Project restructure and feature add.
This commit is contained in:
353
config.go
353
config.go
@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user