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)) + } + } +}