debtmap/cache/
unified_analysis_cache.rs

1use crate::cache::shared_cache::SharedCache;
2use crate::priority::UnifiedAnalysis;
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest, Sha256};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10/// Cache key for unified analysis entries
11#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
12pub struct UnifiedAnalysisCacheKey {
13    /// Hash of all source files and their metrics
14    pub source_hash: String,
15    /// Project root path
16    pub project_path: PathBuf,
17    /// Configuration hash (includes thresholds, coverage file, etc.)
18    pub config_hash: String,
19    /// Coverage file hash (if present)
20    pub coverage_hash: Option<String>,
21}
22
23/// Cached unified analysis entry
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct UnifiedAnalysisCacheEntry {
26    /// The cached unified analysis result
27    pub analysis: UnifiedAnalysis,
28    /// Timestamp when cached
29    pub timestamp: SystemTime,
30    /// Source files included in the cache
31    pub source_files: Vec<PathBuf>,
32    /// Configuration used for this analysis
33    pub config_summary: String,
34}
35
36/// Unified analysis cache manager
37pub struct UnifiedAnalysisCache {
38    /// Shared cache backend
39    shared_cache: SharedCache,
40    /// In-memory cache for current session
41    memory_cache: HashMap<UnifiedAnalysisCacheKey, UnifiedAnalysisCacheEntry>,
42    /// Maximum cache age in seconds (default: 1 hour)
43    max_age: u64,
44}
45
46impl UnifiedAnalysisCache {
47    /// Create a new unified analysis cache manager
48    pub fn new(project_path: Option<&Path>) -> Result<Self> {
49        let shared_cache = SharedCache::new(project_path)?;
50        Ok(Self {
51            shared_cache,
52            memory_cache: HashMap::new(),
53            max_age: 3600, // 1 hour
54        })
55    }
56
57    /// Generate cache key for unified analysis
58    pub fn generate_key(
59        project_path: &Path,
60        source_files: &[PathBuf],
61        complexity_threshold: u32,
62        duplication_threshold: usize,
63        coverage_file: Option<&Path>,
64        semantic_off: bool,
65        parallel: bool,
66    ) -> Result<UnifiedAnalysisCacheKey> {
67        let source_hash = Self::hash_source_files(project_path, source_files);
68        let config_hash = Self::hash_config(
69            complexity_threshold,
70            duplication_threshold,
71            semantic_off,
72            parallel,
73        );
74        let coverage_hash = coverage_file.and_then(Self::hash_coverage_file);
75
76        Ok(UnifiedAnalysisCacheKey {
77            source_hash,
78            project_path: project_path.to_path_buf(),
79            config_hash,
80            coverage_hash,
81        })
82    }
83
84    fn hash_source_files(project_path: &Path, source_files: &[PathBuf]) -> String {
85        let mut hasher = Sha256::new();
86        hasher.update(project_path.to_string_lossy().as_bytes());
87
88        let mut sorted_files = source_files.to_vec();
89        sorted_files.sort();
90
91        for file in &sorted_files {
92            Self::hash_file_content(&mut hasher, file);
93            Self::hash_file_mtime(&mut hasher, file);
94        }
95
96        format!("{:x}", hasher.finalize())
97    }
98
99    fn hash_file_content(hasher: &mut Sha256, file: &Path) {
100        if let Ok(content) = std::fs::read_to_string(file) {
101            hasher.update(file.to_string_lossy().as_bytes());
102            hasher.update(content.as_bytes());
103        }
104    }
105
106    fn hash_file_mtime(hasher: &mut Sha256, file: &Path) {
107        let mtime_secs = std::fs::metadata(file)
108            .ok()
109            .and_then(|m| m.modified().ok())
110            .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
111            .map(|d| d.as_secs());
112
113        if let Some(secs) = mtime_secs {
114            hasher.update(secs.to_le_bytes());
115        }
116    }
117
118    fn hash_config(
119        complexity_threshold: u32,
120        duplication_threshold: usize,
121        semantic_off: bool,
122        parallel: bool,
123    ) -> String {
124        let mut hasher = Sha256::new();
125        hasher.update(complexity_threshold.to_le_bytes());
126        hasher.update(duplication_threshold.to_le_bytes());
127        hasher.update([semantic_off as u8]);
128        hasher.update([parallel as u8]);
129        format!("{:x}", hasher.finalize())
130    }
131
132    fn hash_coverage_file(coverage_path: &Path) -> Option<String> {
133        std::fs::read_to_string(coverage_path).ok().map(|content| {
134            let mut hasher = Sha256::new();
135            hasher.update(content.as_bytes());
136            format!("{:x}", hasher.finalize())
137        })
138    }
139
140    /// Get cached unified analysis if available and valid
141    pub fn get(&mut self, key: &UnifiedAnalysisCacheKey) -> Option<UnifiedAnalysis> {
142        // Check memory cache first
143        if let Some(entry) = self.memory_cache.get(key) {
144            if self.is_entry_valid(entry) {
145                log::info!("Unified analysis cache hit (memory)");
146                return Some(entry.analysis.clone());
147            } else {
148                // Remove expired entry
149                self.memory_cache.remove(key);
150            }
151        }
152
153        // Check shared cache
154        let cache_key = self.generate_shared_cache_key(key);
155        if let Ok(data) = self.shared_cache.get(&cache_key, "unified_analysis") {
156            if let Ok(entry) = serde_json::from_slice::<UnifiedAnalysisCacheEntry>(&data) {
157                if self.is_entry_valid(&entry) {
158                    log::info!("Unified analysis cache hit (shared)");
159                    // Store in memory cache for faster access
160                    self.memory_cache.insert(key.clone(), entry.clone());
161                    return Some(entry.analysis);
162                }
163            }
164        }
165
166        log::info!("Unified analysis cache miss");
167        None
168    }
169
170    /// Put unified analysis result in cache
171    pub fn put(
172        &mut self,
173        key: UnifiedAnalysisCacheKey,
174        analysis: UnifiedAnalysis,
175        source_files: Vec<PathBuf>,
176    ) -> Result<()> {
177        let entry = UnifiedAnalysisCacheEntry {
178            analysis: analysis.clone(),
179            timestamp: SystemTime::now(),
180            source_files,
181            config_summary: format!("{:?}", key), // Simple config summary
182        };
183
184        // Store in memory cache
185        self.memory_cache.insert(key.clone(), entry.clone());
186
187        // Store in shared cache
188        let cache_key = self.generate_shared_cache_key(&key);
189        let data = serde_json::to_vec(&entry)
190            .context("Failed to serialize unified analysis cache entry")?;
191
192        self.shared_cache
193            .put(&cache_key, "unified_analysis", &data)
194            .context("Failed to store unified analysis in shared cache")?;
195
196        log::info!("Unified analysis cached successfully");
197        Ok(())
198    }
199
200    /// Clear all cached unified analysis data
201    pub fn clear(&mut self) -> Result<()> {
202        self.memory_cache.clear();
203        // Note: SharedCache doesn't have a clear method for specific types
204        // This would need to be implemented if needed
205        Ok(())
206    }
207
208    /// Get cache statistics
209    pub fn stats(&self) -> String {
210        format!(
211            "UnifiedAnalysisCache: {} entries in memory, max_age: {}s",
212            self.memory_cache.len(),
213            self.max_age
214        )
215    }
216
217    /// Check if cache entry is still valid
218    fn is_entry_valid(&self, entry: &UnifiedAnalysisCacheEntry) -> bool {
219        if let Ok(elapsed) = entry.timestamp.elapsed() {
220            elapsed.as_secs() <= self.max_age
221        } else {
222            false
223        }
224    }
225
226    /// Generate shared cache key from unified analysis cache key
227    fn generate_shared_cache_key(&self, key: &UnifiedAnalysisCacheKey) -> String {
228        format!(
229            "unified_analysis_{}_{}_{}",
230            key.source_hash,
231            key.config_hash,
232            key.coverage_hash.as_deref().unwrap_or("no_coverage")
233        )
234    }
235
236    /// Set maximum cache age in seconds
237    pub fn set_max_age(&mut self, seconds: u64) {
238        self.max_age = seconds;
239    }
240
241    /// Check if we should use cache based on project size
242    pub fn should_use_cache(file_count: usize, has_coverage: bool) -> bool {
243        // Always use cache for projects with many files or when coverage is involved
244        file_count >= 20 || has_coverage
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use tempfile::TempDir;
252
253    #[test]
254    fn test_cache_key_generation() {
255        let temp_dir = TempDir::new().unwrap();
256        let project_path = temp_dir.path();
257
258        // Create test files
259        let file1 = project_path.join("test1.rs");
260        let file2 = project_path.join("test2.rs");
261        std::fs::write(&file1, "fn test1() {}").unwrap();
262        std::fs::write(&file2, "fn test2() {}").unwrap();
263
264        let files = vec![file1, file2];
265
266        let key = UnifiedAnalysisCache::generate_key(
267            project_path,
268            &files,
269            10,    // complexity_threshold
270            50,    // duplication_threshold
271            None,  // coverage_file
272            false, // semantic_off
273            true,  // parallel
274        )
275        .unwrap();
276
277        assert!(!key.source_hash.is_empty());
278        assert!(!key.config_hash.is_empty());
279        assert_eq!(key.project_path, project_path);
280        assert_eq!(key.coverage_hash, None);
281    }
282
283    #[test]
284    fn test_should_use_cache() {
285        assert!(!UnifiedAnalysisCache::should_use_cache(10, false));
286        assert!(UnifiedAnalysisCache::should_use_cache(25, false));
287        assert!(UnifiedAnalysisCache::should_use_cache(10, true));
288        assert!(UnifiedAnalysisCache::should_use_cache(100, true));
289    }
290
291    #[test]
292    fn test_cache_get_miss_on_empty_cache() {
293        let temp_dir = TempDir::new().unwrap();
294        let mut cache = UnifiedAnalysisCache::new(Some(temp_dir.path())).unwrap();
295
296        let key = UnifiedAnalysisCacheKey {
297            project_path: temp_dir.path().to_path_buf(),
298            source_hash: "test_hash".to_string(),
299            config_hash: "config_hash".to_string(),
300            coverage_hash: None,
301        };
302
303        let result = cache.get(&key);
304        assert!(result.is_none());
305    }
306
307    #[test]
308    fn test_cache_put_and_get_memory_cache() {
309        use crate::priority::{CallGraph, UnifiedAnalysis};
310
311        let temp_dir = TempDir::new().unwrap();
312        let mut cache = UnifiedAnalysisCache::new(Some(temp_dir.path())).unwrap();
313
314        let key = UnifiedAnalysisCacheKey {
315            project_path: temp_dir.path().to_path_buf(),
316            source_hash: "test_hash".to_string(),
317            config_hash: "config_hash".to_string(),
318            coverage_hash: None,
319        };
320
321        let analysis = UnifiedAnalysis::new(CallGraph::new());
322        let source_files = vec![temp_dir.path().join("test.rs")];
323
324        // Put in cache
325        let put_result = cache.put(key.clone(), analysis.clone(), source_files);
326        assert!(put_result.is_ok());
327
328        // Get from cache (should hit memory cache)
329        let result = cache.get(&key);
330        assert!(result.is_some());
331    }
332
333    #[test]
334    fn test_cache_put_and_get_shared_cache() {
335        use crate::priority::{CallGraph, UnifiedAnalysis};
336
337        let temp_dir = TempDir::new().unwrap();
338
339        // Create first cache instance and put data
340        let mut cache1 = UnifiedAnalysisCache::new(Some(temp_dir.path())).unwrap();
341
342        let key = UnifiedAnalysisCacheKey {
343            project_path: temp_dir.path().to_path_buf(),
344            source_hash: "test_hash_shared".to_string(),
345            config_hash: "config_hash_shared".to_string(),
346            coverage_hash: None,
347        };
348
349        let analysis = UnifiedAnalysis::new(CallGraph::new());
350        let source_files = vec![temp_dir.path().join("test.rs")];
351
352        let put_result = cache1.put(key.clone(), analysis.clone(), source_files);
353        assert!(put_result.is_ok());
354
355        // Create second cache instance (different memory cache, same shared cache)
356        let mut cache2 = UnifiedAnalysisCache::new(Some(temp_dir.path())).unwrap();
357
358        // Get from cache (should hit shared cache, not memory)
359        let result = cache2.get(&key);
360        assert!(result.is_some());
361    }
362
363    #[test]
364    fn test_cache_clear() {
365        use crate::priority::{CallGraph, UnifiedAnalysis};
366
367        let temp_dir = TempDir::new().unwrap();
368        let mut cache = UnifiedAnalysisCache::new(Some(temp_dir.path())).unwrap();
369
370        let key = UnifiedAnalysisCacheKey {
371            project_path: temp_dir.path().to_path_buf(),
372            source_hash: "test_hash_clear".to_string(),
373            config_hash: "config_hash_clear".to_string(),
374            coverage_hash: None,
375        };
376
377        let analysis = UnifiedAnalysis::new(CallGraph::new());
378        let source_files = vec![temp_dir.path().join("test.rs")];
379
380        let put_result = cache.put(key.clone(), analysis.clone(), source_files);
381        assert!(put_result.is_ok());
382
383        // Verify entry exists
384        assert!(cache.get(&key).is_some());
385
386        // Clear cache
387        let clear_result = cache.clear();
388        assert!(clear_result.is_ok());
389
390        // Entry should be gone from memory cache
391        // (Note: shared cache entries persist but that's expected behavior)
392    }
393
394    #[test]
395    fn test_cache_put_updates_existing_entry() {
396        use crate::priority::{CallGraph, UnifiedAnalysis};
397
398        let temp_dir = TempDir::new().unwrap();
399        let mut cache = UnifiedAnalysisCache::new(Some(temp_dir.path())).unwrap();
400
401        let key = UnifiedAnalysisCacheKey {
402            project_path: temp_dir.path().to_path_buf(),
403            source_hash: "test_hash_update".to_string(),
404            config_hash: "config_hash_update".to_string(),
405            coverage_hash: None,
406        };
407
408        let analysis1 = UnifiedAnalysis::new(CallGraph::new());
409        let source_files = vec![temp_dir.path().join("test.rs")];
410
411        // Put first entry
412        let put_result1 = cache.put(key.clone(), analysis1.clone(), source_files.clone());
413        assert!(put_result1.is_ok());
414
415        // Put second entry with same key (should update)
416        let analysis2 = UnifiedAnalysis::new(CallGraph::new());
417
418        let put_result2 = cache.put(key.clone(), analysis2.clone(), source_files);
419        assert!(put_result2.is_ok());
420
421        // Get should return the updated entry
422        let result = cache.get(&key);
423        assert!(result.is_some());
424    }
425
426    #[test]
427    fn test_cache_get_with_coverage_hash() {
428        use crate::priority::{CallGraph, UnifiedAnalysis};
429
430        let temp_dir = TempDir::new().unwrap();
431        let mut cache = UnifiedAnalysisCache::new(Some(temp_dir.path())).unwrap();
432
433        let key = UnifiedAnalysisCacheKey {
434            project_path: temp_dir.path().to_path_buf(),
435            source_hash: "test_hash_cov".to_string(),
436            config_hash: "config_hash_cov".to_string(),
437            coverage_hash: Some("coverage_123".to_string()),
438        };
439
440        let analysis = UnifiedAnalysis::new(CallGraph::new());
441        let source_files = vec![temp_dir.path().join("test.rs")];
442
443        let put_result = cache.put(key.clone(), analysis.clone(), source_files);
444        assert!(put_result.is_ok());
445
446        // Get with same coverage hash should succeed
447        let result = cache.get(&key);
448        assert!(result.is_some());
449
450        // Get with different coverage hash should miss
451        let key_different_cov = UnifiedAnalysisCacheKey {
452            coverage_hash: Some("coverage_456".to_string()),
453            ..key
454        };
455        let result2 = cache.get(&key_different_cov);
456        assert!(result2.is_none());
457    }
458}