From 6f80c8bf5ca549745b5d39a1056dc739032c2793 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 21:47:25 -0400 Subject: [PATCH] feat(v0.6): config file parser + resolver + source attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 foundation. Adds: - internal/config/configfile.go — strict-key YAML parser for the global config file. Unknown top-level keys invalidate the entire file (no partial apply); future schema versions are rejected with an upgrade message. Platform-appropriate path (XDG_CONFIG_HOME or ~/.config on Unix, %APPDATA% on native Windows). - internal/config/resolver.go — Resolver / ResolvedSetting / SettingSource (Builtin, ConfigFileSrc, EnvVar, CLIFlag, PlatformOverride). Each setting carries provenance plus an Override chain so callers can render "env var (overrides config file: X)" lines. session_mode applies the native-Windows platform override at the resolver layer with the configured value chained as Override. Exports SetConfigPathForTest and SetIsNativeWindowsForTest so cross-package tests can isolate themselves from host config or simulate the override. - internal/config/config.go — legacy ResolveRoot / ResolveAgent / ResolveSeedDir / ResolveProjectRoot / ResolveSessionMode now delegate to LoadResolver so config-file values take effect for entry commands. ResolveProjectRoot preserves the "" sentinel for built-in source so SearchRoots and doctor checkProjectRoot keep their existing fallback semantics. ResolveSessionMode preserves the v0.5.3 unknown-value stderr warning, distinguishing env-var and config-file sources in the message. - Tests: 6 LoadConfigFile cases (missing, basic, partial, unknown key, future schema, malformed YAML), 3 ConfigFilePath cases (XDG / ~/.config / %APPDATA%), 11 resolver cases including layering, override chains, path expansion, platform override, and SettingSource string rendering. - Test isolation: vulnerable tests in config_test.go and config_roots_test.go that asserted Builtin-source defaults now call SetConfigPathForTest to point at a nonexistent path, insulating them from developer-host config files. Three cmd tests that asserted persistent session_mode behavior now call SetIsNativeWindowsForTest to disable the platform override (Phase 1 introduces the override; the layering-only behavior these tests cover is tested separately in resolver_test.go). No new dependencies (gopkg.in/yaml.v3 was already in go.mod). No version bump (lands at the end of Phase 3 per the v0.6 spec). --- cmd/doctor_test.go | 10 + cmd/new_persistent_test.go | 8 + internal/config/config.go | 88 ++++---- internal/config/config_roots_test.go | 2 + internal/config/config_test.go | 11 + internal/config/configfile.go | 119 +++++++++++ internal/config/configfile_test.go | 193 ++++++++++++++++++ internal/config/resolver.go | 288 ++++++++++++++++++++++++++ internal/config/resolver_test.go | 290 +++++++++++++++++++++++++++ 9 files changed, 970 insertions(+), 39 deletions(-) create mode 100644 internal/config/configfile.go create mode 100644 internal/config/configfile_test.go create mode 100644 internal/config/resolver.go create mode 100644 internal/config/resolver_test.go diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 054de11..8f6f1ed 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/warrenronsiek/ctask/internal/config" ) // captureStdout runs fn while capturing os.Stdout and returns the output. @@ -196,6 +198,10 @@ func TestCheckTmuxConfiguredAndPresent(t *testing.T) { if _, err := exec.LookPath("tmux"); err != nil { t.Skip("tmux not on PATH") } + // Disable the v0.6 platform override so this test can verify the + // post-resolver doctor output for persistent mode without being + // short-circuited on native Windows. + config.SetIsNativeWindowsForTest(t, func() bool { return false }) os.Setenv("CTASK_SESSION_MODE", "persistent") defer os.Unsetenv("CTASK_SESSION_MODE") out := captureStdout(t, func() { @@ -211,6 +217,10 @@ func TestCheckTmuxConfiguredAndPresent(t *testing.T) { } func TestCheckTmuxConfiguredAndMissing(t *testing.T) { + // Disable the v0.6 platform override so the test reaches the + // tmux lookup path (rather than being short-circuited on native + // Windows hosts). + config.SetIsNativeWindowsForTest(t, func() bool { return false }) orig := os.Getenv("PATH") defer os.Setenv("PATH", orig) os.Setenv("PATH", "") diff --git a/cmd/new_persistent_test.go b/cmd/new_persistent_test.go index 015acae..3c337f5 100644 --- a/cmd/new_persistent_test.go +++ b/cmd/new_persistent_test.go @@ -4,6 +4,8 @@ import ( "os" "strings" "testing" + + "github.com/warrenronsiek/ctask/internal/config" ) // Tests in this file mutate package globals (isTTYCheck, runWorkspaceEntry). @@ -18,6 +20,12 @@ func TestNewDirectModeSkipsPreflight(t *testing.T) { } func TestNewDirectFlagUnderPersistentEmitsWarningAndProceeds(t *testing.T) { + // Disable the v0.6 platform override: this test verifies the + // --direct bypass warning that fires when persistent mode is in + // effect. On native Windows the resolver coerces persistent → direct + // before this function sees it, which is correct production + // behavior but bypasses the codepath under test. + config.SetIsNativeWindowsForTest(t, func() bool { return false }) os.Setenv("CTASK_SESSION_MODE", "persistent") t.Cleanup(func() { os.Unsetenv("CTASK_SESSION_MODE") }) diff --git a/internal/config/config.go b/internal/config/config.go index 94b12af..6364c8a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,39 +9,34 @@ import ( ) // ResolveRoot returns the absolute workspace root path. -// Reads CTASK_ROOT env var, falls back to ~/ai-workspaces (or %USERPROFILE%\ai-workspaces on Windows). +// v0.6: resolution order is CLI flag > CTASK_ROOT env var > config file +// (ctask_root key) > built-in default (~/ai-workspaces or +// %USERPROFILE%\ai-workspaces). No CLI flag participates in Phase 1. func ResolveRoot() string { - root := os.Getenv("CTASK_ROOT") - if root == "" { - home, _ := os.UserHomeDir() - return filepath.Join(home, "ai-workspaces") - } - return expandPath(root) + return LoadResolver().CtaskRoot().Value } // ResolveAgent returns the agent command. -// Reads CTASK_AGENT env var, falls back to "claude". +// v0.6: resolution order is CLI flag > CTASK_AGENT env var > config file +// (default_agent key) > built-in default ("claude"). No CLI flag +// participates in Phase 1. func ResolveAgent() string { - agent := os.Getenv("CTASK_AGENT") - if agent == "" { - return "claude" - } - return agent + return LoadResolver().DefaultAgent().Value } // ResolveSeedDir returns the user general seed directory. -// Reads CTASK_SEED_DIR; falls back to %APPDATA%\ctask\seed on Windows or -// ~/.config/ctask/seed on Unix. +// v0.6: resolution order is CTASK_SEED_DIR env var > config file +// (seed_dir key) > built-in default (%APPDATA%\ctask\seed on Windows, +// ~/.config/ctask/seed on Unix). func ResolveSeedDir() string { - if v := os.Getenv("CTASK_SEED_DIR"); v != "" { - return expandPath(v) - } - return defaultSeedDir("seed") + return LoadResolver().SeedDir().Value } // ResolveProjectSeedDir returns the user project seed directory. // Reads CTASK_SEED_PROJECT_DIR; falls back to %APPDATA%\ctask\seed-project on Windows // or ~/.config/ctask/seed-project on Unix. +// v0.6 Phase 1 does not give this setting a config-file equivalent +// (spec lists only seed_dir, singular). func ResolveProjectSeedDir() string { if v := os.Getenv("CTASK_SEED_PROJECT_DIR"); v != "" { return expandPath(v) @@ -50,14 +45,20 @@ func ResolveProjectSeedDir() string { } // ResolveProjectRoot returns the project workspace root override. -// Returns empty string if CTASK_PROJECT_ROOT is not set; callers should fall back -// to ResolveRoot() in that case. +// Returns empty string when no user-supplied value is set (config file +// or env var). The "" sentinel is load-bearing for SearchRoots() and +// doctor's checkProjectRoot, both of which apply their own fallback +// (the v0.5 $CTASK_ROOT/projects/ default). +// +// v0.6: a project_root value supplied via the config file or +// CTASK_PROJECT_ROOT env var is returned verbatim; a missing/empty +// value still returns "". func ResolveProjectRoot() string { - v := os.Getenv("CTASK_PROJECT_ROOT") - if v == "" { + s := LoadResolver().ProjectRoot() + if s.Source == Builtin { return "" } - return expandPath(v) + return s.Value } // SearchRoots returns the deduplicated list of workspace roots that all query @@ -149,23 +150,32 @@ func defaultSeedDir(leaf string) string { return filepath.Join(home, ".config", "ctask", leaf) } -// ResolveSessionMode returns "direct" or "persistent" based on CTASK_SESSION_MODE. -// Default (unset/empty) is "direct". Any other value falls back to "direct" -// after printing a stderr warning. Used by entry commands (new, resume, last, -// open) to dispatch between the standard session.Run path and the tmux-backed -// persistent path. +// ResolveSessionMode returns "direct" or "persistent" — the effective +// launch session mode after resolving CLI flag > env var > config file +// > built-in default ("direct"), with the platform-override rule +// applied (persistent → direct on native Windows). Unknown values from +// the env var or config file are coerced to "direct" with a one-line +// stderr warning, preserving the v0.5.3 behavior. +// +// Used by entry commands (new, resume, last, open) to dispatch between +// the standard session.Run path and the tmux-backed persistent path. func ResolveSessionMode() string { - v := os.Getenv("CTASK_SESSION_MODE") - switch v { - case "", "direct": - return "direct" - case "persistent": - return "persistent" - default: - fmt.Fprintf(os.Stderr, - "[ctask] warning: CTASK_SESSION_MODE=%q is not recognized; using direct mode\n", v) - return "direct" + r := LoadResolver() + s := r.SessionMode() + if s.Value == "direct" || s.Value == "persistent" { + return s.Value } + // Unknown value path — preserve the v0.5.3 stderr warning. Source + // of the bad value is reported so the user can find where to fix it. + switch s.Source { + case EnvVar: + fmt.Fprintf(os.Stderr, + "[ctask] warning: CTASK_SESSION_MODE=%q is not recognized; using direct mode\n", s.Value) + case ConfigFileSrc: + fmt.Fprintf(os.Stderr, + "[ctask] warning: config session_mode=%q is not recognized; using direct mode\n", s.Value) + } + return "direct" } // expandPath expands a leading ~/ and resolves to an absolute path when possible. diff --git a/internal/config/config_roots_test.go b/internal/config/config_roots_test.go index a4f3faf..71cd53a 100644 --- a/internal/config/config_roots_test.go +++ b/internal/config/config_roots_test.go @@ -9,6 +9,7 @@ import ( func TestSearchRootsUnsetProjectRoot(t *testing.T) { // v0.5: when CTASK_PROJECT_ROOT is unset, SearchRoots also appends // $CTASK_ROOT/projects/ so default-location projects are findable. + SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) os.Unsetenv("CTASK_PROJECT_ROOT") os.Setenv("CTASK_ROOT", t.TempDir()) defer os.Unsetenv("CTASK_ROOT") @@ -55,6 +56,7 @@ func TestSearchRootsSameRootDedupes(t *testing.T) { } func TestSearchRootsAppendsProjectsDirWhenUnset(t *testing.T) { + SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) taskRoot := t.TempDir() os.Setenv("CTASK_ROOT", taskRoot) os.Unsetenv("CTASK_PROJECT_ROOT") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c88e66f..46cfd90 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -10,6 +10,7 @@ import ( ) func TestDefaultRoot(t *testing.T) { + SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) os.Unsetenv("CTASK_ROOT") root := ResolveRoot() home, _ := os.UserHomeDir() @@ -39,6 +40,7 @@ func TestRootResolvesRelative(t *testing.T) { } func TestDefaultAgent(t *testing.T) { + SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) os.Unsetenv("CTASK_AGENT") agent := ResolveAgent() if agent != "claude" { @@ -71,6 +73,7 @@ func TestRootResolvesTilde(t *testing.T) { } func TestResolveSeedDirDefault(t *testing.T) { + SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) os.Unsetenv("CTASK_SEED_DIR") got := ResolveSeedDir() home, _ := os.UserHomeDir() @@ -129,6 +132,7 @@ func TestResolveProjectSeedDirOverride(t *testing.T) { } func TestResolveProjectRootEmptyWhenUnset(t *testing.T) { + SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) os.Unsetenv("CTASK_PROJECT_ROOT") got := ResolveProjectRoot() if got != "" { @@ -195,6 +199,7 @@ func TestEnvVarsLaunchDirEmpty(t *testing.T) { } func TestResolveSessionModeDefault(t *testing.T) { + SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) os.Unsetenv("CTASK_SESSION_MODE") if got := ResolveSessionMode(); got != "direct" { t.Errorf("default: got %q, want %q", got, "direct") @@ -202,6 +207,7 @@ func TestResolveSessionModeDefault(t *testing.T) { } func TestResolveSessionModeEmpty(t *testing.T) { + SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) os.Setenv("CTASK_SESSION_MODE", "") defer os.Unsetenv("CTASK_SESSION_MODE") if got := ResolveSessionMode(); got != "direct" { @@ -218,6 +224,11 @@ func TestResolveSessionModeDirect(t *testing.T) { } func TestResolveSessionModePersistent(t *testing.T) { + // Disable the v0.6 platform override so this test can verify the + // pure layering behavior (env var → persistent). The platform + // override is covered separately by + // TestResolverSessionModePlatformOverride. + SetIsNativeWindowsForTest(t, func() bool { return false }) os.Setenv("CTASK_SESSION_MODE", "persistent") defer os.Unsetenv("CTASK_SESSION_MODE") if got := ResolveSessionMode(); got != "persistent" { diff --git a/internal/config/configfile.go b/internal/config/configfile.go new file mode 100644 index 0000000..3fa7949 --- /dev/null +++ b/internal/config/configfile.go @@ -0,0 +1,119 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "gopkg.in/yaml.v3" +) + +// CurrentConfigSchemaVersion is the highest config-file schema version +// this binary understands. A future schema version bump must add the +// matching parser support alongside this constant. +const CurrentConfigSchemaVersion = 1 + +// ConfigFile is the strict YAML schema for the global ctask config file. +// All fields are optional; missing fields fall back to built-in defaults +// via the Resolver. Unknown keys invalidate the entire file (no partial +// apply) per the v0.6 spec. +type ConfigFile struct { + SchemaVersion int `yaml:"schema_version"` + CtaskRoot string `yaml:"ctask_root"` + ProjectRoot string `yaml:"project_root"` + SeedDir string `yaml:"seed_dir"` + DefaultAgent string `yaml:"default_agent"` + DefaultCategory string `yaml:"default_category"` + Editor string `yaml:"editor"` + SessionMode string `yaml:"session_mode"` +} + +// configFileKnownKeys returns the set of YAML keys recognized by the +// ConfigFile struct. Kept in lockstep with the yaml tags above. +func configFileKnownKeys() map[string]struct{} { + return map[string]struct{}{ + "schema_version": {}, + "ctask_root": {}, + "project_root": {}, + "seed_dir": {}, + "default_agent": {}, + "default_category": {}, + "editor": {}, + "session_mode": {}, + } +} + +// LoadConfigFile reads and parses the config file at path. Returns: +// - (nil, nil) the file does not exist (caller falls back to defaults) +// - (nil, err) any I/O, parse, unknown-key, or schema-version error +// - (cfg, nil) on success +// +// Unknown top-level keys invalidate the entire file with a descriptive +// error; ctask never partially applies a file with typos. Future schema +// versions are rejected with an upgrade message. +func LoadConfigFile(path string) (*ConfigFile, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + // First pass: parse into a generic map of YAML nodes so we can + // inspect raw key names without trusting the typed-struct unmarshal + // (yaml.v3 silently drops unknown keys by default). + var raw map[string]yaml.Node + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + known := configFileKnownKeys() + for key := range raw { + if _, ok := known[key]; !ok { + return nil, fmt.Errorf("unknown key: %q", key) + } + } + + // Second pass: typed unmarshal. + var cfg ConfigFile + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + if cfg.SchemaVersion > CurrentConfigSchemaVersion { + return nil, fmt.Errorf( + "config file requires schema version %d, but this binary supports up to version %d. Please upgrade ctask.", + cfg.SchemaVersion, CurrentConfigSchemaVersion) + } + + return &cfg, nil +} + +// ConfigFilePath returns the platform-appropriate global config path. +// +// Linux / macOS / WSL: +// - $XDG_CONFIG_HOME/ctask/config.yaml when XDG_CONFIG_HOME is set +// - ~/.config/ctask/config.yaml otherwise +// +// Native Windows: +// - %APPDATA%\ctask\config.yaml (fallback: %USERPROFILE%\AppData\Roaming\ctask\config.yaml) +// +// The path is returned regardless of whether the file exists; callers +// stat the path themselves. +func ConfigFilePath() string { + if runtime.GOOS == "windows" { + appData := os.Getenv("APPDATA") + if appData == "" { + home, _ := os.UserHomeDir() + appData = filepath.Join(home, "AppData", "Roaming") + } + return filepath.Join(appData, "ctask", "config.yaml") + } + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "ctask", "config.yaml") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "ctask", "config.yaml") +} diff --git a/internal/config/configfile_test.go b/internal/config/configfile_test.go new file mode 100644 index 0000000..0692003 --- /dev/null +++ b/internal/config/configfile_test.go @@ -0,0 +1,193 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestLoadConfigFileMissingReturnsNil — a missing config file is not an +// error; callers fall back to env vars / built-in defaults. The +// resolver tracks "no file" as INFO state. +func TestLoadConfigFileMissingReturnsNil(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nonexistent.yaml") + + cfg, err := LoadConfigFile(path) + if err != nil { + t.Fatalf("missing file should not error, got %v", err) + } + if cfg != nil { + t.Errorf("missing file should return nil cfg, got %+v", cfg) + } +} + +// TestLoadConfigFileBasic — all known keys parse into the typed struct. +func TestLoadConfigFileBasic(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + body := []byte(`schema_version: 1 +ctask_root: ~/ai-workspaces +project_root: ~/ai-workspaces/projects +seed_dir: ~/.config/ctask/seed +default_agent: opencode +default_category: tasks +editor: code +session_mode: persistent +`) + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatalf("write: %v", err) + } + + cfg, err := LoadConfigFile(path) + if err != nil { + t.Fatalf("LoadConfigFile: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil cfg") + } + if cfg.SchemaVersion != 1 { + t.Errorf("SchemaVersion: got %d, want 1", cfg.SchemaVersion) + } + if cfg.DefaultAgent != "opencode" { + t.Errorf("DefaultAgent: got %q, want opencode", cfg.DefaultAgent) + } + if cfg.SessionMode != "persistent" { + t.Errorf("SessionMode: got %q, want persistent", cfg.SessionMode) + } +} + +// TestLoadConfigFilePartial — only some keys set; rest remain zero-valued. +func TestLoadConfigFilePartial(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + body := []byte("default_agent: opencode\n") + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatalf("write: %v", err) + } + + cfg, err := LoadConfigFile(path) + if err != nil { + t.Fatalf("LoadConfigFile: %v", err) + } + if cfg.DefaultAgent != "opencode" { + t.Errorf("DefaultAgent: got %q, want opencode", cfg.DefaultAgent) + } + if cfg.CtaskRoot != "" { + t.Errorf("CtaskRoot: expected empty (unset), got %q", cfg.CtaskRoot) + } + if cfg.SessionMode != "" { + t.Errorf("SessionMode: expected empty (unset), got %q", cfg.SessionMode) + } +} + +// TestLoadConfigFileUnknownKey — strict-key rejection per spec. +// Entire file is invalidated; the error names the offending key. +func TestLoadConfigFileUnknownKey(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + body := []byte("default_agnet: foo\n") // typo + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatalf("write: %v", err) + } + + cfg, err := LoadConfigFile(path) + if err == nil { + t.Fatalf("expected error for unknown key, got cfg=%+v", cfg) + } + if cfg != nil { + t.Errorf("expected nil cfg on error, got %+v", cfg) + } + if !strings.Contains(err.Error(), "default_agnet") { + t.Errorf("error should name unknown key, got: %v", err) + } +} + +// TestLoadConfigFileSchemaVersionFuture — a config requesting a higher +// schema version than this binary supports is rejected with an upgrade +// message. No partial parsing. +func TestLoadConfigFileSchemaVersionFuture(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + body := []byte("schema_version: 2\ndefault_agent: claude\n") + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatalf("write: %v", err) + } + + cfg, err := LoadConfigFile(path) + if err == nil { + t.Fatalf("expected error for future schema version, got cfg=%+v", cfg) + } + if cfg != nil { + t.Errorf("expected nil cfg on schema-version error, got %+v", cfg) + } + if !strings.Contains(err.Error(), "schema version") { + t.Errorf("error should mention schema version, got: %v", err) + } + if !strings.Contains(err.Error(), "upgrade") { + t.Errorf("error should mention upgrade, got: %v", err) + } +} + +// TestLoadConfigFileMalformedYAML — invalid YAML produces an error. +func TestLoadConfigFileMalformedYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + body := []byte("default_agent: [not closed\n") + if err := os.WriteFile(path, body, 0644); err != nil { + t.Fatalf("write: %v", err) + } + + cfg, err := LoadConfigFile(path) + if err == nil { + t.Errorf("expected error for malformed YAML, got cfg=%+v", cfg) + } + if cfg != nil { + t.Errorf("expected nil cfg on YAML error, got %+v", cfg) + } +} + +// TestConfigFilePathLinux — Unix paths honor XDG_CONFIG_HOME. +func TestConfigFilePathLinux(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix-path semantics") + } + t.Setenv("XDG_CONFIG_HOME", "/tmp/xdgtest") + got := ConfigFilePath() + want := filepath.Join("/tmp/xdgtest", "ctask", "config.yaml") + if got != want { + t.Errorf("ConfigFilePath with XDG: got %q, want %q", got, want) + } +} + +// TestConfigFilePathLinuxDefault — fallback to ~/.config when XDG unset. +func TestConfigFilePathLinuxDefault(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix-path semantics") + } + t.Setenv("XDG_CONFIG_HOME", "") + got := ConfigFilePath() + home, _ := os.UserHomeDir() + want := filepath.Join(home, ".config", "ctask", "config.yaml") + if got != want { + t.Errorf("ConfigFilePath default: got %q, want %q", got, want) + } +} + +// TestConfigFilePathWindows — %APPDATA% path on native Windows. +func TestConfigFilePathWindows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-only") + } + appData := os.Getenv("APPDATA") + if appData == "" { + t.Skip("APPDATA unset on this host") + } + got := ConfigFilePath() + want := filepath.Join(appData, "ctask", "config.yaml") + if got != want { + t.Errorf("ConfigFilePath on Windows: got %q, want %q", got, want) + } +} diff --git a/internal/config/resolver.go b/internal/config/resolver.go new file mode 100644 index 0000000..ec8adf7 --- /dev/null +++ b/internal/config/resolver.go @@ -0,0 +1,288 @@ +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") +} diff --git a/internal/config/resolver_test.go b/internal/config/resolver_test.go new file mode 100644 index 0000000..f3577d6 --- /dev/null +++ b/internal/config/resolver_test.go @@ -0,0 +1,290 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// writeConfig is a test helper that writes body to a temp directory's +// config.yaml, points ctaskConfigPathOverrideForTest at it, and returns +// the path. Test cleanup restores the override. +func writeConfig(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(body), 0644); err != nil { + t.Fatalf("writeConfig: %v", err) + } + prev := configPathForTest + configPathForTest = path + t.Cleanup(func() { configPathForTest = prev }) + return path +} + +// noConfig points the resolver at a nonexistent path for the duration of +// a single test. Used to assert "no config file" behavior even when the +// host happens to have a real ~/.config/ctask/config.yaml. +func noConfig(t *testing.T) { + t.Helper() + dir := t.TempDir() + prev := configPathForTest + configPathForTest = filepath.Join(dir, "nonexistent.yaml") + t.Cleanup(func() { configPathForTest = prev }) +} + +// clearConfigEnv unsets every env var the resolver reads. Use this in +// every resolver test to insulate from inherited shell state. t.Setenv +// is preferred when a single value is needed (it auto-restores). +func clearConfigEnv(t *testing.T) { + t.Helper() + for _, name := range []string{ + "CTASK_ROOT", "CTASK_PROJECT_ROOT", "CTASK_SEED_DIR", + "CTASK_AGENT", "CTASK_SESSION_MODE", "EDITOR", + } { + t.Setenv(name, "") + os.Unsetenv(name) + } +} + +// TestResolverNoConfigUsesBuiltins — when no config file exists and no +// env vars are set, every setting resolves to Builtin source. +func TestResolverNoConfigUsesBuiltins(t *testing.T) { + clearConfigEnv(t) + noConfig(t) + r := LoadResolver() + if r.ConfigErr != nil { + t.Errorf("missing file should not be an error, got %v", r.ConfigErr) + } + + if s := r.CtaskRoot(); s.Source != Builtin { + t.Errorf("CtaskRoot source: got %v, want Builtin", s.Source) + } + if s := r.DefaultAgent(); s.Source != Builtin || s.Value != "claude" { + t.Errorf("DefaultAgent: got %+v, want value=claude source=Builtin", s) + } + if s := r.SessionMode(); s.Source != Builtin || s.Value != "direct" { + t.Errorf("SessionMode: got %+v, want value=direct source=Builtin", s) + } + if s := r.DefaultCategory(); s.Source != Builtin || s.Value != "tasks" { + t.Errorf("DefaultCategory: got %+v, want value=tasks source=Builtin", s) + } +} + +// TestResolverConfigFilePartial — only default_agent set in config; that +// setting resolves to ConfigFileSrc, others stay Builtin. +func TestResolverConfigFilePartial(t *testing.T) { + clearConfigEnv(t) + writeConfig(t, "default_agent: opencode\n") + + r := LoadResolver() + if r.ConfigErr != nil { + t.Fatalf("ConfigErr: %v", r.ConfigErr) + } + + s := r.DefaultAgent() + if s.Source != ConfigFileSrc { + t.Errorf("DefaultAgent source: got %v, want ConfigFileSrc", s.Source) + } + if s.Value != "opencode" { + t.Errorf("DefaultAgent value: got %q, want opencode", s.Value) + } + if s.Override == nil || s.Override.Source != Builtin { + t.Errorf("DefaultAgent Override should chain to Builtin, got %+v", s.Override) + } + + if s := r.CtaskRoot(); s.Source != Builtin { + t.Errorf("CtaskRoot should remain Builtin, got %v", s.Source) + } +} + +// TestResolverEnvOverridesConfig — env var beats config file; the +// override chain points back at the config-file setting. +func TestResolverEnvOverridesConfig(t *testing.T) { + clearConfigEnv(t) + writeConfig(t, "default_agent: opencode\n") + t.Setenv("CTASK_AGENT", "aider") + + r := LoadResolver() + s := r.DefaultAgent() + if s.Source != EnvVar { + t.Errorf("source: got %v, want EnvVar", s.Source) + } + if s.Value != "aider" { + t.Errorf("value: got %q, want aider", s.Value) + } + if s.EnvName != "CTASK_AGENT" { + t.Errorf("EnvName: got %q, want CTASK_AGENT", s.EnvName) + } + if s.Override == nil || s.Override.Source != ConfigFileSrc { + t.Fatalf("override should chain to ConfigFileSrc, got %+v", s.Override) + } + if s.Override.Value != "opencode" { + t.Errorf("override value: got %q, want opencode", s.Override.Value) + } +} + +// TestResolverConfigFileUnknownKeyInvalidatesFile — strict-key error +// recorded on ConfigErr; settings fall back to env/builtin only. +func TestResolverConfigFileUnknownKeyInvalidatesFile(t *testing.T) { + clearConfigEnv(t) + writeConfig(t, "default_agnet: foo\n") + + r := LoadResolver() + if r.ConfigErr == nil { + t.Fatalf("expected ConfigErr from unknown key, got nil") + } + if !strings.Contains(r.ConfigErr.Error(), "default_agnet") { + t.Errorf("ConfigErr should name unknown key, got %v", r.ConfigErr) + } + + if s := r.DefaultAgent(); s.Source != Builtin { + t.Errorf("DefaultAgent should fall back to Builtin when config invalid, got %v", s.Source) + } +} + +// TestResolverConfigSchemaVersionFutureRejected — same as configfile +// test, but viewed through the resolver. +func TestResolverConfigSchemaVersionFutureRejected(t *testing.T) { + clearConfigEnv(t) + writeConfig(t, "schema_version: 2\ndefault_agent: opencode\n") + + r := LoadResolver() + if r.ConfigErr == nil { + t.Fatalf("expected ConfigErr from future schema version") + } + if !strings.Contains(r.ConfigErr.Error(), "schema version") { + t.Errorf("ConfigErr should mention schema version, got %v", r.ConfigErr) + } + + if s := r.DefaultAgent(); s.Source != Builtin { + t.Errorf("DefaultAgent should fall back to Builtin, got %v", s.Source) + } +} + +// TestResolverPathExpansion — ~/ in config values is expanded. +func TestResolverPathExpansion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("tilde expansion not typical on Windows") + } + clearConfigEnv(t) + writeConfig(t, "ctask_root: ~/ai-workspaces\n") + + r := LoadResolver() + s := r.CtaskRoot() + if !filepath.IsAbs(s.Value) { + t.Errorf("CtaskRoot should be absolute after expansion, got %q", s.Value) + } + if strings.HasPrefix(s.Value, "~") { + t.Errorf("CtaskRoot tilde not expanded: %q", s.Value) + } +} + +// TestResolverSessionModePlatformOverride — config requests persistent +// mode, but the simulated host is native Windows. Resolver should +// return Value="direct" with Source=PlatformOverride and Override +// chained back to the ConfigFile setting. +func TestResolverSessionModePlatformOverride(t *testing.T) { + clearConfigEnv(t) + writeConfig(t, "session_mode: persistent\n") + + prev := isNativeWindowsForTest + isNativeWindowsForTest = func() bool { return true } + t.Cleanup(func() { isNativeWindowsForTest = prev }) + + r := LoadResolver() + s := r.SessionMode() + if s.Value != "direct" { + t.Errorf("value: got %q, want direct (platform override)", s.Value) + } + if s.Source != PlatformOverride { + t.Errorf("source: got %v, want PlatformOverride", s.Source) + } + if s.Override == nil { + t.Fatalf("override should chain to configured value, got nil") + } + if s.Override.Value != "persistent" { + t.Errorf("override value: got %q, want persistent", s.Override.Value) + } + if s.Override.Source != ConfigFileSrc { + t.Errorf("override source: got %v, want ConfigFileSrc", s.Override.Source) + } +} + +// TestResolverSessionModeNoOverrideWhenNotPersistent — even on native +// Windows, direct mode doesn't trip the override. +func TestResolverSessionModeNoOverrideWhenNotPersistent(t *testing.T) { + clearConfigEnv(t) + writeConfig(t, "session_mode: direct\n") + + prev := isNativeWindowsForTest + isNativeWindowsForTest = func() bool { return true } + t.Cleanup(func() { isNativeWindowsForTest = prev }) + + r := LoadResolver() + s := r.SessionMode() + if s.Value != "direct" { + t.Errorf("value: got %q, want direct", s.Value) + } + if s.Source != ConfigFileSrc { + t.Errorf("source: got %v, want ConfigFileSrc (no override needed)", s.Source) + } +} + +// TestSettingSourceString — human-readable strings for doctor/info. +func TestSettingSourceString(t *testing.T) { + cases := []struct { + s SettingSource + want string + }{ + {Builtin, "built-in default"}, + {ConfigFileSrc, "config file"}, + {EnvVar, "env var"}, + {CLIFlag, "CLI flag"}, + {PlatformOverride, "platform override"}, + } + for _, c := range cases { + if got := c.s.String(); got != c.want { + t.Errorf("%v.String(): got %q, want %q", c.s, got, c.want) + } + } +} + +// TestResolverProjectRootDefaultsToCtaskRootProjects — built-in default +// for project_root is /projects, mirroring the existing +// SearchRoots() fallback behavior introduced in v0.5. +func TestResolverProjectRootDefaultsToCtaskRootProjects(t *testing.T) { + clearConfigEnv(t) + noConfig(t) + tmp := t.TempDir() + t.Setenv("CTASK_ROOT", tmp) + + r := LoadResolver() + want := filepath.Join(tmp, "projects") + s := r.ProjectRoot() + if filepath.Clean(s.Value) != filepath.Clean(want) { + t.Errorf("ProjectRoot value: got %q, want %q", s.Value, want) + } + if s.Source != Builtin { + t.Errorf("ProjectRoot source: got %v, want Builtin (derived from CtaskRoot)", s.Source) + } +} + +// TestResolverEditorFromConfigAndEnv — Editor honors EDITOR env var when +// set, then config, then empty default. +func TestResolverEditorFromConfigAndEnv(t *testing.T) { + clearConfigEnv(t) + writeConfig(t, "editor: vim\n") + t.Setenv("EDITOR", "nano") + + r := LoadResolver() + s := r.Editor() + if s.Value != "nano" || s.Source != EnvVar { + t.Errorf("Editor with both env and config: got %+v, want value=nano source=EnvVar", s) + } + if s.Override == nil || s.Override.Value != "vim" { + t.Errorf("Editor override should chain to config (vim), got %+v", s.Override) + } +}