1use crate::analyze::{AnalysisOutput, FileAnalysisOutput, FocusedAnalysisOutput};
9use crate::graph::structural::StructuralGraph;
10use crate::traversal::WalkEntry;
11use crate::types::{AnalysisMode, SymbolMatchMode};
12use lru::LruCache;
13use rayon::prelude::*;
14use std::num::NonZeroUsize;
15use std::path::PathBuf;
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::{Arc, Mutex};
18use std::time::SystemTime;
19use tracing::{debug, instrument, warn};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CacheTier {
24 L1Memory,
25 L2Disk,
26 Miss,
27 L1OnlyMiss,
28 L1L2Miss,
29}
30
31#[must_use]
39pub fn parse_cache_capacity(env_key: &str, default: usize) -> usize {
40 std::env::var(env_key)
41 .ok()
42 .and_then(|v| v.parse::<usize>().ok())
43 .unwrap_or(default)
44 .max(1)
45}
46
47impl CacheTier {
48 #[must_use]
49 pub fn as_str(&self) -> &'static str {
50 match self {
51 CacheTier::L1Memory => "l1_memory",
52 CacheTier::L2Disk => "l2_disk",
53 CacheTier::Miss => "miss",
54 CacheTier::L1OnlyMiss => "l1_only_miss",
55 CacheTier::L1L2Miss => "l1_l2_miss",
56 }
57 }
58
59 #[must_use]
61 pub fn is_hit(&self) -> bool {
62 matches!(self, CacheTier::L1Memory | CacheTier::L2Disk)
63 }
64}
65
66#[derive(Debug, Clone, Eq, PartialEq, Hash)]
68pub struct CacheKey {
69 pub path: PathBuf,
70 pub modified: SystemTime,
71 pub mode: AnalysisMode,
72}
73
74#[derive(Debug, Clone, Eq, PartialEq, Hash)]
76pub struct DirectoryCacheKey {
77 files: Vec<(PathBuf, SystemTime)>,
78 mode: AnalysisMode,
79 max_depth: Option<u32>,
80 git_ref: Option<String>,
81}
82
83impl DirectoryCacheKey {
84 #[must_use]
90 pub fn from_entries(
91 entries: &[WalkEntry],
92 max_depth: Option<u32>,
93 mode: AnalysisMode,
94 git_ref: Option<&str>,
95 ) -> Self {
96 let mut files: Vec<(PathBuf, SystemTime)> = entries
97 .par_iter()
98 .filter(|e| !e.is_dir)
99 .map(|e| {
100 let mtime = e.mtime.unwrap_or(SystemTime::UNIX_EPOCH);
101 (e.path.clone(), mtime)
102 })
103 .collect();
104 files.sort_by(|a, b| a.0.cmp(&b.0));
105 Self {
106 files,
107 mode,
108 max_depth,
109 git_ref: git_ref.map(ToOwned::to_owned),
110 }
111 }
112}
113
114#[allow(clippy::expect_used)]
117const DEFAULT_LOCK_RECOVER_CAPACITY: NonZeroUsize =
118 NonZeroUsize::new(100).expect("100 is non-zero");
119
120fn lock_or_recover<K, V, T, F>(mutex: &Mutex<LruCache<K, V>>, capacity: usize, recovery: F) -> T
123where
124 K: std::hash::Hash + Eq,
125 F: FnOnce(&mut LruCache<K, V>) -> T,
126{
127 match mutex.lock() {
128 Ok(mut guard) => recovery(&mut guard),
129 Err(poisoned) => {
130 tracing::warn!("Mutex poisoned in lock_or_recover; creating fresh LruCache");
131 let cache_size = NonZeroUsize::new(capacity).unwrap_or(DEFAULT_LOCK_RECOVER_CAPACITY);
132 let new_cache = LruCache::new(cache_size);
133 let mut guard = poisoned.into_inner();
134 *guard = new_cache;
135 recovery(&mut guard)
136 }
137 }
138}
139
140#[derive(Debug, Clone, Eq, PartialEq, Hash)]
142pub struct CallGraphCacheKey {
143 root_path: PathBuf,
144 git_ref: Option<String>,
145 follow_depth: u32,
146 match_mode: SymbolMatchMode,
147 impl_only: bool,
148 ast_recursion_limit: Option<usize>,
149 file_mtimes: Vec<(PathBuf, u64)>,
151}
152
153impl CallGraphCacheKey {
154 #[must_use]
158 pub fn from_entries(
159 root: &std::path::Path,
160 entries: &[WalkEntry],
161 git_ref: Option<&str>,
162 follow_depth: u32,
163 match_mode: &SymbolMatchMode,
164 impl_only: bool,
165 ast_recursion_limit: Option<usize>,
166 ) -> Self {
167 let mut file_mtimes: Vec<(PathBuf, u64)> = entries
168 .par_iter()
169 .filter(|e| !e.is_dir)
170 .map(|e| {
171 let mtime = e
172 .mtime
173 .unwrap_or(SystemTime::UNIX_EPOCH)
174 .duration_since(SystemTime::UNIX_EPOCH)
175 .map(|d| d.as_nanos() as u64)
176 .unwrap_or(0);
177 (e.path.clone(), mtime)
178 })
179 .collect();
180 file_mtimes.sort_by(|a, b| a.0.cmp(&b.0));
181 Self {
182 root_path: root.to_path_buf(),
183 git_ref: git_ref.map(ToOwned::to_owned),
184 follow_depth,
185 match_mode: match_mode.clone(),
186 impl_only,
187 ast_recursion_limit,
188 file_mtimes,
189 }
190 }
191}
192
193pub type CallGraphCacheValue = Arc<FocusedAnalysisOutput>;
196
197pub struct AnalysisLruCache<K: std::hash::Hash + Eq + Clone, V: Clone> {
202 capacity: usize,
203 cache: Arc<Mutex<LruCache<K, V>>>,
204 eviction_count: Arc<AtomicU64>,
205}
206
207impl<K: std::hash::Hash + Eq + Clone, V: Clone> AnalysisLruCache<K, V> {
208 #[must_use]
212 pub fn new(capacity: usize) -> Self {
213 let capacity = capacity.max(1);
214 #[allow(clippy::expect_used)]
216 let cache_size = NonZeroUsize::new(capacity).expect("capacity is non-zero after .max(1)");
217 Self {
218 capacity,
219 cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
220 eviction_count: Arc::new(AtomicU64::new(0)),
221 }
222 }
223
224 #[must_use]
226 pub fn get(&self, key: &K) -> Option<V> {
227 lock_or_recover(&self.cache, self.capacity, |guard| guard.get(key).cloned())
228 }
229
230 pub fn put(&self, key: K, value: V) {
236 lock_or_recover(&self.cache, self.capacity, |guard| {
237 if let Some((evicted_key, _)) = guard.push(key.clone(), value)
238 && evicted_key != key
239 {
240 self.eviction_count.fetch_add(1, Ordering::Relaxed);
241 }
242 });
243 }
244
245 #[must_use]
247 pub fn eviction_count(&self) -> u64 {
248 self.eviction_count.load(Ordering::Relaxed)
249 }
250}
251
252impl<K: std::hash::Hash + Eq + Clone, V: Clone> Clone for AnalysisLruCache<K, V> {
253 fn clone(&self) -> Self {
254 Self {
255 capacity: self.capacity,
256 cache: Arc::clone(&self.cache),
257 eviction_count: Arc::clone(&self.eviction_count),
258 }
259 }
260}
261
262#[derive(Clone)]
265pub struct CallGraphCache(AnalysisLruCache<CallGraphCacheKey, CallGraphCacheValue>);
266
267impl CallGraphCache {
268 #[must_use]
272 pub fn new(capacity: usize) -> Self {
273 Self(AnalysisLruCache::new(capacity))
274 }
275
276 #[must_use]
278 pub fn get(&self, key: &CallGraphCacheKey) -> Option<CallGraphCacheValue> {
279 self.0.get(key)
280 }
281
282 pub fn put(&self, key: CallGraphCacheKey, value: CallGraphCacheValue) {
284 self.0.put(key, value);
285 }
286
287 #[must_use]
289 pub fn eviction_count(&self) -> u64 {
290 self.0.eviction_count()
291 }
292}
293
294pub type StructuralGraphCacheValue = Arc<StructuralGraph>;
296
297#[derive(Clone)]
300pub struct StructuralGraphCache(AnalysisLruCache<String, StructuralGraphCacheValue>);
301
302impl StructuralGraphCache {
303 #[must_use]
307 pub fn new(capacity: usize) -> Self {
308 Self(AnalysisLruCache::new(capacity))
309 }
310
311 #[must_use]
313 pub fn get(&self, key: &str) -> Option<StructuralGraphCacheValue> {
314 self.0.get(&key.to_string())
315 }
316
317 pub fn put(&self, key: String, value: StructuralGraphCacheValue) {
319 self.0.put(key, value);
320 }
321
322 #[must_use]
324 pub fn eviction_count(&self) -> u64 {
325 self.0.eviction_count()
326 }
327}
328
329pub struct AnalysisCache {
331 file_capacity: usize,
332 dir_capacity: usize,
333 cache: Arc<Mutex<LruCache<CacheKey, Arc<FileAnalysisOutput>>>>,
334 directory_cache: Arc<Mutex<LruCache<DirectoryCacheKey, Arc<AnalysisOutput>>>>,
335 eviction_count: Arc<AtomicU64>,
336}
337
338impl AnalysisCache {
339 #[must_use]
343 pub fn new(capacity: usize) -> Self {
344 let file_capacity = capacity.max(1);
345 let dir_capacity = parse_cache_capacity("APTU_CODER_DIR_CACHE_CAPACITY", 20);
346 #[allow(clippy::expect_used)]
348 let cache_size =
349 NonZeroUsize::new(file_capacity).expect("file_capacity is non-zero after .max(1)");
350 #[allow(clippy::expect_used)]
352 let dir_cache_size =
353 NonZeroUsize::new(dir_capacity).expect("dir_capacity is non-zero after .max(1)");
354 Self {
355 file_capacity,
356 dir_capacity,
357 cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
358 directory_cache: Arc::new(Mutex::new(LruCache::new(dir_cache_size))),
359 eviction_count: Arc::new(AtomicU64::new(0)),
360 }
361 }
362
363 #[instrument(skip(self), fields(path = ?key.path))]
365 pub fn get(&self, key: &CacheKey) -> Option<Arc<FileAnalysisOutput>> {
366 lock_or_recover(&self.cache, self.file_capacity, |guard| {
367 let result = guard.get(key).cloned();
368 let cache_size = guard.len();
369 if let Some(v) = result {
370 debug!(cache_event = "hit", cache_size = cache_size, path = ?key.path);
371 Some(v)
372 } else {
373 debug!(cache_event = "miss", cache_size = cache_size, path = ?key.path);
374 None
375 }
376 })
377 }
378
379 #[instrument(skip(self, value), fields(path = ?key.path))]
381 #[allow(clippy::needless_pass_by_value)]
383 pub fn put(&self, key: CacheKey, value: Arc<FileAnalysisOutput>) {
384 lock_or_recover(&self.cache, self.file_capacity, |guard| {
385 let push_result = guard.push(key.clone(), value);
386 let cache_size = guard.len();
387 match push_result {
388 None => {
389 debug!(cache_event = "insert", cache_size = cache_size, path = ?key.path);
390 }
391 Some((returned_key, _)) => {
392 if returned_key == key {
393 debug!(cache_event = "update", cache_size = cache_size, path = ?key.path);
394 } else {
395 debug!(cache_event = "eviction", cache_size = cache_size, path = ?key.path, evicted_path = ?returned_key.path);
396 self.eviction_count.fetch_add(1, Ordering::Relaxed);
397 }
398 }
399 }
400 });
401 }
402
403 #[instrument(skip(self))]
405 pub fn get_directory(&self, key: &DirectoryCacheKey) -> Option<Arc<AnalysisOutput>> {
406 lock_or_recover(&self.directory_cache, self.dir_capacity, |guard| {
407 let result = guard.get(key).cloned();
408 let cache_size = guard.len();
409 if let Some(v) = result {
410 debug!(cache_event = "hit", cache_size = cache_size);
411 Some(v)
412 } else {
413 debug!(cache_event = "miss", cache_size = cache_size);
414 None
415 }
416 })
417 }
418
419 #[instrument(skip(self, value))]
421 pub fn put_directory(&self, key: DirectoryCacheKey, value: Arc<AnalysisOutput>) {
422 lock_or_recover(&self.directory_cache, self.dir_capacity, |guard| {
423 let push_result = guard.push(key, value);
424 let cache_size = guard.len();
425 match push_result {
426 None => {
427 debug!(cache_event = "insert", cache_size = cache_size);
428 }
429 Some((_, _)) => {
430 debug!(cache_event = "eviction", cache_size = cache_size);
431 }
432 }
433 });
434 }
435
436 #[doc(hidden)]
439 #[must_use]
440 pub fn file_capacity(&self) -> usize {
441 self.file_capacity
442 }
443
444 #[instrument(skip(self), fields(path = ?path))]
447 pub fn invalidate_file(&self, path: &std::path::Path) {
448 lock_or_recover(&self.cache, self.file_capacity, |guard| {
449 let keys: Vec<CacheKey> = guard
450 .iter()
451 .filter(|(k, _)| k.path == path)
452 .map(|(k, _)| k.clone())
453 .collect();
454 for key in keys {
455 guard.pop(&key);
456 }
457 let cache_size = guard.len();
458 debug!(cache_event = "invalidate_file", cache_size = cache_size, path = ?path);
459 });
460 }
461
462 #[must_use]
464 pub fn eviction_count(&self) -> u64 {
465 self.eviction_count.load(Ordering::Relaxed)
466 }
467}
468
469impl Clone for AnalysisCache {
470 fn clone(&self) -> Self {
471 Self {
472 file_capacity: self.file_capacity,
473 dir_capacity: self.dir_capacity,
474 cache: Arc::clone(&self.cache),
475 directory_cache: Arc::clone(&self.directory_cache),
476 eviction_count: Arc::clone(&self.eviction_count),
477 }
478 }
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::types::SemanticAnalysis;
485
486 #[test]
487 fn test_from_entries_skips_dirs() {
488 let dir = tempfile::tempdir().expect("tempdir");
490 let file = tempfile::NamedTempFile::new_in(dir.path()).expect("tempfile");
491 let file_path = file.path().to_path_buf();
492
493 let entries = vec![
494 WalkEntry {
495 path: dir.path().to_path_buf(),
496 depth: 0,
497 is_dir: true,
498 is_symlink: false,
499 symlink_target: None,
500 mtime: None,
501 canonical_path: PathBuf::new(),
502 },
503 WalkEntry {
504 path: file_path.clone(),
505 depth: 0,
506 is_dir: false,
507 is_symlink: false,
508 symlink_target: None,
509 mtime: None,
510 canonical_path: PathBuf::new(),
511 },
512 ];
513
514 let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview, None);
516
517 assert_eq!(key.files.len(), 1);
520 assert_eq!(key.files[0].0, file_path);
521 }
522
523 #[test]
524 fn test_invalidate_file_single_mode() {
525 let cache = AnalysisCache::new(10);
527 let path = PathBuf::from("/test/file.rs");
528 let key = CacheKey {
529 path: path.clone(),
530 modified: SystemTime::UNIX_EPOCH,
531 mode: AnalysisMode::Overview,
532 };
533 let output = Arc::new(FileAnalysisOutput::new(
534 String::new(),
535 SemanticAnalysis::default(),
536 0,
537 None,
538 ));
539 cache.put(key.clone(), output);
540
541 cache.invalidate_file(&path);
543
544 assert!(cache.get(&key).is_none());
546 }
547
548 #[test]
549 fn test_invalidate_file_multi_mode() {
550 let cache = AnalysisCache::new(10);
552 let path = PathBuf::from("/test/file.rs");
553 let key1 = CacheKey {
554 path: path.clone(),
555 modified: SystemTime::UNIX_EPOCH,
556 mode: AnalysisMode::Overview,
557 };
558 let key2 = CacheKey {
559 path: path.clone(),
560 modified: SystemTime::UNIX_EPOCH,
561 mode: AnalysisMode::FileDetails,
562 };
563 let output = Arc::new(FileAnalysisOutput::new(
564 String::new(),
565 SemanticAnalysis::default(),
566 0,
567 None,
568 ));
569 cache.put(key1.clone(), output.clone());
570 cache.put(key2.clone(), output);
571
572 cache.invalidate_file(&path);
574
575 assert!(cache.get(&key1).is_none());
577 assert!(cache.get(&key2).is_none());
578 }
579
580 static DIR_CACHE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
582
583 #[test]
584 fn test_dir_cache_capacity_default() {
585 let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
586
587 unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
589
590 let cache = AnalysisCache::new(100);
592
593 assert_eq!(cache.dir_capacity, 20);
595 }
596
597 #[test]
598 fn test_dir_cache_capacity_from_env() {
599 let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
600
601 unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
603
604 let cache = AnalysisCache::new(100);
606
607 unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
609
610 assert_eq!(cache.dir_capacity, 7);
612 }
613
614 static PARSE_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
616
617 #[test]
618 fn test_parse_cache_capacity_missing_returns_default() {
619 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
620
621 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
623
624 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
626
627 assert_eq!(result, 42);
629 }
630
631 #[test]
632 fn test_parse_cache_capacity_valid_returns_value() {
633 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
634
635 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
637
638 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
640
641 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
643
644 assert_eq!(result, 64);
646 }
647
648 #[test]
649 fn test_parse_cache_capacity_zero_returns_one() {
650 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
651
652 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
654
655 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
657
658 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
660
661 assert_eq!(result, 1);
663 }
664
665 #[test]
666 fn test_parse_cache_capacity_garbage_returns_default() {
667 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
668
669 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
671
672 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
674
675 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
677
678 assert_eq!(result, 8);
680 }
681
682 #[test]
683 fn test_analysis_lru_cache_hit_miss_recency_and_eviction() {
684 let cache = AnalysisLruCache::<&'static str, i32>::new(2);
685 cache.put("a", 1);
686 cache.put("b", 2);
687 assert_eq!(cache.get(&"a"), Some(1));
689 assert_eq!(cache.get(&"z"), None);
690 cache.put("c", 3);
692 assert_eq!(cache.get(&"a"), Some(1));
693 assert_eq!(cache.get(&"b"), None);
694 assert_eq!(cache.eviction_count(), 1);
695 }
696
697 #[test]
698 fn test_analysis_lru_cache_put_update_at_capacity_no_false_eviction() {
699 let cache = AnalysisLruCache::<String, i32>::new(2);
700 cache.put("k1".to_string(), 10);
701 cache.put("k2".to_string(), 20);
702 assert_eq!(cache.eviction_count(), 0);
703 cache.put("k1".to_string(), 99);
705 assert_eq!(cache.eviction_count(), 0);
707 assert_eq!(cache.get(&"k1".to_string()), Some(99));
708 assert_eq!(cache.get(&"k2".to_string()), Some(20));
709 }
710
711 #[test]
712 fn test_analysis_lru_cache_clone_shares_eviction_counter() {
713 let cache1 = AnalysisLruCache::<&'static str, i32>::new(1);
714 let cache2 = cache1.clone();
715 cache1.put("k1", 1);
716 cache2.put("k2", 2);
717 assert_eq!(cache1.eviction_count(), 1);
719 assert_eq!(cache2.eviction_count(), 1);
720 assert_eq!(cache1.get(&"k1"), None);
721 assert_eq!(cache1.get(&"k2"), Some(2));
722 }
723
724 #[test]
725 fn test_analysis_lru_cache_new_clamps_zero_capacity_to_one() {
726 let cache = AnalysisLruCache::<&'static str, i32>::new(0);
727 assert_eq!(cache.capacity, 1);
728 cache.put("item1", 1);
729 assert_eq!(cache.get(&"item1"), Some(1));
730 assert_eq!(cache.eviction_count(), 0);
731 cache.put("item2", 2);
732 assert_eq!(cache.eviction_count(), 1);
733 assert_eq!(cache.get(&"item1"), None);
734 assert_eq!(cache.get(&"item2"), Some(2));
735 }
736
737 #[test]
738 fn test_structural_graph_cache_hit_miss() {
739 let cache = StructuralGraphCache::new(2);
740 let key1 = "key1".to_string();
741 let key2 = "key2".to_string();
742 let graph = Arc::new(StructuralGraph::from_graph(petgraph::graph::DiGraph::new()));
743
744 assert!(cache.get("nonexistent").is_none());
746
747 cache.put(key1.clone(), graph.clone());
749 assert!(cache.get("key1").is_some());
750
751 assert!(cache.get("key2").is_none());
753
754 cache.put(key2.clone(), graph.clone());
756 assert!(cache.get("key2").is_some());
757 }
758
759 #[test]
760 fn test_structural_graph_cache_eviction() {
761 let cache = StructuralGraphCache::new(2);
762 let graph = Arc::new(StructuralGraph::from_graph(petgraph::graph::DiGraph::new()));
763
764 cache.put("k1".to_string(), graph.clone());
765 cache.put("k2".to_string(), graph.clone());
766 assert_eq!(cache.eviction_count(), 0);
767
768 cache.put("k3".to_string(), graph.clone());
770 assert_eq!(cache.eviction_count(), 1);
771 assert!(cache.get("k1").is_none());
772 assert!(cache.get("k2").is_some());
773 assert!(cache.get("k3").is_some());
774 }
775
776 #[test]
777 fn test_structural_graph_cache_clone_shares_counter() {
778 let cache1 = StructuralGraphCache::new(1);
779 let cache2 = cache1.clone();
780 let graph = Arc::new(StructuralGraph::from_graph(petgraph::graph::DiGraph::new()));
781
782 cache1.put("k1".to_string(), graph.clone());
783 cache2.put("k2".to_string(), graph);
784
785 assert_eq!(cache1.eviction_count(), 1);
787 assert_eq!(cache2.eviction_count(), 1);
788 }
789}
790
791pub use crate::cache_disk::DiskCache;