summaryrefslogtreecommitdiff
path: root/internal/askcli/task_alias_cache.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/askcli/task_alias_cache.go')
-rw-r--r--internal/askcli/task_alias_cache.go260
1 files changed, 260 insertions, 0 deletions
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
+}