Skip to main content

aptu_coder_core/
cache.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! LRU cache for analysis results indexed by path, modification time, and mode.
4//!
5//! Provides thread-safe, capacity-bounded caching of file analysis outputs using LRU eviction.
6//! Recovers gracefully from poisoned mutex conditions.
7
8use 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/// Indicates which cache tier served the result.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum CacheTier {
23    L1Memory,
24    L2Disk,
25    Miss,
26    L1OnlyMiss,
27    L1L2Miss,
28}
29
30/// Parse an LRU cache capacity from an environment variable.
31///
32/// Reads `env_key`, parses it as `usize`, and returns the value clamped to a minimum of 1.
33/// Falls back to `default` when the variable is absent or unparseable, then also clamps
34/// the fallback to at least 1.
35///
36/// This helper centralises all three LRU init sites so the `.max(1)` guard lives in one place.
37#[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    /// Returns `true` when the result was served from any cache tier (L1 or L2).
59    #[must_use]
60    pub fn is_hit(&self) -> bool {
61        matches!(self, CacheTier::L1Memory | CacheTier::L2Disk)
62    }
63}
64
65/// Cache key combining path, modification time, and analysis mode.
66#[derive(Debug, Clone, Eq, PartialEq, Hash)]
67pub struct CacheKey {
68    pub path: PathBuf,
69    pub modified: SystemTime,
70    pub mode: AnalysisMode,
71}
72
73/// Cache key for directory analysis combining file mtimes, mode, and `max_depth`.
74#[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    /// Build a cache key from walk entries, capturing mtime for each file.
84    /// Files are sorted by path for deterministic hashing.
85    /// Directories are filtered out; only file entries are processed.
86    /// Metadata collection is parallelized using rayon.
87    /// The `git_ref` is included so that filtered and unfiltered results have distinct keys.
88    #[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/// Fallback cache capacity used in `lock_or_recover` when the caller-supplied capacity is zero.
114// SAFETY: 100 is non-zero; verified at compile time by NonZeroUsize::new.
115#[allow(clippy::expect_used)]
116const DEFAULT_LOCK_RECOVER_CAPACITY: NonZeroUsize =
117    NonZeroUsize::new(100).expect("100 is non-zero");
118
119/// Recover from a poisoned mutex by clearing the cache.
120/// On poison, creates a new empty cache and returns the recovery value.
121fn 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/// Cache key for call graph analysis combining path, parameters, and file mtimes.
140#[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    /// Sorted (path, mtime_as_unix_nanos) pairs for all non-dir entries.
149    file_mtimes: Vec<(PathBuf, u64)>,
150}
151
152impl CallGraphCacheKey {
153    /// Build a `CallGraphCacheKey` from walk entries and analysis parameters.
154    /// Files are sorted by path for deterministic hashing.
155    /// Directories are filtered out; only file entries contribute to the key.
156    #[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
192/// Cached call graph result: the fully-built `FocusedAnalysisOutput`.
193/// `CallGraph` is not serializable, so caching is L1 memory only.
194pub type CallGraphCacheValue = Arc<FocusedAnalysisOutput>;
195
196/// L1 in-memory LRU cache for call graph results.
197/// Capacity is controlled via `APTU_CODER_SYMBOL_CACHE_CAPACITY` env var (default 32).
198pub struct CallGraphCache {
199    capacity: usize,
200    cache: Arc<Mutex<LruCache<CallGraphCacheKey, CallGraphCacheValue>>>,
201    eviction_count: Arc<AtomicU64>,
202}
203
204impl CallGraphCache {
205    /// Create a new `CallGraphCache` with the given capacity.
206    ///
207    /// `capacity` is clamped to a minimum of 1 so a zero value does not panic.
208    #[must_use]
209    pub fn new(capacity: usize) -> Self {
210        let capacity = capacity.max(1);
211        // SAFETY: capacity is clamped to a minimum of 1 by .max(1), so NonZeroUsize::new() returns Some.
212        #[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    /// Look up a cached result by key. Returns `None` on miss or mutex poison.
222    #[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    /// Store a result in the cache.
228    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    /// Returns the number of LRU evictions that have occurred in this cache.
238    #[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
254/// LRU cache for file analysis results with mutex protection.
255pub 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    /// Create a new cache with the specified file capacity.
265    /// The directory cache capacity is read from the `APTU_CODER_DIR_CACHE_CAPACITY`
266    /// environment variable (default: 20).
267    #[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        // SAFETY: file_capacity is clamped to a minimum of 1 by .max(1), so NonZeroUsize::new() returns Some.
272        #[allow(clippy::expect_used)]
273        let cache_size =
274            NonZeroUsize::new(file_capacity).expect("file_capacity is non-zero after .max(1)");
275        // SAFETY: dir_capacity is clamped to a minimum of 1 by parse_cache_capacity, so NonZeroUsize::new() returns Some.
276        #[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    /// Get a cached analysis result if it exists.
289    #[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    /// Store an analysis result in the cache.
305    #[instrument(skip(self, value), fields(path = ?key.path))]
306    // public API; callers expect owned semantics
307    #[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    /// Get a cached directory analysis result if it exists.
329    #[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    /// Store a directory analysis result in the cache.
345    #[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    /// Returns the configured file-cache capacity.
362    /// Exposed for testing across crate boundaries; not part of the stable API.
363    #[doc(hidden)]
364    #[must_use]
365    pub fn file_capacity(&self) -> usize {
366        self.file_capacity
367    }
368
369    /// Invalidate all cache entries for a given file path.
370    /// Removes all entries regardless of modification time or analysis mode.
371    #[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    /// Returns the number of LRU evictions that have occurred in this cache.
388    #[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        // Arrange: create a real temp dir and a real temp file for hermetic isolation.
414        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        // Act: build cache key from entries
440        let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview, None);
441
442        // Assert: only the file entry should be in the cache key
443        // The directory entry should be filtered out
444        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        // Arrange: create a cache and insert one entry for a path
451        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        // Act: invalidate the file
467        cache.invalidate_file(&path);
468
469        // Assert: the entry should be removed
470        assert!(cache.get(&key).is_none());
471    }
472
473    #[test]
474    fn test_invalidate_file_multi_mode() {
475        // Arrange: create a cache and insert two entries for the same path with different modes
476        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        // Act: invalidate the file
498        cache.invalidate_file(&path);
499
500        // Assert: both entries should be removed
501        assert!(cache.get(&key1).is_none());
502        assert!(cache.get(&key2).is_none());
503    }
504
505    // Mutex serialises the two dir-cache-capacity tests to prevent env var races.
506    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        // Arrange: ensure the env var is not set
513        unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
514
515        // Act
516        let cache = AnalysisCache::new(100);
517
518        // Assert: default dir capacity is 20
519        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        // Arrange
527        unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
528
529        // Act
530        let cache = AnalysisCache::new(100);
531
532        // Cleanup before assertions to minimise env pollution window
533        unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
534
535        // Assert
536        assert_eq!(cache.dir_capacity, 7);
537    }
538
539    // Mutex serialises parse_cache_capacity tests that set env vars.
540    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        // Arrange: env var is absent
547        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
548
549        // Act
550        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
551
552        // Assert: default is returned as-is
553        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        // Arrange
561        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
562
563        // Act
564        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
565
566        // Cleanup
567        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
568
569        // Assert: parsed value is returned
570        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        // Arrange: zero is below the minimum of 1
578        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
579
580        // Act
581        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
582
583        // Cleanup
584        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
585
586        // Assert: clamped to 1
587        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        // Arrange: unparseable string
595        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
596
597        // Act
598        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
599
600        // Cleanup
601        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
602
603        // Assert: falls back to default
604        assert_eq!(result, 8);
605    }
606}
607
608pub use crate::cache_disk::DiskCache;