From 6740c3835e1da052ae599a445e5ac1f18613c7e6 Mon Sep 17 00:00:00 2001 From: warren Date: Sun, 5 Apr 2026 18:32:12 -0400 Subject: [PATCH] feat: query resolution and workspace listing 5-step query resolution (exact dir, exact slug, substring), archived exclusion. Listing with category filter, limit, reverse-chronological sort. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/workspace/list.go | 43 ++++++++++ internal/workspace/list_test.go | 63 ++++++++++++++ internal/workspace/query.go | 101 ++++++++++++++++++++++ internal/workspace/query_test.go | 138 +++++++++++++++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 internal/workspace/list.go create mode 100644 internal/workspace/list_test.go create mode 100644 internal/workspace/query.go create mode 100644 internal/workspace/query_test.go diff --git a/internal/workspace/list.go b/internal/workspace/list.go new file mode 100644 index 0000000..e96d943 --- /dev/null +++ b/internal/workspace/list.go @@ -0,0 +1,43 @@ +package workspace + +import ( + "path/filepath" + "sort" +) + +// ListOpts configures workspace listing. +type ListOpts struct { + IncludeArchived bool + Category string + Limit int +} + +// ListWorkspaces returns workspaces in reverse-chronological order. +func ListWorkspaces(root string, opts ListOpts) ([]QueryResult, error) { + all, err := scanWorkspaces(root) + if err != nil { + return nil, err + } + + var filtered []QueryResult + for _, ws := range all { + if !opts.IncludeArchived && ws.Meta.Status == "archived" { + continue + } + if opts.Category != "" && ws.Meta.Category != opts.Category { + continue + } + filtered = append(filtered, ws) + } + + // Sort reverse-chronological by directory name (date prefix sorts naturally) + sort.Slice(filtered, func(i, j int) bool { + return filepath.Base(filtered[i].Path) > filepath.Base(filtered[j].Path) + }) + + if opts.Limit > 0 && len(filtered) > opts.Limit { + filtered = filtered[:opts.Limit] + } + + return filtered, nil +} diff --git a/internal/workspace/list_test.go b/internal/workspace/list_test.go new file mode 100644 index 0000000..3517496 --- /dev/null +++ b/internal/workspace/list_test.go @@ -0,0 +1,63 @@ +package workspace + +import ( + "testing" +) + +func TestListWorkspaces(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_newer-task", "active") + createTestWorkspace(t, root, "scripts", "2026-04-03_older-task", "active") + createTestWorkspace(t, root, "general", "2026-04-01_archived-task", "archived") + + // Default: no archived, no filter + results, err := ListWorkspaces(root, ListOpts{Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 active workspaces, got %d", len(results)) + } + + // With --all + results, err = ListWorkspaces(root, ListOpts{IncludeArchived: true, Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces --all: %v", err) + } + if len(results) != 3 { + t.Fatalf("expected 3 workspaces with --all, got %d", len(results)) + } + + // Category filter + results, err = ListWorkspaces(root, ListOpts{Category: "scripts", Limit: 20}) + if err != nil { + t.Fatalf("ListWorkspaces -c scripts: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 scripts workspace, got %d", len(results)) + } + + // Limit + results, err = ListWorkspaces(root, ListOpts{Limit: 1}) + if err != nil { + t.Fatalf("ListWorkspaces -n 1: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 with limit, got %d", len(results)) + } +} + +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}) + if len(results) < 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } + // Most recent first (by directory name date prefix) + if results[0].Meta.Slug != "second" { + t.Errorf("first result should be newest: got %q", results[0].Meta.Slug) + } +} diff --git a/internal/workspace/query.go b/internal/workspace/query.go new file mode 100644 index 0000000..7ace861 --- /dev/null +++ b/internal/workspace/query.go @@ -0,0 +1,101 @@ +package workspace + +import ( + "os" + "path/filepath" + "strings" +) + +// QueryResult holds a resolved workspace match. +type QueryResult struct { + Path 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) + if err != nil { + return nil, err + } + + // Filter archived unless includeArchived + var candidates []QueryResult + for _, ws := range all { + if !includeArchived && ws.Meta.Status == "archived" { + continue + } + candidates = append(candidates, ws) + } + + // Step 1: Exact directory name match under root/*/* + for _, ws := range candidates { + dirName := filepath.Base(ws.Path) + if dirName == query { + return []QueryResult{ws}, nil + } + } + + // Step 2: Exact slug match (portion after date prefix) + var exactSlug []QueryResult + for _, ws := range candidates { + if ws.Meta.Slug == query { + exactSlug = append(exactSlug, ws) + } + } + if len(exactSlug) > 0 { + return exactSlug, nil + } + + // Step 3: Case-insensitive substring match against slug + queryLower := strings.ToLower(query) + var substring []QueryResult + for _, ws := range candidates { + if strings.Contains(strings.ToLower(ws.Meta.Slug), queryLower) { + substring = append(substring, ws) + } + } + + return substring, nil +} + +// scanWorkspaces walks root/*/dir looking for task.yaml files. +func scanWorkspaces(root string) ([]QueryResult, error) { + var results []QueryResult + + categories, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + for _, cat := range categories { + if !cat.IsDir() { + continue + } + catPath := filepath.Join(root, cat.Name()) + entries, err := os.ReadDir(catPath) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + metaPath := filepath.Join(catPath, entry.Name(), "task.yaml") + meta, err := ReadMeta(metaPath) + if err != nil { + continue + } + results = append(results, QueryResult{ + Path: filepath.Join(catPath, entry.Name()), + Meta: meta, + }) + } + } + + return results, nil +} diff --git a/internal/workspace/query_test.go b/internal/workspace/query_test.go new file mode 100644 index 0000000..45ed1a7 --- /dev/null +++ b/internal/workspace/query_test.go @@ -0,0 +1,138 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// helper to create a minimal workspace for query testing +func createTestWorkspace(t *testing.T, root, category, dirName string, status string) { + t.Helper() + dir := filepath.Join(root, category, dirName) + os.MkdirAll(dir, 0755) + now := time.Now().UTC().Truncate(time.Second) + // Extract slug from dirName (skip "YYYY-MM-DD_") + slug := dirName[11:] + meta := &TaskMeta{ + ID: "test", + Slug: slug, + Title: slug, + CreatedAt: now, + UpdatedAt: now, + Status: status, + Category: category, + Mode: "local", + Agent: "claude", + WorkspacePath: dir, + } + WriteMeta(filepath.Join(dir, "task.yaml"), meta) +} + +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) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Meta.Slug != "arch-notes" { + t.Errorf("slug: got %q, want \"arch-notes\"", results[0].Meta.Slug) + } +} + +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) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } +} + +func TestQuerySubstringUnique(t *testing.T) { + root := t.TempDir() + 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) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 result, got %d", len(results)) + } + if results[0].Meta.Slug != "arch-notes" { + t.Errorf("slug: got %q", results[0].Meta.Slug) + } +} + +func TestQuerySubstringMultiple(t *testing.T) { + root := t.TempDir() + 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) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 2 { + t.Fatalf("expected 2 results, got %d", len(results)) + } +} + +func TestQueryNoMatch(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_arch-notes", "active") + + results, err := ResolveQuery(root, "nonexistent", false) + if err != nil { + t.Fatalf("ResolveQuery: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected 0 results, got %d", len(results)) + } +} + +func TestQueryExcludesArchived(t *testing.T) { + root := t.TempDir() + createTestWorkspace(t, root, "general", "2026-04-05_old-task", "archived") + createTestWorkspace(t, root, "general", "2026-04-05_new-task", "active") + + // Without --all: archived excluded + results, _ := ResolveQuery(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) + if len(results) != 1 { + t.Errorf("with includeArchived, expected 1, got %d", len(results)) + } +} + +func TestQueryResolutionOrder(t *testing.T) { + root := t.TempDir() + // Create two workspaces where one has an exact slug match + createTestWorkspace(t, root, "general", "2026-04-05_backup", "active") + 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) + if len(results) != 1 { + t.Fatalf("exact slug should return 1, got %d", len(results)) + } + if results[0].Meta.Slug != "backup" { + t.Errorf("should match exact slug \"backup\", got %q", results[0].Meta.Slug) + } +}