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::{Arc, Mutex};
16use std::time::SystemTime;
17use tracing::{debug, instrument, warn};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CacheTier {
22 L1Memory,
23 L2Disk,
24 Miss,
25}
26
27#[must_use]
35pub fn parse_cache_capacity(env_key: &str, default: usize) -> usize {
36 std::env::var(env_key)
37 .ok()
38 .and_then(|v| v.parse::<usize>().ok())
39 .unwrap_or(default)
40 .max(1)
41}
42
43impl CacheTier {
44 #[must_use]
45 pub fn as_str(&self) -> &'static str {
46 match self {
47 CacheTier::L1Memory => "l1_memory",
48 CacheTier::L2Disk => "l2_disk",
49 CacheTier::Miss => "miss",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Eq, PartialEq, Hash)]
56pub struct CacheKey {
57 pub path: PathBuf,
58 pub modified: SystemTime,
59 pub mode: AnalysisMode,
60}
61
62#[derive(Debug, Clone, Eq, PartialEq, Hash)]
64pub struct DirectoryCacheKey {
65 files: Vec<(PathBuf, SystemTime)>,
66 mode: AnalysisMode,
67 max_depth: Option<u32>,
68 git_ref: Option<String>,
69}
70
71impl DirectoryCacheKey {
72 #[must_use]
78 pub fn from_entries(
79 entries: &[WalkEntry],
80 max_depth: Option<u32>,
81 mode: AnalysisMode,
82 git_ref: Option<&str>,
83 ) -> Self {
84 let mut files: Vec<(PathBuf, SystemTime)> = entries
85 .par_iter()
86 .filter(|e| !e.is_dir)
87 .map(|e| {
88 let mtime = e.mtime.unwrap_or(SystemTime::UNIX_EPOCH);
89 (e.path.clone(), mtime)
90 })
91 .collect();
92 files.sort_by(|a, b| a.0.cmp(&b.0));
93 Self {
94 files,
95 mode,
96 max_depth,
97 git_ref: git_ref.map(ToOwned::to_owned),
98 }
99 }
100}
101
102#[allow(clippy::expect_used)]
105const DEFAULT_LOCK_RECOVER_CAPACITY: NonZeroUsize =
106 NonZeroUsize::new(100).expect("100 is non-zero");
107
108fn lock_or_recover<K, V, T, F>(mutex: &Mutex<LruCache<K, V>>, capacity: usize, recovery: F) -> T
111where
112 K: std::hash::Hash + Eq,
113 F: FnOnce(&mut LruCache<K, V>) -> T,
114{
115 match mutex.lock() {
116 Ok(mut guard) => recovery(&mut guard),
117 Err(poisoned) => {
118 tracing::warn!("Mutex poisoned in lock_or_recover; creating fresh LruCache");
119 let cache_size = NonZeroUsize::new(capacity).unwrap_or(DEFAULT_LOCK_RECOVER_CAPACITY);
120 let new_cache = LruCache::new(cache_size);
121 let mut guard = poisoned.into_inner();
122 *guard = new_cache;
123 recovery(&mut guard)
124 }
125 }
126}
127
128#[derive(Debug, Clone, Eq, PartialEq, Hash)]
130pub struct CallGraphCacheKey {
131 root_path: PathBuf,
132 git_ref: Option<String>,
133 follow_depth: u32,
134 match_mode: SymbolMatchMode,
135 impl_only: bool,
136 ast_recursion_limit: Option<usize>,
137 file_mtimes: Vec<(PathBuf, u64)>,
139}
140
141impl CallGraphCacheKey {
142 #[must_use]
146 pub fn from_entries(
147 root: &std::path::Path,
148 entries: &[WalkEntry],
149 git_ref: Option<&str>,
150 follow_depth: u32,
151 match_mode: &SymbolMatchMode,
152 impl_only: bool,
153 ast_recursion_limit: Option<usize>,
154 ) -> Self {
155 let mut file_mtimes: Vec<(PathBuf, u64)> = entries
156 .par_iter()
157 .filter(|e| !e.is_dir)
158 .map(|e| {
159 let mtime = e
160 .mtime
161 .unwrap_or(SystemTime::UNIX_EPOCH)
162 .duration_since(SystemTime::UNIX_EPOCH)
163 .map(|d| d.as_nanos() as u64)
164 .unwrap_or(0);
165 (e.path.clone(), mtime)
166 })
167 .collect();
168 file_mtimes.sort_by(|a, b| a.0.cmp(&b.0));
169 Self {
170 root_path: root.to_path_buf(),
171 git_ref: git_ref.map(ToOwned::to_owned),
172 follow_depth,
173 match_mode: match_mode.clone(),
174 impl_only,
175 ast_recursion_limit,
176 file_mtimes,
177 }
178 }
179}
180
181pub type CallGraphCacheValue = Arc<FocusedAnalysisOutput>;
184
185pub struct CallGraphCache {
188 capacity: usize,
189 cache: Arc<Mutex<LruCache<CallGraphCacheKey, CallGraphCacheValue>>>,
190}
191
192impl CallGraphCache {
193 #[must_use]
197 pub fn new(capacity: usize) -> Self {
198 let capacity = capacity.max(1);
199 #[allow(clippy::expect_used)]
201 let cache_size = NonZeroUsize::new(capacity).expect("capacity is non-zero after .max(1)");
202 Self {
203 capacity,
204 cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
205 }
206 }
207
208 #[must_use]
210 pub fn get(&self, key: &CallGraphCacheKey) -> Option<CallGraphCacheValue> {
211 lock_or_recover(&self.cache, self.capacity, |guard| guard.get(key).cloned())
212 }
213
214 pub fn put(&self, key: CallGraphCacheKey, value: CallGraphCacheValue) {
216 lock_or_recover(&self.cache, self.capacity, |guard| {
217 guard.put(key, value);
218 });
219 }
220}
221
222impl Clone for CallGraphCache {
223 fn clone(&self) -> Self {
224 Self {
225 capacity: self.capacity,
226 cache: Arc::clone(&self.cache),
227 }
228 }
229}
230
231pub struct AnalysisCache {
233 file_capacity: usize,
234 dir_capacity: usize,
235 cache: Arc<Mutex<LruCache<CacheKey, Arc<FileAnalysisOutput>>>>,
236 directory_cache: Arc<Mutex<LruCache<DirectoryCacheKey, Arc<AnalysisOutput>>>>,
237}
238
239impl AnalysisCache {
240 #[must_use]
244 pub fn new(capacity: usize) -> Self {
245 let file_capacity = capacity.max(1);
246 let dir_capacity = parse_cache_capacity("APTU_CODER_DIR_CACHE_CAPACITY", 20);
247 #[allow(clippy::expect_used)]
249 let cache_size =
250 NonZeroUsize::new(file_capacity).expect("file_capacity is non-zero after .max(1)");
251 #[allow(clippy::expect_used)]
253 let dir_cache_size =
254 NonZeroUsize::new(dir_capacity).expect("dir_capacity is non-zero after .max(1)");
255 Self {
256 file_capacity,
257 dir_capacity,
258 cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
259 directory_cache: Arc::new(Mutex::new(LruCache::new(dir_cache_size))),
260 }
261 }
262
263 #[instrument(skip(self), fields(path = ?key.path))]
265 pub fn get(&self, key: &CacheKey) -> Option<Arc<FileAnalysisOutput>> {
266 lock_or_recover(&self.cache, self.file_capacity, |guard| {
267 let result = guard.get(key).cloned();
268 let cache_size = guard.len();
269 if let Some(v) = result {
270 debug!(cache_event = "hit", cache_size = cache_size, path = ?key.path);
271 Some(v)
272 } else {
273 debug!(cache_event = "miss", cache_size = cache_size, path = ?key.path);
274 None
275 }
276 })
277 }
278
279 #[instrument(skip(self, value), fields(path = ?key.path))]
281 #[allow(clippy::needless_pass_by_value)]
283 pub fn put(&self, key: CacheKey, value: Arc<FileAnalysisOutput>) {
284 lock_or_recover(&self.cache, self.file_capacity, |guard| {
285 let push_result = guard.push(key.clone(), value);
286 let cache_size = guard.len();
287 match push_result {
288 None => {
289 debug!(cache_event = "insert", cache_size = cache_size, path = ?key.path);
290 }
291 Some((returned_key, _)) => {
292 if returned_key == key {
293 debug!(cache_event = "update", cache_size = cache_size, path = ?key.path);
294 } else {
295 debug!(cache_event = "eviction", cache_size = cache_size, path = ?key.path, evicted_path = ?returned_key.path);
296 }
297 }
298 }
299 });
300 }
301
302 #[instrument(skip(self))]
304 pub fn get_directory(&self, key: &DirectoryCacheKey) -> Option<Arc<AnalysisOutput>> {
305 lock_or_recover(&self.directory_cache, self.dir_capacity, |guard| {
306 let result = guard.get(key).cloned();
307 let cache_size = guard.len();
308 if let Some(v) = result {
309 debug!(cache_event = "hit", cache_size = cache_size);
310 Some(v)
311 } else {
312 debug!(cache_event = "miss", cache_size = cache_size);
313 None
314 }
315 })
316 }
317
318 #[instrument(skip(self, value))]
320 pub fn put_directory(&self, key: DirectoryCacheKey, value: Arc<AnalysisOutput>) {
321 lock_or_recover(&self.directory_cache, self.dir_capacity, |guard| {
322 let push_result = guard.push(key, value);
323 let cache_size = guard.len();
324 match push_result {
325 None => {
326 debug!(cache_event = "insert", cache_size = cache_size);
327 }
328 Some((_, _)) => {
329 debug!(cache_event = "eviction", cache_size = cache_size);
330 }
331 }
332 });
333 }
334
335 #[doc(hidden)]
338 #[must_use]
339 pub fn file_capacity(&self) -> usize {
340 self.file_capacity
341 }
342
343 #[instrument(skip(self), fields(path = ?path))]
346 pub fn invalidate_file(&self, path: &std::path::Path) {
347 lock_or_recover(&self.cache, self.file_capacity, |guard| {
348 let keys: Vec<CacheKey> = guard
349 .iter()
350 .filter(|(k, _)| k.path == path)
351 .map(|(k, _)| k.clone())
352 .collect();
353 for key in keys {
354 guard.pop(&key);
355 }
356 let cache_size = guard.len();
357 debug!(cache_event = "invalidate_file", cache_size = cache_size, path = ?path);
358 });
359 }
360}
361
362impl Clone for AnalysisCache {
363 fn clone(&self) -> Self {
364 Self {
365 file_capacity: self.file_capacity,
366 dir_capacity: self.dir_capacity,
367 cache: Arc::clone(&self.cache),
368 directory_cache: Arc::clone(&self.directory_cache),
369 }
370 }
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376 use crate::types::SemanticAnalysis;
377
378 #[test]
379 fn test_from_entries_skips_dirs() {
380 let dir = tempfile::tempdir().expect("tempdir");
382 let file = tempfile::NamedTempFile::new_in(dir.path()).expect("tempfile");
383 let file_path = file.path().to_path_buf();
384
385 let entries = vec![
386 WalkEntry {
387 path: dir.path().to_path_buf(),
388 depth: 0,
389 is_dir: true,
390 is_symlink: false,
391 symlink_target: None,
392 mtime: None,
393 canonical_path: PathBuf::new(),
394 },
395 WalkEntry {
396 path: file_path.clone(),
397 depth: 0,
398 is_dir: false,
399 is_symlink: false,
400 symlink_target: None,
401 mtime: None,
402 canonical_path: PathBuf::new(),
403 },
404 ];
405
406 let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview, None);
408
409 assert_eq!(key.files.len(), 1);
412 assert_eq!(key.files[0].0, file_path);
413 }
414
415 #[test]
416 fn test_invalidate_file_single_mode() {
417 let cache = AnalysisCache::new(10);
419 let path = PathBuf::from("/test/file.rs");
420 let key = CacheKey {
421 path: path.clone(),
422 modified: SystemTime::UNIX_EPOCH,
423 mode: AnalysisMode::Overview,
424 };
425 let output = Arc::new(FileAnalysisOutput::new(
426 String::new(),
427 SemanticAnalysis::default(),
428 0,
429 None,
430 ));
431 cache.put(key.clone(), output);
432
433 cache.invalidate_file(&path);
435
436 assert!(cache.get(&key).is_none());
438 }
439
440 #[test]
441 fn test_invalidate_file_multi_mode() {
442 let cache = AnalysisCache::new(10);
444 let path = PathBuf::from("/test/file.rs");
445 let key1 = CacheKey {
446 path: path.clone(),
447 modified: SystemTime::UNIX_EPOCH,
448 mode: AnalysisMode::Overview,
449 };
450 let key2 = CacheKey {
451 path: path.clone(),
452 modified: SystemTime::UNIX_EPOCH,
453 mode: AnalysisMode::FileDetails,
454 };
455 let output = Arc::new(FileAnalysisOutput::new(
456 String::new(),
457 SemanticAnalysis::default(),
458 0,
459 None,
460 ));
461 cache.put(key1.clone(), output.clone());
462 cache.put(key2.clone(), output);
463
464 cache.invalidate_file(&path);
466
467 assert!(cache.get(&key1).is_none());
469 assert!(cache.get(&key2).is_none());
470 }
471
472 static DIR_CACHE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
474
475 #[test]
476 fn test_dir_cache_capacity_default() {
477 let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
478
479 unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
481
482 let cache = AnalysisCache::new(100);
484
485 assert_eq!(cache.dir_capacity, 20);
487 }
488
489 #[test]
490 fn test_dir_cache_capacity_from_env() {
491 let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
492
493 unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
495
496 let cache = AnalysisCache::new(100);
498
499 unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
501
502 assert_eq!(cache.dir_capacity, 7);
504 }
505
506 static PARSE_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
508
509 #[test]
510 fn test_parse_cache_capacity_missing_returns_default() {
511 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
512
513 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
515
516 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
518
519 assert_eq!(result, 42);
521 }
522
523 #[test]
524 fn test_parse_cache_capacity_valid_returns_value() {
525 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
526
527 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
529
530 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
532
533 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
535
536 assert_eq!(result, 64);
538 }
539
540 #[test]
541 fn test_parse_cache_capacity_zero_returns_one() {
542 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
543
544 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
546
547 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
549
550 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
552
553 assert_eq!(result, 1);
555 }
556
557 #[test]
558 fn test_parse_cache_capacity_garbage_returns_default() {
559 let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
560
561 unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
563
564 let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
566
567 unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
569
570 assert_eq!(result, 8);
572 }
573}
574
575pub use crate::cache_disk::DiskCache;