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 std::fs;
11use std::num::NonZeroUsize;
12use std::path::PathBuf;
13use std::sync::{Arc, Mutex};
14use std::time::SystemTime;
15use tracing::{debug, instrument};
16
17const DIR_CACHE_CAPACITY: usize = 20;
18
19/// Cache key combining path, modification time, and analysis mode.
20#[derive(Debug, Clone, Eq, PartialEq, Hash)]
21pub struct CacheKey {
22    pub path: PathBuf,
23    pub modified: SystemTime,
24    pub mode: AnalysisMode,
25}
26
27/// Cache key for directory analysis combining file mtimes, mode, and max_depth.
28#[derive(Debug, Clone, Eq, PartialEq, Hash)]
29pub struct DirectoryCacheKey {
30    files: Vec<(PathBuf, SystemTime)>,
31    mode: AnalysisMode,
32    max_depth: Option<u32>,
33}
34
35impl DirectoryCacheKey {
36    /// Build a cache key from walk entries, capturing mtime for each file.
37    /// Files are sorted by path for deterministic hashing.
38    pub fn from_entries(entries: &[WalkEntry], max_depth: Option<u32>, mode: AnalysisMode) -> Self {
39        let mut files: Vec<(PathBuf, SystemTime)> = entries
40            .iter()
41            .map(|e| {
42                let mtime = fs::metadata(&e.path)
43                    .and_then(|m| m.modified())
44                    .unwrap_or(SystemTime::UNIX_EPOCH);
45                (e.path.clone(), mtime)
46            })
47            .collect();
48        files.sort_by(|a, b| a.0.cmp(&b.0));
49        Self {
50            files,
51            mode,
52            max_depth,
53        }
54    }
55}
56
57/// Recover from a poisoned mutex by clearing the cache.
58/// On poison, creates a new empty cache and returns the recovery value.
59fn lock_or_recover<K, V, T, F>(mutex: &Mutex<LruCache<K, V>>, capacity: usize, recovery: F) -> T
60where
61    K: std::hash::Hash + Eq,
62    F: FnOnce(&mut LruCache<K, V>) -> T,
63{
64    match mutex.lock() {
65        Ok(mut guard) => recovery(&mut guard),
66        Err(poisoned) => {
67            let cache_size = NonZeroUsize::new(capacity).unwrap_or(NonZeroUsize::new(100).unwrap());
68            let new_cache = LruCache::new(cache_size);
69            let mut guard = poisoned.into_inner();
70            *guard = new_cache;
71            recovery(&mut guard)
72        }
73    }
74}
75
76/// LRU cache for file analysis results with mutex protection.
77pub struct AnalysisCache {
78    file_capacity: usize,
79    cache: Arc<Mutex<LruCache<CacheKey, Arc<FileAnalysisOutput>>>>,
80    directory_cache: Arc<Mutex<LruCache<DirectoryCacheKey, Arc<AnalysisOutput>>>>,
81}
82
83impl AnalysisCache {
84    /// Create a new cache with the specified capacity.
85    pub fn new(capacity: usize) -> Self {
86        let file_capacity = capacity.max(1);
87        let cache_size = NonZeroUsize::new(file_capacity).unwrap();
88        let dir_cache_size = NonZeroUsize::new(DIR_CACHE_CAPACITY).unwrap();
89        Self {
90            file_capacity,
91            cache: Arc::new(Mutex::new(LruCache::new(cache_size))),
92            directory_cache: Arc::new(Mutex::new(LruCache::new(dir_cache_size))),
93        }
94    }
95
96    /// Get a cached analysis result if it exists.
97    #[instrument(skip(self), fields(path = ?key.path))]
98    pub fn get(&self, key: &CacheKey) -> Option<Arc<FileAnalysisOutput>> {
99        lock_or_recover(&self.cache, self.file_capacity, |guard| {
100            let result = guard.get(key).cloned();
101            let cache_size = guard.len();
102            match result {
103                Some(v) => {
104                    debug!(cache_event = "hit", cache_size = cache_size, path = ?key.path);
105                    Some(v)
106                }
107                None => {
108                    debug!(cache_event = "miss", cache_size = cache_size, path = ?key.path);
109                    None
110                }
111            }
112        })
113    }
114
115    /// Store an analysis result in the cache.
116    #[instrument(skip(self, value), fields(path = ?key.path))]
117    pub fn put(&self, key: CacheKey, value: Arc<FileAnalysisOutput>) {
118        lock_or_recover(&self.cache, self.file_capacity, |guard| {
119            let push_result = guard.push(key.clone(), value);
120            let cache_size = guard.len();
121            match push_result {
122                None => {
123                    debug!(cache_event = "insert", cache_size = cache_size, path = ?key.path);
124                }
125                Some((returned_key, _)) => {
126                    if returned_key == key {
127                        debug!(cache_event = "update", cache_size = cache_size, path = ?key.path);
128                    } else {
129                        debug!(cache_event = "eviction", cache_size = cache_size, path = ?key.path, evicted_path = ?returned_key.path);
130                    }
131                }
132            }
133        });
134    }
135
136    /// Get a cached directory analysis result if it exists.
137    #[instrument(skip(self))]
138    pub fn get_directory(&self, key: &DirectoryCacheKey) -> Option<Arc<AnalysisOutput>> {
139        lock_or_recover(&self.directory_cache, DIR_CACHE_CAPACITY, |guard| {
140            let result = guard.get(key).cloned();
141            let cache_size = guard.len();
142            match result {
143                Some(v) => {
144                    debug!(cache_event = "hit", cache_size = cache_size);
145                    Some(v)
146                }
147                None => {
148                    debug!(cache_event = "miss", cache_size = cache_size);
149                    None
150                }
151            }
152        })
153    }
154
155    /// Store a directory analysis result in the cache.
156    #[instrument(skip(self, value))]
157    pub fn put_directory(&self, key: DirectoryCacheKey, value: Arc<AnalysisOutput>) {
158        lock_or_recover(&self.directory_cache, DIR_CACHE_CAPACITY, |guard| {
159            let push_result = guard.push(key.clone(), value);
160            let cache_size = guard.len();
161            match push_result {
162                None => {
163                    debug!(cache_event = "insert", cache_size = cache_size);
164                }
165                Some((_, _)) => {
166                    debug!(cache_event = "eviction", cache_size = cache_size);
167                }
168            }
169        });
170    }
171}
172
173impl Clone for AnalysisCache {
174    fn clone(&self) -> Self {
175        Self {
176            file_capacity: self.file_capacity,
177            cache: Arc::clone(&self.cache),
178            directory_cache: Arc::clone(&self.directory_cache),
179        }
180    }
181}