From 57c6c909d31f399392445c60f11cae4954d0647e Mon Sep 17 00:00:00 2001 From: typebasedio Date: Wed, 22 Apr 2026 17:56:27 -0400 Subject: [PATCH] feat(v0.4.1): add seed directory checks to ctask doctor Replaces the always-informational seed-dir block with three-state checks: INFO when the env var is unset (defaults will be used), PASS when set and the path exists, FAIL when set but the directory is missing. Only the configured-but-missing state counts as a failed check and raises the doctor exit code. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/doctor.go | 58 ++++++++++++++++++-------- cmd/doctor_test.go | 100 +++++++++++++++++++++++++++++++++++++++++++++ docs/commands.md | 4 +- 3 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 cmd/doctor_test.go diff --git a/cmd/doctor.go b/cmd/doctor.go index d40730c..ef4f1e2 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -178,23 +178,27 @@ func runDoctor(cmd *cobra.Command, args []string) error { failed++ } - // Informational checks (do not affect pass/fail counters). - fmt.Println() - fmt.Println("Seed directories (informational):") - - seedDir := config.ResolveSeedDir() - if info, err := os.Stat(seedDir); err == nil && info.IsDir() { - fmt.Printf(" [INFO] General seed directory: %s (present)\n", seedDir) - } else { - fmt.Printf(" [INFO] General seed directory: %s (not present)\n", seedDir) - } - - projectSeedDir := config.ResolveProjectSeedDir() - if info, err := os.Stat(projectSeedDir); err == nil && info.IsDir() { - fmt.Printf(" [INFO] Project seed directory: %s (present)\n", projectSeedDir) - } else { - fmt.Printf(" [INFO] Project seed directory: %s (not present)\n", projectSeedDir) - } + // Checks 6 + 7: Seed directory configuration. + // Three states per variable: + // - env not set -> informational (built-in defaults will be used) + // - env set, path exists -> pass + // - env set, path missing -> fail + checkSeedDir( + "General seed directory", + os.Getenv("CTASK_SEED_DIR"), + config.ResolveSeedDir(), + "CTASK_SEED_DIR", + &passed, + &failed, + ) + checkSeedDir( + "Project seed directory", + os.Getenv("CTASK_SEED_PROJECT_DIR"), + config.ResolveProjectSeedDir(), + "CTASK_SEED_PROJECT_DIR", + &passed, + &failed, + ) // Summary fmt.Println() @@ -205,3 +209,23 @@ func runDoctor(cmd *cobra.Command, args []string) error { } return nil } + +// checkSeedDir prints one of the three seed-directory doctor states. +// envValue is the raw value of the CTASK_SEED_* variable (empty means unset). +// resolved is the absolute path returned by the config resolver (used only +// when envValue is non-empty; for the "not configured" state the built-in +// default path is not displayed). +func checkSeedDir(label, envValue, resolved, envName string, passed, failed *int) { + if envValue == "" { + fmt.Printf(" [INFO] %s: not configured (using built-in defaults)\n", label) + return + } + if info, err := os.Stat(resolved); err == nil && info.IsDir() { + fmt.Printf(" [PASS] %s: %s\n", label, resolved) + *passed++ + return + } + fmt.Printf(" [FAIL] %s configured but not found: %s\n", label, resolved) + fmt.Printf(" Fix: create the directory or unset %s to use built-in defaults.\n", envName) + *failed++ +} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go new file mode 100644 index 0000000..ec2bbb3 --- /dev/null +++ b/cmd/doctor_test.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "bytes" + "os" + "strings" + "testing" +) + +// This file swaps process-global os.Stdout and env vars. Do not call +// t.Parallel() in this file. + +// runDoctorCapture invokes runDoctor with a given CTASK_ROOT (and optional +// seed env vars) and returns stdout and the returned error. +func runDoctorCapture(t *testing.T, root string, seedDir, seedProjDir string, seedSet, seedProjSet bool) (string, error) { + t.Helper() + + prevRoot := os.Getenv("CTASK_ROOT") + prevSeed := os.Getenv("CTASK_SEED_DIR") + prevSeedProj := os.Getenv("CTASK_SEED_PROJECT_DIR") + os.Setenv("CTASK_ROOT", root) + if seedSet { + os.Setenv("CTASK_SEED_DIR", seedDir) + } else { + os.Unsetenv("CTASK_SEED_DIR") + } + if seedProjSet { + os.Setenv("CTASK_SEED_PROJECT_DIR", seedProjDir) + } else { + os.Unsetenv("CTASK_SEED_PROJECT_DIR") + } + defer func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + if prevSeed == "" { + os.Unsetenv("CTASK_SEED_DIR") + } else { + os.Setenv("CTASK_SEED_DIR", prevSeed) + } + if prevSeedProj == "" { + os.Unsetenv("CTASK_SEED_PROJECT_DIR") + } else { + os.Setenv("CTASK_SEED_PROJECT_DIR", prevSeedProj) + } + }() + + stdoutR, stdoutW, _ := os.Pipe() + prevStdout := os.Stdout + os.Stdout = stdoutW + defer func() { os.Stdout = prevStdout }() + + err := runDoctor(doctorCmd, nil) + + stdoutW.Close() + var buf bytes.Buffer + buf.ReadFrom(stdoutR) + return buf.String(), err +} + +func TestDoctorSeedDirNotConfigured(t *testing.T) { + root := t.TempDir() + out, _ := runDoctorCapture(t, root, "", "", false, false) + + if !strings.Contains(out, "[INFO] General seed directory: not configured") { + t.Errorf("expected 'not configured' INFO line, got:\n%s", out) + } + if !strings.Contains(out, "[INFO] Project seed directory: not configured") { + t.Errorf("expected project 'not configured' INFO line, got:\n%s", out) + } +} + +func TestDoctorSeedDirConfiguredAndExists(t *testing.T) { + root := t.TempDir() + seedDir := t.TempDir() + seedProjDir := t.TempDir() + out, _ := runDoctorCapture(t, root, seedDir, seedProjDir, true, true) + + if !strings.Contains(out, "[PASS] General seed directory: "+seedDir) { + t.Errorf("expected PASS line for general seed dir, got:\n%s", out) + } + if !strings.Contains(out, "[PASS] Project seed directory: "+seedProjDir) { + t.Errorf("expected PASS line for project seed dir, got:\n%s", out) + } +} + +func TestDoctorSeedDirConfiguredButMissingFails(t *testing.T) { + root := t.TempDir() + missing := root + string(os.PathSeparator) + "no-such-seed-dir" + out, err := runDoctorCapture(t, root, missing, "", true, false) + + if err == nil { + t.Fatal("expected non-nil error (failed check), got nil") + } + if !strings.Contains(out, "[FAIL] General seed directory configured but not found: "+missing) { + t.Errorf("expected FAIL line for missing seed dir, got:\n%s", out) + } +} diff --git a/docs/commands.md b/docs/commands.md index bd53135..c732365 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -260,7 +260,7 @@ Checks: Exits 0 if all checks pass, 1 if any fail. Each failure includes a concrete fix instruction. -v0.3 also reports the resolved general and project seed directory paths as informational `[INFO]` lines. These do not contribute to the pass/fail count -- absent seed directories are normal and supported. +`ctask doctor` also reports seed directory status: `[INFO]` if the `CTASK_SEED_DIR` / `CTASK_SEED_PROJECT_DIR` variable is unset (built-in defaults will be used), `[PASS]` if the variable is set and the path exists, `[FAIL]` if the variable is set but the path is missing. Only the configured-but-missing state counts as a failure. **Example output:** @@ -270,6 +270,8 @@ v0.3 also reports the resolved general and project seed directory paths as infor [PASS] Status line helper found: C:\Users\Warren\AppData\Local\ctask\bin\ctask-statusline.sh [PASS] Claude Code status line configured [PASS] Workspaces found: 5 tasks (2 archived) + [INFO] General seed directory: not configured (using built-in defaults) + [INFO] Project seed directory: not configured (using built-in defaults) 5 checks passed, 0 failed ```