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()
}