vct_core/cache/
file_cache.rs1use 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#[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
46pub struct FileParseCache {
55 cache: RwLock<LruCache<PathBuf, CachedFile>>,
56}
57
58impl FileParseCache {
59 pub fn new() -> Self {
61 let cache_size = NonZeroUsize::new(capacity::FILE_CACHE_SIZE).unwrap();
63 Self {
64 cache: RwLock::new(LruCache::new(cache_size)),
65 }
66 }
67
68 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 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 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 {
128 if let Ok(cache_read) = self.cache.read() {
129 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 drop(cache_read);
142
143 if let Ok(mut cache_write) = self.cache.write() {
145 cache_write.get(&path_buf); }
147
148 return Ok(result);
149 }
150 }
151 }
152 }
153
154 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 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 pub fn clear(&self) {
188 if let Ok(mut cache) = self.cache.write() {
189 cache.clear();
190 }
191 }
192
193 pub fn cleanup_stale(&self) {
198 if let Ok(mut cache) = self.cache.write() {
199 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 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 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 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#[derive(Debug, Default, Clone)]
280pub struct CacheStats {
281 pub entry_count: usize,
283 pub estimated_memory_kb: usize,
285}
286
287fn 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 for (k, _) in &record.conversation_usage {
336 bytes += k.capacity();
337 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}