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.
This commit is contained in:
2026-05-07 19:47:33 -04:00
parent b923ae8892
commit 56d2e07716
2 changed files with 162 additions and 2 deletions
+14
View File
@@ -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:
+148 -2
View File
@@ -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))
}
}
}