From bd1cff5b2653ee040bc100b277061fae5572707d Mon Sep 17 00:00:00 2001 From: typebasedio Date: Fri, 10 Apr 2026 16:49:49 -0400 Subject: [PATCH] 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. --- cmd/delete.go | 11 ++---- cmd/last.go | 15 ++----- cmd/list.go | 6 ++- internal/workspace/list.go | 32 ++++++++++----- internal/workspace/list_test.go | 69 +++++++++++++++++++++++---------- 5 files changed, 82 insertions(+), 51 deletions(-) diff --git a/cmd/delete.go b/cmd/delete.go index 6a24bb1..28f561d 100644 --- a/cmd/delete.go +++ b/cmd/delete.go @@ -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:] { diff --git a/cmd/last.go b/cmd/last.go index 8e418a5..096d742 100644 --- a/cmd/last.go +++ b/cmd/last.go @@ -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.") diff --git a/cmd/list.go b/cmd/list.go index 1542d62..d8b06d7 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -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 diff --git a/internal/workspace/list.go b/internal/workspace/list.go index 3688637..038e1fb 100644 --- a/internal/workspace/list.go +++ b/internal/workspace/list.go @@ -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) diff --git a/internal/workspace/list_test.go b/internal/workspace/list_test.go index da5f538..d337b8a 100644 --- a/internal/workspace/list_test.go +++ b/internal/workspace/list_test.go @@ -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") } }