From c918e5ceabd70a4daabe345453daf41d18464e97 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 14 May 2026 21:55:02 -0400 Subject: [PATCH] feat(v0.6): doctor Settings section with source attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new ── Settings ── block at the bottom of `ctask doctor` (after the existing tmux INFO line, before the summary). The block reports every user-level default tracked by the resolver — ctask_root, project_root, seed_dir, default_agent, default_category, session_mode, editor — together with the source it was drawn from. Implementation: - runDoctor calls config.LoadResolver() once and passes the resolver into checkSettings; no setting is re-resolved mid-block (the user's correction: "new doctor/info code should load the resolver once and reuse it"). - checkSettings prints the config-file lifecycle line first (`Config file: ` / `Config file: not found — using built-in defaults` / `[FAIL] unknown key: "..."` per the resolver's ConfigErr state), then iterates the resolver's ResolvedSetting accessors and feeds each one through printSettingLine. - printSettingLine renders the key + value pair plus a `source: …` child line. When the resolver chains an Override, the source line embeds the overridden source + value in parens. For the PlatformOverride session_mode case, an additional `configured: ` line surfaces what the user asked for. - formatSettingSource centralises the "EnvVar → CTASK_X env var" / "PlatformOverride → ... (persistent mode requires tmux; not available on native Windows)" / override-chain wording so doctor and info can share the same labels in commit 4. - Only the invalid-config-file branch increments the failed counter. Missing file is INFO. The valid-file branch is purely informational. Tests (cmd/doctor_settings_test.go, 6 cases): - TestDoctorShowsSettingsSection — header present + all keys appear - TestDoctorShowsSourceAttribution — every line has source: + at least one "built-in default" - TestDoctorShowsOverrides — env vs config override chain rendered - TestDoctorConfigNotFound — INFO, no failed bump - TestDoctorConfigInvalid — FAIL bump + unknown key named - TestDoctorSessionModePlatformOverrideRendered — direct value + "platform override" label + "configured: persistent" row Smoke-verified end-to-end against the installed binary: - no config + no env → all "built-in default" - env CTASK_AGENT=aider + config default_agent=opencode → "CTASK_AGENT env var (overrides config file: opencode)" - config session_mode=persistent on native Windows → "platform override (...)" plus "configured: persistent" - config with typo "default_agnet: foo" → [FAIL] line names the key and the "settings not applied" advisory --- cmd/doctor.go | 105 +++++++++++++++++++++ cmd/doctor_settings_test.go | 179 ++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 cmd/doctor_settings_test.go diff --git a/cmd/doctor.go b/cmd/doctor.go index 7dc9972..eee0c13 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -207,6 +207,12 @@ func runDoctor(cmd *cobra.Command, args []string) error { // Check 9: tmux availability for persistent session mode (v0.5.3). checkTmux(&passed, &failed) + // Check 10: v0.6 — global config file + per-setting source attribution. + // Loaded once and reused across the whole settings block so doctor + // stays internally consistent (no re-reading the config file mid-render). + resolver := config.LoadResolver() + checkSettings(resolver, &passed, &failed) + // Summary fmt.Println() fmt.Printf("%d checks passed, %d failed\n", passed, failed) @@ -259,6 +265,105 @@ func checkSeedDir(label, envValue, resolved, envName string, passed, failed *int *failed++ } +// checkSettings prints the v0.6 ── Settings ── block. The block shows +// the effective value of every user-level default tracked by the +// resolver, along with the source it was drawn from (built-in default, +// config file, env var, or platform override). When an env var +// overrides a config value, the override line displays both values so +// the user can see what they would inherit if they unset the env. +// +// The block also reports the config-file lifecycle state: +// - not found → INFO line only (no counters bumped) +// - found, valid → INFO line listing the path +// - found, invalid → FAIL line naming the offending key (failed++) +// +// Increments failed only for the invalid-config case. Passed is +// reserved for future per-setting checks (e.g., "ctask_root exists +// and is writable" — currently covered by Check 1). +// +// All values must come from the resolver, never re-read from env or +// disk inside this function — the resolver is the single source of +// truth and was already loaded once by runDoctor. +func checkSettings(r *config.Resolver, passed, failed *int) { + _ = passed // reserved (see doc comment) + fmt.Println() + fmt.Println("── Settings ──────────────────────────────────") + + // Config-file lifecycle line. + switch { + case r.ConfigErr != nil: + fmt.Printf("Config file: %s\n", r.ConfigPath) + fmt.Printf(" [FAIL] %s\n", r.ConfigErr) + fmt.Println(" Config file settings not applied — using env vars and built-in defaults only.") + *failed++ + case r.ConfigPath != "": + if _, err := os.Stat(r.ConfigPath); err == nil { + fmt.Printf("Config file: %s\n", r.ConfigPath) + } else { + fmt.Printf("Config file: not found — using built-in defaults (%s)\n", r.ConfigPath) + } + default: + fmt.Println("Config file: not found — using built-in defaults") + } + + // Per-setting lines. Each line: `: ` then a `source:` + // child line. When Override is non-nil the source line names the + // overridden source and value, matching the spec example + // `CTASK_AGENT env var (overrides config file: claude)`. + for _, s := range []config.ResolvedSetting{ + r.CtaskRoot(), + r.ProjectRoot(), + r.SeedDir(), + r.DefaultAgent(), + r.DefaultCategory(), + r.SessionMode(), + r.Editor(), + } { + fmt.Println() + printSettingLine(s) + } +} + +// printSettingLine renders one ResolvedSetting as a key/value/source +// trio, including override-chain context. Extracted so future settings +// (e.g., per-workspace launch session mode in info) can reuse it. +func printSettingLine(s config.ResolvedSetting) { + fmt.Printf("%s: %s\n", s.Key, s.Value) + fmt.Printf(" source: %s\n", formatSettingSource(s)) + if s.Source == config.PlatformOverride && s.Override != nil { + // Spec section 1.8 calls for an extra "configured: " + // row so the user sees what they asked for in addition to + // what's in effect. + fmt.Printf(" configured: %s\n", s.Override.Value) + } +} + +// formatSettingSource builds the "source: ..." annotation. The base +// label comes from the SettingSource.String(); env-var sources also +// surface the actual env-var name; PlatformOverride spells out why +// it fired; and when an override chain is present, the next-lower +// source's value is appended in parentheses so doctor renders lines +// like "CTASK_AGENT env var (overrides config file: claude)". +func formatSettingSource(s config.ResolvedSetting) string { + var base string + switch s.Source { + case config.EnvVar: + if s.EnvName != "" { + base = s.EnvName + " env var" + } else { + base = "env var" + } + case config.PlatformOverride: + base = "platform override (persistent mode requires tmux; not available on native Windows)" + default: + base = s.Source.String() + } + if s.Override != nil && s.Source != config.PlatformOverride { + base += fmt.Sprintf(" (overrides %s: %s)", s.Override.Source, s.Override.Value) + } + return base +} + // checkTmux reports the three-state tmux check (v0.5.3): // - CTASK_SESSION_MODE != "persistent" -> INFO (direct mode, tmux optional) // - persistent + tmux on PATH + version OK -> two INFO lines diff --git a/cmd/doctor_settings_test.go b/cmd/doctor_settings_test.go new file mode 100644 index 0000000..185f0eb --- /dev/null +++ b/cmd/doctor_settings_test.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/warrenronsiek/ctask/internal/config" +) + +// captureCheckSettings runs checkSettings with a fresh resolver state +// and returns (stdout, passed, failed). Each test supplies its own +// resolver via the config-path test seam. +func captureCheckSettings(t *testing.T) (string, int, int) { + t.Helper() + passed, failed := 0, 0 + out := captureStdout(t, func() { + r := config.LoadResolver() + checkSettings(r, &passed, &failed) + }) + return out, passed, failed +} + +// TestDoctorShowsSettingsSection — the new ── Settings ── header is +// printed and each resolver-tracked key surfaces. +func TestDoctorShowsSettingsSection(t *testing.T) { + config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + clearResolverEnv(t) + + out, _, failed := captureCheckSettings(t) + if failed != 0 { + t.Errorf("no-config path should not increment failed counter, got %d", failed) + } + if !strings.Contains(out, "Settings") { + t.Errorf("expected Settings header in output, got %q", out) + } + // Each documented setting should appear by its config key name. + for _, key := range []string{"ctask_root", "default_agent", "session_mode", "seed_dir"} { + if !strings.Contains(out, key) { + t.Errorf("expected key %q in settings output, got %q", key, out) + } + } +} + +// TestDoctorShowsSourceAttribution — every settings line carries a +// "source:" annotation. +func TestDoctorShowsSourceAttribution(t *testing.T) { + config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml")) + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + clearResolverEnv(t) + + out, _, _ := captureCheckSettings(t) + // In the no-config / no-env state every setting falls back to its + // built-in default. Each source line must label that fact. + if strings.Count(out, "source:") < 4 { + t.Errorf("expected source: lines for at least 4 settings, got %q", out) + } + if !strings.Contains(out, "built-in default") { + t.Errorf("expected 'built-in default' source attribution, got %q", out) + } +} + +// TestDoctorShowsOverrides — when an env var overrides a config value, +// both surface in the doctor output (with the overridden value in +// parentheses). +func TestDoctorShowsOverrides(t *testing.T) { + clearResolverEnv(t) + // Plant a config file with default_agent: opencode. + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("default_agent: opencode\n"), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + config.SetConfigPathForTest(t, cfgPath) + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + t.Setenv("CTASK_AGENT", "aider") + + out, _, _ := captureCheckSettings(t) + if !strings.Contains(out, "aider") { + t.Errorf("expected env-var value 'aider' in output, got %q", out) + } + if !strings.Contains(out, "CTASK_AGENT") { + t.Errorf("expected env-var name 'CTASK_AGENT' in source line, got %q", out) + } + if !strings.Contains(out, "overrides") { + t.Errorf("expected 'overrides' wording when env beats config, got %q", out) + } + if !strings.Contains(out, "opencode") { + t.Errorf("expected overridden config value 'opencode' in output, got %q", out) + } +} + +// TestDoctorConfigNotFound — missing config file is INFO, not FAIL. +func TestDoctorConfigNotFound(t *testing.T) { + clearResolverEnv(t) + config.SetConfigPathForTest(t, filepath.Join(t.TempDir(), "absent.yaml")) + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + + out, _, failed := captureCheckSettings(t) + if failed != 0 { + t.Errorf("missing config should not fail, got failed=%d", failed) + } + if !strings.Contains(out, "Config file") { + t.Errorf("expected 'Config file' line, got %q", out) + } + if !strings.Contains(out, "not found") { + t.Errorf("expected 'not found' wording, got %q", out) + } + // "built-in defaults" should also be mentioned so the user knows + // what's actually in effect. + if !strings.Contains(out, "built-in") { + t.Errorf("expected mention of built-in defaults, got %q", out) + } +} + +// TestDoctorConfigInvalid — unknown key in config file marks the +// settings section as failed and names the offending key. +func TestDoctorConfigInvalid(t *testing.T) { + clearResolverEnv(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("default_agnet: foo\n"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + config.SetConfigPathForTest(t, cfgPath) + config.SetIsNativeWindowsForTest(t, func() bool { return false }) + + out, _, failed := captureCheckSettings(t) + if failed < 1 { + t.Errorf("invalid config should increment failed counter; got %d", failed) + } + if !strings.Contains(out, "default_agnet") { + t.Errorf("expected unknown key name in output, got %q", out) + } + if !strings.Contains(out, "[FAIL]") { + t.Errorf("expected [FAIL] label on the invalid-config line, got %q", out) + } +} + +// TestDoctorSessionModePlatformOverrideRendered — when the platform +// override kicks in, the Settings section renders the final value +// (direct) plus the configured-before-override value. +func TestDoctorSessionModePlatformOverrideRendered(t *testing.T) { + clearResolverEnv(t) + dir := t.TempDir() + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, []byte("session_mode: persistent\n"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + config.SetConfigPathForTest(t, cfgPath) + config.SetIsNativeWindowsForTest(t, func() bool { return true }) + + out, _, _ := captureCheckSettings(t) + if !strings.Contains(out, "session_mode: direct") { + t.Errorf("expected 'session_mode: direct' after platform override, got %q", out) + } + if !strings.Contains(out, "platform override") { + t.Errorf("expected 'platform override' wording, got %q", out) + } + if !strings.Contains(out, "configured: persistent") { + t.Errorf("expected 'configured: persistent' line, got %q", out) + } +} + +// clearResolverEnv is the cmd-package counterpart to the helper in +// internal/config; resets every env var the resolver reads so a +// developer-host shell state cannot leak into the test. +func clearResolverEnv(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) + } +}