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