From 176e788f6718a30ec7a6cf5a8079fb0ee2d9f7a9 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 7 May 2026 19:47:14 -0400 Subject: [PATCH] feat(v0.5.2): add restore, notes, path commands with completion plumbing Three new direct-lookup commands per v0.5.2-spec.md: - ctask restore un-archive a workspace (metadata-only flip, mirrors archive's lease guard, refuses to restore an already-active workspace) - ctask notes stream a workspace's notes.md to stdout (raw, no framing, [ctask]-prefixed stderr on error) so AI agents can read prior workspace context through standard shell pipelines - ctask path print the absolute filesystem path of a workspace, OS-native separators, one line All three resolve archived-inclusive: the user typed a name, so we find the workspace whether or not it's archived. Listing stays filtered (active-only by default) per the v0.5.2 design rule "listing is filtered, direct lookup is comprehensive". Adds shared completion infrastructure (cmd/completion.go) used by these commands and wired into the existing workspace-accepting commands in a follow-up commit. Candidates are workspace directory basenames (e.g. 2026-04-22_promptvolley) rather than bare slugs because basenames are unique under the resolver's exact-match step while slugs can collide across categories or dates. --- cmd/completion.go | 63 +++++++++++ cmd/completion_test.go | 230 +++++++++++++++++++++++++++++++++++++++++ cmd/notes.go | 51 +++++++++ cmd/notes_test.go | 139 +++++++++++++++++++++++++ cmd/path.go | 31 ++++++ cmd/path_test.go | 99 ++++++++++++++++++ cmd/restore.go | 85 +++++++++++++++ cmd/restore_test.go | 204 ++++++++++++++++++++++++++++++++++++ 8 files changed, 902 insertions(+) create mode 100644 cmd/completion.go create mode 100644 cmd/completion_test.go create mode 100644 cmd/notes.go create mode 100644 cmd/notes_test.go create mode 100644 cmd/path.go create mode 100644 cmd/path_test.go create mode 100644 cmd/restore.go create mode 100644 cmd/restore_test.go diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 0000000..093165b --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "path/filepath" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// completionFilter selects which workspaces a command's tab-completion should +// surface. It does not affect lookup — that's still the resolver's job — only +// what the user sees as candidates while typing. +type completionFilter int + +const ( + completionActive completionFilter = iota + completionArchived + completionAny +) + +// completeWorkspaces returns a Cobra ValidArgsFunction that enumerates +// workspace directory basenames matching the requested filter. Basenames are +// emitted (not bare slugs) because the resolver's exact-match step accepts +// basenames unambiguously, while bare slugs can collide across categories or +// dates. +// +// On any internal error we surface ShellCompDirectiveError so the shell shows +// nothing rather than a partial / misleading list. +func completeWorkspaces(filter completionFilter) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // Only the first positional argument is a workspace name. + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + roots := config.SearchRoots() + results, err := workspace.ListWorkspaces(roots, workspace.ListOpts{ + IncludeArchived: filter != completionActive, + }) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + var names []string + for _, ws := range results { + switch filter { + case completionActive: + if ws.Meta.Status == "archived" { + continue + } + case completionArchived: + if ws.Meta.Status != "archived" { + continue + } + case completionAny: + // no filter + } + names = append(names, filepath.Base(ws.Path)) + } + return names, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/cmd/completion_test.go b/cmd/completion_test.go new file mode 100644 index 0000000..e9bab8d --- /dev/null +++ b/cmd/completion_test.go @@ -0,0 +1,230 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// completionTestEnv mirrors listTestEnv but is duplicated here so the +// completion tests are self-contained and can vary the fixtures. +func completionTestEnv(t *testing.T) string { + t.Helper() + root := t.TempDir() + mk := func(category, dirName, status, taskType string) { + dir := filepath.Join(root, category, dirName) + os.MkdirAll(dir, 0755) + now := time.Now().UTC().Truncate(time.Second) + slug := dirName[11:] + meta := &workspace.TaskMeta{ + ID: "t", Slug: slug, Title: slug, + CreatedAt: now, UpdatedAt: now, + Status: status, Category: category, Type: taskType, + Mode: "local", Agent: "claude", + } + if status == "archived" { + meta.ArchivedAt = &now + } + workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) + } + mk("general", "2026-04-05_alpha-active", "active", "task") + mk("general", "2026-04-04_beta-archived", "archived", "task") + mk("projects", "2026-04-03_gamma-active", "active", "project") + mk("projects", "2026-04-02_delta-archived", "archived", "project") + return root +} + +// callCompletion invokes a ValidArgsFunction directly under a CTASK_ROOT +// override, returning the candidate list (sorted for stable comparisons) +// and the shell directive. +func callCompletion(t *testing.T, root string, fn func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective)) ([]string, cobra.ShellCompDirective) { + t.Helper() + prev := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + defer func() { + if prev == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prev) + } + }() + candidates, dir := fn(nil, nil, "") + sort.Strings(candidates) + return candidates, dir +} + +func TestCompleteWorkspacesActiveOnly(t *testing.T) { + root := completionTestEnv(t) + got, _ := callCompletion(t, root, completeWorkspaces(completionActive)) + want := []string{ + "2026-04-03_gamma-active", + "2026-04-05_alpha-active", + } + if !equalStringSlices(got, want) { + t.Errorf("active filter:\nwant %v\ngot %v", want, got) + } +} + +func TestCompleteWorkspacesArchivedOnly(t *testing.T) { + root := completionTestEnv(t) + got, _ := callCompletion(t, root, completeWorkspaces(completionArchived)) + want := []string{ + "2026-04-02_delta-archived", + "2026-04-04_beta-archived", + } + if !equalStringSlices(got, want) { + t.Errorf("archived filter:\nwant %v\ngot %v", want, got) + } +} + +func TestCompleteWorkspacesAny(t *testing.T) { + root := completionTestEnv(t) + got, _ := callCompletion(t, root, completeWorkspaces(completionAny)) + want := []string{ + "2026-04-02_delta-archived", + "2026-04-03_gamma-active", + "2026-04-04_beta-archived", + "2026-04-05_alpha-active", + } + if !equalStringSlices(got, want) { + t.Errorf("any filter:\nwant %v\ngot %v", want, got) + } +} + +func TestCompleteWorkspacesSecondArgReturnsNoCompletion(t *testing.T) { + // Once the user has typed the first positional arg, completion of further + // args must not enumerate workspaces. + root := completionTestEnv(t) + prev := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + defer func() { + if prev == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prev) + } + }() + candidates, dir := completeWorkspaces(completionAny)(nil, []string{"already-typed"}, "") + if len(candidates) != 0 { + t.Errorf("expected no candidates after first positional arg, got: %v", candidates) + } + if dir != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("expected ShellCompDirectiveNoFileComp, got: %v", dir) + } +} + +// genCompletion calls the shell-specific generator that Cobra's auto-injected +// `ctask completion ` subcommand uses internally. Bypasses rootCmd +// argument-routing state so the tests are independent. +func genCompletion(shell string) (string, error) { + var buf bytes.Buffer + var err error + switch shell { + case "bash": + err = rootCmd.GenBashCompletionV2(&buf, true) + case "zsh": + err = rootCmd.GenZshCompletion(&buf) + case "fish": + err = rootCmd.GenFishCompletion(&buf, true) + case "powershell": + err = rootCmd.GenPowerShellCompletionWithDesc(&buf) + } + return buf.String(), err +} + +func TestCompletionBashGenerates(t *testing.T) { + out, err := genCompletion("bash") + if err != nil { + t.Fatalf("GenBashCompletionV2: %v", err) + } + if len(out) == 0 { + t.Fatal("expected non-empty bash completion script") + } + if !strings.Contains(out, "ctask") { + t.Errorf("bash completion script should mention 'ctask':\n%s", truncate(out, 200)) + } +} + +func TestCompletionPowerShellGenerates(t *testing.T) { + out, err := genCompletion("powershell") + if err != nil { + t.Fatalf("GenPowerShellCompletionWithDesc: %v", err) + } + if len(out) == 0 { + t.Fatal("expected non-empty powershell completion script") + } + if !strings.Contains(out, "ctask") { + t.Errorf("powershell completion script should mention 'ctask':\n%s", truncate(out, 200)) + } +} + +func TestCompletionZshGenerates(t *testing.T) { + out, err := genCompletion("zsh") + if err != nil { + t.Fatalf("GenZshCompletion: %v", err) + } + if len(out) == 0 { + t.Fatal("expected non-empty zsh completion script") + } +} + +func TestCompletionFishGenerates(t *testing.T) { + out, err := genCompletion("fish") + if err != nil { + t.Fatalf("GenFishCompletion: %v", err) + } + if len(out) == 0 { + t.Fatal("expected non-empty fish completion script") + } +} + +// TestCompletionSubcommandViaExecute verifies that the user-facing path +// `ctask completion bash` actually runs end-to-end through Cobra. Cobra +// adds the `completion` subcommand lazily on first Execute(), so a Find() +// before any Execute() returns "unknown command" — exercising the real +// path is the right test. +func TestCompletionSubcommandViaExecute(t *testing.T) { + var buf bytes.Buffer + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + defer rootCmd.SetOut(os.Stdout) + defer rootCmd.SetErr(os.Stderr) + + rootCmd.SetArgs([]string{"completion", "bash"}) + defer rootCmd.SetArgs(nil) + if err := rootCmd.Execute(); err != nil { + t.Fatalf("rootCmd.Execute(\"completion\", \"bash\"): %v", err) + } + if buf.Len() == 0 { + t.Fatal("expected non-empty bash completion via Execute") + } + if !strings.Contains(buf.String(), "ctask") { + t.Errorf("end-to-end bash completion should mention 'ctask'") + } +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/cmd/notes.go b/cmd/notes.go new file mode 100644 index 0000000..44074e8 --- /dev/null +++ b/cmd/notes.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" +) + +var notesCmd = &cobra.Command{ + Use: "notes ", + Short: "Print a workspace's notes.md to stdout", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: runNotes, +} + +func init() { + notesCmd.ValidArgsFunction = completeWorkspaces(completionAny) + rootCmd.AddCommand(notesCmd) +} + +// runNotes streams /notes.md to stdout. SilenceErrors is enabled +// so the [ctask]-prefixed message we print to stderr is the only diagnostic +// the user sees — no Cobra "Error: ..." line on top of it. +func runNotes(cmd *cobra.Command, args []string) error { + roots := config.SearchRoots() + ws := resolveOne(roots, args[0], true) + + notesPath := filepath.Join(ws.Path, "notes.md") + f, err := os.Open(notesPath) + if err != nil { + if os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "[ctask] no notes.md found in workspace %q\n", args[0]) + return fmt.Errorf("notes.md missing") + } + fmt.Fprintf(os.Stderr, "[ctask] error reading notes.md: %v\n", err) + return err + } + defer f.Close() + + if _, err := io.Copy(os.Stdout, f); err != nil { + fmt.Fprintf(os.Stderr, "[ctask] error streaming notes.md: %v\n", err) + return err + } + return nil +} diff --git a/cmd/notes_test.go b/cmd/notes_test.go new file mode 100644 index 0000000..6adcb53 --- /dev/null +++ b/cmd/notes_test.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// callNotes invokes runNotes with stdout AND stderr captured. +func callNotes(t *testing.T, root, query string) (stdout, stderr string, err error) { + t.Helper() + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + defer func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }() + + outR, outW, _ := os.Pipe() + prevStdout := os.Stdout + os.Stdout = outW + defer func() { os.Stdout = prevStdout }() + + errR, errW, _ := os.Pipe() + prevStderr := os.Stderr + os.Stderr = errW + defer func() { os.Stderr = prevStderr }() + + err = runNotes(notesCmd, []string{query}) + + outW.Close() + errW.Close() + var bo, be bytes.Buffer + bo.ReadFrom(outR) + be.ReadFrom(errR) + return bo.String(), be.String(), err +} + +// makeNotesWs creates a workspace with optional notes.md content (empty +// string means do not create notes.md). +func makeNotesWs(t *testing.T, root, category, dirName, status, notesBody string) string { + t.Helper() + dir := filepath.Join(root, category, dirName) + os.MkdirAll(dir, 0755) + now := time.Now().UTC().Truncate(time.Second) + slug := dirName[11:] + meta := &workspace.TaskMeta{ + ID: "t", Slug: slug, Title: slug, + CreatedAt: now, UpdatedAt: now, + Status: status, Category: category, Type: "task", + Mode: "local", Agent: "claude", + } + if status == "archived" { + meta.ArchivedAt = &now + } + workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) + if notesBody != "" { + os.WriteFile(filepath.Join(dir, "notes.md"), []byte(notesBody), 0644) + } + return dir +} + +func TestNotesPrintsRawContent(t *testing.T) { + root := t.TempDir() + body := "# my notes\n\n- item one\n- item two\n" + makeNotesWs(t, root, "general", "2026-04-22_notes-active", "active", body) + + stdout, stderr, err := callNotes(t, root, "notes-active") + if err != nil { + t.Fatalf("notes should succeed: %v", err) + } + if stdout != body { + t.Errorf("stdout mismatch:\nwant:\n%q\ngot:\n%q", body, stdout) + } + if stderr != "" { + t.Errorf("stderr should be empty on success, got: %q", stderr) + } +} + +func TestNotesNoFramingOnStdout(t *testing.T) { + // Output must be raw — no banner, no [ctask] prefix on stdout. + root := t.TempDir() + body := "hello world" + makeNotesWs(t, root, "general", "2026-04-22_notes-bare", "active", body) + + stdout, _, err := callNotes(t, root, "notes-bare") + if err != nil { + t.Fatalf("notes: %v", err) + } + if strings.Contains(stdout, "[ctask]") { + t.Errorf("stdout must not contain [ctask] framing: %q", stdout) + } + if stdout != body { + t.Errorf("stdout must equal raw file content exactly: want=%q got=%q", body, stdout) + } +} + +func TestNotesMissingFileExitsNonZero(t *testing.T) { + root := t.TempDir() + // notesBody="" → notes.md is NOT created + makeNotesWs(t, root, "general", "2026-04-22_notes-gone", "active", "") + + stdout, stderr, err := callNotes(t, root, "notes-gone") + if err == nil { + t.Fatal("expected error when notes.md is missing") + } + if stdout != "" { + t.Errorf("stdout should be empty on error, got: %q", stdout) + } + if !strings.Contains(stderr, "[ctask] no notes.md found") { + t.Errorf("stderr should explain missing notes.md, got: %q", stderr) + } + if !strings.Contains(stderr, "notes-gone") { + t.Errorf("stderr should name the workspace, got: %q", stderr) + } +} + +func TestNotesWorksOnArchivedWorkspace(t *testing.T) { + root := t.TempDir() + body := "archived workspace notes\n" + makeNotesWs(t, root, "general", "2026-04-22_notes-archived", "archived", body) + + stdout, _, err := callNotes(t, root, "notes-archived") + if err != nil { + t.Fatalf("notes should find archived workspace: %v", err) + } + if stdout != body { + t.Errorf("archived ws stdout mismatch: want=%q got=%q", body, stdout) + } +} diff --git a/cmd/path.go b/cmd/path.go new file mode 100644 index 0000000..97fb6f3 --- /dev/null +++ b/cmd/path.go @@ -0,0 +1,31 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" +) + +var pathCmd = &cobra.Command{ + Use: "path ", + Short: "Print the absolute filesystem path of a workspace", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: runPath, +} + +func init() { + pathCmd.ValidArgsFunction = completeWorkspaces(completionAny) + rootCmd.AddCommand(pathCmd) +} + +// runPath prints QueryResult.Path to stdout with a single trailing newline. +// Uses native path separators — backslashes on Windows, forward slashes on +// Linux. Output is meant to be consumed by shell pipelines and agent tooling. +func runPath(cmd *cobra.Command, args []string) error { + roots := config.SearchRoots() + ws := resolveOne(roots, args[0], true) + fmt.Println(ws.Path) + return nil +} diff --git a/cmd/path_test.go b/cmd/path_test.go new file mode 100644 index 0000000..08c90a1 --- /dev/null +++ b/cmd/path_test.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// callPath invokes runPath with stdout captured. +func callPath(t *testing.T, root, query string) (string, error) { + t.Helper() + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + defer func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }() + + r, w, _ := os.Pipe() + prevStdout := os.Stdout + os.Stdout = w + defer func() { os.Stdout = prevStdout }() + + err := runPath(pathCmd, []string{query}) + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + return buf.String(), err +} + +func TestPathPrintsAbsolutePath(t *testing.T) { + root := t.TempDir() + dirName := "2026-04-22_path-active" + wsDir := filepath.Join(root, "general", dirName) + os.MkdirAll(wsDir, 0755) + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "path-active", Title: "path-active", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + } + workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) + + out, err := callPath(t, root, "path-active") + if err != nil { + t.Fatalf("path: %v", err) + } + got := strings.TrimRight(out, "\r\n") + want, _ := filepath.Abs(wsDir) + if got != want { + t.Errorf("output path mismatch:\nwant: %q\ngot: %q", want, got) + } + if !strings.HasSuffix(out, "\n") { + t.Errorf("output should end with a newline, got: %q", out) + } + if filepath.IsAbs(got) != true { + t.Errorf("output must be absolute, got: %q", got) + } +} + +func TestPathWorksOnArchivedWorkspace(t *testing.T) { + root := t.TempDir() + dirName := "2026-04-22_path-archived" + wsDir := filepath.Join(root, "general", dirName) + os.MkdirAll(wsDir, 0755) + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "path-archived", Title: "path-archived", + CreatedAt: now, + UpdatedAt: now, + ArchivedAt: &now, + Status: "archived", + Category: "general", + Type: "task", + Mode: "local", + Agent: "claude", + } + workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) + + out, err := callPath(t, root, "path-archived") + if err != nil { + t.Fatalf("path should find archived workspace: %v", err) + } + got := strings.TrimRight(out, "\r\n") + want, _ := filepath.Abs(wsDir) + if got != want { + t.Errorf("output path mismatch:\nwant: %q\ngot: %q", want, got) + } +} diff --git a/cmd/restore.go b/cmd/restore.go new file mode 100644 index 0000000..cc26fc8 --- /dev/null +++ b/cmd/restore.go @@ -0,0 +1,85 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + "github.com/warrenronsiek/ctask/internal/config" + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +var restoreCmd = &cobra.Command{ + Use: "restore ", + Short: "Un-archive a workspace (set status back to active)", + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: runRestore, +} + +func init() { + restoreCmd.ValidArgsFunction = completeWorkspaces(completionArchived) + rootCmd.AddCommand(restoreCmd) +} + +func runRestore(cmd *cobra.Command, args []string) error { + roots := config.SearchRoots() + ws := resolveOne(roots, args[0], true) + + if ws.Meta.Status != "archived" { + return fmt.Errorf("workspace %q is already active", args[0]) + } + + // Mirror the active-session lease guard from archive: if a session is + // holding a fresh lease, prompt on TTY and refuse on non-TTY. Restoring + // under an active session is unusual but not catastrophic — the same + // "refuse before silent surprise" rule applies. + if lease, err := session.ReadLease(session.LeasePath(ws.Path)); err == nil && lease != nil { + if session.IsFresh(lease, time.Now(), session.StaleLeaseAfter) { + fmt.Print(formatRestoreActiveWarning(lease, time.Now())) + + if !isStdinTerminal() { + fmt.Println() + return fmt.Errorf("refusing to restore workspace with active session (stdin is not a terminal)") + } + + if !session.ConfirmYN(os.Stdin, os.Stdout, " Restore anyway? [y/N] ", false) { + fmt.Println(" Cancelled.") + return nil + } + } + } + + now := time.Now().UTC().Truncate(time.Second) + ws.Meta.Status = "active" + ws.Meta.ArchivedAt = nil + ws.Meta.UpdatedAt = now + + metaPath := filepath.Join(ws.Path, "task.yaml") + if err := workspace.WriteMetaLocked(metaPath, ws.Meta); err != nil { + return fmt.Errorf("updating metadata: %w", err) + } + + relPath := workspace.RelativePath(ws.Root, ws.Path) + fmt.Printf("[ctask] restored: %s\n", relPath) + fmt.Printf("[ctask] status: active\n") + return nil +} + +func formatRestoreActiveWarning(l *session.Lease, now time.Time) string { + startedAgo := now.Sub(l.StartedAt) + return fmt.Sprintf( + "[ctask] Warning: this workspace has an active session:\n"+ + " Host: %s\n"+ + " Agent: %s\n"+ + " Started: %s (%s ago)\n\n"+ + " Restoring will mark it active again.\n\n", + l.Hostname, + l.Agent, + l.StartedAt.Local().Format("2006-01-02 15:04"), + session.FormatAgo(startedAgo), + ) +} diff --git a/cmd/restore_test.go b/cmd/restore_test.go new file mode 100644 index 0000000..9251920 --- /dev/null +++ b/cmd/restore_test.go @@ -0,0 +1,204 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/session" + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// This file swaps process-global os.Stdin, os.Stdout, and env vars. Do not +// call t.Parallel() in this file. + +// makeArchivedWs creates a minimal workspace with status=archived. +func makeArchivedWs(t *testing.T, root, category, dirName string) string { + t.Helper() + dir := filepath.Join(root, category, dirName) + os.MkdirAll(dir, 0755) + now := time.Now().UTC().Truncate(time.Second) + archived := now.Add(-time.Hour) + slug := dirName[11:] // skip "YYYY-MM-DD_" + meta := &workspace.TaskMeta{ + ID: "test", + Slug: slug, + Title: slug, + CreatedAt: now.Add(-2 * time.Hour), + UpdatedAt: archived, + ArchivedAt: &archived, + Status: "archived", + Category: category, + Type: "task", + Mode: "local", + Agent: "claude", + } + workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) + return dir +} + +// callRestore invokes runRestore with a captured (non-TTY) stdin and stdout. +func callRestore(t *testing.T, root, query, stdinInput string) (string, error) { + t.Helper() + + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + defer func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }() + + stdinR, stdinW, _ := os.Pipe() + go func() { + stdinW.WriteString(stdinInput) + stdinW.Close() + }() + prevStdin := os.Stdin + os.Stdin = stdinR + defer func() { os.Stdin = prevStdin }() + + stdoutR, stdoutW, _ := os.Pipe() + prevStdout := os.Stdout + os.Stdout = stdoutW + defer func() { os.Stdout = prevStdout }() + + err := runRestore(restoreCmd, []string{query}) + + stdoutW.Close() + var buf bytes.Buffer + buf.ReadFrom(stdoutR) + return buf.String(), err +} + +func TestRestoreFlipsArchivedToActive(t *testing.T) { + root := t.TempDir() + wsDir := makeArchivedWs(t, root, "general", "2026-04-22_revive") + + out, err := callRestore(t, root, "revive", "") + if err != nil { + t.Fatalf("restore should succeed on archived ws, got error: %v", err) + } + if !strings.Contains(out, "[ctask] restored:") { + t.Errorf("expected restored confirmation, got: %s", out) + } + if !strings.Contains(out, "[ctask] status: active") { + t.Errorf("expected status confirmation, got: %s", out) + } + + // task.yaml on disk: Status active, ArchivedAt cleared, UpdatedAt bumped. + got, rerr := workspace.ReadMeta(filepath.Join(wsDir, "task.yaml")) + if rerr != nil { + t.Fatalf("ReadMeta: %v", rerr) + } + if got.Status != "active" { + t.Errorf("Status: got %q, want \"active\"", got.Status) + } + if got.ArchivedAt != nil { + t.Errorf("ArchivedAt: expected nil after restore, got %v", got.ArchivedAt) + } +} + +func TestRestoreWritesStatusActiveExplicitly(t *testing.T) { + // Per spec: write the value explicitly; do not omit the field or set it + // to empty. The on-disk YAML must contain `status: active` literally. + root := t.TempDir() + wsDir := makeArchivedWs(t, root, "general", "2026-04-22_explicit") + + if _, err := callRestore(t, root, "explicit", ""); err != nil { + t.Fatalf("restore: %v", err) + } + + body, err := os.ReadFile(filepath.Join(wsDir, "task.yaml")) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if !strings.Contains(string(body), "status: active") { + t.Errorf("task.yaml must contain literal 'status: active':\n%s", body) + } +} + +func TestRestoreUpdatesUpdatedAt(t *testing.T) { + root := t.TempDir() + wsDir := makeArchivedWs(t, root, "general", "2026-04-22_bumpup") + before, _ := workspace.ReadMeta(filepath.Join(wsDir, "task.yaml")) + + // Sleep a hair so UpdatedAt comparison is meaningful at second resolution. + time.Sleep(1100 * time.Millisecond) + if _, err := callRestore(t, root, "bumpup", ""); err != nil { + t.Fatalf("restore: %v", err) + } + after, _ := workspace.ReadMeta(filepath.Join(wsDir, "task.yaml")) + + if !after.UpdatedAt.After(before.UpdatedAt) { + t.Errorf("UpdatedAt did not advance: before=%v, after=%v", before.UpdatedAt, after.UpdatedAt) + } +} + +func TestRestoreRefusesAlreadyActive(t *testing.T) { + root := t.TempDir() + wsDir := filepath.Join(root, "general", "2026-04-22_already") + os.MkdirAll(wsDir, 0755) + now := time.Now().UTC().Truncate(time.Second) + meta := &workspace.TaskMeta{ + ID: "t", Slug: "already", Title: "already", + CreatedAt: now, UpdatedAt: now, + Status: "active", Category: "general", Type: "task", + Mode: "local", Agent: "claude", + } + workspace.WriteMeta(filepath.Join(wsDir, "task.yaml"), meta) + + _, err := callRestore(t, root, "already", "") + if err == nil { + t.Fatal("expected error restoring an already-active workspace") + } + if !strings.Contains(err.Error(), "already active") { + t.Errorf("expected 'already active' in error, got: %v", err) + } +} + +func TestRestoreRefusesActiveSessionNonTTY(t *testing.T) { + // Mirror archive's non-TTY refusal. A fresh lease + non-TTY stdin must + // refuse without mutation, matching the archive guard. + root := t.TempDir() + wsDir := makeArchivedWs(t, root, "general", "2026-04-22_freshlease") + + // Drop a fresh lease at .ctask/session.json + now := time.Now().UTC() + l := &session.Lease{ + SessionID: "test-session", + PID: 1234, + Hostname: "test-host", + Username: "tester", + Agent: "claude", + Mode: "local", + StartedAt: now.Add(-30 * time.Second), + LastHeartbeatAt: now.Add(-5 * time.Second), + Terminal: "test", + } + if err := session.WriteLease(session.LeasePath(wsDir), l); err != nil { + t.Fatalf("WriteLease: %v", err) + } + + out, err := callRestore(t, root, "freshlease", "") + if err == nil { + t.Fatal("expected error refusing to restore active session on non-TTY stdin") + } + if !strings.Contains(out, "active session") { + t.Errorf("expected active-session warning in output, got: %s", out) + } + + // Workspace must still be archived (no mutation). + got, _ := workspace.ReadMeta(filepath.Join(wsDir, "task.yaml")) + if got.Status != "archived" { + t.Errorf("workspace mutated despite refusal: status=%s", got.Status) + } + if got.ArchivedAt == nil { + t.Errorf("ArchivedAt was cleared despite refusal") + } +}