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