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}