1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub struct SummaryScanCacheStats {
18 pub entries: usize,
20 pub parsed_sources: usize,
22 pub total_parsed_sources: usize,
24}
25
26#[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 pub fn new() -> Self {
43 Self::default()
44 }
45
46 pub(crate) fn begin_scan(&mut self) {
48 self.parsed_sources = 0;
49 }
50
51 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 pub(crate) fn record_parse(&mut self) {
66 self.parsed_sources += 1;
67 self.total_parsed_sources += 1;
68 }
69
70 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
135pub(crate) enum SummaryKind {
136 File,
137 UsageDatabase,
138 AnalysisDatabase,
139}
140
141#[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#[derive(Debug, Clone, PartialEq, Eq)]
176pub(crate) struct SourceFingerprint(Vec<(PathBuf, Option<FileStamp>)>);
177
178impl SourceFingerprint {
179 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 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 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
282pub(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#[derive(Debug, Clone, Default)]
292pub(crate) struct CompactSourceSummary {
293 pub(crate) usage: UsageResult,
297 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 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 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 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(¤t)).unwrap();
530
531 assert_ne!(paired, changed);
532 }
533}