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::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/// Indicates which cache tier served the result.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum CacheTier {
24    L1Memory,
25    L2Disk,
26    Miss,
27    L1OnlyMiss,
28    L1L2Miss,
29}
30
31/// Parse an LRU cache capacity from an environment variable.
32///
33/// Reads `env_key`, parses it as `usize`, and returns the value clamped to a minimum of 1.
34/// Falls back to `default` when the variable is absent or unparseable, then also clamps
35/// the fallback to at least 1.
36///
37/// This helper centralises all three LRU init sites so the `.max(1)` guard lives in one place.
38#[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    /// Returns `true` when the result was served from any cache tier (L1 or L2).
60    #[must_use]
61    pub fn is_hit(&self) -> bool {
62        matches!(self, CacheTier::L1Memory | CacheTier::L2Disk)
63    }
64}
65
66/// Cache key combining path, modification time, and analysis mode.
67#[derive(Debug, Clone, Eq, PartialEq, Hash)]
68pub struct CacheKey {
69    pub path: PathBuf,
70    pub modified: SystemTime,
71    pub mode: AnalysisMode,
72}
73
74/// Cache key for directory analysis combining file mtimes, mode, and `max_depth`.
75#[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    /// Build a cache key from walk entries, capturing mtime for each file.
85    /// Files are sorted by path for deterministic hashing.
86    /// Directories are filtered out; only file entries are processed.
87    /// Metadata collection is parallelized using rayon.
88    /// The `git_ref` is included so that filtered and unfiltered results have distinct keys.
89    #[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/// Fallback cache capacity used in `lock_or_recover` when the caller-supplied capacity is zero.
115// SAFETY: 100 is non-zero; verified at compile time by NonZeroUsize::new.
116#[allow(clippy::expect_used)]
117const DEFAULT_LOCK_RECOVER_CAPACITY: NonZeroUsize =
118    NonZeroUsize::new(100).expect("100 is non-zero");
119
120/// Recover from a poisoned mutex by clearing the cache.
121/// On poison, creates a new empty cache and returns the recovery value.
122fn 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/// Cache key for call graph analysis combining path, parameters, and file mtimes.
141#[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    /// Sorted (path, mtime_as_unix_nanos) pairs for all non-dir entries.
150    file_mtimes: Vec<(PathBuf, u64)>,
151}
152
153impl CallGraphCacheKey {
154    /// Build a `CallGraphCacheKey` from walk entries and analysis parameters.
155    /// Files are sorted by path for deterministic hashing.
156    /// Directories are filtered out; only file entries contribute to the key.
157    #[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
193/// Cached call graph result: the fully-built `FocusedAnalysisOutput`.
194/// `CallGraph` is not serializable, so caching is L1 memory only.
195pub type CallGraphCacheValue = Arc<FocusedAnalysisOutput>;
196
197/// Generic thread-safe LRU cache with eviction tracking and poison recovery.
198///
199/// Wraps an [`LruCache`] in `Arc<Mutex<...>>` with an atomic eviction counter.
200/// Recovers gracefully from mutex poisoning by clearing the cache.
201pub 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    /// Create a new `AnalysisLruCache` with the given capacity.
209    ///
210    /// `capacity` is clamped to a minimum of 1 so a zero value does not panic.
211    #[must_use]
212    pub fn new(capacity: usize) -> Self {
213        let capacity = capacity.max(1);
214        // SAFETY: capacity is clamped to a minimum of 1 by .max(1), so NonZeroUsize::new() returns Some.
215        #[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    /// Look up a cached result by key. Returns `None` on miss or mutex poison.
225    #[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    /// Store a key-value pair in the cache.
231    ///
232    /// Uses [`LruCache::push`] to distinguish between insertions, updates of existing
233    /// keys, and evictions of other entries. Eviction count only increments when a
234    /// different key is evicted at capacity.
235    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    /// Returns the number of LRU evictions that have occurred in this cache.
246    #[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/// L1 in-memory LRU cache for call graph results.
263/// Capacity is controlled via `APTU_CODER_SYMBOL_CACHE_CAPACITY` env var (default 32).
264#[derive(Clone)]
265pub struct CallGraphCache(AnalysisLruCache<CallGraphCacheKey, CallGraphCacheValue>);
266
267impl CallGraphCache {
268    /// Create a new `CallGraphCache` with the given capacity.
269    ///
270    /// `capacity` is clamped to a minimum of 1 so a zero value does not panic.
271    #[must_use]
272    pub fn new(capacity: usize) -> Self {
273        Self(AnalysisLruCache::new(capacity))
274    }
275
276    /// Look up a cached result by key. Returns `None` on miss or mutex poison.
277    #[must_use]
278    pub fn get(&self, key: &CallGraphCacheKey) -> Option<CallGraphCacheValue> {
279        self.0.get(key)
280    }
281
282    /// Store a result in the cache.
283    pub fn put(&self, key: CallGraphCacheKey, value: CallGraphCacheValue) {
284        self.0.put(key, value);
285    }
286
287    /// Returns the number of LRU evictions that have occurred in this cache.
288    #[must_use]
289    pub fn eviction_count(&self) -> u64 {
290        self.0.eviction_count()
291    }
292}
293
294/// Cached structural graph result: the fully-built `StructuralGraph`.
295pub type StructuralGraphCacheValue = Arc<StructuralGraph>;
296
297/// L1 in-memory LRU cache for structural graph results.
298/// Capacity is controlled via `APTU_CODER_STRUCTURAL_CACHE_CAPACITY` env var (default 16).
299#[derive(Clone)]
300pub struct StructuralGraphCache(AnalysisLruCache<String, StructuralGraphCacheValue>);
301
302impl StructuralGraphCache {
303    /// Create a new `StructuralGraphCache` with the given capacity.
304    ///
305    /// `capacity` is clamped to a minimum of 1 so a zero value does not panic.
306    #[must_use]
307    pub fn new(capacity: usize) -> Self {
308        Self(AnalysisLruCache::new(capacity))
309    }
310
311    /// Look up a cached result by key. Returns `None` on miss or mutex poison.
312    #[must_use]
313    pub fn get(&self, key: &str) -> Option<StructuralGraphCacheValue> {
314        self.0.get(&key.to_string())
315    }
316
317    /// Store a result in the cache.
318    pub fn put(&self, key: String, value: StructuralGraphCacheValue) {
319        self.0.put(key, value);
320    }
321
322    /// Returns the number of LRU evictions that have occurred in this cache.
323    #[must_use]
324    pub fn eviction_count(&self) -> u64 {
325        self.0.eviction_count()
326    }
327}
328
329/// LRU cache for file analysis results with mutex protection.
330pub 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    /// Create a new cache with the specified file capacity.
340    /// The directory cache capacity is read from the `APTU_CODER_DIR_CACHE_CAPACITY`
341    /// environment variable (default: 20).
342    #[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        // SAFETY: file_capacity is clamped to a minimum of 1 by .max(1), so NonZeroUsize::new() returns Some.
347        #[allow(clippy::expect_used)]
348        let cache_size =
349            NonZeroUsize::new(file_capacity).expect("file_capacity is non-zero after .max(1)");
350        // SAFETY: dir_capacity is clamped to a minimum of 1 by parse_cache_capacity, so NonZeroUsize::new() returns Some.
351        #[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    /// Get a cached analysis result if it exists.
364    #[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    /// Store an analysis result in the cache.
380    #[instrument(skip(self, value), fields(path = ?key.path))]
381    // public API; callers expect owned semantics
382    #[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    /// Get a cached directory analysis result if it exists.
404    #[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    /// Store a directory analysis result in the cache.
420    #[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    /// Returns the configured file-cache capacity.
437    /// Exposed for testing across crate boundaries; not part of the stable API.
438    #[doc(hidden)]
439    #[must_use]
440    pub fn file_capacity(&self) -> usize {
441        self.file_capacity
442    }
443
444    /// Invalidate all cache entries for a given file path.
445    /// Removes all entries regardless of modification time or analysis mode.
446    #[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    /// Returns the number of LRU evictions that have occurred in this cache.
463    #[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        // Arrange: create a real temp dir and a real temp file for hermetic isolation.
489        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        // Act: build cache key from entries
515        let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview, None);
516
517        // Assert: only the file entry should be in the cache key
518        // The directory entry should be filtered out
519        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        // Arrange: create a cache and insert one entry for a path
526        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            String::new(),
536            SemanticAnalysis::default(),
537            0,
538            None,
539        ));
540        cache.put(key.clone(), output);
541
542        // Act: invalidate the file
543        cache.invalidate_file(&path);
544
545        // Assert: the entry should be removed
546        assert!(cache.get(&key).is_none());
547    }
548
549    #[test]
550    fn test_invalidate_file_multi_mode() {
551        // Arrange: create a cache and insert two entries for the same path with different modes
552        let cache = AnalysisCache::new(10);
553        let path = PathBuf::from("/test/file.rs");
554        let key1 = CacheKey {
555            path: path.clone(),
556            modified: SystemTime::UNIX_EPOCH,
557            mode: AnalysisMode::Overview,
558        };
559        let key2 = CacheKey {
560            path: path.clone(),
561            modified: SystemTime::UNIX_EPOCH,
562            mode: AnalysisMode::FileDetails,
563        };
564        let output = Arc::new(FileAnalysisOutput::new(
565            String::new(),
566            String::new(),
567            SemanticAnalysis::default(),
568            0,
569            None,
570        ));
571        cache.put(key1.clone(), output.clone());
572        cache.put(key2.clone(), output);
573
574        // Act: invalidate the file
575        cache.invalidate_file(&path);
576
577        // Assert: both entries should be removed
578        assert!(cache.get(&key1).is_none());
579        assert!(cache.get(&key2).is_none());
580    }
581
582    // Mutex serialises the two dir-cache-capacity tests to prevent env var races.
583    static DIR_CACHE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
584
585    #[test]
586    fn test_dir_cache_capacity_default() {
587        let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
588
589        // Arrange: ensure the env var is not set
590        unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
591
592        // Act
593        let cache = AnalysisCache::new(100);
594
595        // Assert: default dir capacity is 20
596        assert_eq!(cache.dir_capacity, 20);
597    }
598
599    #[test]
600    fn test_dir_cache_capacity_from_env() {
601        let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
602
603        // Arrange
604        unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
605
606        // Act
607        let cache = AnalysisCache::new(100);
608
609        // Cleanup before assertions to minimise env pollution window
610        unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
611
612        // Assert
613        assert_eq!(cache.dir_capacity, 7);
614    }
615
616    // Mutex serialises parse_cache_capacity tests that set env vars.
617    static PARSE_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
618
619    #[test]
620    fn test_parse_cache_capacity_missing_returns_default() {
621        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
622
623        // Arrange: env var is absent
624        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
625
626        // Act
627        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
628
629        // Assert: default is returned as-is
630        assert_eq!(result, 42);
631    }
632
633    #[test]
634    fn test_parse_cache_capacity_valid_returns_value() {
635        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
636
637        // Arrange
638        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
639
640        // Act
641        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
642
643        // Cleanup
644        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
645
646        // Assert: parsed value is returned
647        assert_eq!(result, 64);
648    }
649
650    #[test]
651    fn test_parse_cache_capacity_zero_returns_one() {
652        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
653
654        // Arrange: zero is below the minimum of 1
655        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
656
657        // Act
658        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
659
660        // Cleanup
661        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
662
663        // Assert: clamped to 1
664        assert_eq!(result, 1);
665    }
666
667    #[test]
668    fn test_parse_cache_capacity_garbage_returns_default() {
669        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
670
671        // Arrange: unparseable string
672        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
673
674        // Act
675        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
676
677        // Cleanup
678        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
679
680        // Assert: falls back to default
681        assert_eq!(result, 8);
682    }
683
684    #[test]
685    fn test_analysis_lru_cache_hit_miss_recency_and_eviction() {
686        let cache = AnalysisLruCache::<&'static str, i32>::new(2);
687        cache.put("a", 1);
688        cache.put("b", 2);
689        // hit returns Some, miss returns None
690        assert_eq!(cache.get(&"a"), Some(1));
691        assert_eq!(cache.get(&"z"), None);
692        // "a" is now MRU; inserting "c" evicts "b" (LRU)
693        cache.put("c", 3);
694        assert_eq!(cache.get(&"a"), Some(1));
695        assert_eq!(cache.get(&"b"), None);
696        assert_eq!(cache.eviction_count(), 1);
697    }
698
699    #[test]
700    fn test_analysis_lru_cache_put_update_at_capacity_no_false_eviction() {
701        let cache = AnalysisLruCache::<String, i32>::new(2);
702        cache.put("k1".to_string(), 10);
703        cache.put("k2".to_string(), 20);
704        assert_eq!(cache.eviction_count(), 0);
705        // update existing key at full capacity
706        cache.put("k1".to_string(), 99);
707        // no false eviction on update
708        assert_eq!(cache.eviction_count(), 0);
709        assert_eq!(cache.get(&"k1".to_string()), Some(99));
710        assert_eq!(cache.get(&"k2".to_string()), Some(20));
711    }
712
713    #[test]
714    fn test_analysis_lru_cache_clone_shares_eviction_counter() {
715        let cache1 = AnalysisLruCache::<&'static str, i32>::new(1);
716        let cache2 = cache1.clone();
717        cache1.put("k1", 1);
718        cache2.put("k2", 2);
719        // cloned instances share eviction_count
720        assert_eq!(cache1.eviction_count(), 1);
721        assert_eq!(cache2.eviction_count(), 1);
722        assert_eq!(cache1.get(&"k1"), None);
723        assert_eq!(cache1.get(&"k2"), Some(2));
724    }
725
726    #[test]
727    fn test_analysis_lru_cache_new_clamps_zero_capacity_to_one() {
728        let cache = AnalysisLruCache::<&'static str, i32>::new(0);
729        assert_eq!(cache.capacity, 1);
730        cache.put("item1", 1);
731        assert_eq!(cache.get(&"item1"), Some(1));
732        assert_eq!(cache.eviction_count(), 0);
733        cache.put("item2", 2);
734        assert_eq!(cache.eviction_count(), 1);
735        assert_eq!(cache.get(&"item1"), None);
736        assert_eq!(cache.get(&"item2"), Some(2));
737    }
738
739    #[test]
740    fn test_structural_graph_cache_hit_miss() {
741        let cache = StructuralGraphCache::new(2);
742        let key1 = "key1".to_string();
743        let key2 = "key2".to_string();
744        let graph = Arc::new(StructuralGraph::from_graph(petgraph::graph::DiGraph::new()));
745
746        // Miss on non-existent key
747        assert!(cache.get("nonexistent").is_none());
748
749        // Put and hit
750        cache.put(key1.clone(), graph.clone());
751        assert!(cache.get("key1").is_some());
752
753        // Miss on different key
754        assert!(cache.get("key2").is_none());
755
756        // Put second key
757        cache.put(key2.clone(), graph.clone());
758        assert!(cache.get("key2").is_some());
759    }
760
761    #[test]
762    fn test_structural_graph_cache_eviction() {
763        let cache = StructuralGraphCache::new(2);
764        let graph = Arc::new(StructuralGraph::from_graph(petgraph::graph::DiGraph::new()));
765
766        cache.put("k1".to_string(), graph.clone());
767        cache.put("k2".to_string(), graph.clone());
768        assert_eq!(cache.eviction_count(), 0);
769
770        // Inserting k3 evicts k1 (LRU)
771        cache.put("k3".to_string(), graph.clone());
772        assert_eq!(cache.eviction_count(), 1);
773        assert!(cache.get("k1").is_none());
774        assert!(cache.get("k2").is_some());
775        assert!(cache.get("k3").is_some());
776    }
777
778    #[test]
779    fn test_structural_graph_cache_clone_shares_counter() {
780        let cache1 = StructuralGraphCache::new(1);
781        let cache2 = cache1.clone();
782        let graph = Arc::new(StructuralGraph::from_graph(petgraph::graph::DiGraph::new()));
783
784        cache1.put("k1".to_string(), graph.clone());
785        cache2.put("k2".to_string(), graph);
786
787        // Cloned instances share eviction_count
788        assert_eq!(cache1.eviction_count(), 1);
789        assert_eq!(cache2.eviction_count(), 1);
790    }
791}
792
793pub use crate::cache_disk::DiskCache;