Skip to main content

vct_core/
summary_cache.rs

1//! Process-local cache for compact usage and analysis scan contributions.
2
3use crate::constants::{FastHashMap, FastHashSet};
4use crate::models::TimeRange;
5use crate::models::{AggregatedAnalysisRow, CodeAnalysis, ExtensionType, UsageResult};
6use crate::session::diagnostics::{UsageContribution, UsageTokenContribution};
7use crate::session::sqlite::{DatabaseFingerprint, append_suffix};
8use crate::utils::{extract_token_counts, merge_usage_values};
9use anyhow::Result;
10use std::collections::HashSet;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::time::SystemTime;
14
15/// Observable cache statistics used by tests and diagnostic logging.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub struct SummaryScanCacheStats {
18    /// Compact source entries currently retained.
19    pub entries: usize,
20    /// Sources parsed during the most recent scan cycle.
21    pub parsed_sources: usize,
22    /// Sources parsed since this cache was created.
23    pub total_parsed_sources: usize,
24}
25
26/// Compact, process-local cache reused by a TUI refresh worker.
27///
28/// It never stores raw JSON or complete [`CodeAnalysis`] values. File-backed
29/// entries retain only model usage, operation counters, dates, and a compact
30/// diagnostic summary. Database readers use the same container for their
31/// already-aggregated rows.
32#[derive(Default)]
33pub struct SummaryScanCache {
34    entries: FastHashMap<SummaryCacheKey, CachedSourceSummary>,
35    parsed_sources: usize,
36    total_parsed_sources: usize,
37    tier_fingerprint: u64,
38}
39
40impl SummaryScanCache {
41    /// Creates an empty cache.
42    pub fn new() -> Self {
43        Self::default()
44    }
45
46    /// Starts one refresh cycle and resets its parse counter.
47    pub(crate) fn begin_scan(&mut self) {
48        self.parsed_sources = 0;
49    }
50
51    /// Drops every entry when the context-tier snapshot changed.
52    ///
53    /// Cached summaries embed the per-request tier classification, so a new
54    /// thresholds snapshot (daily pricing reload, or pricing becoming
55    /// available after an offline start) must invalidate them; unchanged
56    /// snapshots keep the incremental behavior.
57    pub(crate) fn ensure_tier_fingerprint(&mut self, fingerprint: u64) {
58        if self.tier_fingerprint != fingerprint {
59            self.entries.clear();
60            self.tier_fingerprint = fingerprint;
61        }
62    }
63
64    /// Records that a source loader ran during this refresh.
65    pub(crate) fn record_parse(&mut self) {
66        self.parsed_sources += 1;
67        self.total_parsed_sources += 1;
68    }
69
70    /// Returns current and cumulative cache statistics.
71    pub fn stats(&self) -> SummaryScanCacheStats {
72        SummaryScanCacheStats {
73            entries: self.entries.len(),
74            parsed_sources: self.parsed_sources,
75            total_parsed_sources: self.total_parsed_sources,
76        }
77    }
78
79    pub(crate) fn get(
80        &self,
81        key: &SummaryCacheKey,
82        fingerprint: &SourceFingerprint,
83    ) -> Option<&CachedSourceSummary> {
84        self.entries
85            .get(key)
86            .filter(|entry| &entry.fingerprint == fingerprint)
87    }
88
89    pub(crate) fn insert(
90        &mut self,
91        key: SummaryCacheKey,
92        fingerprint: SourceFingerprint,
93        summary: CompactSourceSummary,
94        parsed: bool,
95        failure: Option<String>,
96    ) {
97        self.entries.insert(
98            key,
99            CachedSourceSummary {
100                fingerprint,
101                summary,
102                parsed,
103                failure,
104            },
105        );
106    }
107
108    pub(crate) fn retain_kinds(
109        &mut self,
110        seen: &FastHashSet<SummaryCacheKey>,
111        kinds: &[SummaryKind],
112    ) {
113        self.entries
114            .retain(|key, _| !kinds.contains(&key.kind) || seen.contains(key));
115    }
116
117    /// Keeps existing entries for a provider when source discovery was partial.
118    pub(crate) fn preserve_provider_keys(
119        &self,
120        seen: &mut FastHashSet<SummaryCacheKey>,
121        kind: SummaryKind,
122        provider: ExtensionType,
123    ) {
124        seen.extend(
125            self.entries
126                .keys()
127                .filter(|key| key.kind == kind && key.provider == provider)
128                .cloned(),
129        );
130    }
131}
132
133/// Which compact projection a database-backed cache entry contains.
134#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
135pub(crate) enum SummaryKind {
136    File,
137    UsageDatabase,
138    AnalysisDatabase,
139}
140
141/// Stable source identity, including the effective date cutoff.
142#[derive(Debug, Clone, PartialEq, Eq, Hash)]
143pub(crate) struct SummaryCacheKey {
144    kind: SummaryKind,
145    provider: ExtensionType,
146    path: PathBuf,
147    cutoff: Option<String>,
148}
149
150impl SummaryCacheKey {
151    pub(crate) fn new(
152        kind: SummaryKind,
153        provider: ExtensionType,
154        path: &Path,
155        time_range: TimeRange,
156    ) -> Self {
157        Self {
158            kind,
159            provider,
160            path: path.to_path_buf(),
161            cutoff: time_range
162                .cutoff_date()
163                .map(|date| date.format("%Y-%m-%d").to_string()),
164        }
165    }
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169struct FileStamp {
170    modified: SystemTime,
171    len: u64,
172}
173
174/// Fingerprint of one source and every sidecar that changes its result.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub(crate) struct SourceFingerprint(Vec<(PathBuf, Option<FileStamp>)>);
177
178impl SourceFingerprint {
179    /// Fingerprints a file source and provider-specific dependencies.
180    pub(crate) fn file(path: &Path, provider: ExtensionType) -> Result<Self> {
181        let mut paths = vec![(path.to_path_buf(), Some(required_stamp(path)?))];
182        if provider == ExtensionType::Grok {
183            paths.push((
184                path.with_file_name("summary.json"),
185                optional_stamp(&path.with_file_name("summary.json"))?,
186            ));
187            paths.push((
188                path.with_file_name("updates.jsonl"),
189                optional_stamp(&path.with_file_name("updates.jsonl"))?,
190            ));
191            if let Some(workspace) = path.parent().and_then(Path::parent) {
192                let cwd = workspace.join(".cwd");
193                paths.push((cwd.clone(), optional_stamp(&cwd)?));
194            }
195        }
196        Ok(Self(paths))
197    }
198
199    /// Fingerprints a SQLite database, its WAL, and optional extra databases.
200    pub(crate) fn sqlite(path: &Path, extras: &[&Path]) -> Result<Self> {
201        let mut paths = Vec::with_capacity(2 + extras.len() * 2);
202        push_sqlite_stamps(&mut paths, path, true)?;
203        for extra in extras {
204            push_sqlite_stamps(&mut paths, extra, false)?;
205        }
206        Ok(Self(paths))
207    }
208
209    /// Fingerprints a SQLite source plus a previously validated dependency
210    /// snapshot. This prevents a cached Cursor summary from pairing model map A
211    /// with tracking fingerprint B when the tracking DB changes mid-read.
212    pub(crate) fn sqlite_with_dependency(
213        path: &Path,
214        dependency_path: &Path,
215        dependency: Option<&DatabaseFingerprint>,
216    ) -> Result<Self> {
217        let mut paths = Vec::with_capacity(4);
218        push_sqlite_stamps(&mut paths, path, true)?;
219        match dependency {
220            Some(fingerprint) => {
221                paths.push((
222                    dependency_path.to_path_buf(),
223                    Some(FileStamp {
224                        modified: fingerprint.database.modified,
225                        len: fingerprint.database.length,
226                    }),
227                ));
228                let wal = append_suffix(dependency_path, "-wal");
229                paths.push((
230                    wal,
231                    fingerprint.wal.as_ref().map(|stamp| FileStamp {
232                        modified: stamp.modified,
233                        len: stamp.length,
234                    }),
235                ));
236            }
237            None => {
238                paths.push((dependency_path.to_path_buf(), None));
239                paths.push((append_suffix(dependency_path, "-wal"), None));
240            }
241        }
242        Ok(Self(paths))
243    }
244}
245
246fn required_stamp(path: &Path) -> Result<FileStamp> {
247    let metadata = fs::metadata(path)?;
248    Ok(FileStamp {
249        modified: metadata.modified()?,
250        len: metadata.len(),
251    })
252}
253
254fn optional_stamp(path: &Path) -> Result<Option<FileStamp>> {
255    let metadata = match fs::metadata(path) {
256        Ok(metadata) => metadata,
257        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
258        Err(error) => return Err(error.into()),
259    };
260    Ok(Some(FileStamp {
261        modified: metadata.modified()?,
262        len: metadata.len(),
263    }))
264}
265
266fn push_sqlite_stamps(
267    paths: &mut Vec<(PathBuf, Option<FileStamp>)>,
268    path: &Path,
269    required: bool,
270) -> Result<()> {
271    let stamp = if required {
272        Some(required_stamp(path)?)
273    } else {
274        optional_stamp(path)?
275    };
276    paths.push((path.to_path_buf(), stamp));
277    let wal = append_suffix(path, "-wal");
278    paths.push((wal.clone(), optional_stamp(&wal)?));
279    Ok(())
280}
281
282/// Cached result and its source-level diagnostic state.
283pub(crate) struct CachedSourceSummary {
284    fingerprint: SourceFingerprint,
285    pub(crate) summary: CompactSourceSummary,
286    pub(crate) parsed: bool,
287    pub(crate) failure: Option<String>,
288}
289
290/// Compact contribution retained between refreshes.
291#[derive(Debug, Clone, Default)]
292pub(crate) struct CompactSourceSummary {
293    /// File-parser usage values, retained in their provider-specific shape so
294    /// the diagnostics-aware collector stays identical to the public legacy
295    /// aggregation API.
296    pub(crate) usage: UsageResult,
297    /// Typed database usage. SQLite rows never need a per-row JSON object or
298    /// model map in the incremental path.
299    pub(crate) database_usage: FastHashMap<String, UsageTokenContribution>,
300    pub(crate) stored_costs: FastHashMap<String, f64>,
301    pub(crate) usage_dates: HashSet<String>,
302    pub(crate) analysis: FastHashMap<String, AggregatedAnalysisRow>,
303    pub(crate) analysis_dates: HashSet<String>,
304}
305
306impl CompactSourceSummary {
307    /// Consumes a UsageOnly parse without retaining the full analysis.
308    pub(crate) fn from_file(analysis: CodeAnalysis, date: String, emit_analysis: bool) -> Self {
309        let mut summary = Self::default();
310        summary.add_analysis(analysis, date, 0.0, emit_analysis);
311        summary
312    }
313
314    /// Folds one compact database usage row without materializing CodeAnalysis.
315    pub(crate) fn add_usage_contribution(&mut self, contribution: UsageContribution) {
316        let UsageContribution {
317            date,
318            timestamp_ms: _,
319            model,
320            tokens,
321            stored_cost,
322        } = contribution;
323        let date_has_usage = stored_cost != 0.0 || tokens.has_activity();
324        *self.stored_costs.entry(model.clone()).or_insert(0.0) += stored_cost;
325        self.database_usage
326            .entry(model)
327            .and_modify(|existing| existing.merge(tokens))
328            .or_insert(tokens);
329        if date_has_usage {
330            self.usage_dates.insert(date);
331        }
332    }
333
334    /// Folds one owned analysis row and optional provider-stored cost.
335    pub(crate) fn add_analysis(
336        &mut self,
337        analysis: CodeAnalysis,
338        date: String,
339        stored_cost: f64,
340        emit_analysis: bool,
341    ) {
342        let mut date_has_usage = stored_cost != 0.0;
343        for record in analysis.records {
344            let counters = (
345                record.total_edit_lines,
346                record.total_read_lines,
347                record.total_write_lines,
348                record.tool_call_counts.bash,
349                record.tool_call_counts.edit,
350                record.tool_call_counts.read,
351                record.tool_call_counts.todo_write,
352                record.tool_call_counts.write,
353            );
354
355            for (model, usage) in record.conversation_usage {
356                if meaningful_usage(&usage) {
357                    date_has_usage = true;
358                }
359                if stored_cost != 0.0 {
360                    *self.stored_costs.entry(model.clone()).or_insert(0.0) += stored_cost;
361                }
362                merge_model_usage(&mut self.usage, model.clone(), usage);
363
364                if emit_analysis && !model.contains("<synthetic>") {
365                    let row = self.analysis.entry(model.clone()).or_insert_with(|| {
366                        AggregatedAnalysisRow {
367                            model,
368                            edit_lines: 0,
369                            read_lines: 0,
370                            write_lines: 0,
371                            bash_count: 0,
372                            edit_count: 0,
373                            read_count: 0,
374                            todo_write_count: 0,
375                            write_count: 0,
376                        }
377                    });
378                    row.edit_lines += counters.0;
379                    row.read_lines += counters.1;
380                    row.write_lines += counters.2;
381                    row.bash_count += counters.3;
382                    row.edit_count += counters.4;
383                    row.read_count += counters.5;
384                    row.todo_write_count += counters.6;
385                    row.write_count += counters.7;
386                }
387            }
388
389            for (model, usage) in record.advisor_usage {
390                if meaningful_usage(&usage) {
391                    date_has_usage = true;
392                }
393                merge_model_usage(&mut self.usage, model, usage);
394            }
395        }
396
397        if date_has_usage {
398            self.usage_dates.insert(date.clone());
399        }
400        if emit_analysis {
401            self.analysis_dates.insert(date);
402        }
403    }
404}
405
406fn merge_model_usage(result: &mut UsageResult, model: String, usage: serde_json::Value) {
407    result
408        .entry(model)
409        .and_modify(|existing| merge_usage_values(existing, &usage))
410        .or_insert(usage);
411}
412
413fn meaningful_usage(value: &serde_json::Value) -> bool {
414    extract_token_counts(value).has_activity()
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use crate::models::{CodeAnalysisRecord, CodeAnalysisToolCalls};
421    use serde_json::json;
422
423    fn analysis_with_usage(tokens: i64) -> CodeAnalysis {
424        analysis_with_usage_value(json!({ "input_tokens": tokens }))
425    }
426
427    fn analysis_with_usage_value(value: serde_json::Value) -> CodeAnalysis {
428        let mut usage = FastHashMap::default();
429        usage.insert("model".to_string(), value);
430        CodeAnalysis {
431            user: String::new(),
432            extension_name: "Codex".to_string(),
433            insights_version: String::new(),
434            machine_id: String::new(),
435            records: vec![CodeAnalysisRecord {
436                total_unique_files: 0,
437                total_write_lines: 0,
438                total_read_lines: 0,
439                total_edit_lines: 0,
440                total_write_characters: 0,
441                total_read_characters: 0,
442                total_edit_characters: 0,
443                write_file_details: Vec::new(),
444                read_file_details: Vec::new(),
445                edit_file_details: Vec::new(),
446                run_command_details: Vec::new(),
447                tool_call_counts: CodeAnalysisToolCalls::default(),
448                conversation_usage: usage,
449                advisor_usage: FastHashMap::default(),
450                task_id: String::new(),
451                timestamp: 0,
452                folder_path: String::new(),
453                git_remote_url: String::new(),
454            }],
455        }
456    }
457
458    #[test]
459    fn zero_usage_does_not_mark_an_active_day() {
460        let summary =
461            CompactSourceSummary::from_file(analysis_with_usage(0), "2026-07-14".to_string(), true);
462        assert!(summary.usage_dates.is_empty());
463    }
464
465    #[test]
466    fn nonzero_usage_marks_an_active_day() {
467        let summary =
468            CompactSourceSummary::from_file(analysis_with_usage(1), "2026-07-14".to_string(), true);
469        assert!(summary.usage_dates.contains("2026-07-14"));
470    }
471
472    #[test]
473    fn published_total_alone_marks_an_active_day() {
474        let summary = CompactSourceSummary::from_file(
475            analysis_with_usage_value(json!({
476                "total_token_usage": { "total_tokens": 7 }
477            })),
478            "2026-07-14".to_string(),
479            false,
480        );
481        assert!(summary.usage_dates.contains("2026-07-14"));
482    }
483
484    #[test]
485    fn tool_only_usage_marks_an_active_day() {
486        let summary = CompactSourceSummary::from_file(
487            analysis_with_usage_value(json!({
488                "input_tokens": 0,
489                "output_tokens": 0,
490                "tool_tokens": 5,
491                "total_tokens": 5
492            })),
493            "2026-07-14".to_string(),
494            false,
495        );
496        assert!(summary.usage_dates.contains("2026-07-14"));
497    }
498
499    #[test]
500    fn emitted_synthetic_session_keeps_analysis_active_day() {
501        let mut analysis = analysis_with_usage(0);
502        let usage = analysis.records[0]
503            .conversation_usage
504            .remove("model")
505            .unwrap();
506        analysis.records[0]
507            .conversation_usage
508            .insert("<synthetic>".to_string(), usage);
509
510        let summary = CompactSourceSummary::from_file(analysis, "2026-07-14".to_string(), true);
511        assert!(summary.analysis.is_empty());
512        assert!(summary.analysis_dates.contains("2026-07-14"));
513    }
514
515    #[test]
516    fn dependency_fingerprint_stays_paired_with_its_snapshot() {
517        let dir = tempfile::tempdir().unwrap();
518        let store = dir.path().join("store.db");
519        let tracking = dir.path().join("tracking.db");
520        std::fs::write(&store, b"store").unwrap();
521        std::fs::write(&tracking, b"model-a").unwrap();
522        let snapshot = crate::session::sqlite::database_fingerprint(&tracking).unwrap();
523
524        let paired =
525            SourceFingerprint::sqlite_with_dependency(&store, &tracking, Some(&snapshot)).unwrap();
526        std::fs::write(&tracking, b"model-b-longer").unwrap();
527        let current = crate::session::sqlite::database_fingerprint(&tracking).unwrap();
528        let changed =
529            SourceFingerprint::sqlite_with_dependency(&store, &tracking, Some(&current)).unwrap();
530
531        assert_ne!(paired, changed);
532    }
533}