Skip to main content

lens_core/
cache.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::{Duration, Instant};
4use tokio::sync::RwLock;
5use serde::{Deserialize, Serialize};
6use tracing::{debug, info, warn};
7
8use crate::lsp::hint::SymbolHint;
9
10/// LSP hint cache for 24-hour persistence
11#[derive(Debug, Clone)]
12pub struct HintCache {
13    cache: Arc<RwLock<HashMap<String, CacheEntry>>>,
14    max_entries: usize,
15    ttl: Duration,
16}
17
18#[derive(Debug, Clone)]
19struct CacheEntry {
20    hints: Vec<SymbolHint>,
21    created_at: Instant,
22    file_hash: u64,
23    access_count: u64,
24}
25
26impl HintCache {
27    /// Create a new hint cache
28    pub fn new(max_entries: usize, ttl_hours: u64) -> Self {
29        Self {
30            cache: Arc::new(RwLock::new(HashMap::new())),
31            max_entries,
32            ttl: Duration::from_secs(ttl_hours * 3600),
33        }
34    }
35
36    /// Create cache with 24-hour TTL as specified in TODO.md
37    pub fn with_24h_ttl(max_entries: usize) -> Self {
38        Self::new(max_entries, 24)
39    }
40
41    /// Get hints from cache if valid
42    pub async fn get_hints(&self, file_path: &str, file_hash: u64) -> Option<Vec<SymbolHint>> {
43        let mut cache = self.cache.write().await;
44        
45        if let Some(entry) = cache.get_mut(file_path) {
46            // Check if entry is still valid
47            if entry.created_at.elapsed() < self.ttl && entry.file_hash == file_hash {
48                entry.access_count += 1;
49                debug!("Cache hit for {}: {} hints", file_path, entry.hints.len());
50                return Some(entry.hints.clone());
51            } else {
52                // Remove stale entry
53                cache.remove(file_path);
54                debug!("Cache miss (stale) for {}", file_path);
55            }
56        } else {
57            debug!("Cache miss for {}", file_path);
58        }
59
60        None
61    }
62
63    /// Store hints in cache
64    pub async fn store_hints(&self, file_path: String, file_hash: u64, hints: Vec<SymbolHint>) {
65        let mut cache = self.cache.write().await;
66
67        // Evict old entries if at capacity
68        if cache.len() >= self.max_entries {
69            self.evict_lru(&mut cache).await;
70        }
71
72        let entry = CacheEntry {
73            hints,
74            created_at: Instant::now(),
75            file_hash,
76            access_count: 1,
77        };
78
79        cache.insert(file_path.clone(), entry);
80        debug!("Cached hints for {}: {} entries", file_path, cache.len());
81    }
82
83    /// Invalidate cache entry for a file (e.g., on file change)
84    pub async fn invalidate(&self, file_path: &str) {
85        let mut cache = self.cache.write().await;
86        if cache.remove(file_path).is_some() {
87            debug!("Invalidated cache for {}", file_path);
88        }
89    }
90
91    /// Clear all cache entries
92    pub async fn clear(&self) {
93        let mut cache = self.cache.write().await;
94        let count = cache.len();
95        cache.clear();
96        info!("Cleared cache: {} entries removed", count);
97    }
98
99    /// Get cache statistics
100    pub async fn stats(&self) -> CacheStats {
101        let cache = self.cache.read().await;
102        let now = Instant::now();
103        
104        let mut total_access_count = 0;
105        let mut valid_entries = 0;
106        let mut stale_entries = 0;
107
108        for entry in cache.values() {
109            total_access_count += entry.access_count;
110            if entry.created_at.elapsed() < self.ttl {
111                valid_entries += 1;
112            } else {
113                stale_entries += 1;
114            }
115        }
116
117        CacheStats {
118            total_entries: cache.len(),
119            valid_entries,
120            stale_entries,
121            total_access_count,
122        }
123    }
124
125    /// Evict least recently used entry
126    async fn evict_lru(&self, cache: &mut HashMap<String, CacheEntry>) {
127        if let Some(lru_key) = cache
128            .iter()
129            .min_by_key(|(_, entry)| entry.access_count)
130            .map(|(k, _)| k.clone())
131        {
132            cache.remove(&lru_key);
133            debug!("Evicted LRU entry: {}", lru_key);
134        }
135    }
136
137    /// Cleanup stale entries
138    pub async fn cleanup_stale(&self) {
139        let mut cache = self.cache.write().await;
140        let initial_count = cache.len();
141        
142        cache.retain(|_, entry| entry.created_at.elapsed() < self.ttl);
143        
144        let removed_count = initial_count - cache.len();
145        if removed_count > 0 {
146            info!("Cleaned up {} stale cache entries", removed_count);
147        }
148    }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct CacheStats {
153    pub total_entries: usize,
154    pub valid_entries: usize,
155    pub stale_entries: usize,
156    pub total_access_count: u64,
157}
158
159impl Default for HintCache {
160    fn default() -> Self {
161        Self::with_24h_ttl(10000) // Default 10k entries with 24h TTL
162    }
163}
164
165/// File hash calculator for cache invalidation
166pub struct FileHasher;
167
168impl FileHasher {
169    /// Calculate a simple hash for file content validation
170    pub fn hash_file_content(content: &[u8]) -> u64 {
171        use std::collections::hash_map::DefaultHasher;
172        use std::hash::{Hash, Hasher};
173        
174        let mut hasher = DefaultHasher::new();
175        content.hash(&mut hasher);
176        hasher.finish()
177    }
178
179    /// Calculate hash from file metadata (size + mtime)
180    pub async fn hash_file_metadata(path: &std::path::Path) -> Result<u64, std::io::Error> {
181        let metadata = tokio::fs::metadata(path).await?;
182        
183        let mut hasher = std::collections::hash_map::DefaultHasher::new();
184        use std::hash::{Hash, Hasher};
185        
186        metadata.len().hash(&mut hasher);
187        if let Ok(modified) = metadata.modified() {
188            if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
189                duration.as_secs().hash(&mut hasher);
190            }
191        }
192        
193        Ok(hasher.finish())
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200    use crate::lsp::hint::SymbolHint;
201    use std::path::PathBuf;
202    use tokio::time::{sleep, Duration};
203
204    fn create_test_hint(name: &str) -> SymbolHint {
205        SymbolHint::new(
206            name.to_string(),
207            "function".to_string(),
208            "/test/file.rs".to_string(),
209            1,
210            1,
211            0.8,
212        )
213    }
214
215    #[tokio::test]
216    async fn test_hint_cache_new() {
217        let cache = HintCache::new(100, 1);
218        
219        assert_eq!(cache.max_entries, 100);
220        assert_eq!(cache.ttl, Duration::from_secs(3600));
221    }
222
223    #[tokio::test]
224    async fn test_hint_cache_24h_ttl() {
225        let cache = HintCache::with_24h_ttl(50);
226        
227        assert_eq!(cache.max_entries, 50);
228        assert_eq!(cache.ttl, Duration::from_secs(24 * 3600));
229    }
230
231    #[tokio::test]
232    async fn test_cache_store_and_get() {
233        let cache = HintCache::new(10, 1);
234        let hints = vec![
235            create_test_hint("function1"),
236            create_test_hint("function2"),
237        ];
238        
239        // Store hints
240        cache.store_hints("test.rs".to_string(), 12345, hints.clone()).await;
241        
242        // Retrieve hints with same hash
243        let retrieved = cache.get_hints("test.rs", 12345).await;
244        assert!(retrieved.is_some());
245        
246        let retrieved_hints = retrieved.unwrap();
247        assert_eq!(retrieved_hints.len(), 2);
248        assert_eq!(retrieved_hints[0].symbol_name, "function1");
249        assert_eq!(retrieved_hints[1].symbol_name, "function2");
250    }
251
252    #[tokio::test]
253    async fn test_cache_miss_different_hash() {
254        let cache = HintCache::new(10, 1);
255        let hints = vec![create_test_hint("function1")];
256        
257        cache.store_hints("test.rs".to_string(), 12345, hints).await;
258        
259        // Try to get with different hash
260        let retrieved = cache.get_hints("test.rs", 67890).await;
261        assert!(retrieved.is_none());
262    }
263
264    #[tokio::test]
265    async fn test_cache_miss_different_file() {
266        let cache = HintCache::new(10, 1);
267        let hints = vec![create_test_hint("function1")];
268        
269        cache.store_hints("test.rs".to_string(), 12345, hints).await;
270        
271        // Try to get different file
272        let retrieved = cache.get_hints("other.rs", 12345).await;
273        assert!(retrieved.is_none());
274    }
275
276    #[tokio::test]
277    async fn test_cache_eviction_by_size() {
278        let cache = HintCache::new(2, 1); // Small cache
279        let hints1 = vec![create_test_hint("func1")];
280        let hints2 = vec![create_test_hint("func2")];
281        let hints3 = vec![create_test_hint("func3")];
282        
283        // Fill cache to capacity
284        cache.store_hints("file1.rs".to_string(), 1, hints1).await;
285        cache.store_hints("file2.rs".to_string(), 2, hints2).await;
286        
287        // This should trigger eviction
288        cache.store_hints("file3.rs".to_string(), 3, hints3).await;
289        
290        // Verify cache size is maintained
291        let stats = cache.stats().await;
292        assert!(stats.total_entries <= 2);
293    }
294
295    #[tokio::test]
296    async fn test_cache_clear() {
297        let cache = HintCache::new(10, 1);
298        let hints = vec![create_test_hint("function1")];
299        
300        cache.store_hints("test.rs".to_string(), 12345, hints).await;
301        
302        // Verify it exists
303        let retrieved = cache.get_hints("test.rs", 12345).await;
304        assert!(retrieved.is_some());
305        
306        // Clear cache
307        cache.clear().await;
308        
309        // Should be gone
310        let cleared = cache.get_hints("test.rs", 12345).await;
311        assert!(cleared.is_none());
312    }
313
314    #[tokio::test]
315    async fn test_cache_invalidate_file() {
316        let cache = HintCache::new(10, 1);
317        let hints1 = vec![create_test_hint("func1")];
318        let hints2 = vec![create_test_hint("func2")];
319        
320        cache.store_hints("file1.rs".to_string(), 1, hints1).await;
321        cache.store_hints("file2.rs".to_string(), 2, hints2).await;
322        
323        // Invalidate specific file
324        cache.invalidate("file1.rs").await;
325        
326        // file1 should be gone, file2 should remain
327        assert!(cache.get_hints("file1.rs", 1).await.is_none());
328        assert!(cache.get_hints("file2.rs", 2).await.is_some());
329    }
330
331    #[tokio::test]
332    async fn test_cache_stats() {
333        let cache = HintCache::new(10, 1);
334        let hints = vec![create_test_hint("function1")];
335        
336        // Initial stats
337        let initial_stats = cache.stats().await;
338        assert_eq!(initial_stats.total_entries, 0);
339        
340        // Add entry
341        cache.store_hints("test.rs".to_string(), 12345, hints).await;
342        
343        // Check stats after store
344        let after_store = cache.stats().await;
345        assert_eq!(after_store.total_entries, 1);
346    }
347
348    #[tokio::test]
349    async fn test_concurrent_cache_access() {
350        let cache = Arc::new(HintCache::new(10, 1));
351        let hints = vec![create_test_hint("function1")];
352        
353        cache.store_hints("test.rs".to_string(), 12345, hints).await;
354        
355        // Spawn multiple concurrent readers
356        let mut handles = vec![];
357        for i in 0..10 {
358            let cache_clone = cache.clone();
359            let handle = tokio::spawn(async move {
360                let result = cache_clone.get_hints("test.rs", 12345).await;
361                assert!(result.is_some(), "Read {} should succeed", i);
362            });
363            handles.push(handle);
364        }
365        
366        // Wait for all reads to complete
367        for handle in handles {
368            handle.await.expect("Task should complete successfully");
369        }
370    }
371
372    #[test]
373    fn test_file_hasher_consistency() {
374        let content1 = b"hello world";
375        let content2 = b"hello world";
376        let content3 = b"hello world!";
377        
378        // Same content should produce same hash
379        assert_eq!(
380            FileHasher::hash_file_content(content1),
381            FileHasher::hash_file_content(content2)
382        );
383        
384        // Different content should produce different hash
385        assert_ne!(
386            FileHasher::hash_file_content(content1),
387            FileHasher::hash_file_content(content3)
388        );
389    }
390
391    #[test]
392    fn test_file_hasher_empty_content() {
393        let empty = b"";
394        let hash = FileHasher::hash_file_content(empty);
395        
396        // Empty content should still produce a valid hash
397        assert!(hash != 0); // Just verify it's not zero (implementation detail)
398    }
399
400    #[test]
401    fn test_cache_stats_default() {
402        let stats = CacheStats {
403            total_entries: 0,
404            valid_entries: 0,
405            stale_entries: 0,
406            total_access_count: 0,
407        };
408        
409        assert_eq!(stats.total_entries, 0);
410        assert_eq!(stats.valid_entries, 0);
411        assert_eq!(stats.stale_entries, 0);
412        assert_eq!(stats.total_access_count, 0);
413    }
414
415    #[test]
416    fn test_cache_entry_creation() {
417        let hints = vec![create_test_hint("test_func")];
418        let entry = CacheEntry {
419            hints: hints.clone(),
420            created_at: Instant::now(),
421            file_hash: 12345,
422            access_count: 0,
423        };
424        
425        assert_eq!(entry.hints.len(), 1);
426        assert_eq!(entry.file_hash, 12345);
427        assert_eq!(entry.access_count, 0);
428        assert_eq!(entry.hints[0].symbol_name, "test_func");
429    }
430
431    #[tokio::test]
432    async fn test_default_cache_configuration() {
433        let cache = HintCache::default();
434        
435        // Should use 24-hour TTL and 10k entries as per TODO.md
436        assert_eq!(cache.ttl, Duration::from_secs(24 * 3600));
437        assert_eq!(cache.max_entries, 10000);
438    }
439}