commit 9f8cb6cd0e3958b3df7940a797abe541a6ee0d24 Author: Snider Date: Fri Mar 6 13:03:52 2026 +0000 feat: extract cache package from core/go pkg/cache File-based cache with TTL, JSON serialisation, go-io Medium backend. Moved from forge.lthn.ai/core/go/pkg/cache to standalone module. Co-Authored-By: Virgil diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..030fe1b --- /dev/null +++ b/cache.go @@ -0,0 +1,171 @@ +// Package cache provides a file-based cache for GitHub API responses. +package cache + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "time" + + "forge.lthn.ai/core/go-io" +) + +// DefaultTTL is the default cache expiry time. +const DefaultTTL = 1 * time.Hour + +// Cache represents a file-based cache. +type Cache struct { + medium io.Medium + baseDir string + ttl time.Duration +} + +// Entry represents a cached item with metadata. +type Entry struct { + Data json.RawMessage `json:"data"` + CachedAt time.Time `json:"cached_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// New creates a new cache instance. +// If medium is nil, uses io.Local (filesystem). +// If baseDir is empty, uses .core/cache in current directory. +func New(medium io.Medium, baseDir string, ttl time.Duration) (*Cache, error) { + if medium == nil { + medium = io.Local + } + + if baseDir == "" { + // Use .core/cache in current working directory + cwd, err := os.Getwd() + if err != nil { + return nil, err + } + baseDir = filepath.Join(cwd, ".core", "cache") + } + + if ttl == 0 { + ttl = DefaultTTL + } + + // Ensure cache directory exists + if err := medium.EnsureDir(baseDir); err != nil { + return nil, err + } + + return &Cache{ + medium: medium, + baseDir: baseDir, + ttl: ttl, + }, nil +} + +// Path returns the full path for a cache key. +func (c *Cache) Path(key string) string { + return filepath.Join(c.baseDir, key+".json") +} + +// Get retrieves a cached item if it exists and hasn't expired. +func (c *Cache) Get(key string, dest any) (bool, error) { + path := c.Path(key) + + dataStr, err := c.medium.Read(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, err + } + + var entry Entry + if err := json.Unmarshal([]byte(dataStr), &entry); err != nil { + // Invalid cache file, treat as miss + return false, nil + } + + // Check expiry + if time.Now().After(entry.ExpiresAt) { + return false, nil + } + + // Unmarshal the actual data + if err := json.Unmarshal(entry.Data, dest); err != nil { + return false, err + } + + return true, nil +} + +// Set stores an item in the cache. +func (c *Cache) Set(key string, data any) error { + path := c.Path(key) + + // Ensure parent directory exists + if err := c.medium.EnsureDir(filepath.Dir(path)); err != nil { + return err + } + + // Marshal the data + dataBytes, err := json.Marshal(data) + if err != nil { + return err + } + + entry := Entry{ + Data: dataBytes, + CachedAt: time.Now(), + ExpiresAt: time.Now().Add(c.ttl), + } + + entryBytes, err := json.MarshalIndent(entry, "", " ") + if err != nil { + return err + } + + return c.medium.Write(path, string(entryBytes)) +} + +// Delete removes an item from the cache. +func (c *Cache) Delete(key string) error { + path := c.Path(key) + err := c.medium.Delete(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// Clear removes all cached items. +func (c *Cache) Clear() error { + return c.medium.DeleteAll(c.baseDir) +} + +// Age returns how old a cached item is, or -1 if not cached. +func (c *Cache) Age(key string) time.Duration { + path := c.Path(key) + + dataStr, err := c.medium.Read(path) + if err != nil { + return -1 + } + + var entry Entry + if err := json.Unmarshal([]byte(dataStr), &entry); err != nil { + return -1 + } + + return time.Since(entry.CachedAt) +} + +// GitHub-specific cache keys + +// GitHubReposKey returns the cache key for an org's repo list. +func GitHubReposKey(org string) string { + return filepath.Join("github", org, "repos") +} + +// GitHubRepoKey returns the cache key for a specific repo's metadata. +func GitHubRepoKey(org, repo string) string { + return filepath.Join("github", org, repo, "meta") +} diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..e47ddce --- /dev/null +++ b/cache_test.go @@ -0,0 +1,104 @@ +package cache_test + +import ( + "testing" + "time" + + "forge.lthn.ai/core/go-cache" + "forge.lthn.ai/core/go-io" +) + +func TestCache(t *testing.T) { + m := io.NewMockMedium() + // Use a path that MockMedium will understand + baseDir := "/tmp/cache" + c, err := cache.New(m, baseDir, 1*time.Minute) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + + key := "test-key" + data := map[string]string{"foo": "bar"} + + // Test Set + if err := c.Set(key, data); err != nil { + t.Errorf("Set failed: %v", err) + } + + // Test Get + var retrieved map[string]string + found, err := c.Get(key, &retrieved) + if err != nil { + t.Errorf("Get failed: %v", err) + } + if !found { + t.Error("expected to find cached item") + } + if retrieved["foo"] != "bar" { + t.Errorf("expected foo=bar, got %v", retrieved["foo"]) + } + + // Test Age + age := c.Age(key) + if age < 0 { + t.Error("expected age >= 0") + } + + // Test Delete + if err := c.Delete(key); err != nil { + t.Errorf("Delete failed: %v", err) + } + found, err = c.Get(key, &retrieved) + if err != nil { + t.Errorf("Get after delete returned an unexpected error: %v", err) + } + if found { + t.Error("expected item to be deleted") + } + + // Test Expiry + cshort, err := cache.New(m, "/tmp/cache-short", 10*time.Millisecond) + if err != nil { + t.Fatalf("failed to create short-lived cache: %v", err) + } + if err := cshort.Set(key, data); err != nil { + t.Fatalf("Set for expiry test failed: %v", err) + } + time.Sleep(50 * time.Millisecond) + found, err = cshort.Get(key, &retrieved) + if err != nil { + t.Errorf("Get for expired item returned an unexpected error: %v", err) + } + if found { + t.Error("expected item to be expired") + } + + // Test Clear + if err := c.Set("key1", data); err != nil { + t.Fatalf("Set for clear test failed for key1: %v", err) + } + if err := c.Set("key2", data); err != nil { + t.Fatalf("Set for clear test failed for key2: %v", err) + } + if err := c.Clear(); err != nil { + t.Errorf("Clear failed: %v", err) + } + found, err = c.Get("key1", &retrieved) + if err != nil { + t.Errorf("Get after clear returned an unexpected error: %v", err) + } + if found { + t.Error("expected key1 to be cleared") + } +} + +func TestCacheDefaults(t *testing.T) { + // Test default Medium (io.Local) and default TTL + c, err := cache.New(nil, "", 0) + if err != nil { + t.Fatalf("failed to create cache with defaults: %v", err) + } + if c == nil { + t.Fatal("expected cache instance") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fff85ed --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module forge.lthn.ai/core/go-cache + +go 1.26.0 + +require forge.lthn.ai/core/go-io v0.0.3 + +require forge.lthn.ai/core/go-log v0.0.1 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6446e33 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +forge.lthn.ai/core/go-io v0.0.3 h1:TlhYpGTyjPgAlbEHyYrVSeUChZPhJXcLZ7D/8IbFqfI= +forge.lthn.ai/core/go-io v0.0.3/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0= +forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= +forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=