Skip to main content

vct_core/cache/
file_cache.rs

1use crate::constants::capacity;
2use crate::models::{
3    CodeAnalysis, CodeAnalysisApplyDiffDetail, CodeAnalysisReadDetail, CodeAnalysisRecord,
4    CodeAnalysisRunCommandDetail, CodeAnalysisWriteDetail, ExtensionType,
5};
6use crate::session::ParseMode;
7use anyhow::Result;
8use lru::LruCache;
9use std::fs;
10use std::num::NonZeroUsize;
11use std::path::{Path, PathBuf};
12use std::sync::{Arc, RwLock};
13use std::time::SystemTime;
14
15/// Cached file entry with dependency fingerprint tracking for invalidation.
16///
17/// `size_bytes` is captured once at insertion via [`estimate_analysis_bytes`]
18/// so the `stats()` path can report a realistic memory footprint without
19/// walking the analysis on every call.
20#[derive(Debug, Clone)]
21struct CachedFile {
22    fingerprint: FileFingerprint,
23    analysis: Arc<CodeAnalysis>,
24    size_bytes: usize,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28struct FileStamp {
29    modified: SystemTime,
30    len: u64,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34struct GrokDependencyStamps {
35    summary: Option<FileStamp>,
36    updates: Option<FileStamp>,
37    cwd: Option<FileStamp>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41struct FileFingerprint {
42    primary: FileStamp,
43    grok_dependencies: Option<GrokDependencyStamps>,
44}
45
46/// Thread-safe LRU cache for parsed session analyses with automatic eviction.
47///
48/// Entries are held as `Arc<CodeAnalysis>` (the typed struct). We deliberately
49/// avoid caching `Arc<serde_json::Value>` because `serde_json::to_value` deep-
50/// clones every string and adds per-node `Value` enum overhead on top — for
51/// long Claude sessions that roughly doubles the working set. Callers that
52/// need a `Value` (CLI single-file dump) serialise on demand from the typed
53/// form, which only happens once per request rather than once per cache entry.
54pub struct FileParseCache {
55    cache: RwLock<LruCache<PathBuf, CachedFile>>,
56}
57
58impl FileParseCache {
59    /// Creates a new LRU cache with capacity from `constants::capacity::FILE_CACHE_SIZE`.
60    pub fn new() -> Self {
61        // SAFETY: FILE_CACHE_SIZE is a const > 0
62        let cache_size = NonZeroUsize::new(capacity::FILE_CACHE_SIZE).unwrap();
63        Self {
64            cache: RwLock::new(LruCache::new(cache_size)),
65        }
66    }
67
68    /// Retrieves cached analysis or parses the file if needed, auto-detecting
69    /// the provider from file contents.
70    ///
71    /// Prefer [`Self::get_or_parse_as`] whenever the caller already knows which
72    /// provider the file belongs to (e.g. walking a specific session
73    /// directory). Content detection is only safe for ad-hoc single-file paths.
74    ///
75    /// Workflow:
76    /// 1. Check cache hit with read-only peek (no lock contention)
77    /// 2. If valid, promote entry to front with write lock
78    /// 3. If miss/stale, parse file and cache result (may evict LRU entry)
79    ///
80    /// Optimized to minimize write lock contention in parallel workloads.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the file's metadata cannot be read or if parsing the
85    /// session file fails (malformed data, unreadable contents, etc.). A
86    /// poisoned cache lock does not error — it simply forces a reparse.
87    pub fn get_or_parse<P: AsRef<Path>>(&self, path: P) -> Result<Arc<CodeAnalysis>> {
88        self.get_or_parse_inner(path.as_ref(), None)
89    }
90
91    /// Same as [`Self::get_or_parse`] but the caller specifies which provider
92    /// the file belongs to — the parser skips content-based detection, so
93    /// metadata sentinels at the top of a Claude session (`permission-mode`,
94    /// `file-history-snapshot`) cannot cause the file to be mis-filed under a
95    /// different provider.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if the file's metadata cannot be read or if parsing the
100    /// session file as `provider` fails.
101    pub fn get_or_parse_as<P: AsRef<Path>>(
102        &self,
103        path: P,
104        provider: ExtensionType,
105    ) -> Result<Arc<CodeAnalysis>> {
106        self.get_or_parse_inner(path.as_ref(), Some(provider))
107    }
108
109    /// Shared cache lookup + parse path behind [`Self::get_or_parse`] and
110    /// [`Self::get_or_parse_as`]; `provider` of `None` triggers content-based
111    /// auto-detection.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if `fs::metadata` fails for `path` or if the underlying
116    /// session parser rejects the file.
117    fn get_or_parse_inner(
118        &self,
119        path: &Path,
120        provider: Option<ExtensionType>,
121    ) -> Result<Arc<CodeAnalysis>> {
122        let path_buf = path.to_path_buf();
123
124        let primary = file_stamp(path)?;
125
126        // Fast path: Check cache with read lock (no contention)
127        {
128            if let Ok(cache_read) = self.cache.read() {
129                // Use peek() instead of get() to avoid requiring write lock
130                if let Some(cached) = cache_read.peek(&path_buf) {
131                    let is_grok = provider == Some(ExtensionType::Grok)
132                        || cached.analysis.extension_name == "Grok";
133                    let fingerprint = FileFingerprint {
134                        primary,
135                        grok_dependencies: is_grok.then(|| grok_dependency_stamps(path)),
136                    };
137                    if cached.fingerprint == fingerprint {
138                        log::trace!("LRU cache hit for {}", path.display());
139                        let result = Arc::clone(&cached.analysis);
140                        // Release read lock before acquiring write lock
141                        drop(cache_read);
142
143                        // Promote entry to front (requires write lock but quick operation)
144                        if let Ok(mut cache_write) = self.cache.write() {
145                            cache_write.get(&path_buf); // Updates LRU position
146                        }
147
148                        return Ok(result);
149                    }
150                }
151            }
152        }
153
154        // Cache miss or outdated - need to parse.
155        log::debug!("LRU cache miss for {}, parsing...", path.display());
156        let possible_grok_dependencies = (provider.is_none()
157            || provider == Some(ExtensionType::Grok))
158        .then(|| grok_dependency_stamps(path));
159        let analysis = match provider {
160            Some(p) => crate::session::parse_session_file_typed_as(path, p, ParseMode::Full)?,
161            None => crate::session::parse_session_file_typed(path)?,
162        };
163        let arc_analysis = Arc::new(analysis);
164        let size_bytes = estimate_analysis_bytes(arc_analysis.as_ref());
165
166        // Update cache (write lock) - LRU will auto-evict if at capacity
167        if let Ok(mut cache_write) = self.cache.write() {
168            let is_grok =
169                provider == Some(ExtensionType::Grok) || arc_analysis.extension_name == "Grok";
170            cache_write.put(
171                path_buf,
172                CachedFile {
173                    fingerprint: FileFingerprint {
174                        primary,
175                        grok_dependencies: is_grok.then_some(possible_grok_dependencies).flatten(),
176                    },
177                    analysis: Arc::clone(&arc_analysis),
178                    size_bytes,
179                },
180            );
181        }
182
183        Ok(arc_analysis)
184    }
185
186    /// Clears all entries from the cache.
187    pub fn clear(&self) {
188        if let Ok(mut cache) = self.cache.write() {
189            cache.clear();
190        }
191    }
192
193    /// Removes entries for non-existent files (manual cleanup).
194    ///
195    /// With LRU eviction, stale entries are naturally removed over time, so this
196    /// is typically not needed in production.
197    pub fn cleanup_stale(&self) {
198        if let Ok(mut cache) = self.cache.write() {
199            // LRU cache doesn't have retain(), so we collect keys first
200            let stale_keys: Vec<PathBuf> = cache
201                .iter()
202                .filter(|(path, _)| !path.exists())
203                .map(|(path, _)| path.clone())
204                .collect();
205
206            for key in stale_keys {
207                cache.pop(&key);
208            }
209        }
210    }
211
212    /// Returns cache statistics for monitoring and debugging.
213    ///
214    /// `estimated_memory_kb` is a real sum of per-entry sizes captured by
215    /// `estimate_analysis_bytes` at insertion time.
216    pub fn stats(&self) -> CacheStats {
217        if let Ok(cache) = self.cache.write() {
218            let total_bytes: usize = cache.iter().map(|(_, c)| c.size_bytes).sum();
219            CacheStats {
220                entry_count: cache.len(),
221                estimated_memory_kb: total_bytes / 1024,
222            }
223        } else {
224            CacheStats::default()
225        }
226    }
227
228    /// Removes a specific file from the cache.
229    pub fn invalidate<P: AsRef<Path>>(&self, path: P) {
230        if let Ok(mut cache) = self.cache.write() {
231            cache.pop(&path.as_ref().to_path_buf());
232        }
233    }
234
235    /// Returns all currently cached file paths.
236    pub fn get_cached_paths(&self) -> Vec<PathBuf> {
237        if let Ok(cache) = self.cache.write() {
238            cache.iter().map(|(path, _)| path.clone()).collect()
239        } else {
240            Vec::new()
241        }
242    }
243}
244
245fn file_stamp(path: &Path) -> Result<FileStamp> {
246    let metadata = fs::metadata(path)?;
247    Ok(FileStamp {
248        modified: metadata.modified()?,
249        len: metadata.len(),
250    })
251}
252
253fn optional_file_stamp(path: &Path) -> Option<FileStamp> {
254    let metadata = fs::metadata(path).ok()?;
255    Some(FileStamp {
256        modified: metadata.modified().ok()?,
257        len: metadata.len(),
258    })
259}
260
261fn grok_dependency_stamps(signals_path: &Path) -> GrokDependencyStamps {
262    GrokDependencyStamps {
263        summary: optional_file_stamp(&signals_path.with_file_name("summary.json")),
264        updates: optional_file_stamp(&signals_path.with_file_name("updates.jsonl")),
265        cwd: signals_path
266            .parent()
267            .and_then(Path::parent)
268            .and_then(|workspace| optional_file_stamp(&workspace.join(".cwd"))),
269    }
270}
271
272impl Default for FileParseCache {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278/// Cache usage statistics for monitoring.
279#[derive(Debug, Default, Clone)]
280pub struct CacheStats {
281    /// Number of entries currently held in the cache.
282    pub entry_count: usize,
283    /// Summed per-entry heap estimate in KiB (see `estimate_analysis_bytes`).
284    pub estimated_memory_kb: usize,
285}
286
287/// Best-effort byte estimate of a [`CodeAnalysis`]'s heap footprint.
288///
289/// Counts the inline struct + every owned `String` capacity reachable from
290/// the records. Ignores allocator padding, `HashMap` bucket overhead, and the
291/// `serde_json::Value` payload inside `conversation_usage` (we only count the
292/// keys) — still more honest than a flat constant and vastly cheaper than
293/// `serde_json::to_vec(...).len()`.
294fn estimate_analysis_bytes(analysis: &CodeAnalysis) -> usize {
295    use std::mem::size_of;
296
297    let mut bytes = size_of::<CodeAnalysis>();
298    bytes += analysis.user.capacity();
299    bytes += analysis.extension_name.capacity();
300    bytes += analysis.insights_version.capacity();
301    bytes += analysis.machine_id.capacity();
302    bytes += analysis.records.capacity() * size_of::<CodeAnalysisRecord>();
303
304    for record in &analysis.records {
305        bytes += record.task_id.capacity();
306        bytes += record.folder_path.capacity();
307        bytes += record.git_remote_url.capacity();
308
309        bytes += record.write_file_details.capacity() * size_of::<CodeAnalysisWriteDetail>();
310        for detail in &record.write_file_details {
311            bytes += detail.base.file_path.capacity();
312            bytes += detail.content.capacity();
313        }
314
315        bytes += record.read_file_details.capacity() * size_of::<CodeAnalysisReadDetail>();
316        for detail in &record.read_file_details {
317            bytes += detail.base.file_path.capacity();
318        }
319
320        bytes += record.edit_file_details.capacity() * size_of::<CodeAnalysisApplyDiffDetail>();
321        for detail in &record.edit_file_details {
322            bytes += detail.base.file_path.capacity();
323            bytes += detail.old_string.capacity();
324            bytes += detail.new_string.capacity();
325        }
326
327        bytes += record.run_command_details.capacity() * size_of::<CodeAnalysisRunCommandDetail>();
328        for detail in &record.run_command_details {
329            bytes += detail.base.file_path.capacity();
330            bytes += detail.command.capacity();
331            bytes += detail.description.capacity();
332        }
333
334        // conversation_usage: FastHashMap<String, serde_json::Value>
335        for (k, _) in &record.conversation_usage {
336            bytes += k.capacity();
337            // Values are small token-count objects; approximate at 256 B each
338            // rather than walking Value trees on every insertion.
339            bytes += 256;
340        }
341    }
342
343    bytes
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn test_cache_basic() {
352        let cache = FileParseCache::new();
353        let stats = cache.stats();
354        assert_eq!(stats.entry_count, 0);
355    }
356
357    #[test]
358    fn test_cache_clear() {
359        let cache = FileParseCache::new();
360        cache.clear();
361        let stats = cache.stats();
362        assert_eq!(stats.entry_count, 0);
363    }
364}