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            SemanticAnalysis::default(),
536            0,
537            None,
538        ));
539        cache.put(key.clone(), output);
540
541        // Act: invalidate the file
542        cache.invalidate_file(&path);
543
544        // Assert: the entry should be removed
545        assert!(cache.get(&key).is_none());
546    }
547
548    #[test]
549    fn test_invalidate_file_multi_mode() {
550        // Arrange: create a cache and insert two entries for the same path with different modes
551        let cache = AnalysisCache::new(10);
552        let path = PathBuf::from("/test/file.rs");
553        let key1 = CacheKey {
554            path: path.clone(),
555            modified: SystemTime::UNIX_EPOCH,
556            mode: AnalysisMode::Overview,
557        };
558        let key2 = CacheKey {
559            path: path.clone(),
560            modified: SystemTime::UNIX_EPOCH,
561            mode: AnalysisMode::FileDetails,
562        };
563        let output = Arc::new(FileAnalysisOutput::new(
564            String::new(),
565            SemanticAnalysis::default(),
566            0,
567            None,
568        ));
569        cache.put(key1.clone(), output.clone());
570        cache.put(key2.clone(), output);
571
572        // Act: invalidate the file
573        cache.invalidate_file(&path);
574
575        // Assert: both entries should be removed
576        assert!(cache.get(&key1).is_none());
577        assert!(cache.get(&key2).is_none());
578    }
579
580    // Mutex serialises the two dir-cache-capacity tests to prevent env var races.
581    static DIR_CACHE_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
582
583    #[test]
584    fn test_dir_cache_capacity_default() {
585        let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
586
587        // Arrange: ensure the env var is not set
588        unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
589
590        // Act
591        let cache = AnalysisCache::new(100);
592
593        // Assert: default dir capacity is 20
594        assert_eq!(cache.dir_capacity, 20);
595    }
596
597    #[test]
598    fn test_dir_cache_capacity_from_env() {
599        let _guard = DIR_CACHE_ENV_LOCK.lock().unwrap();
600
601        // Arrange
602        unsafe { std::env::set_var("APTU_CODER_DIR_CACHE_CAPACITY", "7") };
603
604        // Act
605        let cache = AnalysisCache::new(100);
606
607        // Cleanup before assertions to minimise env pollution window
608        unsafe { std::env::remove_var("APTU_CODER_DIR_CACHE_CAPACITY") };
609
610        // Assert
611        assert_eq!(cache.dir_capacity, 7);
612    }
613
614    // Mutex serialises parse_cache_capacity tests that set env vars.
615    static PARSE_CAP_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
616
617    #[test]
618    fn test_parse_cache_capacity_missing_returns_default() {
619        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
620
621        // Arrange: env var is absent
622        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
623
624        // Act
625        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 42);
626
627        // Assert: default is returned as-is
628        assert_eq!(result, 42);
629    }
630
631    #[test]
632    fn test_parse_cache_capacity_valid_returns_value() {
633        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
634
635        // Arrange
636        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "64") };
637
638        // Act
639        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
640
641        // Cleanup
642        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
643
644        // Assert: parsed value is returned
645        assert_eq!(result, 64);
646    }
647
648    #[test]
649    fn test_parse_cache_capacity_zero_returns_one() {
650        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
651
652        // Arrange: zero is below the minimum of 1
653        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "0") };
654
655        // Act
656        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 10);
657
658        // Cleanup
659        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
660
661        // Assert: clamped to 1
662        assert_eq!(result, 1);
663    }
664
665    #[test]
666    fn test_parse_cache_capacity_garbage_returns_default() {
667        let _guard = PARSE_CAP_ENV_LOCK.lock().unwrap();
668
669        // Arrange: unparseable string
670        unsafe { std::env::set_var("_TEST_APTU_PARSE_CAP", "not_a_number") };
671
672        // Act
673        let result = parse_cache_capacity("_TEST_APTU_PARSE_CAP", 8);
674
675        // Cleanup
676        unsafe { std::env::remove_var("_TEST_APTU_PARSE_CAP") };
677
678        // Assert: falls back to default
679        assert_eq!(result, 8);
680    }
681
682    #[test]
683    fn test_analysis_lru_cache_hit_miss_recency_and_eviction() {
684        let cache = AnalysisLruCache::<&'static str, i32>::new(2);
685        cache.put("a", 1);
686        cache.put("b", 2);
687        // hit returns Some, miss returns None
688        assert_eq!(cache.get(&"a"), Some(1));
689        assert_eq!(cache.get(&"z"), None);
690        // "a" is now MRU; inserting "c" evicts "b" (LRU)
691        cache.put("c", 3);
692        assert_eq!(cache.get(&"a"), Some(1));
693        assert_eq!(cache.get(&"b"), None);
694        assert_eq!(cache.eviction_count(), 1);
695    }
696
697    #[test]
698    fn test_analysis_lru_cache_put_update_at_capacity_no_false_eviction() {
699        let cache = AnalysisLruCache::<String, i32>::new(2);
700        cache.put("k1".to_string(), 10);
701        cache.put("k2".to_string(), 20);
702        assert_eq!(cache.eviction_count(), 0);
703        // update existing key at full capacity
704        cache.put("k1".to_string(), 99);
705        // no false eviction on update
706        assert_eq!(cache.eviction_count(), 0);
707        assert_eq!(cache.get(&"k1".to_string()), Some(99));
708        assert_eq!(cache.get(&"k2".to_string()), Some(20));
709    }
710
711    #[test]
712    fn test_analysis_lru_cache_clone_shares_eviction_counter() {
713        let cache1 = AnalysisLruCache::<&'static str, i32>::new(1);
714        let cache2 = cache1.clone();
715        cache1.put("k1", 1);
716        cache2.put("k2", 2);
717        // cloned instances share eviction_count
718        assert_eq!(cache1.eviction_count(), 1);
719        assert_eq!(cache2.eviction_count(), 1);
720        assert_eq!(cache1.get(&"k1"), None);
721        assert_eq!(cache1.get(&"k2"), Some(2));
722    }
723
724    #[test]
725    fn test_analysis_lru_cache_new_clamps_zero_capacity_to_one() {
726        let cache = AnalysisLruCache::<&'static str, i32>::new(0);
727        assert_eq!(cache.capacity, 1);
728        cache.put("item1", 1);
729        assert_eq!(cache.get(&"item1"), Some(1));
730        assert_eq!(cache.eviction_count(), 0);
731        cache.put("item2", 2);
732        assert_eq!(cache.eviction_count(), 1);
733        assert_eq!(cache.get(&"item1"), None);
734        assert_eq!(cache.get(&"item2"), Some(2));
735    }
736
737    #[test]
738    fn test_structural_graph_cache_hit_miss() {
739        let cache = StructuralGraphCache::new(2);
740        let key1 = "key1".to_string();
741        let key2 = "key2".to_string();
742        let graph = Arc::new(StructuralGraph::from_graph(petgraph::graph::DiGraph::new()));
743
744        // Miss on non-existent key
745        assert!(cache.get("nonexistent").is_none());
746
747        // Put and hit
748        cache.put(key1.clone(), graph.clone());
749        assert!(cache.get("key1").is_some());
750
751        // Miss on different key
752        assert!(cache.get("key2").is_none());
753
754        // Put second key
755        cache.put(key2.clone(), graph.clone());
756        assert!(cache.get("key2").is_some());
757    }
758
759    #[test]
760    fn test_structural_graph_cache_eviction() {
761        let cache = StructuralGraphCache::new(2);
762        let graph = Arc::new(StructuralGraph::from_graph(petgraph::graph::DiGraph::new()));
763
764        cache.put("k1".to_string(), graph.clone());
765        cache.put("k2".to_string(), graph.clone());
766        assert_eq!(cache.eviction_count(), 0);
767
768        // Inserting k3 evicts k1 (LRU)
769        cache.put("k3".to_string(), graph.clone());
770        assert_eq!(cache.eviction_count(), 1);
771        assert!(cache.get("k1").is_none());
772        assert!(cache.get("k2").is_some());
773        assert!(cache.get("k3").is_some());
774    }
775
776    #[test]
777    fn test_structural_graph_cache_clone_shares_counter() {
778        let cache1 = StructuralGraphCache::new(1);
779        let cache2 = cache1.clone();
780        let graph = Arc::new(StructuralGraph::from_graph(petgraph::graph::DiGraph::new()));
781
782        cache1.put("k1".to_string(), graph.clone());
783        cache2.put("k2".to_string(), graph);
784
785        // Cloned instances share eviction_count
786        assert_eq!(cache1.eviction_count(), 1);
787        assert_eq!(cache2.eviction_count(), 1);
788    }
789}
790
791pub use crate::cache_disk::DiskCache;