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) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:32:12 -04:00
parent 17789e4b9f
commit 6740c3835e
4 changed files with 345 additions and 0 deletions
+43
View File
@@ -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
}
+63
View File
@@ -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)
}
}
+101
View File
@@ -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
}
+138
View File
@@ -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)
}
}