diff --git a/cmd/list.go b/cmd/list.go index d8b06d7..3fbd108 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -13,7 +13,12 @@ import ( var listCmd = &cobra.Command{ Use: "list", - Short: "Show recent workspaces in reverse-chronological order", + Short: "List workspaces (tasks and projects)", + Long: `List workspaces in reverse-chronological order. + +By default, ctask list shows all active workspaces -- both tasks and projects. +Use --task or --projects to narrow by type, and --all to include archived +workspaces. --task and --projects are mutually exclusive.`, Args: cobra.NoArgs, SilenceUsage: true, RunE: runList, @@ -24,23 +29,33 @@ var ( listCategory string listLimit int listProjects bool + listTask bool ) func init() { listCmd.Flags().BoolVarP(&listAll, "all", "a", false, "Include archived workspaces") - listCmd.Flags().BoolVar(&listProjects, "projects", false, "Show projects instead of tasks") + listCmd.Flags().BoolVar(&listTask, "task", false, "Show task workspaces only") + 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") rootCmd.AddCommand(listCmd) } func runList(cmd *cobra.Command, args []string) error { + if listTask && listProjects { + return fmt.Errorf("--task and --projects are mutually exclusive; pass at most one") + } + root := config.ResolveRoot() - wsType := workspace.TypeTask - if listProjects { + wsType := workspace.TypeAny + switch { + case listTask: + wsType = workspace.TypeTask + case listProjects: wsType = workspace.TypeProject } + results, err := workspace.ListWorkspaces(root, workspace.ListOpts{ IncludeArchived: listAll, Category: listCategory, @@ -52,10 +67,13 @@ func runList(cmd *cobra.Command, args []string) error { } if len(results) == 0 { - if listProjects { - fmt.Println("No projects found.") - } else { + switch { + case listTask: fmt.Println("No tasks found.") + case listProjects: + fmt.Println("No projects found.") + default: + fmt.Println("No workspaces found.") } return nil } @@ -68,8 +86,9 @@ func runList(cmd *cobra.Command, args []string) error { date = dirName[:10] } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", ws.Meta.Status, + workspace.EffectiveType(ws.Meta), ws.Meta.Mode, ws.Meta.Category, date, diff --git a/cmd/list_test.go b/cmd/list_test.go new file mode 100644 index 0000000..8f427e3 --- /dev/null +++ b/cmd/list_test.go @@ -0,0 +1,241 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/warrenronsiek/ctask/internal/workspace" +) + +// listTestEnv builds a fixture root with a known mix of workspaces and +// returns the root path. Wall-clock UpdatedAt is fine for these tests -- +// we only assert on which slugs appear, not order. +func listTestEnv(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: "test", + Slug: slug, + Title: slug, + CreatedAt: now, + UpdatedAt: now, + Status: status, + Category: category, + Type: taskType, + Mode: "local", + Agent: "claude", + WorkspacePath: dir, + } + workspace.WriteMeta(filepath.Join(dir, "task.yaml"), meta) + } + + mk("general", "2026-04-05_task-active", "active", "task") + mk("general", "2026-04-04_task-archived", "archived", "task") + mk("general", "2026-04-03_legacy", "active", "") // v0.2: no Type field + mk("projects", "2026-04-02_proj-active", "active", "project") + mk("projects", "2026-04-01_proj-archived", "archived", "project") + + return root +} + +// runListCapture invokes runList with the given flag values and returns the +// captured stdout, stderr, and the returned error. +func runListCapture(t *testing.T, root string, all, projects, task bool) (string, string, error) { + t.Helper() + + // Save and restore the package-level flag state. + prevAll, prevProjects, prevTask, prevCategory, prevLimit := listAll, listProjects, listTask, listCategory, listLimit + defer func() { + listAll, listProjects, listTask, listCategory, listLimit = prevAll, prevProjects, prevTask, prevCategory, prevLimit + }() + + listAll = all + listProjects = projects + listTask = task + listCategory = "" + listLimit = 20 + + // Save and restore env / stdout / stderr. + prevRoot := os.Getenv("CTASK_ROOT") + os.Setenv("CTASK_ROOT", root) + defer func() { + if prevRoot == "" { + os.Unsetenv("CTASK_ROOT") + } else { + os.Setenv("CTASK_ROOT", prevRoot) + } + }() + + stdoutR, stdoutW, _ := os.Pipe() + stderrR, stderrW, _ := os.Pipe() + prevStdout, prevStderr := os.Stdout, os.Stderr + os.Stdout, os.Stderr = stdoutW, stderrW + defer func() { + os.Stdout, os.Stderr = prevStdout, prevStderr + }() + + err := runList(listCmd, nil) + + stdoutW.Close() + stderrW.Close() + var outBuf, errBuf bytes.Buffer + outBuf.ReadFrom(stdoutR) + errBuf.ReadFrom(stderrR) + + return outBuf.String(), errBuf.String(), err +} + +func TestListDefaultShowsActiveTasksAndProjects(t *testing.T) { + root := listTestEnv(t) + out, _, err := runListCapture(t, root, false, false, false) + if err != nil { + t.Fatalf("runList: %v", err) + } + for _, want := range []string{"task-active", "legacy", "proj-active"} { + if !strings.Contains(out, want) { + t.Errorf("default list missing %q in output:\n%s", want, out) + } + } + for _, dontWant := range []string{"task-archived", "proj-archived"} { + if strings.Contains(out, dontWant) { + t.Errorf("default list should not include archived %q:\n%s", dontWant, out) + } + } +} + +func TestListAllShowsEverything(t *testing.T) { + root := listTestEnv(t) + out, _, err := runListCapture(t, root, true, false, false) + if err != nil { + t.Fatalf("runList: %v", err) + } + for _, want := range []string{"task-active", "task-archived", "legacy", "proj-active", "proj-archived"} { + if !strings.Contains(out, want) { + t.Errorf("--all list missing %q:\n%s", want, out) + } + } +} + +func TestListTaskActiveOnly(t *testing.T) { + root := listTestEnv(t) + out, _, err := runListCapture(t, root, false, false, true) + if err != nil { + t.Fatalf("runList: %v", err) + } + for _, want := range []string{"task-active", "legacy"} { + if !strings.Contains(out, want) { + t.Errorf("--task list missing %q:\n%s", want, out) + } + } + for _, dontWant := range []string{"task-archived", "proj-active", "proj-archived"} { + if strings.Contains(out, dontWant) { + t.Errorf("--task list should not include %q:\n%s", dontWant, out) + } + } +} + +func TestListTaskAll(t *testing.T) { + root := listTestEnv(t) + out, _, err := runListCapture(t, root, true, false, true) + if err != nil { + t.Fatalf("runList: %v", err) + } + for _, want := range []string{"task-active", "task-archived", "legacy"} { + if !strings.Contains(out, want) { + t.Errorf("--task --all list missing %q:\n%s", want, out) + } + } + for _, dontWant := range []string{"proj-active", "proj-archived"} { + if strings.Contains(out, dontWant) { + t.Errorf("--task --all list should not include %q:\n%s", dontWant, out) + } + } +} + +func TestListProjectsActiveOnly(t *testing.T) { + root := listTestEnv(t) + out, _, err := runListCapture(t, root, false, true, false) + if err != nil { + t.Fatalf("runList: %v", err) + } + if !strings.Contains(out, "proj-active") { + t.Errorf("--projects missing proj-active:\n%s", out) + } + for _, dontWant := range []string{"task-active", "legacy", "task-archived", "proj-archived"} { + if strings.Contains(out, dontWant) { + t.Errorf("--projects should not include %q:\n%s", dontWant, out) + } + } +} + +func TestListProjectsAll(t *testing.T) { + root := listTestEnv(t) + out, _, err := runListCapture(t, root, true, true, false) + if err != nil { + t.Fatalf("runList: %v", err) + } + for _, want := range []string{"proj-active", "proj-archived"} { + if !strings.Contains(out, want) { + t.Errorf("--projects --all missing %q:\n%s", want, out) + } + } + for _, dontWant := range []string{"task-active", "task-archived", "legacy"} { + if strings.Contains(out, dontWant) { + t.Errorf("--projects --all should not include %q:\n%s", dontWant, out) + } + } +} + +func TestListTaskAndProjectsConflictErrors(t *testing.T) { + root := listTestEnv(t) + _, _, err := runListCapture(t, root, false, true, true) + if err == nil { + t.Fatal("expected an error when both --task and --projects are passed") + } + if !strings.Contains(err.Error(), "--task") || !strings.Contains(err.Error(), "--projects") { + t.Errorf("error should mention both flags, got: %v", err) + } +} + +func TestListEmptyDefaultMessage(t *testing.T) { + root := t.TempDir() // empty + out, _, err := runListCapture(t, root, false, false, false) + if err != nil { + t.Fatalf("runList: %v", err) + } + if !strings.Contains(out, "No workspaces found") { + t.Errorf("expected empty message, got: %q", out) + } +} + +func TestListEmptyTaskMessage(t *testing.T) { + root := t.TempDir() + out, _, err := runListCapture(t, root, false, false, true) + if err != nil { + t.Fatalf("runList: %v", err) + } + if !strings.Contains(out, "No tasks found") { + t.Errorf("expected task-specific empty message, got: %q", out) + } +} + +func TestListEmptyProjectsMessage(t *testing.T) { + root := t.TempDir() + out, _, err := runListCapture(t, root, false, true, false) + if err != nil { + t.Fatalf("runList: %v", err) + } + if !strings.Contains(out, "No projects found") { + t.Errorf("expected project-specific empty message, got: %q", out) + } +}