1use crate::analyze::{AnalysisOutput, FileAnalysisOutput, FocusedAnalysisOutput};
9use crate::traversal::WalkEntry;
10use crate::types::{AnalysisMode, SymbolMatchMode};
11use lru::LruCache;
12use rayon::prelude::*;
13use std::num::NonZeroUsize;
14use std::path::PathBuf;
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::{Arc, Mutex};
17use std::time::SystemTime;
18use tracing::{debug, instrument, warn};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum CacheTier {
23 L1Memory,
24 L2Disk,
25 Miss,
26 L1OnlyMiss,
27 L1L2Miss,
28}
29
30#[must_use]
38pub fn parse_cache_capacity(env_key: &str, default: usize) -> usize {
39 std::env::var(env_key)
40 .ok()
41 .and_then(|v| v.parse::<usize>().ok())
42 .unwrap_or(default)
43 .max(1)
44}
45
46impl CacheTier {
47 #[must_use]
48 pub fn as_str(&self) -> &'static str {
49 match self {
50 CacheTier::L1Memory => "l1_memory",
51 CacheTier::L2Disk => "l2_disk",
52 CacheTier::Miss => "miss",
53 CacheTier::L1OnlyMiss => "l1_only_miss",
54 CacheTier::L1L2Miss => "l1_l2_miss",
55 }
56 }
57
58 #[must_use]
60 pub fn is_hit(&self) -> bool {
61 matches!(self, CacheTier::L1Memory | CacheTier::L2Disk)
62 }
63}
64
65#[derive(Debug, Clone, Eq, PartialEq, Hash)]
67pub struct CacheKey {
68 pub path: PathBuf,
69 pub modified: SystemTime,
70 pub mode: AnalysisMode,
71}
72
73#[derive(Debug, Clone, Eq, PartialEq, Hash)]
75pub struct DirectoryCacheKey {
76 files: Vec<(PathBuf, SystemTime)>,
77 mode: AnalysisMode,
78 max_depth: Option<u32>,
79 git_ref: Option<String>,
80}
81
82impl DirectoryCacheKey {
83 #[must_use]
89 pub fn from_entries(
90 entries: &[WalkEntry],
91 max_depth: Option<u32>,
92 mode: AnalysisMode,
93 git_ref: Option<&str>,
94 ) -> Self {
95 let mut files: Vec<(PathBuf, SystemTime)> = entries
96 .par_iter()
97 .filter(|e| !e.is_dir)
98 .map(|e| {
99 let mtime = e.mtime.unwrap_or(SystemTime::UNIX_EPOCH);
100 (e.path.clone(), mtime)
101 })
102 .collect();
103 files.sort_by(|a, b| a.0.cmp(&b.0));
104 Self {
105 files,
106 mode,
107 max_depth,
108 git_ref: git_ref.map(ToOwned::to_owned),
109 }
110 }
111}
112
113#[allow(clippy::expect_used)]
116const DEFAULT_LOCK_RECOVER_CAPACITY: NonZeroUsize =
117 NonZeroUsize::new(100).expect("100 is non-zero");
118
119fn lock_or_recover<K, V, T, F>(mutex: &Mutex<LruCache<K, V>>, capacity: usize, recovery: F) -> T
122where
123 K: std::hash::Hash + Eq,
124 F: FnOnce(&mut LruCache<K, V>) -> T,
125{
126 match mutex.lock() {
127 Ok(mut guard) => recovery(&mut guard),
128 Err(poisoned) => {
129 tracing::warn!("Mutex poisoned in lock_or_recover; creating fresh LruCache");
130 let cache_size = NonZeroUsize::new(capacity).unwrap_or(DEFAULT_LOCK_RECOVER_CAPACITY);
131 let new_cache = LruCache::new(cache_size);
132 let mut guard = poisoned.into_inner();
133 *guard = new_cache;
134 recovery(&mut guard)
135 }
136 }
137}
138
139#[derive(Debug, Clone, Eq, PartialEq, Hash)]
141pub struct CallGraphCacheKey {
142 root_path: PathBuf,
143 git_ref: Option<String>,
144 follow_depth: u32,
145 match_mode: SymbolMatchMode,
146 impl_only: bool,
147 ast_recursion_limit: Option<usize>,
148 file_mtimes: Vec<(PathBuf, u64)>,
150}
151
152impl CallGraphCacheKey {
153 #[must_use]
157 pub fn from_entries(
158 root: &std::path::Path,
159 entries: &[WalkEntry],
160 git_ref: Option<&str>,
161 follow_depth: u32,
162 match_mode: &SymbolMatchMode,
163 impl_only: bool,
164 ast_recursion_limit: Option<usize>,
165 ) -> Self {
166 let mut file_mtimes: Vec<(PathBuf, u64)> = entries
167 .par_iter()
168 .filter(|e| !e.is_dir)
169 .map(|e| {
170 let mtime = e
171 .mtime
172 .unwrap_or(SystemTime::UNIX_EPOCH)
173 .duration_since(SystemTime::UNIX_EPOCH)
174 .map(|d| d.as_nanos() as u64)
175 .unwrap_or(0);
176 (e.path.clone(), mtime)
177 })
178 .collect();
179 file_mtimes.sort_by(|a, b| a.0.cmp(&b.0));
180 Self {
181 root_path: root.to_path_buf(),
182 git_ref: git_ref.map(ToOwned::to_owned),
183 follow_depth,
184 match_mode: match_mode.clone(),
185 impl_only,
186 ast_recursion_limit,
187 file_mtimes,
188 }
189 }
190}
191
192pub type CallGraphCacheValue = Arc<FocusedAnalysisOutput>;
195
196pub struct CallGraphCache {
199 capacity: usize,
200 cache: Arc<Mutex<LruCache<CallGraphCacheKey, CallGraphCacheValue>>>,
201 eviction_count: Arc<AtomicU64>,
202}
203
204impl CallGraphCache {
205 #[must_use]
209 pub fn new(capacity: usize) -> Self {
210 let capacity = capacity.max(1);
211 #[allow(clippy::expect_used)]
213 let cache_size = NonZeroUsize::new(capacity).expect("capacity is non-zero after .max(1)");
214 Self {
215 capacity,
216 cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
217 eviction_count: Arc::new(AtomicU64::new(0)),
218 }
219 }
220
221 #[must_use]
223 pub fn get(&self, key: &CallGraphCacheKey) -> Option<CallGraphCacheValue> {
224 lock_or_recover(&self.cache, self.capacity, |guard| guard.get(key).cloned())
225 }
226
227 pub fn put(&self, key: CallGraphCacheKey, value: CallGraphCacheValue) {
229 lock_or_recover(&self.cache, self.capacity, |guard| {
230 if guard.len() >= self.capacity {
231 self.eviction_count.fetch_add(1, Ordering::Relaxed);
232 }
233 guard.put(key, value);
234 });
235 }
236
237 #[must_use]
239 pub fn eviction_count(&self) -> u64 {
240 self.eviction_count.load(Ordering::Relaxed)
241 }
242}
243
244impl Clone for CallGraphCache {
245 fn clone(&self) -> Self {
246 Self {
247 capacity: self.capacity,
248 cache: Arc::clone(&self.cache),
249 eviction_count: Arc::clone(&self.eviction_count),
250 }
251 }
252}
253
254pub struct AnalysisCache {
256 file_capacity: usize,
257 dir_capacity: usize,
258 cache: Arc<Mutex<LruCache<CacheKey, Arc<FileAnalysisOutput>>>>,
259 directory_cache: Arc<Mutex<LruCache<DirectoryCacheKey, Arc<AnalysisOutput>>>>,
260 eviction_count: Arc<AtomicU64>,
261}
262
263impl AnalysisCache {
264 #[must_use]
268 pub fn new(capacity: usize) -> Self {
269 let file_capacity = capacity.max(1);
270 let dir_capacity = parse_cache_capacity("APTU_CODER_DIR_CACHE_CAPACITY", 20);
271 #[allow(clippy::expect_used)]
273 let cache_size =
274 NonZeroUsize::new(file_capacity).expect("file_capacity is non-zero after .max(1)");
275 #[allow(clippy::expect_used)]
277 let dir_cache_size =
278 NonZeroUsize::new(dir_capacity).expect("dir_capacity is non-zero after .max(1)");
279 Self {
280 file_capacity,
281 dir_capacity,
282 cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
283 directory_cache: Arc::new(Mutex::new(LruCache::new(dir_cache_size))),
284 eviction_count: Arc::new(AtomicU64::new(0)),
285 }
286 }
287
288 #[instrument(skip(self), fields(path = ?key.path))]
290 pub fn get(&self, key: &CacheKey) -> Option<Arc<FileAnalysisOutput>> {
291 lock_or_recover(&self.cache, self.file_capacity, |guard| {
292 let result = guard.get(key).cloned();
293 let cache_size = guard.len();
294 if let Some(v) = result {
295 debug!(cache_event = "hit", cache_size = cache_size, path = ?key.path);
296 Some(v)
297 } else {
298 debug!(cache_event = "miss", cache_size = cache_size, path = ?key.path);
299 None
300 }
301 })
302 }
303
304 #[instrument(skip(self, value), fields(path = ?key.path))]
306 #[allow(clippy::needless_pass_by_value)]
308 pub fn put(&self, key: CacheKey, value: Arc<FileAnalysisOutput>) {
309 lock_or_recover(&self.cache, self.file_capacity, |guard| {
310 let push_result = guard.push(key.clone(), value);
311 let cache_size = guard.len();
312 match push_result {
313 None => {
314 debug!(cache_event = "insert", cache_size = cache_size, path = ?key.path);
315 }
316 Some((returned_key, _)) => {
317 if returned_key == key {
318 debug!(cache_event = "update", cache_size = cache_size, path = ?key.path);
319 } else {
320 debug!(cache_event = "eviction", cache_size = cache_size, path = ?key.path, evicted_path = ?returned_key.path);
321 self.eviction_count.fetch_add(1, Ordering::Relaxed);
322 }
323 }
324 }
325 });
326 }
327
328 #[instrument(skip(self))]
330 pub fn get_directory(&self, key: &DirectoryCacheKey) -> Option<Arc<AnalysisOutput>> {
331 lock_or_recover(&self.directory_cache, self.dir_capacity, |guard| {
332 let result = guard.get(key).cloned();
333 let cache_size = guard.len();
334 if let Some(v) = result {
335 debug!(cache_event = "hit", cache_size = cache_size);
336 Some(v)
337 } else {
338 debug!(cache_event = "miss", cache_size = cache_size);
339 None
340 }
341 })
342 }
343
344 #[instrument(skip(self, value))]
346 pub fn put_directory(&self, key: DirectoryCacheKey, value: Arc<AnalysisOutput>) {
347 lock_or_recover(&self.directory_cache, self.dir_capacity, |guard| {
348 let push_result = guard.push(key, value);
349 let cache_size = guard.len();
350 match push_result {
351 None => {
352 debug!(cache_event = "insert", cache_size = cache_size);
353 }
354 Some((_, _)) => {
355 debug!(cache_event = "eviction", cache_size = cache_size);
356 }
357 }
358 });
359 }
360
361 #[doc(hidden)]
364 #[must_use]
365 pub fn file_capacity(&self) -> usize {
366 self.file_capacity
367 }
368
369 #[instrument(skip(self), fields(path = ?path))]
372 pub fn invalidate_file(&self, path: &std::path::Path) {
373 lock_or_recover(&self.cache, self.file_capacity, |guard| {
374 let keys: Vec<CacheKey> = guard
375 .iter()
376 .filter(|(k, _)| k.path == path)
377 .map(|(k, _)| k.clone())
378 .collect();
379 for key in keys {
380 guard.pop(&key);
381 }
382 let cache_size = guard.len();
383 debug!(cache_event = "invalidate_file", cache_size = cache_size, path = ?path);
384 });
385 }
386
387 #[must_use]
389 pub fn eviction_count(&self) -> u64 {
390 self.eviction_count.load(Ordering::Relaxed)
391 }
392}
393
394impl Clone for AnalysisCache {
395 fn clone(&self) -> Self {
396 Self {
397 file_capacity: self.file_capacity,
398 dir_capacity: self.dir_capacity,
399 cache: Arc::clone(&self.cache),
400 directory_cache: Arc::clone(&self.directory_cache),
401 eviction_count: Arc::clone(&self.eviction_count),
402 }
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::types::SemanticAnalysis;
410
411 #[test]
412 fn test_from_entries_skips_dirs() {
413 let dir = tempfile::tempdir().expect("tempdir");
415 let file = tempfile::NamedTempFile::new_in(dir.path()).expect("tempfile");
416 let file_path = file.path().to_path_buf();
417
418 let entries = vec![
419 WalkEntry {
420 path: dir.path().to_path_buf(),
421 depth: 0,
422 is_dir: true,
423 is_symlink: false,
424 symlink_target: None,
425 mtime: None,
426 canonical_path: PathBuf::new(),
427 },
428 WalkEntry {
429 path: file_path.clone(),
430 depth: 0,
431 is_dir: false,
432 is_symlink: false,
433 symlink_target: None,
434 mtime: None,
435 canonical_path: PathBuf::new(),
436 },
437 ];
438
439 let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview, None);
441
442 assert_eq!(key.files.len(), 1);
445 assert_eq!(key.files[0].0, file_path);
446 }
447
448 #[test]
449 fn test_invalidate_file_single_mode() {
450 let cache = AnalysisCache::new(10);
452 let path = PathBuf::from("/test/file.rs");
453 let key = CacheKey {
454 path: path.clone(),
455 modified: SystemTime::UNIX_EPOCH,
456 mode: AnalysisMode::Overview,
457 };
458 let output = Arc::new(FileAnalysisOutput::new(
459 String::new(),
460 SemanticAnalysis::default(),
461 0,
462 None,
463 ));
464 cache.put(key.clone(), output);
465
466 cache.invalidate_file(&path);
468
469 assert!(cache.get(&key).is_none());
471 }
472
473 #[test]
474 fn test_invalidate_file_multi_mode() {
475 let cache = AnalysisCache::new(10);
477 let path = PathBuf::from("/test/file.rs");
478 let key1 = CacheKey {
479 path: path.clone(),
480 modified: SystemTime::UNIX_EPOCH,
481 mode: AnalysisMode::Overview,
482 };
483 let key2 = CacheKey {
484 path: path.clone(),
485 modified: SystemTime::UNIX_EPOCH,
486 mode: AnalysisMode::FileDetails,
487 };
488 let output = Arc::new(FileAnalysisOutput::new(
489 String::new(),
490 SemanticAnalysis::default(),
491 0,
492 None,
493 ));
494 cache.put(key1.clone(), output.clone());
495 cache.put(key2.clone(), output);
496
497 cache.invalidate_file(&path);
499
500 assert!(cache.get(&key1).is_none());
502 assert!(cache.get(&key2).is_none());
503 }
504
505 static DIR_CACHE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
507
508 #[test]
509 fn test_dir_cache_capacity_default() {
510 let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
511
512 unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
514
515 let cache = AnalysisCache::new(100);
517
518 assert_eq!(cache.dir_capacity, 20);
520 }
521
522 #[test]
523 fn test_dir_cache_capacity_from_env() {
524 let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
525
526 unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
528
529 let cache = AnalysisCache::new(100);
531
532 unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
534
535 assert_eq!(cache.dir_capacity, 7);
537 }
538
539 static PARSE_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
541
542 #[test]
543 fn test_parse_cache_capacity_missing_returns_default() {
544 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
545
546 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
548
549 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
551
552 assert_eq!(result, 42);
554 }
555
556 #[test]
557 fn test_parse_cache_capacity_valid_returns_value() {
558 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
559
560 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
562
563 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
565
566 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
568
569 assert_eq!(result, 64);
571 }
572
573 #[test]
574 fn test_parse_cache_capacity_zero_returns_one() {
575 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
576
577 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
579
580 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
582
583 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
585
586 assert_eq!(result, 1);
588 }
589
590 #[test]
591 fn test_parse_cache_capacity_garbage_returns_default() {
592 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
593
594 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
596
597 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
599
600 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
602
603 assert_eq!(result, 8);
605 }
606}
607
608pub use crate::cache_disk::DiskCache;