main
1package gitlab
2
3import (
4 "crypto/sha256"
5 "encoding/json"
6 "fmt"
7 "os"
8 "path/filepath"
9 "sync"
10 "time"
11)
12
13// CacheConfig defines cache behavior settings
14type CacheConfig struct {
15 CacheDir string // Base directory for cache files (e.g., ~/.mcp/gitlab)
16 TTL time.Duration // Time-to-live for cached entries
17 MaxEntries int // Maximum number of entries per cache type
18 EnableOffline bool // Whether to return stale data when offline
19 CompressData bool // Whether to compress cached data
20}
21
22// CacheEntry represents a single cached item
23type CacheEntry struct {
24 Key string `json:"key"`
25 Data json.RawMessage `json:"data"`
26 Timestamp time.Time `json:"timestamp"`
27 LastAccessed time.Time `json:"last_accessed"`
28 HitCount int `json:"hit_count"`
29 Size int `json:"size"`
30 ETag string `json:"etag,omitempty"`
31 StatusCode int `json:"status_code,omitempty"`
32}
33
34// Cache provides thread-safe caching functionality
35type Cache struct {
36 mu sync.RWMutex
37 config CacheConfig
38 metadata map[string]*CacheMetadata
39}
40
41// CacheMetadata tracks cache statistics and metadata
42type CacheMetadata struct {
43 TotalHits int64 `json:"total_hits"`
44 TotalMisses int64 `json:"total_misses"`
45 LastUpdated time.Time `json:"last_updated"`
46 EntryCount int `json:"entry_count"`
47 TotalSize int64 `json:"total_size"`
48}
49
50// NewCache creates a new cache instance
51func NewCache(config CacheConfig) (*Cache, error) {
52 // Set default values
53 if config.CacheDir == "" {
54 homeDir, err := os.UserHomeDir()
55 if err != nil {
56 return nil, fmt.Errorf("failed to get home directory: %w", err)
57 }
58 config.CacheDir = filepath.Join(homeDir, ".mcp", "gitlab")
59 }
60
61 if config.TTL == 0 {
62 config.TTL = 5 * time.Minute // Default 5 minute TTL
63 }
64
65 if config.MaxEntries == 0 {
66 config.MaxEntries = 1000 // Default max entries
67 }
68
69 // Create cache directory structure
70 if err := os.MkdirAll(config.CacheDir, 0755); err != nil {
71 return nil, fmt.Errorf("failed to create cache directory: %w", err)
72 }
73
74 cache := &Cache{
75 config: config,
76 metadata: make(map[string]*CacheMetadata),
77 }
78
79 // Load existing metadata
80 if err := cache.loadMetadata(); err != nil {
81 // Non-fatal error, just log it
82 fmt.Fprintf(os.Stderr, "Warning: failed to load cache metadata: %v\n", err)
83 }
84
85 return cache, nil
86}
87
88// generateCacheKey creates a deterministic cache key from request parameters
89func (c *Cache) generateCacheKey(endpoint string, params map[string]string) string {
90 h := sha256.New()
91 h.Write([]byte(endpoint))
92
93 // Sort params for consistent hashing
94 for k, v := range params {
95 h.Write([]byte(k))
96 h.Write([]byte(v))
97 }
98
99 return fmt.Sprintf("%x", h.Sum(nil))
100}
101
102// getCachePath returns the file path for a cache entry
103func (c *Cache) getCachePath(cacheType, key string) string {
104 // Create subdirectories for different cache types (issues, projects, etc.)
105 dir := filepath.Join(c.config.CacheDir, cacheType)
106 os.MkdirAll(dir, 0755)
107
108 // Use first 2 chars of key for sharding
109 if len(key) >= 2 {
110 shardDir := filepath.Join(dir, key[:2])
111 os.MkdirAll(shardDir, 0755)
112 return filepath.Join(shardDir, key+".json")
113 }
114
115 return filepath.Join(dir, key+".json")
116}
117
118// Get retrieves a cached entry if it exists and is valid
119func (c *Cache) Get(cacheType, endpoint string, params map[string]string) ([]byte, bool) {
120 c.mu.RLock()
121 defer c.mu.RUnlock()
122
123 key := c.generateCacheKey(endpoint, params)
124 cachePath := c.getCachePath(cacheType, key)
125
126 // Read cache file
127 data, err := os.ReadFile(cachePath)
128 if err != nil {
129 c.recordMiss(cacheType)
130 return nil, false
131 }
132
133 var entry CacheEntry
134 if err := json.Unmarshal(data, &entry); err != nil {
135 c.recordMiss(cacheType)
136 return nil, false
137 }
138
139 // Check if entry is still valid
140 if time.Since(entry.Timestamp) > c.config.TTL {
141 if !c.config.EnableOffline {
142 c.recordMiss(cacheType)
143 return nil, false
144 }
145 // Return stale data in offline mode
146 }
147
148 // Update access time and hit count
149 entry.LastAccessed = time.Now()
150 entry.HitCount++
151
152 // Write updated entry back (async to avoid blocking)
153 go func() {
154 c.mu.Lock()
155 defer c.mu.Unlock()
156
157 updatedData, _ := json.MarshalIndent(entry, "", " ")
158 os.WriteFile(cachePath, updatedData, 0644)
159 }()
160
161 c.recordHit(cacheType)
162 return entry.Data, true
163}
164
165// Set stores data in the cache
166func (c *Cache) Set(cacheType, endpoint string, params map[string]string, data []byte, statusCode int) error {
167 c.mu.Lock()
168 defer c.mu.Unlock()
169
170 key := c.generateCacheKey(endpoint, params)
171 cachePath := c.getCachePath(cacheType, key)
172
173 entry := CacheEntry{
174 Key: key,
175 Data: json.RawMessage(data),
176 Timestamp: time.Now(),
177 LastAccessed: time.Now(),
178 HitCount: 0,
179 Size: len(data),
180 StatusCode: statusCode,
181 }
182
183 // Marshal and save entry
184 entryData, err := json.MarshalIndent(entry, "", " ")
185 if err != nil {
186 return fmt.Errorf("failed to marshal cache entry: %w", err)
187 }
188
189 if err := os.WriteFile(cachePath, entryData, 0644); err != nil {
190 return fmt.Errorf("failed to write cache file: %w", err)
191 }
192
193 // Update metadata
194 c.updateMetadata(cacheType, len(data))
195
196 // Enforce max entries limit
197 go c.enforceMaxEntries(cacheType)
198
199 return nil
200}
201
202// Delete removes a specific cache entry
203func (c *Cache) Delete(cacheType, endpoint string, params map[string]string) error {
204 c.mu.Lock()
205 defer c.mu.Unlock()
206
207 key := c.generateCacheKey(endpoint, params)
208 cachePath := c.getCachePath(cacheType, key)
209
210 return os.Remove(cachePath)
211}
212
213// Clear removes all cached data for a specific type or all types
214func (c *Cache) Clear(cacheType string) error {
215 c.mu.Lock()
216 defer c.mu.Unlock()
217
218 if cacheType == "" {
219 // Clear all cache but preserve the base directory
220 entries, err := os.ReadDir(c.config.CacheDir)
221 if err != nil {
222 return err
223 }
224 for _, entry := range entries {
225 if err := os.RemoveAll(filepath.Join(c.config.CacheDir, entry.Name())); err != nil {
226 return err
227 }
228 }
229 // Reset metadata
230 c.metadata = make(map[string]*CacheMetadata)
231 return c.saveMetadata()
232 }
233
234 // Clear specific cache type
235 cacheDir := filepath.Join(c.config.CacheDir, cacheType)
236 if err := os.RemoveAll(cacheDir); err != nil {
237 return err
238 }
239
240 // Update metadata
241 delete(c.metadata, cacheType)
242 return c.saveMetadata()
243}
244
245// GetStats returns cache statistics
246func (c *Cache) GetStats() map[string]*CacheMetadata {
247 c.mu.RLock()
248 defer c.mu.RUnlock()
249
250 // Create a copy to avoid concurrent access
251 stats := make(map[string]*CacheMetadata)
252 for k, v := range c.metadata {
253 stats[k] = &CacheMetadata{
254 TotalHits: v.TotalHits,
255 TotalMisses: v.TotalMisses,
256 LastUpdated: v.LastUpdated,
257 EntryCount: v.EntryCount,
258 TotalSize: v.TotalSize,
259 }
260 }
261
262 return stats
263}
264
265// Helper methods
266
267func (c *Cache) recordHit(cacheType string) {
268 if c.metadata[cacheType] == nil {
269 c.metadata[cacheType] = &CacheMetadata{}
270 }
271 c.metadata[cacheType].TotalHits++
272}
273
274func (c *Cache) recordMiss(cacheType string) {
275 if c.metadata[cacheType] == nil {
276 c.metadata[cacheType] = &CacheMetadata{}
277 }
278 c.metadata[cacheType].TotalMisses++
279}
280
281func (c *Cache) updateMetadata(cacheType string, sizeAdded int) {
282 if c.metadata[cacheType] == nil {
283 c.metadata[cacheType] = &CacheMetadata{}
284 }
285
286 meta := c.metadata[cacheType]
287 meta.LastUpdated = time.Now()
288 meta.EntryCount++
289 meta.TotalSize += int64(sizeAdded)
290
291 // Save metadata to disk
292 c.saveMetadata()
293}
294
295func (c *Cache) loadMetadata() error {
296 metaPath := filepath.Join(c.config.CacheDir, "metadata.json")
297 data, err := os.ReadFile(metaPath)
298 if err != nil {
299 return err
300 }
301
302 return json.Unmarshal(data, &c.metadata)
303}
304
305func (c *Cache) saveMetadata() error {
306 metaPath := filepath.Join(c.config.CacheDir, "metadata.json")
307 data, err := json.MarshalIndent(c.metadata, "", " ")
308 if err != nil {
309 return err
310 }
311
312 return os.WriteFile(metaPath, data, 0644)
313}
314
315// enforceMaxEntries removes oldest entries when limit is exceeded
316func (c *Cache) enforceMaxEntries(cacheType string) {
317 // Implementation would scan cache directory and remove oldest entries
318 // based on LastAccessed time when entry count exceeds MaxEntries
319 // This is left as a TODO for brevity
320}
321
322// MergeStrategy defines how to merge cached data with fresh API data
323type MergeStrategy int
324
325const (
326 MergeReplace MergeStrategy = iota // Replace cache with new data
327 MergeAppend // Append new items to existing
328 MergeDiff // Only add/update changed items
329)
330
331// MergeAPIResponse merges fresh API data with cached data based on strategy
332func (c *Cache) MergeAPIResponse(cacheType string, cached, fresh []byte, strategy MergeStrategy) ([]byte, error) {
333 switch cacheType {
334 case "issues":
335 return c.mergeIssues(cached, fresh, strategy)
336 case "projects":
337 return c.mergeProjects(cached, fresh, strategy)
338 default:
339 // Default to replace strategy
340 return fresh, nil
341 }
342}
343
344func (c *Cache) mergeIssues(cached, fresh []byte, strategy MergeStrategy) ([]byte, error) {
345 var cachedIssues, freshIssues []GitLabIssue
346
347 if err := json.Unmarshal(cached, &cachedIssues); err != nil {
348 return fresh, nil // Return fresh data if cached is invalid
349 }
350
351 if err := json.Unmarshal(fresh, &freshIssues); err != nil {
352 return nil, err
353 }
354
355 switch strategy {
356 case MergeReplace:
357 return fresh, nil
358
359 case MergeAppend:
360 // Append fresh issues to cached, removing duplicates
361 issueMap := make(map[int]GitLabIssue)
362 for _, issue := range cachedIssues {
363 issueMap[issue.ID] = issue
364 }
365 for _, issue := range freshIssues {
366 issueMap[issue.ID] = issue // Fresh data overwrites cached
367 }
368
369 merged := make([]GitLabIssue, 0, len(issueMap))
370 for _, issue := range issueMap {
371 merged = append(merged, issue)
372 }
373
374 return json.Marshal(merged)
375
376 case MergeDiff:
377 // Only update changed issues
378 issueMap := make(map[int]GitLabIssue)
379 for _, issue := range cachedIssues {
380 issueMap[issue.ID] = issue
381 }
382
383 // Update only if UpdatedAt is newer
384 for _, freshIssue := range freshIssues {
385 if cachedIssue, exists := issueMap[freshIssue.ID]; exists {
386 if freshIssue.UpdatedAt.After(cachedIssue.UpdatedAt) {
387 issueMap[freshIssue.ID] = freshIssue
388 }
389 } else {
390 issueMap[freshIssue.ID] = freshIssue
391 }
392 }
393
394 merged := make([]GitLabIssue, 0, len(issueMap))
395 for _, issue := range issueMap {
396 merged = append(merged, issue)
397 }
398
399 return json.Marshal(merged)
400 }
401
402 return fresh, nil
403}
404
405func (c *Cache) mergeProjects(cached, fresh []byte, strategy MergeStrategy) ([]byte, error) {
406 // Similar implementation for projects
407 // Left as TODO for brevity
408 return fresh, nil
409}