package config import ( "bytes" "errors" "fmt" "os" "path/filepath" "strconv" "strings" "github.com/BurntSushi/toml" ) // Load reads configuration from a TOML file and merges overrides from command-line arguments. // 'args' should be the command-line arguments (e.g., os.Args[1:]). // It returns an error if loading or parsing fails. // Specific errors ErrConfigNotFound and ErrCLIParse can be checked using errors.Is. func (c *Config) Load(path string, args []string) error { c.mutex.Lock() defer c.mutex.Unlock() var errNotFound error var errCLI error fileConfig := make(map[string]any) // Holds only file data // --- Load from file --- fileData, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { errNotFound = ErrConfigNotFound // fileData is nil, proceed to CLI args } else { return fmt.Errorf("failed to read config file '%s': %w", path, err) } } else if err := toml.Unmarshal(fileData, &fileConfig); err != nil { return fmt.Errorf("failed to parse TOML config file '%s': %w", path, err) } // --- Flatten file data --- flattenedFileConfig := flattenMap(fileConfig, "") // --- Parse CLI arguments --- cliOverrides := make(map[string]any) // Holds only CLI args data if len(args) > 0 { parsedCliMap, parseErr := parseArgs(args) // parseArgs returns a nested map if parseErr != nil { // Wrap the CLI parsing error with our specific error type errCLI = fmt.Errorf("%w: %w", ErrCLIParse, parseErr) // Do not return yet, proceed to merge what we have } else { // Flatten the nested map from CLI args only if parsing succeeded cliOverrides = flattenMap(parsedCliMap, "") } } // --- Merge and Update Internal State --- // Iterate through registered paths to apply loaded/default values correctly. // The order of precedence is: CLI > File > Registered Default for regPath, item := range c.items { // 1. Check CLI overrides (only if CLI parsing succeeded) if errCLI == nil { if cliVal, cliExists := cliOverrides[regPath]; cliExists { item.currentValue = cliVal c.items[regPath] = item continue } } // 2. Check File config (if no CLI override or CLI parsing failed) if fileVal, fileExists := flattenedFileConfig[regPath]; fileExists { item.currentValue = fileVal } else { // 3. Use Default (if not in CLI or File) item.currentValue = item.defaultValue } c.items[regPath] = item } return errors.Join(errNotFound, errCLI) } // Save writes the current configuration to a TOML file atomically. // Only registered paths are saved. func (c *Config) Save(path string) error { c.mutex.RLock() nestedData := make(map[string]any) for itemPath, item := range c.items { setNestedValue(nestedData, itemPath, item.currentValue) } c.mutex.RUnlock() // --- Marshal using BurntSushi/toml --- var buf bytes.Buffer encoder := toml.NewEncoder(&buf) // encoder.Indent = " " // Optional use of 2 spaces for indentation if err := encoder.Encode(nestedData); err != nil { return fmt.Errorf("failed to marshal config data to TOML: %w", err) } tomlData := buf.Bytes() // --- End Marshal --- // --- Atomic write logic --- dir := filepath.Dir(path) // Ensure the directory exists if err := os.MkdirAll(dir, 0755); err != nil { // 0755 allows owner rwx, group rx, other rx return fmt.Errorf("failed to create config directory '%s': %w", dir, err) } // Create a temporary file in the same directory tempFile, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp") if err != nil { return fmt.Errorf("failed to create temporary config file in '%s': %w", dir, err) } // Defer cleanup in case of errors during write/rename tempFilePath := tempFile.Name() removed := false defer func() { if !removed { os.Remove(tempFilePath) // Clean up temp file if rename fails or we panic } }() // Write data to the temporary file if _, err := tempFile.Write(tomlData); err != nil { tempFile.Close() // Close file before returning error return fmt.Errorf("failed to write temp config file '%s': %w", tempFilePath, err) } // Sync data to disk if err := tempFile.Sync(); err != nil { tempFile.Close() return fmt.Errorf("failed to sync temp config file '%s': %w", tempFilePath, err) } // Close the temporary file if err := tempFile.Close(); err != nil { return fmt.Errorf("failed to close temp config file '%s': %w", tempFilePath, err) } // Set permissions on the temporary file *before* renaming (safer) // Use 0644: owner rw, group r, other r if err := os.Chmod(tempFilePath, 0644); err != nil { return fmt.Errorf("failed to set permissions on temporary config file '%s': %w", tempFilePath, err) } // Atomically replace the original file with the temporary file if err := os.Rename(tempFilePath, path); err != nil { return fmt.Errorf("failed to rename temp file '%s' to '%s': %w", tempFilePath, path, err) } removed = true // Mark temp file as successfully renamed return nil } // parseArgs processes command-line arguments into a nested map structure. // Expects arguments in the format: // // --key.subkey=value // --key.subkey value // --booleanflag (implicitly true) // --booleanflag=true // --booleanflag=false // // Values are parsed into bool, int64, float64, or string. // Returns an error if a key segment is invalid. func parseArgs(args []string) (map[string]any, error) { result := make(map[string]any) i := 0 for i < len(args) { arg := args[i] if !strings.HasPrefix(arg, "--") { // Skip non-flag arguments i++ continue } // Remove the leading "--" argContent := strings.TrimPrefix(arg, "--") if argContent == "" { // Skip "--" argument if used as a separator i++ continue } var keyPath string var valueStr string // Check for "--key=value" format if strings.Contains(argContent, "=") { parts := strings.SplitN(argContent, "=", 2) keyPath = parts[0] valueStr = parts[1] i++ // Consume only this argument } else { // Handle "--key value" or "--booleanflag" keyPath = argContent // Check if it's potentially a boolean flag (next arg starts with -- or end of args) isBoolFlag := i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") if isBoolFlag { // Assume boolean flag is true if no value follows valueStr = "true" i++ // Consume only the flag argument } else { // Potential key-value pair with space separation valueStr = args[i+1] i += 2 // Consume flag and value arguments } } // Validate keyPath segments *after* extracting the key segments := strings.Split(keyPath, ".") for _, segment := range segments { if !isValidKeySegment(segment) { // Return a specific error indicating the problem return nil, fmt.Errorf("invalid command-line key segment %q in path %q", segment, keyPath) } } // Attempt to parse the value string into richer types 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 } else if v, err := strconv.ParseFloat(valueStr, 64); err == nil { value = v } else { // Keep as string if no other parsing succeeded // Remove surrounding quotes if present if len(valueStr) >= 2 && valueStr[0] == '"' && valueStr[len(valueStr)-1] == '"' { value = valueStr[1 : len(valueStr)-1] } else { value = valueStr } } setNestedValue(result, keyPath, value) } return result, nil }