package config import ( "fmt" "os" "path/filepath" "runtime" ) // SettingSource records where a resolved setting's value came from. // Defined high-to-low priority order is implicit in the resolver: // CLIFlag > EnvVar > ConfigFileSrc > Builtin. PlatformOverride is a // special-case post-resolution adjustment (currently used only for // session_mode on native Windows). type SettingSource int const ( // Builtin: ctask's compiled-in default. Builtin SettingSource = iota // ConfigFileSrc: value came from the global config.yaml. // Named with the "Src" suffix to avoid colliding with the // ConfigFile type in this package. ConfigFileSrc // EnvVar: value came from an environment variable. EnvVar // CLIFlag: value came from a CLI flag. Reserved for v0.6 Phase 2 // (--agent) and later flags; no Phase 1 setting reaches this source. CLIFlag // PlatformOverride: ctask itself overrode the user-configured value // because the requested setting is unsupported on the current // platform (currently: persistent session_mode on native Windows). PlatformOverride ) // String returns the human-readable label used in doctor/info output. func (s SettingSource) String() string { switch s { case Builtin: return "built-in default" case ConfigFileSrc: return "config file" case EnvVar: return "env var" case CLIFlag: return "CLI flag" case PlatformOverride: return "platform override" default: return fmt.Sprintf("unknown source (%d)", int(s)) } } // ResolvedSetting carries a single setting's effective value plus // provenance. Override (if non-nil) chains to the next-lower-priority // source that contributed a value but was overridden — letting // doctor/info render lines like "source: CTASK_AGENT env var // (overrides config file: claude)". type ResolvedSetting struct { Key string Value string Source SettingSource EnvName string // for EnvVar source, the env-var name Override *ResolvedSetting } // Resolver carries the loaded config file plus a snapshot of relevant // env vars so callers can compute each setting's effective value and // its source. Build one with LoadResolver() per command invocation; // reuse across calls within the same invocation to keep doctor/info // output internally consistent. type Resolver struct { ConfigPath string // path consulted (regardless of whether file exists) ConfigErr error // non-nil when config file failed to load cfg *ConfigFile envCtaskRoot string envProjectRoot string envSeedDir string envAgent string envSessionMode string envEditor string } // configPathForTest, when non-empty, overrides ConfigFilePath() for // the duration of a test. The legacy production code path consults // ConfigFilePath() directly. var configPathForTest string // SetConfigPathForTest points the resolver at path for the duration // of t. Use this in any test that depends on Builtin-source resolution // (so a real ~/.config/ctask/config.yaml on the developer host cannot // pollute the assertion). Pass a path under t.TempDir() to guarantee // the file does not exist. func SetConfigPathForTest(t interface{ Cleanup(func()) }, path string) { prev := configPathForTest configPathForTest = path t.Cleanup(func() { configPathForTest = prev }) } // isNativeWindowsForTest lets tests simulate the native-Windows-no-WSL // platform regardless of the actual GOOS. Production callers use the // real check via isNativeWindows(). var isNativeWindowsForTest func() bool // SetIsNativeWindowsForTest replaces the native-Windows detector for // the duration of t. Use it from tests outside the config package // that need to exercise the session_mode platform-override path // (or, conversely, disable the override on a Windows host so a test // can assert pre-override layering). Restored via t.Cleanup. func SetIsNativeWindowsForTest(t interface{ Cleanup(func()) }, f func() bool) { prev := isNativeWindowsForTest isNativeWindowsForTest = f t.Cleanup(func() { isNativeWindowsForTest = prev }) } // isNativeWindows reports whether ctask is running on Windows outside // of a WSL distro. WSL exports WSL_DISTRO_NAME for any process started // from a WSL shell. func isNativeWindows() bool { if isNativeWindowsForTest != nil { return isNativeWindowsForTest() } return runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == "" } // resolverPath returns the config-file path the resolver should read, // honoring the test override when present. func resolverPath() string { if configPathForTest != "" { return configPathForTest } return ConfigFilePath() } // LoadResolver reads the config file (if present) and snapshots the // environment variables the resolver needs. Always returns a non-nil // Resolver — when the config file is missing or invalid, the resolver // still serves builtin and env-derived values. func LoadResolver() *Resolver { r := &Resolver{ConfigPath: resolverPath()} cfg, err := LoadConfigFile(r.ConfigPath) if err != nil { r.ConfigErr = err } else if cfg != nil { r.cfg = cfg } r.envCtaskRoot = os.Getenv("CTASK_ROOT") r.envProjectRoot = os.Getenv("CTASK_PROJECT_ROOT") r.envSeedDir = os.Getenv("CTASK_SEED_DIR") r.envAgent = os.Getenv("CTASK_AGENT") r.envSessionMode = os.Getenv("CTASK_SESSION_MODE") r.envEditor = os.Getenv("EDITOR") return r } // stringSetting layers Builtin → ConfigFileSrc → EnvVar for a single // string-valued key. Empty values at any layer are treated as "not // supplied" and skipped. transform, when non-nil, is applied to every // non-empty layer's value (used for path expansion). func (r *Resolver) stringSetting(key, envName, builtin, cfgVal, envVal string, transform func(string) string) ResolvedSetting { apply := func(v string) string { if transform == nil { return v } return transform(v) } s := ResolvedSetting{Key: key, Value: apply(builtin), Source: Builtin} if cfgVal != "" { prev := s s = ResolvedSetting{ Key: key, Value: apply(cfgVal), Source: ConfigFileSrc, Override: &prev, } } if envVal != "" { prev := s s = ResolvedSetting{ Key: key, Value: apply(envVal), Source: EnvVar, EnvName: envName, Override: &prev, } } return s } // CtaskRoot resolves the workspace root path with source attribution. func (r *Resolver) CtaskRoot() ResolvedSetting { cfgVal := "" if r.cfg != nil { cfgVal = r.cfg.CtaskRoot } return r.stringSetting("ctask_root", "CTASK_ROOT", defaultCtaskRoot(), cfgVal, r.envCtaskRoot, expandPath) } // ProjectRoot resolves the project-workspace root. Its built-in // default is derived from the resolved ctask_root, mirroring the // SearchRoots() v0.5 fallback (/projects). The Builtin // chain therefore contains the derived path, not an empty string. func (r *Resolver) ProjectRoot() ResolvedSetting { cfgVal := "" if r.cfg != nil { cfgVal = r.cfg.ProjectRoot } builtin := filepath.Join(r.CtaskRoot().Value, "projects") return r.stringSetting("project_root", "CTASK_PROJECT_ROOT", builtin, cfgVal, r.envProjectRoot, expandPath) } // SeedDir resolves the user general seed directory. Built-in default // is the platform-default seed location. func (r *Resolver) SeedDir() ResolvedSetting { cfgVal := "" if r.cfg != nil { cfgVal = r.cfg.SeedDir } return r.stringSetting("seed_dir", "CTASK_SEED_DIR", defaultSeedDir("seed"), cfgVal, r.envSeedDir, expandPath) } // DefaultAgent resolves the default agent command. func (r *Resolver) DefaultAgent() ResolvedSetting { cfgVal := "" if r.cfg != nil { cfgVal = r.cfg.DefaultAgent } return r.stringSetting("default_agent", "CTASK_AGENT", "claude", cfgVal, r.envAgent, nil) } // DefaultCategory resolves the default category for new workspaces. // No env-var equivalent in v0.6 Phase 1. func (r *Resolver) DefaultCategory() ResolvedSetting { cfgVal := "" if r.cfg != nil { cfgVal = r.cfg.DefaultCategory } return r.stringSetting("default_category", "", "tasks", cfgVal, "", nil) } // Editor resolves the user's text editor (used by future ctask edit). // Env var is the conventional EDITOR. func (r *Resolver) Editor() ResolvedSetting { cfgVal := "" if r.cfg != nil { cfgVal = r.cfg.Editor } return r.stringSetting("editor", "EDITOR", "", cfgVal, r.envEditor, nil) } // SessionMode resolves the launch session mode. Layers Builtin (direct) // → ConfigFile → EnvVar, then applies the native-Windows platform // override: when the resolved value is "persistent" but the host is // native Windows, the value is forced to "direct" with PlatformOverride // as the source and the previously resolved setting chained as // Override (so doctor can render "configured: persistent"). func (r *Resolver) SessionMode() ResolvedSetting { cfgVal := "" if r.cfg != nil { cfgVal = r.cfg.SessionMode } s := r.stringSetting("session_mode", "CTASK_SESSION_MODE", "direct", cfgVal, r.envSessionMode, nil) if s.Value == "persistent" && isNativeWindows() { prev := s s = ResolvedSetting{ Key: "session_mode", Value: "direct", Source: PlatformOverride, Override: &prev, } } return s } // defaultCtaskRoot returns the built-in default for the workspace root. func defaultCtaskRoot() string { home, _ := os.UserHomeDir() return filepath.Join(home, "ai-workspaces") }