summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/askcli/command_complete_uuids.go3
-rw-r--r--internal/askcli/command_complete_uuids_test.go63
-rw-r--r--internal/askcli/task_alias_cache.go260
-rw-r--r--internal/askcli/task_alias_cache_test.go265
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
+}