From 075000497f7604166445dcd8d2cec68af9a3f43a Mon Sep 17 00:00:00 2001 From: typebasedio Date: Wed, 22 Apr 2026 17:53:58 -0400 Subject: [PATCH] fix(v0.4.1): scan both CTASK_ROOT and CTASK_PROJECT_ROOT in workspace queries Rewrites scanWorkspaces to handle both category layout (root///task.yaml) and flat layout (root//task.yaml used under CTASK_PROJECT_ROOT). Adds scanAllRoots to walk multiple roots with absolute-path dedupe. ResolveQuery, ListWorkspaces, and MostRecentActive now accept []string. QueryResult gains a Root field so callers can render display paths and session env vars relative to the originating root. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/workspace/list.go | 8 +- internal/workspace/list_test.go | 46 ++++++-- internal/workspace/most_recent.go | 6 +- internal/workspace/most_recent_test.go | 34 ++++-- internal/workspace/query.go | 88 ++++++++++++--- internal/workspace/query_test.go | 150 +++++++++++++++++++++++-- 6 files changed, 282 insertions(+), 50 deletions(-) diff --git a/internal/workspace/list.go b/internal/workspace/list.go index 038e1fb..af84191 100644 --- a/internal/workspace/list.go +++ b/internal/workspace/list.go @@ -26,9 +26,9 @@ type ListOpts struct { 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) { +// ListWorkspaces returns workspaces in reverse-chronological order across one +// or more roots. Returns an error if Type is set to an unrecognized value. +func ListWorkspaces(roots []string, opts ListOpts) ([]QueryResult, error) { switch opts.Type { case TypeAny, TypeTask, TypeProject: // ok @@ -36,7 +36,7 @@ func ListWorkspaces(root string, opts ListOpts) ([]QueryResult, error) { return nil, fmt.Errorf("invalid Type filter: %q (want %q, %q, or %q)", opts.Type, TypeAny, TypeTask, TypeProject) } - all, err := scanWorkspaces(root) + all, err := scanAllRoots(roots) if err != nil { return nil, err } diff --git a/internal/workspace/list_test.go b/internal/workspace/list_test.go index d337b8a..79846cb 100644 --- a/internal/workspace/list_test.go +++ b/internal/workspace/list_test.go @@ -11,7 +11,7 @@ func TestListWorkspaces(t *testing.T) { createTestWorkspace(t, root, "general", "2026-04-01_archived-task", "archived") // Default: no archived, no filter - results, err := ListWorkspaces(root, ListOpts{Limit: 20}) + results, err := ListWorkspaces([]string{root}, ListOpts{Limit: 20}) if err != nil { t.Fatalf("ListWorkspaces: %v", err) } @@ -20,7 +20,7 @@ func TestListWorkspaces(t *testing.T) { } // With --all - results, err = ListWorkspaces(root, ListOpts{IncludeArchived: true, Limit: 20}) + results, err = ListWorkspaces([]string{root}, ListOpts{IncludeArchived: true, Limit: 20}) if err != nil { t.Fatalf("ListWorkspaces --all: %v", err) } @@ -29,7 +29,7 @@ func TestListWorkspaces(t *testing.T) { } // Category filter - results, err = ListWorkspaces(root, ListOpts{Category: "scripts", Limit: 20}) + results, err = ListWorkspaces([]string{root}, ListOpts{Category: "scripts", Limit: 20}) if err != nil { t.Fatalf("ListWorkspaces -c scripts: %v", err) } @@ -38,7 +38,7 @@ func TestListWorkspaces(t *testing.T) { } // Limit - results, err = ListWorkspaces(root, ListOpts{Limit: 1}) + results, err = ListWorkspaces([]string{root}, ListOpts{Limit: 1}) if err != nil { t.Fatalf("ListWorkspaces -n 1: %v", err) } @@ -55,7 +55,7 @@ func TestListTypeFilter(t *testing.T) { createTestWorkspaceTyped(t, root, "projects", "2026-04-02_proj-b", "archived", "project") // TypeAny: both types, active only - results, err := ListWorkspaces(root, ListOpts{Type: TypeAny, Limit: 20}) + results, err := ListWorkspaces([]string{root}, ListOpts{Type: TypeAny, Limit: 20}) if err != nil { t.Fatalf("ListWorkspaces TypeAny: %v", err) } @@ -64,7 +64,7 @@ func TestListTypeFilter(t *testing.T) { } // TypeAny + IncludeArchived: everything - results, err = ListWorkspaces(root, ListOpts{Type: TypeAny, IncludeArchived: true, Limit: 20}) + results, err = ListWorkspaces([]string{root}, ListOpts{Type: TypeAny, IncludeArchived: true, Limit: 20}) if err != nil { t.Fatalf("ListWorkspaces TypeAny+all: %v", err) } @@ -73,7 +73,7 @@ func TestListTypeFilter(t *testing.T) { } // TypeTask: tasks only, active - results, err = ListWorkspaces(root, ListOpts{Type: TypeTask, Limit: 20}) + results, err = ListWorkspaces([]string{root}, ListOpts{Type: TypeTask, Limit: 20}) if err != nil { t.Fatalf("ListWorkspaces TypeTask: %v", err) } @@ -87,7 +87,7 @@ func TestListTypeFilter(t *testing.T) { } // TypeTask + IncludeArchived: still tasks only (no archived tasks here, so 2) - results, err = ListWorkspaces(root, ListOpts{Type: TypeTask, IncludeArchived: true, Limit: 20}) + results, err = ListWorkspaces([]string{root}, ListOpts{Type: TypeTask, IncludeArchived: true, Limit: 20}) if err != nil { t.Fatalf("ListWorkspaces TypeTask+all: %v", err) } @@ -101,7 +101,7 @@ func TestListTypeFilter(t *testing.T) { } // TypeProject: projects only, active - results, err = ListWorkspaces(root, ListOpts{Type: TypeProject, Limit: 20}) + results, err = ListWorkspaces([]string{root}, ListOpts{Type: TypeProject, Limit: 20}) if err != nil { t.Fatalf("ListWorkspaces TypeProject: %v", err) } @@ -113,7 +113,7 @@ func TestListTypeFilter(t *testing.T) { } // TypeProject + IncludeArchived - results, err = ListWorkspaces(root, ListOpts{Type: TypeProject, IncludeArchived: true, Limit: 20}) + results, err = ListWorkspaces([]string{root}, ListOpts{Type: TypeProject, IncludeArchived: true, Limit: 20}) if err != nil { t.Fatalf("ListWorkspaces TypeProject+all: %v", err) } @@ -124,18 +124,40 @@ func TestListTypeFilter(t *testing.T) { func TestListInvalidTypeFilterReturnsError(t *testing.T) { root := t.TempDir() - _, err := ListWorkspaces(root, ListOpts{Type: "weird"}) + _, err := ListWorkspaces([]string{root}, ListOpts{Type: "weird"}) if err == nil { t.Fatal("expected error for invalid Type filter, got nil") } } +func TestListMergesTaskRootAndProjectRoot(t *testing.T) { + taskRoot := t.TempDir() + projRoot := t.TempDir() + createTestWorkspace(t, taskRoot, "general", "2026-04-05_task-one", "active") + createFlatProjectWorkspace(t, projRoot, "2026-04-06_proj-one", "active") + + results, err := ListWorkspaces([]string{taskRoot, projRoot}, ListOpts{Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results across roots, got %d", len(results)) + } + slugs := map[string]bool{} + for _, r := range results { + slugs[r.Meta.Slug] = true + } + if !slugs["task-one"] || !slugs["proj-one"] { + t.Errorf("expected both task-one and proj-one: got %+v", slugs) + } +} + func TestListReverseChronological(t *testing.T) { root := t.TempDir() createTestWorkspace(t, root, "general", "2026-04-01_first", "active") createTestWorkspace(t, root, "general", "2026-04-05_second", "active") - results, _ := ListWorkspaces(root, ListOpts{Limit: 20}) + results, _ := ListWorkspaces([]string{root}, ListOpts{Limit: 20}) if len(results) < 2 { t.Fatalf("expected 2 results, got %d", len(results)) } diff --git a/internal/workspace/most_recent.go b/internal/workspace/most_recent.go index b170500..029d200 100644 --- a/internal/workspace/most_recent.go +++ b/internal/workspace/most_recent.go @@ -1,15 +1,15 @@ package workspace // MostRecentActive returns the active workspace with the latest UpdatedAt -// timestamp under root, considering both tasks and projects. +// timestamp across the given roots, considering both tasks and projects. // // Returns (nil, nil) if no active workspaces exist (this is not an error // condition; callers decide how to surface "nothing to do"). // // Legacy v0.2 workspaces (no Type field) are included as tasks via // EffectiveType. Archived workspaces are always excluded. -func MostRecentActive(root string) (*QueryResult, error) { - results, err := ListWorkspaces(root, ListOpts{ +func MostRecentActive(roots []string) (*QueryResult, error) { + results, err := ListWorkspaces(roots, ListOpts{ IncludeArchived: false, Limit: 0, Type: TypeAny, diff --git a/internal/workspace/most_recent_test.go b/internal/workspace/most_recent_test.go index 9e5ec99..83cecb8 100644 --- a/internal/workspace/most_recent_test.go +++ b/internal/workspace/most_recent_test.go @@ -14,7 +14,7 @@ func TestMostRecentActiveTasksOnly(t *testing.T) { createTestWorkspaceFull(t, root, "general", "2026-04-01_old", "active", "task", baseT) createTestWorkspaceFull(t, root, "general", "2026-04-02_new", "active", "task", baseT.Add(2*time.Hour)) - got, err := MostRecentActive(root) + got, err := MostRecentActive([]string{root}) if err != nil { t.Fatalf("MostRecentActive: %v", err) } @@ -31,7 +31,7 @@ func TestMostRecentActiveProjectsOnly(t *testing.T) { createTestWorkspaceFull(t, root, "projects", "2026-04-01_old-proj", "active", "project", baseT) createTestWorkspaceFull(t, root, "projects", "2026-04-02_new-proj", "active", "project", baseT.Add(2*time.Hour)) - got, err := MostRecentActive(root) + got, err := MostRecentActive([]string{root}) if err != nil { t.Fatalf("MostRecentActive: %v", err) } @@ -48,7 +48,7 @@ func TestMostRecentActiveProjectWinsOverOlderTask(t *testing.T) { createTestWorkspaceFull(t, root, "general", "2026-04-01_old-task", "active", "task", baseT) createTestWorkspaceFull(t, root, "projects", "2026-04-02_new-proj", "active", "project", baseT.Add(2*time.Hour)) - got, err := MostRecentActive(root) + got, err := MostRecentActive([]string{root}) if err != nil { t.Fatalf("MostRecentActive: %v", err) } @@ -65,7 +65,7 @@ func TestMostRecentActiveTaskWinsOverOlderProject(t *testing.T) { createTestWorkspaceFull(t, root, "projects", "2026-04-01_old-proj", "active", "project", baseT) createTestWorkspaceFull(t, root, "general", "2026-04-02_new-task", "active", "task", baseT.Add(2*time.Hour)) - got, err := MostRecentActive(root) + got, err := MostRecentActive([]string{root}) if err != nil { t.Fatalf("MostRecentActive: %v", err) } @@ -83,7 +83,7 @@ func TestMostRecentActiveLegacyWorkspaceCounts(t *testing.T) { createTestWorkspaceFull(t, root, "projects", "2026-04-01_old-proj", "active", "project", baseT) createTestWorkspaceFull(t, root, "general", "2026-04-02_legacy", "active", "", baseT.Add(2*time.Hour)) - got, err := MostRecentActive(root) + got, err := MostRecentActive([]string{root}) if err != nil { t.Fatalf("MostRecentActive: %v", err) } @@ -103,7 +103,7 @@ func TestMostRecentActiveExcludesArchived(t *testing.T) { createTestWorkspaceFull(t, root, "projects", "2026-04-02_active-proj", "active", "project", baseT.Add(2*time.Hour)) createTestWorkspaceFull(t, root, "general", "2026-04-01_old-task", "active", "task", baseT) - got, err := MostRecentActive(root) + got, err := MostRecentActive([]string{root}) if err != nil { t.Fatalf("MostRecentActive: %v", err) } @@ -114,7 +114,7 @@ func TestMostRecentActiveExcludesArchived(t *testing.T) { func TestMostRecentActiveReturnsNilWhenEmpty(t *testing.T) { root := t.TempDir() - got, err := MostRecentActive(root) + got, err := MostRecentActive([]string{root}) if err != nil { t.Fatalf("MostRecentActive: %v", err) } @@ -123,12 +123,30 @@ func TestMostRecentActiveReturnsNilWhenEmpty(t *testing.T) { } } +func TestMostRecentActiveAcrossRoots(t *testing.T) { + taskRoot := t.TempDir() + projRoot := t.TempDir() + createTestWorkspaceFull(t, taskRoot, "general", "2026-04-05_old-task", "active", "task", baseT) + createFlatProjectWorkspaceFull(t, projRoot, "2026-04-06_new-proj", baseT.Add(time.Hour)) + + got, err := MostRecentActive([]string{taskRoot, projRoot}) + if err != nil { + t.Fatalf("MostRecentActive: %v", err) + } + if got == nil || got.Meta.Slug != "new-proj" { + t.Fatalf("expected new-proj (newest across roots), got %+v", got) + } + if got.Root != projRoot { + t.Errorf("Root: got %q, want %q", got.Root, projRoot) + } +} + func TestMostRecentActiveReturnsNilWhenAllArchived(t *testing.T) { root := t.TempDir() createTestWorkspaceFull(t, root, "general", "2026-04-01_arch-task", "archived", "task", baseT) createTestWorkspaceFull(t, root, "projects", "2026-04-02_arch-proj", "archived", "project", baseT.Add(time.Hour)) - got, err := MostRecentActive(root) + got, err := MostRecentActive([]string{root}) if err != nil { t.Fatalf("MostRecentActive: %v", err) } diff --git a/internal/workspace/query.go b/internal/workspace/query.go index 7ace861..b24bac5 100644 --- a/internal/workspace/query.go +++ b/internal/workspace/query.go @@ -3,24 +3,29 @@ package workspace import ( "os" "path/filepath" + "runtime" "strings" ) // QueryResult holds a resolved workspace match. +// Root is the search root (absolute path) under which this workspace was found. +// Callers use Root to compute display paths and session env vars so that a +// workspace under CTASK_PROJECT_ROOT is displayed relative to that root. type QueryResult struct { Path string + Root string Meta *TaskMeta } -// ResolveQuery implements the 5-step query resolution algorithm. -// Returns matching workspaces. Caller decides what to do with 0, 1, or N results. -func ResolveQuery(root, query string, includeArchived bool) ([]QueryResult, error) { - all, err := scanWorkspaces(root) +// ResolveQuery implements the 5-step query resolution algorithm across one or +// more workspace roots. Returns matching workspaces. Caller decides what to do +// with 0, 1, or N results. +func ResolveQuery(roots []string, query string, includeArchived bool) ([]QueryResult, error) { + all, err := scanAllRoots(roots) if err != nil { return nil, err } - // Filter archived unless includeArchived var candidates []QueryResult for _, ws := range all { if !includeArchived && ws.Meta.Status == "archived" { @@ -60,11 +65,49 @@ func ResolveQuery(root, query string, includeArchived bool) ([]QueryResult, erro return substring, nil } -// scanWorkspaces walks root/*/dir looking for task.yaml files. +// scanAllRoots scans each root with scanWorkspaces, annotates every result +// with the originating root, and deduplicates by absolute workspace path. +// Order is stable: results from earlier roots appear before later ones. +func scanAllRoots(roots []string) ([]QueryResult, error) { + seen := make(map[string]struct{}) + var out []QueryResult + for _, root := range roots { + perRoot, err := scanWorkspaces(root) + if err != nil { + return nil, err + } + for _, ws := range perRoot { + absPath, err := filepath.Abs(ws.Path) + if err != nil { + absPath = ws.Path + } + key := absPath + if isWindows() { + key = strings.ToLower(absPath) + } + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + ws.Root = root + out = append(out, ws) + } + } + return out, nil +} + +// scanWorkspaces walks root looking for task.yaml files at two depths: +// - root//task.yaml (flat project layout under CTASK_PROJECT_ROOT) +// - root///task.yaml (task layout, and projects with -c) +// +// A directory that directly contains task.yaml is treated as a workspace and +// its subdirectories are not descended — this both avoids double-counting and +// ensures non-workspace subdirs (src/, output/, logs/, context/) never leak. +// Returns QueryResults with Path and Meta populated; Root is filled in by scanAllRoots. func scanWorkspaces(root string) ([]QueryResult, error) { var results []QueryResult - categories, err := os.ReadDir(root) + entries, err := os.ReadDir(root) if err != nil { if os.IsNotExist(err) { return nil, nil @@ -72,26 +115,37 @@ func scanWorkspaces(root string) ([]QueryResult, error) { return nil, err } - for _, cat := range categories { - if !cat.IsDir() { + for _, entry := range entries { + if !entry.IsDir() { continue } - catPath := filepath.Join(root, cat.Name()) - entries, err := os.ReadDir(catPath) + entryPath := filepath.Join(root, entry.Name()) + + // Depth 1: root//task.yaml (flat workspace layout) + if meta, err := ReadMeta(filepath.Join(entryPath, "task.yaml")); err == nil { + results = append(results, QueryResult{ + Path: entryPath, + Meta: meta, + }) + continue + } + + // Depth 2: root///task.yaml (category layout) + subs, err := os.ReadDir(entryPath) if err != nil { continue } - for _, entry := range entries { - if !entry.IsDir() { + for _, sub := range subs { + if !sub.IsDir() { continue } - metaPath := filepath.Join(catPath, entry.Name(), "task.yaml") + metaPath := filepath.Join(entryPath, sub.Name(), "task.yaml") meta, err := ReadMeta(metaPath) if err != nil { continue } results = append(results, QueryResult{ - Path: filepath.Join(catPath, entry.Name()), + Path: filepath.Join(entryPath, sub.Name()), Meta: meta, }) } @@ -99,3 +153,7 @@ func scanWorkspaces(root string) ([]QueryResult, error) { return results, nil } + +func isWindows() bool { + return runtime.GOOS == "windows" +} diff --git a/internal/workspace/query_test.go b/internal/workspace/query_test.go index 54b4c98..5278173 100644 --- a/internal/workspace/query_test.go +++ b/internal/workspace/query_test.go @@ -49,7 +49,7 @@ func TestQueryExactDirName(t *testing.T) { root := t.TempDir() createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") - results, err := ResolveQuery(root, "2026-04-05_arch-notes", false) + results, err := ResolveQuery([]string{root}, "2026-04-05_arch-notes", false) if err != nil { t.Fatalf("ResolveQuery: %v", err) } @@ -65,7 +65,7 @@ func TestQueryExactSlug(t *testing.T) { root := t.TempDir() createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") - results, err := ResolveQuery(root, "arch-notes", false) + results, err := ResolveQuery([]string{root}, "arch-notes", false) if err != nil { t.Fatalf("ResolveQuery: %v", err) } @@ -79,7 +79,7 @@ func TestQuerySubstringUnique(t *testing.T) { createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") createTestWorkspace(t, root, "scripts", "2026-04-03_backup-helper", "active") - results, err := ResolveQuery(root, "arch", false) + results, err := ResolveQuery([]string{root}, "arch", false) if err != nil { t.Fatalf("ResolveQuery: %v", err) } @@ -96,7 +96,7 @@ func TestQuerySubstringMultiple(t *testing.T) { createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") createTestWorkspace(t, root, "research", "2026-04-03_migration-notes", "active") - results, err := ResolveQuery(root, "notes", false) + results, err := ResolveQuery([]string{root}, "notes", false) if err != nil { t.Fatalf("ResolveQuery: %v", err) } @@ -109,7 +109,7 @@ func TestQueryNoMatch(t *testing.T) { root := t.TempDir() createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") - results, err := ResolveQuery(root, "nonexistent", false) + results, err := ResolveQuery([]string{root}, "nonexistent", false) if err != nil { t.Fatalf("ResolveQuery: %v", err) } @@ -124,13 +124,13 @@ func TestQueryExcludesArchived(t *testing.T) { createTestWorkspace(t, root, "general", "2026-04-05_new-task", "active") // Without --all: archived excluded - results, _ := ResolveQuery(root, "old-task", false) + results, _ := ResolveQuery([]string{root}, "old-task", false) if len(results) != 0 { t.Errorf("archived should be excluded, got %d results", len(results)) } // With --all: archived included - results, _ = ResolveQuery(root, "old-task", true) + results, _ = ResolveQuery([]string{root}, "old-task", true) if len(results) != 1 { t.Errorf("with includeArchived, expected 1, got %d", len(results)) } @@ -143,7 +143,7 @@ func TestQueryResolutionOrder(t *testing.T) { createTestWorkspace(t, root, "scripts", "2026-04-03_backup-helper", "active") // "backup" should match exactly by slug, not return both as substring matches - results, _ := ResolveQuery(root, "backup", false) + results, _ := ResolveQuery([]string{root}, "backup", false) if len(results) != 1 { t.Fatalf("exact slug should return 1, got %d", len(results)) } @@ -151,3 +151,137 @@ func TestQueryResolutionOrder(t *testing.T) { t.Errorf("should match exact slug \"backup\", got %q", results[0].Meta.Slug) } } + +// createFlatProjectWorkspace creates a workspace directly under root +// (no category subdir), matching the CTASK_PROJECT_ROOT default layout. +func createFlatProjectWorkspace(t *testing.T, root, dirName, status string) { + t.Helper() + now := time.Now().UTC().Truncate(time.Second) + createFlatProjectWorkspaceFull(t, root, dirName, now) + // Status override: createFlatProjectWorkspaceFull hard-codes active; fix up. + if status != "active" { + dir := filepath.Join(root, dirName) + metaPath := filepath.Join(dir, "task.yaml") + meta, _ := ReadMeta(metaPath) + meta.Status = status + WriteMeta(metaPath, meta) + } +} + +// createFlatProjectWorkspaceFull creates a flat-layout workspace with an +// explicit UpdatedAt timestamp. +func createFlatProjectWorkspaceFull(t *testing.T, root, dirName string, updatedAt time.Time) { + t.Helper() + dir := filepath.Join(root, dirName) + os.MkdirAll(dir, 0755) + slug := dirName[11:] + meta := &TaskMeta{ + ID: "test", + Slug: slug, + Title: slug, + CreatedAt: updatedAt, + UpdatedAt: updatedAt, + Status: "active", + Category: "projects", + Type: "project", + Mode: "local", + Agent: "claude", + WorkspacePath: dir, + } + WriteMeta(filepath.Join(dir, "task.yaml"), meta) +} + +func TestQueryFindsWorkspaceInProjectRoot(t *testing.T) { + taskRoot := t.TempDir() + projRoot := t.TempDir() + createFlatProjectWorkspace(t, projRoot, "2026-04-22_standalone-proj", "active") + + results, err := ResolveQuery([]string{taskRoot, projRoot}, "standalone-proj", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Root != projRoot { + t.Errorf("Root: got %q, want %q", results[0].Root, projRoot) + } + if results[0].Meta.Slug != "standalone-proj" { + t.Errorf("Slug: got %q", results[0].Meta.Slug) + } +} + +func TestQueryDedupesOverlappingRoots(t *testing.T) { + // If CTASK_PROJECT_ROOT equals CTASK_ROOT, the same workspace must not + // appear twice in ResolveQuery results. + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-22_only-one", "active") + + results, err := ResolveQuery([]string{root, root}, "only-one", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result after dedupe, got %d", len(results)) + } +} + +// A category directory without its own task.yaml but containing valid +// workspaces must NOT be mistaken for a flat-layout workspace. +func TestScanSkipsCategoryDirWithoutTaskYaml(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_nested-one", "active") + + results, err := ResolveQuery([]string{root}, "general", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + // "general" is the category directory, not a workspace — no match expected + // (neither exact dir-name nor slug; substring against "nested-one" fails). + if len(results) != 0 { + t.Fatalf("category dir leaked as workspace: got %d results (%+v)", len(results), results) + } +} + +// Subdirectories inside a flat-layout project workspace (src/, output/, etc.) +// must be ignored — only the workspace root's task.yaml counts. +func TestScanIgnoresNestedDirsInsideFlatWorkspace(t *testing.T) { + root := t.TempDir() + wsDir := filepath.Join(root, "2026-04-22_flat-proj") + os.MkdirAll(filepath.Join(wsDir, "src"), 0755) + os.MkdirAll(filepath.Join(wsDir, "output"), 0755) + createFlatProjectWorkspace(t, root, "2026-04-22_flat-proj", "active") + + results, err := ResolveQuery([]string{root}, "flat-proj", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected exactly 1 result, got %d (nested src/output leaked?): %+v", len(results), results) + } + if filepath.Base(results[0].Path) != "2026-04-22_flat-proj" { + t.Errorf("resolved path: got %q, want workspace root", results[0].Path) + } +} + +// A category dir under one root must not shadow a workspace under another +// root that happens to share the same directory name. +func TestScanCategoryNameDoesNotClashWithFlatWorkspaceName(t *testing.T) { + taskRoot := t.TempDir() + projRoot := t.TempDir() + + // Under taskRoot, "projects" is a category holding a real workspace. + createTestWorkspace(t, taskRoot, "projects", "2026-04-05_in-category", "active") + + // Under projRoot, a flat workspace literally named "projects" (contrived + // but possible if a user sets -c projects under CTASK_PROJECT_ROOT). + createFlatProjectWorkspace(t, projRoot, "2026-04-06_projects", "active") + + results, err := ResolveQuery([]string{taskRoot, projRoot}, "in-category", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 || results[0].Meta.Slug != "in-category" { + t.Fatalf("expected in-category to resolve; got %+v", results) + } +}