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::{Arc, Mutex};
16use std::time::SystemTime;
17use tracing::{debug, instrument, warn};
18
19/// Indicates which cache tier served the result.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum CacheTier {
22    L1Memory,
23    L2Disk,
24    Miss,
25}
26
27/// Parse an LRU cache capacity from an environment variable.
28///
29/// Reads `env_key`, parses it as `usize`, and returns the value clamped to a minimum of 1.
30/// Falls back to `default` when the variable is absent or unparseable, then also clamps
31/// the fallback to at least 1.
32///
33/// This helper centralises all three LRU init sites so the `.max(1)` guard lives in one place.
34#[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/// Cache key combining path, modification time, and analysis mode.
55#[derive(Debug, Clone, Eq, PartialEq, Hash)]
56pub struct CacheKey {
57    pub path: PathBuf,
58    pub modified: SystemTime,
59    pub mode: AnalysisMode,
60}
61
62/// Cache key for directory analysis combining file mtimes, mode, and `max_depth`.
63#[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    /// Build a cache key from walk entries, capturing mtime for each file.
73    /// Files are sorted by path for deterministic hashing.
74    /// Directories are filtered out; only file entries are processed.
75    /// Metadata collection is parallelized using rayon.
76    /// The `git_ref` is included so that filtered and unfiltered results have distinct keys.
77    #[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/// Fallback cache capacity used in `lock_or_recover` when the caller-supplied capacity is zero.
103// SAFETY: 100 is non-zero; verified at compile time by NonZeroUsize::new.
104#[allow(clippy::expect_used)]
105const DEFAULT_LOCK_RECOVER_CAPACITY: NonZeroUsize =
106    NonZeroUsize::new(100).expect("100 is non-zero");
107
108/// Recover from a poisoned mutex by clearing the cache.
109/// On poison, creates a new empty cache and returns the recovery value.
110fn 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/// Cache key for call graph analysis combining path, parameters, and file mtimes.
129#[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    /// Sorted (path, mtime_as_unix_nanos) pairs for all non-dir entries.
138    file_mtimes: Vec<(PathBuf, u64)>,
139}
140
141impl CallGraphCacheKey {
142    /// Build a `CallGraphCacheKey` from walk entries and analysis parameters.
143    /// Files are sorted by path for deterministic hashing.
144    /// Directories are filtered out; only file entries contribute to the key.
145    #[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
181/// Cached call graph result: the fully-built `FocusedAnalysisOutput`.
182/// `CallGraph` is not serializable, so caching is L1 memory only.
183pub type CallGraphCacheValue = Arc<FocusedAnalysisOutput>;
184
185/// L1 in-memory LRU cache for call graph results.
186/// Capacity is controlled via `APTU_CODER_SYMBOL_CACHE_CAPACITY` env var (default 32).
187pub struct CallGraphCache {
188    capacity: usize,
189    cache: Arc<Mutex<LruCache<CallGraphCacheKey, CallGraphCacheValue>>>,
190}
191
192impl CallGraphCache {
193    /// Create a new `CallGraphCache` with the given capacity.
194    ///
195    /// `capacity` is clamped to a minimum of 1 so a zero value does not panic.
196    #[must_use]
197    pub fn new(capacity: usize) -> Self {
198        let capacity = capacity.max(1);
199        // SAFETY: capacity is clamped to a minimum of 1 by .max(1), so NonZeroUsize::new() returns Some.
200        #[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    /// Look up a cached result by key. Returns `None` on miss or mutex poison.
209    #[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    /// Store a result in the cache.
215    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
231/// LRU cache for file analysis results with mutex protection.
232pub 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    /// Create a new cache with the specified file capacity.
241    /// The directory cache capacity is read from the `APTU_CODER_DIR_CACHE_CAPACITY`
242    /// environment variable (default: 20).
243    #[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        // SAFETY: file_capacity is clamped to a minimum of 1 by .max(1), so NonZeroUsize::new() returns Some.
248        #[allow(clippy::expect_used)]
249        let cache_size =
250            NonZeroUsize::new(file_capacity).expect("file_capacity is non-zero after .max(1)");
251        // SAFETY: dir_capacity is clamped to a minimum of 1 by parse_cache_capacity, so NonZeroUsize::new() returns Some.
252        #[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    /// Get a cached analysis result if it exists.
264    #[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    /// Store an analysis result in the cache.
280    #[instrument(skip(self, value), fields(path = ?key.path))]
281    // public API; callers expect owned semantics
282    #[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    /// Get a cached directory analysis result if it exists.
303    #[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    /// Store a directory analysis result in the cache.
319    #[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    /// Returns the configured file-cache capacity.
336    /// Exposed for testing across crate boundaries; not part of the stable API.
337    #[doc(hidden)]
338    #[must_use]
339    pub fn file_capacity(&self) -> usize {
340        self.file_capacity
341    }
342
343    /// Invalidate all cache entries for a given file path.
344    /// Removes all entries regardless of modification time or analysis mode.
345    #[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        // Arrange: create a real temp dir and a real temp file for hermetic isolation.
381        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        // Act: build cache key from entries
407        let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview, None);
408
409        // Assert: only the file entry should be in the cache key
410        // The directory entry should be filtered out
411        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        // Arrange: create a cache and insert one entry for a path
418        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        // Act: invalidate the file
434        cache.invalidate_file(&path);
435
436        // Assert: the entry should be removed
437        assert!(cache.get(&key).is_none());
438    }
439
440    #[test]
441    fn test_invalidate_file_multi_mode() {
442        // Arrange: create a cache and insert two entries for the same path with different modes
443        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        // Act: invalidate the file
465        cache.invalidate_file(&path);
466
467        // Assert: both entries should be removed
468        assert!(cache.get(&key1).is_none());
469        assert!(cache.get(&key2).is_none());
470    }
471
472    // Mutex serialises the two dir-cache-capacity tests to prevent env var races.
473    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        // Arrange: ensure the env var is not set
480        unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
481
482        // Act
483        let cache = AnalysisCache::new(100);
484
485        // Assert: default dir capacity is 20
486        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        // Arrange
494        unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
495
496        // Act
497        let cache = AnalysisCache::new(100);
498
499        // Cleanup before assertions to minimise env pollution window
500        unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
501
502        // Assert
503        assert_eq!(cache.dir_capacity, 7);
504    }
505
506    // Mutex serialises parse_cache_capacity tests that set env vars.
507    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        // Arrange: env var is absent
514        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
515
516        // Act
517        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
518
519        // Assert: default is returned as-is
520        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        // Arrange
528        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
529
530        // Act
531        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
532
533        // Cleanup
534        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
535
536        // Assert: parsed value is returned
537        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        // Arrange: zero is below the minimum of 1
545        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
546
547        // Act
548        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
549
550        // Cleanup
551        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
552
553        // Assert: clamped to 1
554        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        // Arrange: unparseable string
562        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
563
564        // Act
565        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
566
567        // Cleanup
568        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
569
570        // Assert: falls back to default
571        assert_eq!(result, 8);
572    }
573}
574
575pub use crate::cache_disk::DiskCache;