diff --git a/cmd/doctor.go b/cmd/doctor.go index 0795e8b..e20c956 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/shell" "github.com/warrenronsiek/ctask/internal/workspace" ) @@ -203,6 +204,9 @@ func runDoctor(cmd *cobra.Command, args []string) error { // Check 8: CTASK_PROJECT_ROOT (v0.5). checkProjectRoot(&passed, &failed) + // Check 9: tmux availability for persistent session mode (v0.5.3). + checkTmux(&passed, &failed) + // Summary fmt.Println() fmt.Printf("%d checks passed, %d failed\n", passed, failed) @@ -254,3 +258,29 @@ func checkSeedDir(label, envValue, resolved, envName string, passed, failed *int fmt.Printf(" Fix: create the directory or unset %s to use built-in defaults.\n", envName) *failed++ } + +// 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 +// - persistent + tmux missing or too old -> FAIL with install/update hint +func checkTmux(passed, failed *int) { + _ = passed + mode := config.ResolveSessionMode() + if mode != "persistent" { + fmt.Printf(" [INFO] Session mode: direct (tmux not required)\n") + return + } + fmt.Printf(" [INFO] Session mode: persistent\n") + tmuxPath, ver, err := shell.LookupTmux() + if err != nil { + fmt.Printf(" [FAIL] tmux not found on PATH or unsupported version: %v\n", err) + fmt.Printf(" Fix: install tmux 3.0+ (apt/brew/pacman/dnf), or unset CTASK_SESSION_MODE\n") + *failed++ + return + } + rawVer := ver.Raw + if rawVer == "" { + rawVer = "unknown version" + } + fmt.Printf(" [INFO] tmux found: %s (%s)\n", rawVer, tmuxPath) +} diff --git a/cmd/doctor_test.go b/cmd/doctor_test.go index 4b50c4e..054de11 100644 --- a/cmd/doctor_test.go +++ b/cmd/doctor_test.go @@ -2,12 +2,31 @@ package cmd import ( "bytes" + "io" "os" + "os/exec" "path/filepath" "strings" "testing" ) +// captureStdout runs fn while capturing os.Stdout and returns the output. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + orig := os.Stdout + os.Stdout = w + defer func() { os.Stdout = orig }() + + fn() + w.Close() + data, _ := io.ReadAll(r) + return string(data) +} + // This file swaps process-global os.Stdout and env vars. Do not call // t.Parallel() in this file. @@ -161,3 +180,50 @@ func TestDoctorProjectRootSetButMissingFails(t *testing.T) { t.Errorf("expected FAIL line, got:\n%s", out) } } + +func TestCheckTmuxNotConfigured(t *testing.T) { + os.Unsetenv("CTASK_SESSION_MODE") + out := captureStdout(t, func() { + passed, failed := 0, 0 + checkTmux(&passed, &failed) + }) + if !strings.Contains(out, "Session mode: direct") { + t.Errorf("expected 'Session mode: direct' info line: %q", out) + } +} + +func TestCheckTmuxConfiguredAndPresent(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux not on PATH") + } + os.Setenv("CTASK_SESSION_MODE", "persistent") + defer os.Unsetenv("CTASK_SESSION_MODE") + out := captureStdout(t, func() { + passed, failed := 0, 0 + checkTmux(&passed, &failed) + }) + if !strings.Contains(out, "Session mode: persistent") { + t.Errorf("expected 'Session mode: persistent': %q", out) + } + if !strings.Contains(out, "tmux found") { + t.Errorf("expected 'tmux found' info line: %q", out) + } +} + +func TestCheckTmuxConfiguredAndMissing(t *testing.T) { + orig := os.Getenv("PATH") + defer os.Setenv("PATH", orig) + os.Setenv("PATH", "") + os.Setenv("CTASK_SESSION_MODE", "persistent") + defer os.Unsetenv("CTASK_SESSION_MODE") + + failed := 0 + passed := 0 + out := captureStdout(t, func() { checkTmux(&passed, &failed) }) + if failed != 1 { + t.Errorf("missing tmux must increment failed counter; got %d", failed) + } + if !strings.Contains(out, "tmux not found") { + t.Errorf("expected 'tmux not found' fail line: %q", out) + } +}