ajhahn.de
← eeco
Go 105 lines
package config

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

// LocalFilename is the per-workspace override file name.
const LocalFilename = "config.local"

// WriteLocalKeys upserts key=value pairs into <workspace>/config.local,
// preserving comments, blank lines, unknown keys, and line order. An
// existing non-comment line whose key matches is replaced in place; a
// key not yet present is appended (appended keys in sorted order for a
// deterministic file). The workspace directory must already exist —
// settings are an initialised-workspace operation, like gc and new.
//
// It only edits config.local inside the gitignored workspace; it never
// touches the tracked tree.
func WriteLocalKeys(cfg *Config, kv map[string]string) error {
	if cfg == nil {
		return errors.New("WriteLocalKeys: nil config")
	}
	if len(kv) == 0 {
		return nil
	}
	info, err := os.Stat(cfg.Workspace)
	if err != nil || !info.IsDir() {
		return fmt.Errorf("workspace %s is not initialised", cfg.Workspace)
	}
	return upsertKeys(filepath.Join(cfg.Workspace, LocalFilename), kv)
}

// WriteGlobalKeys upserts key=value pairs into the user-global
// config.local (GlobalConfigDir()/config.local), creating the global
// directory if absent. Same upsert semantics as WriteLocalKeys. This is
// the one writer that touches a file outside any repo, by design — it
// backs `eeco config set --global`, the cross-project settings layer.
func WriteGlobalKeys(kv map[string]string) error {
	if len(kv) == 0 {
		return nil
	}
	dir := GlobalConfigDir()
	if dir == "" {
		return errors.New("cannot resolve a global config directory (set EECO_CONFIG_HOME or HOME)")
	}
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("create global config dir: %w", err)
	}
	return upsertKeys(filepath.Join(dir, LocalFilename), kv)
}

// upsertKeys writes key=value pairs into the config.local-format file at
// path, preserving comments, blank lines, unknown keys, and line order.
// An existing non-comment line whose key matches is replaced in place; a
// key not yet present is appended (appended keys in sorted order for a
// deterministic file).
func upsertKeys(path string, kv map[string]string) error {
	existing, err := os.ReadFile(path)
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("read %s: %w", LocalFilename, err)
	}

	written := map[string]bool{}
	var out []string
	if len(existing) > 0 {
		for _, raw := range strings.Split(strings.TrimRight(string(existing), "\n"), "\n") {
			trimmed := strings.TrimSpace(raw)
			if trimmed == "" || strings.HasPrefix(trimmed, "#") {
				out = append(out, raw)
				continue
			}
			k, _, ok := strings.Cut(trimmed, "=")
			if !ok {
				out = append(out, raw)
				continue
			}
			key := strings.TrimSpace(k)
			if v, replace := kv[key]; replace && !written[key] {
				out = append(out, key+"="+v)
				written[key] = true
				continue
			}
			out = append(out, raw)
		}
	}

	var fresh []string
	for k := range kv {
		if !written[k] {
			fresh = append(fresh, k)
		}
	}
	sort.Strings(fresh)
	for _, k := range fresh {
		out = append(out, k+"="+kv[k])
	}

	return os.WriteFile(path, []byte(strings.Join(out, "\n")+"\n"), 0o644)
}