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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user