ajhahn.de
← eeco
Go 166 lines
package config

import (
	"fmt"
	"os"
	"sort"
	"strconv"
	"strings"
)

// keySpecs maps every settable config.local key to a getter that renders
// its effective value off a resolved *Config. It is the canonical list
// of operator-settable keys, powering `eeco config list|get|set`.
//
// Keep this map in sync with the parse switch in applyConfigFile
// (config.go): a key parsed there should appear here so it is settable
// and inspectable, and vice versa.
var keySpecs = map[string]func(*Config) string{
	"profile":                     func(c *Config) string { return string(c.Profile) },
	"gate":                        func(c *Config) string { return strings.Join(GateSteps(c.Gate), " && ") },
	"stale_days":                  func(c *Config) string { return strconv.Itoa(c.StaleDays) },
	"automation":                  func(c *Config) string { return string(c.Automation) },
	"workspace_history":           func(c *Config) string { return string(c.WorkspaceHistory) },
	"ai_command":                  func(c *Config) string { return strings.Join(c.AICommand, " ") },
	"ai_budget":                   func(c *Config) string { return strconv.Itoa(c.AIBudget) },
	"ai_provider":                 func(c *Config) string { return c.AIProvider },
	"ai_model":                    func(c *Config) string { return c.AIModel },
	"ai_api_key_env":              func(c *Config) string { return c.AIAPIKeyEnv },
	"session_settings_path":       func(c *Config) string { return c.SessionSettingsPath },
	"session_start_docs":          func(c *Config) string { return strings.Join(c.SessionStartDocs, " ") },
	"session_files":               func(c *Config) string { return strings.Join(c.SessionFiles, " ") },
	"session_start_mailbox":       func(c *Config) string { return c.SessionStartMailbox },
	"session_start_roadmap_glob":  func(c *Config) string { return c.SessionStartRoadmapGlob },
	"session_start_pinned_bodies": func(c *Config) string { return strconv.FormatBool(c.SessionStartPinnedBodies) },
	"handover_glob":               func(c *Config) string { return c.HandoverGlob },
	"bug_report_dir":              func(c *Config) string { return c.BugReportDir },
	"context_path":                func(c *Config) string { return c.ContextPath },
	"context_budget":              func(c *Config) string { return strconv.Itoa(c.ContextBudget) },
	"brief_include_notes":         func(c *Config) string { return strconv.FormatBool(c.BriefIncludeNotes) },
	"pre_commit_workflows":        func(c *Config) string { return strings.Join(c.PreCommitWorkflows, " ") },
	"post_merge_workflows":        func(c *Config) string { return strings.Join(c.PostMergeWorkflows, " ") },
	"version_locations":           func(c *Config) string { return strings.Join(c.VersionLocations, " ") },
	"version_anchor":              func(c *Config) string { return c.VersionAnchor },
	"attribution_pattern":         func(c *Config) string { return strings.Join(c.AttributionPatterns, " ") },
	"init_detection_threshold":    func(c *Config) string { return strconv.FormatFloat(c.InitDetectionThreshold, 'g', -1, 64) },
}

// KnownKeys returns the sorted list of operator-settable config keys.
func KnownKeys() []string {
	keys := make([]string, 0, len(keySpecs))
	for k := range keySpecs {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	return keys
}

// KnownKey reports whether key is a recognised config.local key.
func KnownKey(key string) bool {
	_, ok := keySpecs[key]
	return ok
}

// EffectiveValue renders the effective value of key off cfg. The second
// result is false when key is not a known config key.
func EffectiveValue(cfg *Config, key string) (string, bool) {
	get, ok := keySpecs[key]
	if !ok {
		return "", false
	}
	return get(cfg), true
}

// ValidateSetValue checks that key is a known config key and that val
// parses cleanly under the same rules Load applies. It is the guard for
// `eeco config set` so a typo'd key or malformed value is rejected
// before anything is written. It never mutates caller state.
func ValidateSetValue(key, val string) error {
	if !KnownKey(key) {
		return fmt.Errorf("unknown config key %q (run `eeco config list` for valid keys)", key)
	}
	// Validate the value format with the real parser by probing a
	// throwaway file against a throwaway config.
	tmp, err := os.CreateTemp("", "eeco-cfgval-*")
	if err != nil {
		return err
	}
	defer os.Remove(tmp.Name())
	if _, err := tmp.WriteString(key + "=" + val + "\n"); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return applyConfigFile(&Config{}, tmp.Name())
}

// DeclaredKeys returns the set of config keys explicitly declared in the
// config.local-format file at path (unknown keys included). A missing
// file or empty path yields an empty set, not an error.
func DeclaredKeys(path string) (map[string]bool, error) {
	out := map[string]bool{}
	if path == "" {
		return out, nil
	}
	b, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return out, nil
		}
		return nil, err
	}
	for _, raw := range strings.Split(string(b), "\n") {
		line := strings.TrimSpace(raw)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		k, _, ok := strings.Cut(line, "=")
		if !ok {
			continue
		}
		out[strings.TrimSpace(k)] = true
	}
	return out, nil
}

// ParseLocalFile reads a config.local-format file and returns its declared
// key→value pairs (unknown keys included, quotes stripped). For a key that
// appears more than once (a repeatable key such as gate) the last value wins —
// callers that need full multi-occurrence fidelity should copy the file
// verbatim instead. A missing file or empty path yields an empty map, not an
// error.
func ParseLocalFile(path string) (map[string]string, error) {
	out := map[string]string{}
	if path == "" {
		return out, nil
	}
	b, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return out, nil
		}
		return nil, err
	}
	for _, raw := range strings.Split(string(b), "\n") {
		line := strings.TrimSpace(raw)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		k, v, ok := strings.Cut(line, "=")
		if !ok {
			continue
		}
		out[strings.TrimSpace(k)] = unquote(strings.TrimSpace(v))
	}
	return out, nil
}

// GlobalConfigLocalPath is the exported path of the user-global
// config.local file (or "" when no global dir resolves). It lets command
// code attribute a key's origin to the global layer.
func GlobalConfigLocalPath() string {
	return globalConfigLocalPath()
}