1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CacheStatistics {
14 pub hits: u64,
16 pub misses: u64,
18 pub invalidations: u64,
20 pub size_bytes: u64,
22 pub entry_count: usize,
24}
25
26impl CacheStatistics {
27 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#[derive(Debug, Clone, Serialize, Deserialize)]
40struct CacheEntry {
41 data: ProjectContext,
43 created_at: SystemTime,
45 expires_at: SystemTime,
47 file_mtimes: HashMap<PathBuf, SystemTime>,
49}
50
51impl CacheEntry {
52 fn is_expired(&self) -> bool {
54 SystemTime::now() > self.expires_at
55 }
56
57 fn has_file_changes(&self, current_mtimes: &HashMap<PathBuf, SystemTime>) -> bool {
59 if self.file_mtimes.len() != current_mtimes.len() {
61 return true;
62 }
63
64 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, _ => {}
70 }
71 }
72
73 false
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct CacheManager {
80 cache: Arc<RwLock<HashMap<PathBuf, CacheEntry>>>,
82 stats: Arc<RwLock<CacheStatistics>>,
84 pub default_ttl: Duration,
86}
87
88impl CacheManager {
89 pub fn new() -> Self {
91 Self::with_ttl(Duration::from_secs(3600))
92 }
93
94 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 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 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 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 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 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 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 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 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 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 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 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), 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), 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 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(¤t_mtimes));
489
490 let current_mtimes_empty = HashMap::new();
492 assert!(entry.has_file_changes(¤t_mtimes_empty));
493
494 let mut current_mtimes_same = HashMap::new();
496 current_mtimes_same.insert(PathBuf::from("/test/file1.rs"), now);
497 assert!(!entry.has_file_changes(¤t_mtimes_same));
498 }
499}