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