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:
+14
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user