From 56d2e07716041cd570673fdb7f6c67d6655b3793 Mon Sep 17 00:00:00 2001 From: typebasedio Date: Thu, 7 May 2026 19:47:33 -0400 Subject: [PATCH] feat(v0.5.2): list --names for machine-readable enumeration Adds a --names flag to ctask list that emits one workspace directory basename per line, no header, no decoration. Empty result is empty stdout with zero exit code (no "No workspaces found." placeholder). Used by shell completion scripts and external tooling. Candidates are directory basenames rather than bare slugs because basenames are unique under the resolver's exact-match step while slugs can collide across categories or dates. Respects existing list filters: --all, --task, --projects, --category, --limit. So: ctask list --names active workspaces only ctask list --names --all active and archived ctask list --names --projects active project workspaces The new TestListNamesCandidatesResolveUniquely test enforces the spec invariant: every line emitted by list --names must resolve to exactly one workspace via the standard resolver. --- cmd/list.go | 14 +++++ cmd/list_test.go | 150 ++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 2 deletions(-) diff --git a/cmd/list.go b/cmd/list.go index 9cff248..cc348d3 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -30,6 +30,7 @@ var ( listLimit int listProjects bool listTask bool + listNames bool ) func init() { @@ -38,6 +39,7 @@ func init() { listCmd.Flags().BoolVar(&listProjects, "projects", false, "Show project workspaces only") listCmd.Flags().StringVarP(&listCategory, "category", "c", "", "Filter by category") listCmd.Flags().IntVarP(&listLimit, "limit", "n", 20, "Maximum entries to show") + listCmd.Flags().BoolVar(&listNames, "names", false, "Output one workspace directory basename per line, no header (for shell completion and scripting)") rootCmd.AddCommand(listCmd) } @@ -66,6 +68,18 @@ func runList(cmd *cobra.Command, args []string) error { return err } + // v0.5.2: --names emits one directory basename per line, no header, + // empty stdout on no matches. Used by shell completion and scripting. + // We emit 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. + if listNames { + for _, ws := range results { + fmt.Println(filepath.Base(ws.Path)) + } + return nil + } + if len(results) == 0 { switch { case listTask: diff --git a/cmd/list_test.go b/cmd/list_test.go index 43ed02a..afd5abc 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -53,9 +53,9 @@ func runListCapture(t *testing.T, root string, all, projects, task bool) (string t.Helper() // Save and restore the package-level flag state. - prevAll, prevProjects, prevTask, prevCategory, prevLimit := listAll, listProjects, listTask, listCategory, listLimit + prevAll, prevProjects, prevTask, prevCategory, prevLimit, prevNames := listAll, listProjects, listTask, listCategory, listLimit, listNames defer func() { - listAll, listProjects, listTask, listCategory, listLimit = prevAll, prevProjects, prevTask, prevCategory, prevLimit + listAll, listProjects, listTask, listCategory, listLimit, listNames = prevAll, prevProjects, prevTask, prevCategory, prevLimit, prevNames }() listAll = all @@ -63,6 +63,7 @@ func runListCapture(t *testing.T, root string, all, projects, task bool) (string listTask = task listCategory = "" listLimit = 20 + listNames = false // Save and restore env / stdout / stderr. prevRoot := os.Getenv("CTASK_ROOT") @@ -238,3 +239,148 @@ func TestListEmptyProjectsMessage(t *testing.T) { t.Errorf("expected project-specific empty message, got: %q", out) } } + +// runListNamesCapture invokes runList with --names enabled and the given +// --all value. Returns captured stdout and the runList error. +func runListNamesCapture(t *testing.T, root string, all bool) (string, error) { + t.Helper() + + prevAll, prevProjects, prevTask, prevCategory, prevLimit, prevNames := listAll, listProjects, listTask, listCategory, listLimit, listNames + defer func() { + listAll, listProjects, listTask, listCategory, listLimit, listNames = prevAll, prevProjects, prevTask, prevCategory, prevLimit, prevNames + }() + listAll = all + listProjects = false + listTask = false + listCategory = "" + listLimit = 20 + listNames = true + + 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 := runList(listCmd, nil) + w.Close() + var buf bytes.Buffer + buf.ReadFrom(r) + return buf.String(), err +} + +func TestListNamesOutputsBasenames(t *testing.T) { + root := listTestEnv(t) + out, err := runListNamesCapture(t, root, false) + if err != nil { + t.Fatalf("runList: %v", err) + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + + // Default filter is active-only, so 3 lines: task-active, legacy, proj-active. + wantPresent := []string{ + "2026-04-05_task-active", + "2026-04-03_legacy", + "2026-04-02_proj-active", + } + wantAbsent := []string{ + "2026-04-04_task-archived", + "2026-04-01_proj-archived", + } + + for _, w := range wantPresent { + found := false + for _, line := range lines { + if line == w { + found = true + break + } + } + if !found { + t.Errorf("expected basename %q in output, got lines:\n%s", w, out) + } + } + for _, w := range wantAbsent { + for _, line := range lines { + if line == w { + t.Errorf("default --names should not include archived %q", w) + } + } + } + + // No header, no decoration. + if strings.Contains(out, "status") || strings.Contains(out, "category") { + t.Errorf("--names output must not contain table headers:\n%s", out) + } +} + +func TestListNamesAllIncludesArchived(t *testing.T) { + root := listTestEnv(t) + out, err := runListNamesCapture(t, root, true) + if err != nil { + t.Fatalf("runList: %v", err) + } + want := []string{ + "2026-04-05_task-active", + "2026-04-04_task-archived", + "2026-04-03_legacy", + "2026-04-02_proj-active", + "2026-04-01_proj-archived", + } + for _, w := range want { + if !strings.Contains(out, w) { + t.Errorf("--names --all missing %q:\n%s", w, out) + } + } +} + +func TestListNamesEmptyHasEmptyStdout(t *testing.T) { + // Spec rule: empty result is empty stdout, zero exit code. No + // "No workspaces found" placeholder. + root := t.TempDir() + out, err := runListNamesCapture(t, root, false) + if err != nil { + t.Fatalf("runList should not error on empty: %v", err) + } + if out != "" { + t.Errorf("--names with no matches must produce empty stdout, got: %q", out) + } +} + +func TestListNamesCandidatesResolveUniquely(t *testing.T) { + // Spec invariant: every line printed by `ctask list --names` must be + // accepted by workspace-taking commands and resolve to exactly one + // workspace under the same archive/filter policy. + root := listTestEnv(t) + out, err := runListNamesCapture(t, root, true) // include archived too + if err != nil { + t.Fatalf("runList: %v", err) + } + lines := strings.Split(strings.TrimRight(out, "\n"), "\n") + if len(lines) == 0 { + t.Fatal("expected non-empty output for fixture root") + } + + for _, line := range lines { + if line == "" { + continue + } + results, qerr := workspace.ResolveQuery([]string{root}, line, true) + if qerr != nil { + t.Errorf("ResolveQuery(%q): %v", line, qerr) + continue + } + if len(results) != 1 { + t.Errorf("emitted name %q must resolve to exactly 1 workspace, got %d", line, len(results)) + } + } +}