diff --git a/internal/cache/lru.go b/internal/cache/lru.go new file mode 100644 index 0000000..d89fae5 --- /dev/null +++ b/internal/cache/lru.go @@ -0,0 +1,75 @@ +package cache + +import ( + "container/list" + "sync" + "time" + + "forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind" +) + +const DefaultCapacity = 2 + +type entry struct { + key string + profile *cachegrind.Profile + modTime time.Time + elem *list.Element +} + +// Cache is a thread-safe LRU cache keyed by absolute file path. +type Cache struct { + mu sync.Mutex + capacity int + items map[string]*entry + order list.List +} + +func New(capacity int) *Cache { + return &Cache{ + capacity: capacity, + items: make(map[string]*entry), + } +} + +// Get returns the cached Profile if key exists and modTime matches. +func (c *Cache) Get(key string, modTime time.Time) (*cachegrind.Profile, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + e, ok := c.items[key] + if !ok { + return nil, false + } + if !e.modTime.Equal(modTime) { + c.evict(e) + return nil, false + } + c.order.MoveToFront(e.elem) + return e.profile, true +} + +// Set stores a Profile, evicting the least-recently-used entry if at capacity. +func (c *Cache) Set(key string, profile *cachegrind.Profile, modTime time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + + if e, ok := c.items[key]; ok { + c.evict(e) + } + for len(c.items) >= c.capacity { + back := c.order.Back() + if back == nil { + break + } + c.evict(back.Value.(*entry)) + } + e := &entry{key: key, profile: profile, modTime: modTime} + e.elem = c.order.PushFront(e) + c.items[key] = e +} + +func (c *Cache) evict(e *entry) { + c.order.Remove(e.elem) + delete(c.items, e.key) +} diff --git a/internal/cache/lru_test.go b/internal/cache/lru_test.go new file mode 100644 index 0000000..444dce2 --- /dev/null +++ b/internal/cache/lru_test.go @@ -0,0 +1,58 @@ +package cache_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "forge.lclr.dev/AI/xdebug-mcp/internal/cache" + "forge.lclr.dev/AI/xdebug-mcp/internal/cachegrind" +) + +func TestLRU_GetSet(t *testing.T) { + c := cache.New(2) + t0 := time.Now() + p := &cachegrind.Profile{Cmd: "p1"} + + c.Set("/a", p, t0) + got, ok := c.Get("/a", t0) + require.True(t, ok) + assert.Equal(t, p, got) +} + +func TestLRU_Eviction(t *testing.T) { + c := cache.New(2) + t0 := time.Now() + + p1 := &cachegrind.Profile{Cmd: "p1"} + p2 := &cachegrind.Profile{Cmd: "p2"} + p3 := &cachegrind.Profile{Cmd: "p3"} + + c.Set("/a", p1, t0) + c.Set("/b", p2, t0) + c.Get("/a", t0) // access /a → /b becomes LRU + c.Set("/c", p3, t0) // should evict /b + + _, ok := c.Get("/b", t0) + assert.False(t, ok, "b should have been evicted") + + got, ok := c.Get("/a", t0) + require.True(t, ok) + assert.Equal(t, p1, got) + + got, ok = c.Get("/c", t0) + require.True(t, ok) + assert.Equal(t, p3, got) +} + +func TestLRU_ModtimeInvalidation(t *testing.T) { + c := cache.New(2) + t1 := time.Now() + t2 := t1.Add(time.Second) + + c.Set("/a", &cachegrind.Profile{Cmd: "old"}, t1) + _, ok := c.Get("/a", t2) // newer modtime → stale + assert.False(t, ok) +}