package cache import ( "encoding/json" "os" "path/filepath" "sort" "strings" "sync" "time" ) type Entry struct { Name string `json:"name"` IP string `json:"ip"` Kind string `json:"kind"` Tags []string `json:"tags,omitempty"` CachedAt time.Time `json:"cached_at"` LastUsed time.Time `json:"last_used,omitempty"` } type Cache struct { mu sync.RWMutex entries map[string]Entry path string ttl time.Duration } type diskFormat struct { Entries []Entry `json:"entries"` } func New(path string, ttlSeconds int) *Cache { return &Cache{ entries: make(map[string]Entry), path: path, ttl: time.Duration(ttlSeconds) * time.Second, } } func (c *Cache) Load() error { c.mu.Lock() defer c.mu.Unlock() data, err := os.ReadFile(c.path) if os.IsNotExist(err) { return nil } if err != nil { return err } var df diskFormat if err := json.Unmarshal(data, &df); err != nil { return err } c.entries = make(map[string]Entry, len(df.Entries)) for _, e := range df.Entries { c.entries[e.Name] = e } return nil } func (c *Cache) Save() error { c.mu.RLock() df := diskFormat{Entries: make([]Entry, 0, len(c.entries))} for _, e := range c.entries { df.Entries = append(df.Entries, e) } c.mu.RUnlock() if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { return err } data, err := json.MarshalIndent(df, "", " ") if err != nil { return err } return os.WriteFile(c.path, data, 0o644) } func (c *Cache) Upsert(e Entry) { e.CachedAt = time.Now() c.mu.Lock() c.entries[e.Name] = e c.mu.Unlock() } // MarkUsed records the current time as LastUsed for the named entry. // It is a no-op if the entry does not exist. func (c *Cache) MarkUsed(name string) { c.mu.Lock() defer c.mu.Unlock() if e, ok := c.entries[name]; ok { e.LastUsed = time.Now() c.entries[name] = e } } // RecentlyUsed returns the n most recently used entries, sorted by LastUsed desc. // Entries that have never been used (LastUsed zero) are excluded. // If n <= 0, all used entries are returned. func (c *Cache) RecentlyUsed(n int) []Entry { c.mu.RLock() defer c.mu.RUnlock() var used []Entry for _, e := range c.entries { if !e.LastUsed.IsZero() { used = append(used, e) } } sort.Slice(used, func(i, j int) bool { return used[i].LastUsed.After(used[j].LastUsed) }) if n > 0 && len(used) > n { used = used[:n] } return used } // Search returns all entries whose name starts with prefix (case-insensitive). // TTL is intentionally ignored — this is used for shell completion. func (c *Cache) Search(prefix string) []Entry { c.mu.RLock() defer c.mu.RUnlock() prefix = strings.ToLower(prefix) var out []Entry for name, e := range c.entries { if strings.HasPrefix(strings.ToLower(name), prefix) { out = append(out, e) } } return out } // Get returns an entry and reports whether it is still within the TTL. func (c *Cache) Get(name string) (entry Entry, fresh bool) { c.mu.RLock() e, ok := c.entries[name] c.mu.RUnlock() if !ok { return Entry{}, false } if c.ttl == 0 { return e, false } return e, time.Since(e.CachedAt) < c.ttl } func (c *Cache) Clear() { c.mu.Lock() c.entries = make(map[string]Entry) c.mu.Unlock() } func (c *Cache) All() []Entry { c.mu.RLock() defer c.mu.RUnlock() out := make([]Entry, 0, len(c.entries)) for _, e := range c.entries { out = append(out, e) } return out }