refactor(v0.3): replace ListOpts.Projects bool with tri-state Type filter

ListOpts now exposes a Type string field (TypeAny / TypeTask /
TypeProject). TypeAny is the new way to express "both tasks and
projects" in a single ListWorkspaces call -- which the next two
commits will use to consolidate cmd/last and cmd/delete onto a
single helper, and to make 'ctask list' default to showing both
types.

Invalid Type values now return an explicit error from
ListWorkspaces (defensive against typos in callers).

cmd/list, cmd/last, and cmd/delete are migrated to the new field.
External behavior is unchanged in this commit; the cleanup of
ctask list semantics happens in a follow-up commit so the diff
stays reviewable.
This commit is contained in:
2026-04-10 16:49:49 -04:00
parent 4b6c8fad4b
commit bd1cff5b26
5 changed files with 82 additions and 51 deletions
+3 -8
View File
@@ -71,17 +71,12 @@ func runDelete(cmd *cobra.Command, args []string) error {
fmt.Printf(" Files: %d (%s)\n", fileCount, formatSize(totalSize))
// Check if this is the most recently updated workspace.
// v0.3: union tasks and projects so the "most recent" check spans both types.
tasks, _ := workspace.ListWorkspaces(root, workspace.ListOpts{
// Spans both tasks and projects via Type=TypeAny.
results, _ := workspace.ListWorkspaces(root, workspace.ListOpts{
IncludeArchived: false,
Limit: 0,
Type: workspace.TypeAny,
})
projects, _ := workspace.ListWorkspaces(root, workspace.ListOpts{
IncludeArchived: false,
Projects: true,
Limit: 0,
})
results := append(tasks, projects...)
if len(results) > 0 {
best := results[0]
for _, r := range results[1:] {
+3 -12
View File
@@ -32,24 +32,15 @@ func runLast(cmd *cobra.Command, args []string) error {
root := config.ResolveRoot()
// Scan all non-archived workspaces (tasks AND projects) and find the most
// recently updated. v0.3: ListWorkspaces filters by type, so we union the
// two type buckets here so `last` keeps working for both.
tasks, err := workspace.ListWorkspaces(root, workspace.ListOpts{
// recently updated.
results, err := workspace.ListWorkspaces(root, workspace.ListOpts{
IncludeArchived: false,
Limit: 0,
Type: workspace.TypeAny,
})
if err != nil {
return err
}
projects, err := workspace.ListWorkspaces(root, workspace.ListOpts{
IncludeArchived: false,
Projects: true,
Limit: 0,
})
if err != nil {
return err
}
results := append(tasks, projects...)
if len(results) == 0 {
fmt.Fprintln(os.Stderr, "No active workspaces found.")
+5 -1
View File
@@ -37,11 +37,15 @@ func init() {
func runList(cmd *cobra.Command, args []string) error {
root := config.ResolveRoot()
wsType := workspace.TypeTask
if listProjects {
wsType = workspace.TypeProject
}
results, err := workspace.ListWorkspaces(root, workspace.ListOpts{
IncludeArchived: listAll,
Category: listCategory,
Limit: listLimit,
Projects: listProjects,
Type: wsType,
})
if err != nil {
return err
+22 -10
View File
@@ -1,34 +1,46 @@
package workspace
import (
"fmt"
"path/filepath"
"sort"
)
// Type filter values for ListOpts.Type. The empty string means "no filter"
// (return both tasks and projects).
const (
TypeAny = ""
TypeTask = "task"
TypeProject = "project"
)
// ListOpts configures workspace listing.
type ListOpts struct {
IncludeArchived bool
Category string
Limit int
// Projects, when true, returns project workspaces only. When false (default),
// only task workspaces are returned. v0.2 workspaces with no Type field are
// treated as tasks via EffectiveType.
Projects bool
// Type filters by workspace type via EffectiveType.
// Allowed values: TypeAny (""), TypeTask ("task"), TypeProject ("project").
// v0.2 workspaces with no type field count as tasks.
Type string
}
// ListWorkspaces returns workspaces in reverse-chronological order.
// Returns an error if Type is set to an unrecognized value.
func ListWorkspaces(root string, opts ListOpts) ([]QueryResult, error) {
switch opts.Type {
case TypeAny, TypeTask, TypeProject:
// ok
default:
return nil, fmt.Errorf("invalid Type filter: %q (want %q, %q, or %q)", opts.Type, TypeAny, TypeTask, TypeProject)
}
all, err := scanWorkspaces(root)
if err != nil {
return nil, err
}
wantType := "task"
if opts.Projects {
wantType = "project"
}
var filtered []QueryResult
for _, ws := range all {
if !opts.IncludeArchived && ws.Meta.Status == "archived" {
@@ -37,7 +49,7 @@ func ListWorkspaces(root string, opts ListOpts) ([]QueryResult, error) {
if opts.Category != "" && ws.Meta.Category != opts.Category {
continue
}
if EffectiveType(ws.Meta) != wantType {
if opts.Type != TypeAny && EffectiveType(ws.Meta) != opts.Type {
continue
}
filtered = append(filtered, ws)
+49 -20
View File
@@ -47,31 +47,63 @@ func TestListWorkspaces(t *testing.T) {
}
}
func TestListProjectsFilter(t *testing.T) {
func TestListTypeFilter(t *testing.T) {
root := t.TempDir()
createTestWorkspaceTyped(t, root, "general", "2026-04-05_task-a", "active", "task")
createTestWorkspaceTyped(t, root, "general", "2026-04-04_task-b", "active", "") // legacy: no type -> task
createTestWorkspaceTyped(t, root, "projects", "2026-04-03_proj-a", "active", "project")
createTestWorkspaceTyped(t, root, "projects", "2026-04-02_proj-b", "archived", "project")
// Default: tasks only, active
results, err := ListWorkspaces(root, ListOpts{Limit: 20})
// TypeAny: both types, active only
results, err := ListWorkspaces(root, ListOpts{Type: TypeAny, Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
t.Fatalf("ListWorkspaces TypeAny: %v", err)
}
if len(results) != 3 {
t.Fatalf("expected 3 active workspaces (2 tasks + 1 project), got %d", len(results))
}
// TypeAny + IncludeArchived: everything
results, err = ListWorkspaces(root, ListOpts{Type: TypeAny, IncludeArchived: true, Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces TypeAny+all: %v", err)
}
if len(results) != 4 {
t.Fatalf("expected 4 workspaces with --all, got %d", len(results))
}
// TypeTask: tasks only, active
results, err = ListWorkspaces(root, ListOpts{Type: TypeTask, Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces TypeTask: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 active tasks, got %d", len(results))
}
for _, r := range results {
if EffectiveType(r.Meta) != "task" {
t.Errorf("non-task in default list: %s (type %q)", r.Meta.Slug, r.Meta.Type)
t.Errorf("non-task in TypeTask list: %s (type %q)", r.Meta.Slug, r.Meta.Type)
}
}
// --projects: projects only, active
results, err = ListWorkspaces(root, ListOpts{Projects: true, Limit: 20})
// TypeTask + IncludeArchived: still tasks only (no archived tasks here, so 2)
results, err = ListWorkspaces(root, ListOpts{Type: TypeTask, IncludeArchived: true, Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces --projects: %v", err)
t.Fatalf("ListWorkspaces TypeTask+all: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 tasks (no archived in fixture), got %d", len(results))
}
for _, r := range results {
if EffectiveType(r.Meta) != "task" {
t.Errorf("non-task in TypeTask+all list: %s", r.Meta.Slug)
}
}
// TypeProject: projects only, active
results, err = ListWorkspaces(root, ListOpts{Type: TypeProject, Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces TypeProject: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 active project, got %d", len(results))
@@ -80,24 +112,21 @@ func TestListProjectsFilter(t *testing.T) {
t.Errorf("expected proj-a, got %s", results[0].Meta.Slug)
}
// --projects --all
results, err = ListWorkspaces(root, ListOpts{Projects: true, IncludeArchived: true, Limit: 20})
// TypeProject + IncludeArchived
results, err = ListWorkspaces(root, ListOpts{Type: TypeProject, IncludeArchived: true, Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces --projects --all: %v", err)
t.Fatalf("ListWorkspaces TypeProject+all: %v", err)
}
if len(results) != 2 {
t.Fatalf("expected 2 projects with --all, got %d", len(results))
}
}
// --all alone: tasks only with archived
results, err = ListWorkspaces(root, ListOpts{IncludeArchived: true, Limit: 20})
if err != nil {
t.Fatalf("ListWorkspaces --all: %v", err)
}
for _, r := range results {
if EffectiveType(r.Meta) != "task" {
t.Errorf("--all alone returned non-task: %s", r.Meta.Slug)
}
func TestListInvalidTypeFilterReturnsError(t *testing.T) {
root := t.TempDir()
_, err := ListWorkspaces(root, ListOpts{Type: "weird"})
if err == nil {
t.Fatal("expected error for invalid Type filter, got nil")
}
}