Skip to main content

code_analyze_mcp/
cache.rs

1//! LRU cache for analysis results indexed by path, modification time, and mode.
2//!
3//! Provides thread-safe, capacity-bounded caching of file analysis outputs using LRU eviction.
4//! Recovers gracefully from poisoned mutex conditions.
5
6use crate::analyze::{AnalysisOutput, FileAnalysisOutput};
7use crate::traversal::WalkEntry;
8use crate::types::AnalysisMode;
9use lru::LruCache;
10use rayon::prelude::*;
11use std::fs;
12use std::num::NonZeroUsize;
13use std::path::PathBuf;
14use std::sync::{Arc, Mutex};
15use std::time::SystemTime;
16use tracing::{debug, instrument};
17
18const DIR_CACHE_CAPACITY: usize = 20;
19
20/// Cache key combining path, modification time, and analysis mode.
21#[derive(Debug, Clone, Eq, PartialEq, Hash)]
22pub struct CacheKey {
23    pub path: PathBuf,
24    pub modified: SystemTime,
25    pub mode: AnalysisMode,
26}
27
28/// Cache key for directory analysis combining file mtimes, mode, and `max_depth`.
29#[derive(Debug, Clone, Eq, PartialEq, Hash)]
30pub struct DirectoryCacheKey {
31    files: Vec<(PathBuf, SystemTime)>,
32    mode: AnalysisMode,
33    max_depth: Option<u32>,
34}
35
36impl DirectoryCacheKey {
37    /// Build a cache key from walk entries, capturing mtime for each file.
38    /// Files are sorted by path for deterministic hashing.
39    /// Directories are filtered out; only file entries are processed.
40    /// Metadata collection is parallelized using rayon.
41    #[must_use]
42    pub fn from_entries(entries: &[WalkEntry], max_depth: Option<u32>, mode: AnalysisMode) -> Self {
43        let mut files: Vec<(PathBuf, SystemTime)> = entries
44            .par_iter()
45            .filter(|e| !e.is_dir)
46            .map(|e| {
47                let mtime = fs::metadata(&e.path)
48                    .and_then(|m| m.modified())
49                    .unwrap_or(SystemTime::UNIX_EPOCH);
50                (e.path.clone(), mtime)
51            })
52            .collect();
53        files.sort_by(|a, b| a.0.cmp(&b.0));
54        Self {
55            files,
56            mode,
57            max_depth,
58        }
59    }
60}
61
62/// Recover from a poisoned mutex by clearing the cache.
63/// On poison, creates a new empty cache and returns the recovery value.
64fn lock_or_recover<K, V, T, F>(mutex: &Mutex<LruCache<K, V>>, capacity: usize, recovery: F) -> T
65where
66    K: std::hash::Hash + Eq,
67    F: FnOnce(&mut LruCache<K, V>) -> T,
68{
69    match mutex.lock() {
70        Ok(mut guard) => recovery(&mut guard),
71        Err(poisoned) => {
72            let cache_size = NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::new(100).unwrap());
73            let new_cache = LruCache::new(cache_size);
74            let mut guard = poisoned.into_inner();
75            *guard = new_cache;
76            recovery(&mut guard)
77        }
78    }
79}
80
81/// LRU cache for file analysis results with mutex protection.
82pub struct AnalysisCache {
83    file_capacity: usize,
84    cache: Arc<Mutex<LruCache<CacheKey, Arc<FileAnalysisOutput>>>>,
85    directory_cache: Arc<Mutex<LruCache<DirectoryCacheKey, Arc<AnalysisOutput>>>>,
86}
87
88impl AnalysisCache {
89    /// Create a new cache with the specified capacity.
90    #[must_use]
91    pub fn new(capacity: usize) -> Self {
92        let file_capacity = capacity.max(1);
93        let cache_size = NonZeroUsize::new(file_capacity).unwrap();
94        let dir_cache_size = NonZeroUsize::new(DIR_CACHE_CAPACITY).unwrap();
95        Self {
96            file_capacity,
97            cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
98            directory_cache: Arc::new(Mutex::new(LruCache::new(dir_cache_size))),
99        }
100    }
101
102    /// Get a cached analysis result if it exists.
103    #[instrument(skip(self), fields(path = ?key.path))]
104    pub fn get(&self, key: &CacheKey) -> Option<Arc<FileAnalysisOutput>> {
105        lock_or_recover(&self.cache, self.file_capacity, |guard| {
106            let result = guard.get(key).cloned();
107            let cache_size = guard.len();
108            if let Some(v) = result {
109                debug!(cache_event = "hit", cache_size = cache_size, path = ?key.path);
110                Some(v)
111            } else {
112                debug!(cache_event = "miss", cache_size = cache_size, path = ?key.path);
113                None
114            }
115        })
116    }
117
118    /// Store an analysis result in the cache.
119    #[instrument(skip(self, value), fields(path = ?key.path))]
120    // public API; callers expect owned semantics
121    #[allow(clippy::needless_pass_by_value)]
122    pub fn put(&self, key: CacheKey, value: Arc<FileAnalysisOutput>) {
123        lock_or_recover(&self.cache, self.file_capacity, |guard| {
124            let push_result = guard.push(key.clone(), value);
125            let cache_size = guard.len();
126            match push_result {
127                None => {
128                    debug!(cache_event = "insert", cache_size = cache_size, path = ?key.path);
129                }
130                Some((returned_key, _)) => {
131                    if returned_key == key {
132                        debug!(cache_event = "update", cache_size = cache_size, path = ?key.path);
133                    } else {
134                        debug!(cache_event = "eviction", cache_size = cache_size, path = ?key.path, evicted_path = ?returned_key.path);
135                    }
136                }
137            }
138        });
139    }
140
141    /// Get a cached directory analysis result if it exists.
142    #[instrument(skip(self))]
143    pub fn get_directory(&self, key: &DirectoryCacheKey) -> Option<Arc<AnalysisOutput>> {
144        lock_or_recover(&self.directory_cache, DIR_CACHE_CAPACITY, |guard| {
145            let result = guard.get(key).cloned();
146            let cache_size = guard.len();
147            if let Some(v) = result {
148                debug!(cache_event = "hit", cache_size = cache_size);
149                Some(v)
150            } else {
151                debug!(cache_event = "miss", cache_size = cache_size);
152                None
153            }
154        })
155    }
156
157    /// Store a directory analysis result in the cache.
158    #[instrument(skip(self, value))]
159    pub fn put_directory(&self, key: DirectoryCacheKey, value: Arc<AnalysisOutput>) {
160        lock_or_recover(&self.directory_cache, DIR_CACHE_CAPACITY, |guard| {
161            let push_result = guard.push(key, value);
162            let cache_size = guard.len();
163            match push_result {
164                None => {
165                    debug!(cache_event = "insert", cache_size = cache_size);
166                }
167                Some((_, _)) => {
168                    debug!(cache_event = "eviction", cache_size = cache_size);
169                }
170            }
171        });
172    }
173}
174
175impl Clone for AnalysisCache {
176    fn clone(&self) -> Self {
177        Self {
178            file_capacity: self.file_capacity,
179            cache: Arc::clone(&self.cache),
180            directory_cache: Arc::clone(&self.directory_cache),
181        }
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_from_entries_skips_dirs() {
191        // Arrange: create a real temp dir and a real temp file for hermetic isolation.
192        let dir = tempfile::tempdir().expect("tempdir");
193        let file = tempfile::NamedTempFile::new_in(dir.path()).expect("tempfile");
194        let file_path = file.path().to_path_buf();
195
196        let entries = vec![
197            WalkEntry {
198                path: dir.path().to_path_buf(),
199                depth: 0,
200                is_dir: true,
201                is_symlink: false,
202                symlink_target: None,
203            },
204            WalkEntry {
205                path: file_path.clone(),
206                depth: 0,
207                is_dir: false,
208                is_symlink: false,
209                symlink_target: None,
210            },
211        ];
212
213        // Act: build cache key from entries
214        let key = DirectoryCacheKey::from_entries(&entries, None, AnalysisMode::Overview);
215
216        // Assert: only the file entry should be in the cache key
217        // The directory entry should be filtered out
218        assert_eq!(key.files.len(), 1);
219        assert_eq!(key.files[0].0, file_path);
220    }
221}