feat(v0.3): clean up ctask list semantics

Default behavior is now the broadest useful active view: 'ctask
list' shows all active workspaces, both tasks and projects.

Flag matrix:
  ctask list                    active tasks + projects
  ctask list --all              all (incl. archived)
  ctask list --task             active tasks only
  ctask list --task --all       all tasks (incl. archived)
  ctask list --projects         active projects only
  ctask list --projects --all   all projects (incl. archived)

--task and --projects are mutually exclusive; passing both
returns a usage error rather than silently picking one.

Output gains a small "type" column so the mixed default view
is unambiguous: status, type, mode, category, date, slug.

Empty-result message is type-aware ("No tasks found." /
"No projects found." / "No workspaces found.").

Tests cover all six valid flag combinations, the conflict case,
and the three empty-result message variants.
This commit is contained in:
2026-04-10 17:02:27 -04:00
parent ce742470b2
commit 3dbf963d38
2 changed files with 268 additions and 8 deletions
+27 -8
View File
@@ -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,
+241
View File
@@ -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)
}
}