ricecoder_research/
cache_manager.rs

1//! Cache management for research analysis results
2
3use crate::error::ResearchError;
4use crate::models::ProjectContext;
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, RwLock};
9use std::time::{Duration, SystemTime};
10
11/// Statistics about cache performance
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CacheStatistics {
14    /// Total number of cache hits
15    pub hits: u64,
16    /// Total number of cache misses
17    pub misses: u64,
18    /// Total number of cache invalidations
19    pub invalidations: u64,
20    /// Current cache size in bytes
21    pub size_bytes: u64,
22    /// Number of entries in cache
23    pub entry_count: usize,
24}
25
26impl CacheStatistics {
27    /// Calculate hit rate as a percentage (0.0 to 100.0)
28    pub fn hit_rate(&self) -> f64 {
29        let total = self.hits + self.misses;
30        if total == 0 {
31            0.0
32        } else {
33            (self.hits as f64 / total as f64) * 100.0
34        }
35    }
36}
37
38/// Cached entry with metadata
39#[derive(Debug, Clone, Serialize, Deserialize)]
40struct CacheEntry {
41    /// The cached project context
42    data: ProjectContext,
43    /// When the entry was created
44    created_at: SystemTime,
45    /// When the entry expires (TTL)
46    expires_at: SystemTime,
47    /// File modification times when cached
48    file_mtimes: HashMap<PathBuf, SystemTime>,
49}
50
51impl CacheEntry {
52    /// Check if the cache entry has expired
53    fn is_expired(&self) -> bool {
54        SystemTime::now() > self.expires_at
55    }
56
57    /// Check if any tracked files have been modified
58    fn has_file_changes(&self, current_mtimes: &HashMap<PathBuf, SystemTime>) -> bool {
59        // If file count changed, cache is invalid
60        if self.file_mtimes.len() != current_mtimes.len() {
61            return true;
62        }
63
64        // Check if any tracked file has been modified
65        for (path, cached_mtime) in &self.file_mtimes {
66            match current_mtimes.get(path) {
67                Some(current_mtime) if current_mtime > cached_mtime => return true,
68                None => return true, // File was deleted
69                _ => {}
70            }
71        }
72
73        false
74    }
75}
76
77/// Manages caching of analysis results with TTL and file change detection
78#[derive(Debug, Clone)]
79pub struct CacheManager {
80    /// In-memory cache storage
81    cache: Arc<RwLock<HashMap<PathBuf, CacheEntry>>>,
82    /// Cache statistics
83    stats: Arc<RwLock<CacheStatistics>>,
84    /// Default TTL for cache entries
85    pub default_ttl: Duration,
86}
87
88impl CacheManager {
89    /// Create a new cache manager with default TTL of 1 hour
90    pub fn new() -> Self {
91        Self::with_ttl(Duration::from_secs(3600))
92    }
93
94    /// Create a new cache manager with custom TTL
95    pub fn with_ttl(ttl: Duration) -> Self {
96        Self {
97            cache: Arc::new(RwLock::new(HashMap::new())),
98            stats: Arc::new(RwLock::new(CacheStatistics {
99                hits: 0,
100                misses: 0,
101                invalidations: 0,
102                size_bytes: 0,
103                entry_count: 0,
104            })),
105            default_ttl: ttl,
106        }
107    }
108
109    /// Get a cached project context if valid
110    pub fn get(
111        &self,
112        project_root: &Path,
113        file_mtimes: &HashMap<PathBuf, SystemTime>,
114    ) -> Result<Option<ProjectContext>, ResearchError> {
115        let cache = self.cache.read().map_err(|e| ResearchError::CacheError {
116            operation: "read".to_string(),
117            reason: format!("Failed to acquire read lock: {}", e),
118        })?;
119
120        if let Some(entry) = cache.get(project_root) {
121            // Check if entry has expired
122            if entry.is_expired() {
123                drop(cache);
124                self.invalidate(project_root)?;
125                let mut stats = self.stats.write().map_err(|e| ResearchError::CacheError {
126                    operation: "write".to_string(),
127                    reason: format!("Failed to acquire write lock: {}", e),
128                })?;
129                stats.misses += 1;
130                return Ok(None);
131            }
132
133            // Check if any tracked files have changed
134            if entry.has_file_changes(file_mtimes) {
135                drop(cache);
136                self.invalidate(project_root)?;
137                let mut stats = self.stats.write().map_err(|e| ResearchError::CacheError {
138                    operation: "write".to_string(),
139                    reason: format!("Failed to acquire write lock: {}", e),
140                })?;
141                stats.misses += 1;
142                stats.invalidations += 1;
143                return Ok(None);
144            }
145
146            // Cache hit
147            let mut stats = self.stats.write().map_err(|e| ResearchError::CacheError {
148                operation: "write".to_string(),
149                reason: format!("Failed to acquire write lock: {}", e),
150            })?;
151            stats.hits += 1;
152
153            Ok(Some(entry.data.clone()))
154        } else {
155            let mut stats = self.stats.write().map_err(|e| ResearchError::CacheError {
156                operation: "write".to_string(),
157                reason: format!("Failed to acquire write lock: {}", e),
158            })?;
159            stats.misses += 1;
160            Ok(None)
161        }
162    }
163
164    /// Store a project context in the cache
165    pub fn set(
166        &self,
167        project_root: &Path,
168        context: &ProjectContext,
169        file_mtimes: HashMap<PathBuf, SystemTime>,
170    ) -> Result<(), ResearchError> {
171        let now = SystemTime::now();
172        let expires_at = now + self.default_ttl;
173
174        let entry = CacheEntry {
175            data: context.clone(),
176            created_at: now,
177            expires_at,
178            file_mtimes,
179        };
180
181        let mut cache = self.cache.write().map_err(|e| ResearchError::CacheError {
182            operation: "write".to_string(),
183            reason: format!("Failed to acquire write lock: {}", e),
184        })?;
185
186        cache.insert(project_root.to_path_buf(), entry);
187
188        // Update statistics
189        let mut stats = self.stats.write().map_err(|e| ResearchError::CacheError {
190            operation: "write".to_string(),
191            reason: format!("Failed to acquire write lock: {}", e),
192        })?;
193        stats.entry_count = cache.len();
194
195        Ok(())
196    }
197
198    /// Invalidate cache for a specific project
199    pub fn invalidate(&self, project_root: &Path) -> Result<(), ResearchError> {
200        let mut cache = self.cache.write().map_err(|e| ResearchError::CacheError {
201            operation: "write".to_string(),
202            reason: format!("Failed to acquire write lock: {}", e),
203        })?;
204
205        if cache.remove(project_root).is_some() {
206            let mut stats = self.stats.write().map_err(|e| ResearchError::CacheError {
207                operation: "write".to_string(),
208                reason: format!("Failed to acquire write lock: {}", e),
209            })?;
210            stats.invalidations += 1;
211            stats.entry_count = cache.len();
212        }
213
214        Ok(())
215    }
216
217    /// Clear all cache entries
218    pub fn clear(&self) -> Result<(), ResearchError> {
219        let mut cache = self.cache.write().map_err(|e| ResearchError::CacheError {
220            operation: "write".to_string(),
221            reason: format!("Failed to acquire write lock: {}", e),
222        })?;
223
224        let cleared_count = cache.len();
225        cache.clear();
226
227        let mut stats = self.stats.write().map_err(|e| ResearchError::CacheError {
228            operation: "write".to_string(),
229            reason: format!("Failed to acquire write lock: {}", e),
230        })?;
231        stats.invalidations += cleared_count as u64;
232        stats.entry_count = 0;
233
234        Ok(())
235    }
236
237    /// Get current cache statistics
238    pub fn statistics(&self) -> Result<CacheStatistics, ResearchError> {
239        let stats = self.stats.read().map_err(|e| ResearchError::CacheError {
240            operation: "read".to_string(),
241            reason: format!("Failed to acquire read lock: {}", e),
242        })?;
243
244        Ok(stats.clone())
245    }
246
247    /// Check if a project is cached and valid
248    pub fn is_cached(
249        &self,
250        project_root: &Path,
251        file_mtimes: &HashMap<PathBuf, SystemTime>,
252    ) -> Result<bool, ResearchError> {
253        let cache = self.cache.read().map_err(|e| ResearchError::CacheError {
254            operation: "read".to_string(),
255            reason: format!("Failed to acquire read lock: {}", e),
256        })?;
257
258        if let Some(entry) = cache.get(project_root) {
259            Ok(!entry.is_expired() && !entry.has_file_changes(file_mtimes))
260        } else {
261            Ok(false)
262        }
263    }
264
265    /// Get cache entry count
266    pub fn entry_count(&self) -> Result<usize, ResearchError> {
267        let cache = self.cache.read().map_err(|e| ResearchError::CacheError {
268            operation: "read".to_string(),
269            reason: format!("Failed to acquire read lock: {}", e),
270        })?;
271
272        Ok(cache.len())
273    }
274}
275
276impl Default for CacheManager {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_cache_manager_creation() {
288        let manager = CacheManager::new();
289        assert_eq!(manager.default_ttl, Duration::from_secs(3600));
290    }
291
292    #[test]
293    fn test_cache_manager_with_custom_ttl() {
294        let ttl = Duration::from_secs(300);
295        let manager = CacheManager::with_ttl(ttl);
296        assert_eq!(manager.default_ttl, ttl);
297    }
298
299    #[test]
300    fn test_cache_statistics_hit_rate_zero() {
301        let stats = CacheStatistics {
302            hits: 0,
303            misses: 0,
304            invalidations: 0,
305            size_bytes: 0,
306            entry_count: 0,
307        };
308        assert_eq!(stats.hit_rate(), 0.0);
309    }
310
311    #[test]
312    fn test_cache_statistics_hit_rate_calculation() {
313        let stats = CacheStatistics {
314            hits: 75,
315            misses: 25,
316            invalidations: 0,
317            size_bytes: 0,
318            entry_count: 0,
319        };
320        assert_eq!(stats.hit_rate(), 75.0);
321    }
322
323    #[test]
324    fn test_cache_entry_expiration() {
325        let now = SystemTime::now();
326        let entry = CacheEntry {
327            data: ProjectContext {
328                project_type: crate::models::ProjectType::Library,
329                languages: vec![],
330                frameworks: vec![],
331                structure: crate::models::ProjectStructure {
332                    root: PathBuf::from("/test"),
333                    source_dirs: vec![],
334                    test_dirs: vec![],
335                    config_files: vec![],
336                    entry_points: vec![],
337                },
338                patterns: vec![],
339                dependencies: vec![],
340                architectural_intent: crate::models::ArchitecturalIntent {
341                    style: crate::models::ArchitecturalStyle::Unknown,
342                    principles: vec![],
343                    constraints: vec![],
344                    decisions: vec![],
345                },
346                standards: crate::models::StandardsProfile {
347                    naming_conventions: crate::models::NamingConventions {
348                        function_case: crate::models::CaseStyle::SnakeCase,
349                        variable_case: crate::models::CaseStyle::SnakeCase,
350                        class_case: crate::models::CaseStyle::PascalCase,
351                        constant_case: crate::models::CaseStyle::UpperCase,
352                    },
353                    formatting_style: crate::models::FormattingStyle {
354                        indent_size: 4,
355                        indent_type: crate::models::IndentType::Spaces,
356                        line_length: 100,
357                    },
358                    import_organization: crate::models::ImportOrganization {
359                        order: vec![],
360                        sort_within_group: true,
361                    },
362                    documentation_style: crate::models::DocumentationStyle {
363                        format: crate::models::DocFormat::RustDoc,
364                        required_for_public: true,
365                    },
366                },
367            },
368            created_at: now,
369            expires_at: now - Duration::from_secs(1), // Already expired
370            file_mtimes: HashMap::new(),
371        };
372
373        assert!(entry.is_expired());
374    }
375
376    #[test]
377    fn test_cache_entry_not_expired() {
378        let now = SystemTime::now();
379        let entry = CacheEntry {
380            data: ProjectContext {
381                project_type: crate::models::ProjectType::Library,
382                languages: vec![],
383                frameworks: vec![],
384                structure: crate::models::ProjectStructure {
385                    root: PathBuf::from("/test"),
386                    source_dirs: vec![],
387                    test_dirs: vec![],
388                    config_files: vec![],
389                    entry_points: vec![],
390                },
391                patterns: vec![],
392                dependencies: vec![],
393                architectural_intent: crate::models::ArchitecturalIntent {
394                    style: crate::models::ArchitecturalStyle::Unknown,
395                    principles: vec![],
396                    constraints: vec![],
397                    decisions: vec![],
398                },
399                standards: crate::models::StandardsProfile {
400                    naming_conventions: crate::models::NamingConventions {
401                        function_case: crate::models::CaseStyle::SnakeCase,
402                        variable_case: crate::models::CaseStyle::SnakeCase,
403                        class_case: crate::models::CaseStyle::PascalCase,
404                        constant_case: crate::models::CaseStyle::UpperCase,
405                    },
406                    formatting_style: crate::models::FormattingStyle {
407                        indent_size: 4,
408                        indent_type: crate::models::IndentType::Spaces,
409                        line_length: 100,
410                    },
411                    import_organization: crate::models::ImportOrganization {
412                        order: vec![],
413                        sort_within_group: true,
414                    },
415                    documentation_style: crate::models::DocumentationStyle {
416                        format: crate::models::DocFormat::RustDoc,
417                        required_for_public: true,
418                    },
419                },
420            },
421            created_at: now,
422            expires_at: now + Duration::from_secs(3600), // Expires in 1 hour
423            file_mtimes: HashMap::new(),
424        };
425
426        assert!(!entry.is_expired());
427    }
428
429    #[test]
430    fn test_cache_entry_file_changes_detection() {
431        let now = SystemTime::now();
432        let mut cached_mtimes = HashMap::new();
433        cached_mtimes.insert(PathBuf::from("/test/file1.rs"), now);
434
435        let entry = CacheEntry {
436            data: ProjectContext {
437                project_type: crate::models::ProjectType::Library,
438                languages: vec![],
439                frameworks: vec![],
440                structure: crate::models::ProjectStructure {
441                    root: PathBuf::from("/test"),
442                    source_dirs: vec![],
443                    test_dirs: vec![],
444                    config_files: vec![],
445                    entry_points: vec![],
446                },
447                patterns: vec![],
448                dependencies: vec![],
449                architectural_intent: crate::models::ArchitecturalIntent {
450                    style: crate::models::ArchitecturalStyle::Unknown,
451                    principles: vec![],
452                    constraints: vec![],
453                    decisions: vec![],
454                },
455                standards: crate::models::StandardsProfile {
456                    naming_conventions: crate::models::NamingConventions {
457                        function_case: crate::models::CaseStyle::SnakeCase,
458                        variable_case: crate::models::CaseStyle::SnakeCase,
459                        class_case: crate::models::CaseStyle::PascalCase,
460                        constant_case: crate::models::CaseStyle::UpperCase,
461                    },
462                    formatting_style: crate::models::FormattingStyle {
463                        indent_size: 4,
464                        indent_type: crate::models::IndentType::Spaces,
465                        line_length: 100,
466                    },
467                    import_organization: crate::models::ImportOrganization {
468                        order: vec![],
469                        sort_within_group: true,
470                    },
471                    documentation_style: crate::models::DocumentationStyle {
472                        format: crate::models::DocFormat::RustDoc,
473                        required_for_public: true,
474                    },
475                },
476            },
477            created_at: now,
478            expires_at: now + Duration::from_secs(3600),
479            file_mtimes: cached_mtimes,
480        };
481
482        // Test: file was modified
483        let mut current_mtimes = HashMap::new();
484        current_mtimes.insert(
485            PathBuf::from("/test/file1.rs"),
486            now + Duration::from_secs(1),
487        );
488        assert!(entry.has_file_changes(&current_mtimes));
489
490        // Test: file was deleted
491        let current_mtimes_empty = HashMap::new();
492        assert!(entry.has_file_changes(&current_mtimes_empty));
493
494        // Test: no changes
495        let mut current_mtimes_same = HashMap::new();
496        current_mtimes_same.insert(PathBuf::from("/test/file1.rs"), now);
497        assert!(!entry.has_file_changes(&current_mtimes_same));
498    }
499}