diff options
| -rw-r--r-- | internal/askcli/command_complete_uuids.go | 3 | ||||
| -rw-r--r-- | internal/askcli/command_complete_uuids_test.go | 63 | ||||
| -rw-r--r-- | internal/askcli/task_alias_cache.go | 260 | ||||
| -rw-r--r-- | internal/askcli/task_alias_cache_test.go | 265 |
4 files changed, 591 insertions, 0 deletions
diff --git a/internal/askcli/command_complete_uuids.go b/internal/askcli/command_complete_uuids.go index 99f1e0e..755c3bb 100644 --- a/internal/askcli/command_complete_uuids.go +++ b/internal/askcli/command_complete_uuids.go @@ -18,6 +18,9 @@ func (d Dispatcher) handleCompleteUUIDs(ctx context.Context, stdout, stderr io.W fmt.Fprintf(stderr, "error: failed to parse task data: %v\n", err) return 1, nil } + if _, err := ensureTaskAliases(tasks); err != nil { + fmt.Fprintf(stderr, "warning: failed to update task alias cache: %v\n", err) + } for _, task := range tasks { if task.UUID == "" { continue diff --git a/internal/askcli/command_complete_uuids_test.go b/internal/askcli/command_complete_uuids_test.go index ff9d142..2c8fc34 100644 --- a/internal/askcli/command_complete_uuids_test.go +++ b/internal/askcli/command_complete_uuids_test.go @@ -4,11 +4,24 @@ import ( "bytes" "context" "io" + "os" + "path/filepath" "strings" "testing" + "time" ) func TestHandleCompleteUUIDs_PrintsPendingUUIDs(t *testing.T) { + dir := t.TempDir() + oldNow := nowTaskAliasCache + oldRoot := taskAliasCacheRoot + nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + defer func() { + nowTaskAliasCache = oldNow + taskAliasCacheRoot = oldRoot + }() + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { want := []string{"status:pending", "export"} if strings.Join(args, " ") != strings.Join(want, " ") { @@ -32,6 +45,18 @@ func TestHandleCompleteUUIDs_PrintsPendingUUIDs(t *testing.T) { if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) } + + path, err := taskAliasCachePath() + if err != nil { + t.Fatalf("taskAliasCachePath: %v", err) + } + cache := readTaskAliasCacheForTest(t, path) + if got := findTaskAliasEntry(t, cache, "uuid-1").Alias; got != "0" { + t.Fatalf("uuid-1 alias = %q, want 0", got) + } + if got := findTaskAliasEntry(t, cache, "uuid-2").Alias; got != "1" { + t.Fatalf("uuid-2 alias = %q, want 1", got) + } } func TestHandleCompleteUUIDs_ParseError(t *testing.T) { @@ -52,3 +77,41 @@ func TestHandleCompleteUUIDs_ParseError(t *testing.T) { t.Fatalf("stderr = %q, want parse error", stderr.String()) } } + +func TestHandleCompleteUUIDs_WarnsOnInvalidAliasCache(t *testing.T) { + dir := t.TempDir() + oldRoot := taskAliasCacheRoot + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + defer func() { taskAliasCacheRoot = oldRoot }() + + path, err := taskAliasCachePath() + if err != nil { + t.Fatalf("taskAliasCachePath: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte("{bad"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + d := NewDispatcher(&spyRunner{runFn: func(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + _, _ = io.WriteString(stdout, `[{"uuid":"uuid-1"}]`) + return 0, nil + }}) + + var stdout, stderr bytes.Buffer + code, err := d.handleCompleteUUIDs(context.Background(), &stdout, &stderr) + if err != nil { + t.Fatalf("handleCompleteUUIDs returned error: %v", err) + } + if code != 0 { + t.Fatalf("handleCompleteUUIDs code = %d, want 0", code) + } + if got := stdout.String(); got != "uuid-1\n" { + t.Fatalf("stdout = %q, want UUID list", got) + } + if !strings.Contains(stderr.String(), "failed to update task alias cache") { + t.Fatalf("stderr = %q, want cache warning", stderr.String()) + } +} diff --git a/internal/askcli/task_alias_cache.go b/internal/askcli/task_alias_cache.go new file mode 100644 index 0000000..a914e6b --- /dev/null +++ b/internal/askcli/task_alias_cache.go @@ -0,0 +1,260 @@ +package askcli + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "time" + + "codeberg.org/snonux/hexai/internal/stats" +) + +const taskAliasCacheTTL = 120 * 24 * time.Hour + +var ( + nowTaskAliasCache = time.Now + taskAliasCacheRoot = stats.CacheDir +) + +type taskAliasCache struct { + NextID uint64 `json:"next_id"` + Entries []taskAliasCacheEntry `json:"entries"` +} + +type taskAliasCacheEntry struct { + UUID string `json:"uuid"` + Alias string `json:"alias"` + CreatedAt time.Time `json:"created_at"` + LastAccessedAt time.Time `json:"last_accessed_at"` +} + +func ensureTaskAliases(tasks []TaskExport) (map[string]string, error) { + cache, path, err := loadTaskAliasCache() + if err != nil { + return nil, err + } + + now := nowTaskAliasCache().UTC() + changed := cache.prune(now) + aliases := make(map[string]string, len(tasks)) + for _, task := range tasks { + if task.UUID == "" { + continue + } + alias, updated := cache.ensureAlias(task.UUID, now) + aliases[task.UUID] = alias + changed = changed || updated + } + + if !changed { + return aliases, nil + } + if err := cache.save(path); err != nil { + return nil, err + } + return aliases, nil +} + +func loadTaskAliasCache() (taskAliasCache, string, error) { + path, err := taskAliasCachePath() + if err != nil { + return taskAliasCache{}, "", err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return taskAliasCache{}, path, nil + } + if err != nil { + return taskAliasCache{}, "", fmt.Errorf("read task alias cache: %w", err) + } + + var cache taskAliasCache + if err := json.Unmarshal(data, &cache); err != nil { + return taskAliasCache{}, "", fmt.Errorf("parse task alias cache: %w", err) + } + if err := cache.validate(); err != nil { + return taskAliasCache{}, "", fmt.Errorf("validate task alias cache: %w", err) + } + return cache, path, nil +} + +func taskAliasCachePath() (string, error) { + dir, err := taskAliasCacheRoot() + if err != nil { + return "", fmt.Errorf("resolve cache dir: %w", err) + } + return filepath.Join(dir, "ask", "task-aliases-v1.json"), nil +} + +func (c *taskAliasCache) validate() error { + seenUUIDs := make(map[string]struct{}, len(c.Entries)) + seenAliases := make(map[string]struct{}, len(c.Entries)) + var maxID uint64 + hasEntries := false + for _, entry := range c.Entries { + if entry.UUID == "" { + return fmt.Errorf("entry missing uuid") + } + if entry.Alias == "" { + return fmt.Errorf("entry %q missing alias", entry.UUID) + } + id, ok := decodeTaskAliasID(entry.Alias) + if !ok { + return fmt.Errorf("entry %q has invalid alias %q", entry.UUID, entry.Alias) + } + if _, ok := seenUUIDs[entry.UUID]; ok { + return fmt.Errorf("duplicate uuid %q", entry.UUID) + } + if _, ok := seenAliases[entry.Alias]; ok { + return fmt.Errorf("duplicate alias %q", entry.Alias) + } + seenUUIDs[entry.UUID] = struct{}{} + seenAliases[entry.Alias] = struct{}{} + if !hasEntries || id > maxID { + maxID = id + hasEntries = true + } + } + if hasEntries && c.NextID <= maxID { + return fmt.Errorf("next_id %d must be greater than max alias id %d", c.NextID, maxID) + } + return nil +} + +func (c *taskAliasCache) prune(now time.Time) bool { + if len(c.Entries) == 0 { + return false + } + + kept := c.Entries[:0] + changed := false + for _, entry := range c.Entries { + if now.Sub(entry.lastTouchedAt()) > taskAliasCacheTTL { + changed = true + continue + } + kept = append(kept, entry) + } + c.Entries = kept + if changed { + c.sortEntries() + } + return changed +} + +func (c *taskAliasCache) ensureAlias(uuid string, now time.Time) (string, bool) { + for i := range c.Entries { + if c.Entries[i].UUID != uuid { + continue + } + if c.Entries[i].LastAccessedAt.Equal(now) { + return c.Entries[i].Alias, false + } + c.Entries[i].LastAccessedAt = now + return c.Entries[i].Alias, true + } + + alias := encodeTaskAliasID(c.NextID) + c.NextID++ + c.Entries = append(c.Entries, taskAliasCacheEntry{ + UUID: uuid, + Alias: alias, + CreatedAt: now, + LastAccessedAt: now, + }) + c.sortEntries() + return alias, true +} + +func (c taskAliasCache) save(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create task alias cache dir: %w", err) + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return fmt.Errorf("marshal task alias cache: %w", err) + } + + tempPath := path + ".tmp" + if err := os.WriteFile(tempPath, data, 0o600); err != nil { + return fmt.Errorf("write task alias cache: %w", err) + } + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("replace task alias cache: %w", err) + } + return nil +} + +func (c *taskAliasCache) sortEntries() { + slices.SortFunc(c.Entries, func(a, b taskAliasCacheEntry) int { + switch { + case a.UUID < b.UUID: + return -1 + case a.UUID > b.UUID: + return 1 + default: + return 0 + } + }) +} + +func (e taskAliasCacheEntry) lastTouchedAt() time.Time { + if !e.LastAccessedAt.IsZero() { + return e.LastAccessedAt + } + return e.CreatedAt +} + +func encodeTaskAliasID(id uint64) string { + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + + width := 1 + blockSize := uint64(len(alphabet)) + remaining := id + for remaining >= blockSize { + remaining -= blockSize + width++ + blockSize *= uint64(len(alphabet)) + } + + buf := make([]byte, width) + for i := width - 1; i >= 0; i-- { + buf[i] = alphabet[remaining%uint64(len(alphabet))] + remaining /= uint64(len(alphabet)) + } + return string(buf) +} + +func decodeTaskAliasID(alias string) (uint64, bool) { + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + + if alias == "" { + return 0, false + } + + width := len(alias) + var id uint64 + blockSize := uint64(len(alphabet)) + for i := 1; i < width; i++ { + id += blockSize + blockSize *= uint64(len(alphabet)) + } + + var value uint64 + for _, r := range alias { + index := int64(-1) + for i, candidate := range alphabet { + if r == candidate { + index = int64(i) + break + } + } + if index < 0 { + return 0, false + } + value = value*uint64(len(alphabet)) + uint64(index) + } + return id + value, true +} diff --git a/internal/askcli/task_alias_cache_test.go b/internal/askcli/task_alias_cache_test.go new file mode 100644 index 0000000..c9fffd6 --- /dev/null +++ b/internal/askcli/task_alias_cache_test.go @@ -0,0 +1,265 @@ +package askcli + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestEncodeTaskAliasID(t *testing.T) { + t.Parallel() + + tests := []struct { + id uint64 + want string + }{ + {id: 0, want: "0"}, + {id: 9, want: "9"}, + {id: 10, want: "a"}, + {id: 35, want: "z"}, + {id: 36, want: "00"}, + {id: 37, want: "01"}, + {id: 71, want: "0z"}, + {id: 72, want: "10"}, + {id: 1331, want: "zz"}, + {id: 1332, want: "000"}, + {id: 1333, want: "001"}, + } + + for _, tc := range tests { + if got := encodeTaskAliasID(tc.id); got != tc.want { + t.Fatalf("encodeTaskAliasID(%d) = %q, want %q", tc.id, got, tc.want) + } + } +} + +func TestDecodeTaskAliasID(t *testing.T) { + t.Parallel() + + tests := []struct { + alias string + want uint64 + ok bool + }{ + {alias: "0", want: 0, ok: true}, + {alias: "z", want: 35, ok: true}, + {alias: "00", want: 36, ok: true}, + {alias: "01", want: 37, ok: true}, + {alias: "zz", want: 1331, ok: true}, + {alias: "000", want: 1332, ok: true}, + {alias: "", ok: false}, + {alias: "A", ok: false}, + {alias: "-", ok: false}, + } + + for _, tc := range tests { + got, ok := decodeTaskAliasID(tc.alias) + if ok != tc.ok { + t.Fatalf("decodeTaskAliasID(%q) ok = %v, want %v", tc.alias, ok, tc.ok) + } + if ok && got != tc.want { + t.Fatalf("decodeTaskAliasID(%q) = %d, want %d", tc.alias, got, tc.want) + } + } +} + +func TestEnsureTaskAliases_PersistsAliasesAndTracksAccess(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + + oldNow := nowTaskAliasCache + oldRoot := taskAliasCacheRoot + nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + defer func() { + nowTaskAliasCache = oldNow + taskAliasCacheRoot = oldRoot + }() + + tasks := []TaskExport{{UUID: "uuid-1"}, {UUID: "uuid-2"}} + aliases, err := ensureTaskAliases(tasks) + if err != nil { + t.Fatalf("ensureTaskAliases returned error: %v", err) + } + if aliases["uuid-1"] != "0" || aliases["uuid-2"] != "1" { + t.Fatalf("aliases = %#v, want sequential aliases", aliases) + } + + path, err := taskAliasCachePath() + if err != nil { + t.Fatalf("taskAliasCachePath: %v", err) + } + cache := readTaskAliasCacheForTest(t, path) + if cache.NextID != 2 { + t.Fatalf("NextID = %d, want 2", cache.NextID) + } + if len(cache.Entries) != 2 { + t.Fatalf("len(Entries) = %d, want 2", len(cache.Entries)) + } + + nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 27, 12, 0, 0, 0, time.UTC) } + aliases, err = ensureTaskAliases([]TaskExport{{UUID: "uuid-2"}, {UUID: "uuid-3"}}) + if err != nil { + t.Fatalf("ensureTaskAliases second call returned error: %v", err) + } + if aliases["uuid-2"] != "1" || aliases["uuid-3"] != "2" { + t.Fatalf("second aliases = %#v, want existing+new aliases", aliases) + } + + cache = readTaskAliasCacheForTest(t, path) + if cache.NextID != 3 { + t.Fatalf("NextID after second call = %d, want 3", cache.NextID) + } + entry := findTaskAliasEntry(t, cache, "uuid-2") + if got := entry.LastAccessedAt; !got.Equal(nowTaskAliasCache()) { + t.Fatalf("LastAccessedAt = %s, want %s", got, nowTaskAliasCache()) + } +} + +func TestEnsureTaskAliases_PrunesExpiredEntriesWithoutReusingIDs(t *testing.T) { + dir := t.TempDir() + + oldNow := nowTaskAliasCache + oldRoot := taskAliasCacheRoot + nowTaskAliasCache = func() time.Time { return time.Date(2026, 3, 26, 12, 0, 0, 0, time.UTC) } + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + defer func() { + nowTaskAliasCache = oldNow + taskAliasCacheRoot = oldRoot + }() + + path, err := taskAliasCachePath() + if err != nil { + t.Fatalf("taskAliasCachePath: %v", err) + } + + cache := taskAliasCache{ + NextID: 37, + Entries: []taskAliasCacheEntry{ + { + UUID: "expired", + Alias: "z", + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + LastAccessedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + }, + { + UUID: "fresh", + Alias: "00", + CreatedAt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), + LastAccessedAt: time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC), + }, + }, + } + if err := cache.save(path); err != nil { + t.Fatalf("save seed cache: %v", err) + } + + aliases, err := ensureTaskAliases([]TaskExport{{UUID: "fresh"}, {UUID: "new-task"}}) + if err != nil { + t.Fatalf("ensureTaskAliases returned error: %v", err) + } + if aliases["fresh"] != "00" { + t.Fatalf("fresh alias = %q, want 00", aliases["fresh"]) + } + if aliases["new-task"] != "01" { + t.Fatalf("new-task alias = %q, want 01", aliases["new-task"]) + } + + cache = readTaskAliasCacheForTest(t, path) + if cache.NextID != 38 { + t.Fatalf("NextID = %d, want 38", cache.NextID) + } + if len(cache.Entries) != 2 { + t.Fatalf("len(Entries) = %d, want 2 after prune", len(cache.Entries)) + } + if hasTaskAliasEntry(cache, "expired") { + t.Fatalf("expired entry should have been pruned") + } +} + +func TestEnsureTaskAliases_InvalidCacheReturnsError(t *testing.T) { + dir := t.TempDir() + + oldRoot := taskAliasCacheRoot + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + defer func() { taskAliasCacheRoot = oldRoot }() + + path, err := taskAliasCachePath() + if err != nil { + t.Fatalf("taskAliasCachePath: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(path, []byte("{not-json"), 0o600); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + if _, err := ensureTaskAliases([]TaskExport{{UUID: "uuid-1"}}); err == nil { + t.Fatal("expected error for invalid cache file") + } +} + +func TestEnsureTaskAliases_RejectsNextIDReuse(t *testing.T) { + dir := t.TempDir() + + oldRoot := taskAliasCacheRoot + taskAliasCacheRoot = func() (string, error) { return filepath.Join(dir, "hexai"), nil } + defer func() { taskAliasCacheRoot = oldRoot }() + + path, err := taskAliasCachePath() + if err != nil { + t.Fatalf("taskAliasCachePath: %v", err) + } + + cache := taskAliasCache{ + NextID: 36, + Entries: []taskAliasCacheEntry{ + {UUID: "uuid-1", Alias: "00", CreatedAt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)}, + }, + } + if err := cache.save(path); err != nil { + t.Fatalf("save seed cache: %v", err) + } + + if _, err := ensureTaskAliases([]TaskExport{{UUID: "uuid-2"}}); err == nil { + t.Fatal("expected error when next_id would reuse an alias") + } +} + +func readTaskAliasCacheForTest(t *testing.T, path string) taskAliasCache { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + var cache taskAliasCache + if err := json.Unmarshal(data, &cache); err != nil { + t.Fatalf("Unmarshal(%s): %v", path, err) + } + return cache +} + +func findTaskAliasEntry(t *testing.T, cache taskAliasCache, uuid string) taskAliasCacheEntry { + t.Helper() + + for _, entry := range cache.Entries { + if entry.UUID == uuid { + return entry + } + } + t.Fatalf("missing alias entry for %q", uuid) + return taskAliasCacheEntry{} +} + +func hasTaskAliasEntry(cache taskAliasCache, uuid string) bool { + for _, entry := range cache.Entries { + if entry.UUID == uuid { + return true + } + } + return false +} |
