1mod format;
2mod io;
3mod model;
4
5pub use format::*;
6pub use model::*;
7
8pub fn project_stores() -> Vec<(String, StatsStore)> {
11 io::project_keys()
12 .into_iter()
13 .filter_map(|key| {
14 io::load_projects(std::iter::once(key.as_str()))
15 .pop()
16 .map(|store| (key, store))
17 })
18 .collect()
19}
20
21use std::collections::HashMap;
22use std::sync::Mutex;
23use std::time::Instant;
24
25static STATS_BUFFER: Mutex<Option<(StatsStore, StatsStore, Instant)>> = Mutex::new(None);
27
28const FLUSH_INTERVAL_SECS: u64 = 2;
29
30pub(super) const MAX_DAILY_HISTORY_DAYS: usize = 3650;
35
36pub fn load() -> StatsStore {
37 let guard = STATS_BUFFER
38 .lock()
39 .unwrap_or_else(std::sync::PoisonError::into_inner);
40 if let Some((ref current, ref baseline, _)) = *guard {
41 let disk = io::load_from_disk();
42 return io::apply_deltas(&disk, current, baseline);
43 }
44 drop(guard);
45 io::load_from_disk()
46}
47
48pub fn load_for_display() -> StatsStore {
64 let primary = load();
65 if std::env::var_os("LEAN_CTX_DATA_DIR").is_some() {
67 return primary;
68 }
69 let primary_dir = crate::core::data_dir::lean_ctx_data_dir()
70 .ok()
71 .and_then(|p| std::fs::canonicalize(&p).ok());
72 let siblings: Vec<StatsStore> = crate::core::data_dir::all_data_dirs_with_stats()
73 .into_iter()
74 .filter(|d| std::fs::canonicalize(d).ok() != primary_dir)
75 .map(|d| io::load_from_dir(&d))
76 .collect();
77 aggregate_for_display(primary, &siblings)
78}
79
80fn aggregate_for_display(primary: StatsStore, siblings: &[StatsStore]) -> StatsStore {
85 let zero = StatsStore::default();
86 siblings
87 .iter()
88 .fold(primary, |acc, other| io::apply_deltas(&acc, other, &zero))
89}
90
91pub fn save(store: &StatsStore) {
92 io::locked_write(store);
93}
94
95pub fn merge_additive(base: &StatsStore, other: &StatsStore) -> StatsStore {
98 let zero = StatsStore::default();
99 io::apply_deltas(base, other, &zero)
100}
101
102fn maybe_flush(store: &mut StatsStore, baseline: &mut StatsStore, last_flush: &mut Instant) {
103 if last_flush.elapsed().as_secs() >= FLUSH_INTERVAL_SECS
104 && let Some(merged) = io::merge_and_save(store, baseline)
105 {
106 *store = merged.clone();
107 *baseline = merged;
108 *last_flush = Instant::now();
109 }
110}
111
112pub fn flush() {
113 let mut guard = STATS_BUFFER
114 .lock()
115 .unwrap_or_else(std::sync::PoisonError::into_inner);
116 if let Some((ref mut store, ref mut baseline, ref mut last_flush)) = *guard
120 && store != baseline
121 && let Some(merged) = io::merge_and_save(store, baseline)
122 {
123 *store = merged.clone();
124 *baseline = merged;
125 *last_flush = Instant::now();
126 }
127}
128
129pub fn flush_if_due() {
133 let due = {
134 let guard = STATS_BUFFER
135 .lock()
136 .unwrap_or_else(std::sync::PoisonError::into_inner);
137 match guard.as_ref() {
138 Some((_, _, last_flush)) => last_flush.elapsed().as_secs() >= FLUSH_INTERVAL_SECS,
139 None => false,
140 }
141 };
142 if due {
143 flush();
144 }
145}
146
147pub fn adjust_savings(command: &str, over_report_delta: i64) {
153 let mut guard = STATS_BUFFER
154 .lock()
155 .unwrap_or_else(std::sync::PoisonError::into_inner);
156 let Some((store, baseline, last_flush)) = guard.as_mut() else {
157 return;
158 };
159 apply_adjust(store, command, over_report_delta);
160 if let Some(merged) = io::merge_and_save(store, baseline) {
161 *store = merged.clone();
162 *baseline = merged;
163 *last_flush = Instant::now();
164 }
165 drop(guard);
166 record_for_project(|project| apply_adjust(project, command, over_report_delta));
167}
168
169fn apply_adjust(store: &mut StatsStore, command: &str, over_report_delta: i64) {
170 let cmd_key = format::normalize_command(command);
171 let stream_tracked = classify_command(&cmd_key) == TrafficClass::Compressible;
172 if over_report_delta > 0 {
173 let adj = over_report_delta as u64;
174 store.total_output_tokens = store.total_output_tokens.saturating_add(adj);
175 if let Some(cmd) = store.commands.get_mut(&cmd_key) {
176 cmd.output_tokens = cmd.output_tokens.saturating_add(adj);
177 }
178 if stream_tracked {
179 store.first_inject_tokens_saved = store.first_inject_tokens_saved.saturating_sub(adj);
180 store.active_tool_result_tokens_saved =
181 store.active_tool_result_tokens_saved.saturating_sub(adj);
182 }
183 } else {
184 let adj = over_report_delta.unsigned_abs();
185 store.total_output_tokens = store.total_output_tokens.saturating_sub(adj);
186 if let Some(cmd) = store.commands.get_mut(&cmd_key) {
187 cmd.output_tokens = cmd.output_tokens.saturating_sub(adj);
188 }
189 if stream_tracked {
190 store.first_inject_tokens_saved = store.first_inject_tokens_saved.saturating_add(adj);
191 if store.last_tool_result_turn > 0 {
192 store.active_tool_result_tokens_saved =
193 store.active_tool_result_tokens_saved.saturating_add(adj);
194 }
195 }
196 }
197}
198
199pub fn record(command: &str, input_tokens: usize, output_tokens: usize) {
200 record_at_turn(command, input_tokens, output_tokens, 0);
201}
202
203pub fn record_reread(tokens_saved: usize) {
204 let mut guard = STATS_BUFFER
205 .lock()
206 .unwrap_or_else(std::sync::PoisonError::into_inner);
207 if guard.is_none() {
208 let disk = io::load_from_disk();
209 *guard = Some((disk.clone(), disk, Instant::now()));
210 }
211 let Some((store, baseline, last_flush)) = guard.as_mut() else {
212 return;
213 };
214 store.reread_tokens_saved = store
215 .reread_tokens_saved
216 .saturating_add(tokens_saved as u64);
217 if let Some(merged) = io::merge_and_save(store, baseline) {
218 *store = merged.clone();
219 *baseline = merged;
220 *last_flush = Instant::now();
221 }
222 drop(guard);
223 record_for_project(|project| {
224 project.reread_tokens_saved = project
225 .reread_tokens_saved
226 .saturating_add(tokens_saved as u64);
227 });
228}
229
230pub fn record_at_turn(command: &str, input_tokens: usize, output_tokens: usize, turn: u64) {
233 let mut guard = STATS_BUFFER
234 .lock()
235 .unwrap_or_else(std::sync::PoisonError::into_inner);
236 if guard.is_none() {
237 let disk = io::load_from_disk();
238 *guard = Some((disk.clone(), disk, Instant::now()));
239 }
240 let Some((store, baseline, last_flush)) = guard.as_mut() else {
241 return;
242 };
243
244 apply_record(store, command, input_tokens, output_tokens, turn);
245
246 if let Some(merged) = io::merge_and_save(store, baseline) {
250 *store = merged.clone();
251 *baseline = merged;
252 *last_flush = Instant::now();
253 }
254 drop(guard);
255 record_for_project(|project| {
256 apply_record(project, command, input_tokens, output_tokens, turn);
257 });
258}
259
260fn record_for_project(apply: impl FnOnce(&mut StatsStore)) {
263 if let Some(key) = crate::core::savings_ledger::project_scope::current_project_key() {
264 io::update_project(&key, apply);
265 }
266}
267
268pub fn load_for_project(scope: &crate::core::savings_ledger::ProjectScope) -> StatsStore {
270 let zero = StatsStore::default();
271 let hashes = scope.repo_hashes();
272 io::load_projects(hashes.iter().map(String::as_str))
273 .iter()
274 .fold(StatsStore::default(), |acc, other| {
275 io::apply_deltas(&acc, other, &zero)
276 })
277}
278
279pub fn load_all_projects() -> Vec<(String, StatsStore)> {
281 io::project_keys()
282 .into_iter()
283 .filter_map(|key| {
284 let store = io::load_projects(std::iter::once(key.as_str())).pop()?;
285 Some((key, store))
286 })
287 .collect()
288}
289
290fn apply_record(
292 store: &mut StatsStore,
293 command: &str,
294 input_tokens: usize,
295 output_tokens: usize,
296 turn: u64,
297) {
298 let now = chrono::Local::now();
300 let today = now.format("%Y-%m-%d").to_string();
301 let timestamp = now.to_rfc3339();
302
303 store.total_commands = store.total_commands.saturating_add(1);
304 store.total_input_tokens = store.total_input_tokens.saturating_add(input_tokens as u64);
305 store.total_output_tokens = store
306 .total_output_tokens
307 .saturating_add(output_tokens as u64);
308
309 if store.first_use.is_none() {
310 store.first_use = Some(timestamp.clone());
311 }
312 store.last_use = Some(timestamp);
313
314 let cmd_key = format::normalize_command(command);
315 if classify_command(&cmd_key) == TrafficClass::Compressible {
316 let saved = input_tokens.saturating_sub(output_tokens) as u64;
317 store.record_tool_result_savings(saved, turn);
318 }
319 store
320 .command_classes
321 .insert(cmd_key.clone(), classify_command(&cmd_key));
322 let is_compressible = classify_command(&cmd_key) == TrafficClass::Compressible;
323 let entry = store.commands.entry(cmd_key).or_default();
324 entry.count = entry.count.saturating_add(1);
325 entry.input_tokens = entry.input_tokens.saturating_add(input_tokens as u64);
326 entry.output_tokens = entry.output_tokens.saturating_add(output_tokens as u64);
327
328 let current_version = env!("CARGO_PKG_VERSION").to_string();
329 let ci = if is_compressible {
330 input_tokens as u64
331 } else {
332 0
333 };
334 let co = if is_compressible {
335 output_tokens as u64
336 } else {
337 0
338 };
339 if let Some(day) = store.daily.last_mut() {
340 if day.date == today {
341 day.commands = day.commands.saturating_add(1);
342 day.input_tokens = day.input_tokens.saturating_add(input_tokens as u64);
343 day.output_tokens = day.output_tokens.saturating_add(output_tokens as u64);
344 day.compressible_input_tokens = day.compressible_input_tokens.saturating_add(ci);
345 day.compressible_output_tokens = day.compressible_output_tokens.saturating_add(co);
346 day.version = current_version;
347 } else {
348 store.daily.push(DayStats {
349 date: today,
350 commands: 1,
351 input_tokens: input_tokens as u64,
352 output_tokens: output_tokens as u64,
353 compressible_input_tokens: ci,
354 compressible_output_tokens: co,
355 version: current_version,
356 });
357 }
358 } else {
359 store.daily.push(DayStats {
360 date: today,
361 commands: 1,
362 input_tokens: input_tokens as u64,
363 output_tokens: output_tokens as u64,
364 compressible_input_tokens: ci,
365 compressible_output_tokens: co,
366 version: current_version,
367 });
368 }
369
370 if store.daily.len() > MAX_DAILY_HISTORY_DAYS {
371 store
372 .daily
373 .drain(..store.daily.len() - MAX_DAILY_HISTORY_DAYS);
374 }
375}
376
377pub fn reset_cep() {
378 let mut guard = STATS_BUFFER
379 .lock()
380 .unwrap_or_else(std::sync::PoisonError::into_inner);
381 let mut store = io::load_from_disk();
382 store.cep = CepStats::default();
383 io::locked_write(&store);
384 *guard = Some((store.clone(), store, Instant::now()));
385}
386
387pub fn reset_all() {
388 let mut guard = STATS_BUFFER
389 .lock()
390 .unwrap_or_else(std::sync::PoisonError::into_inner);
391 let store = StatsStore::default();
392 io::locked_write(&store);
393 *guard = Some((store.clone(), store, Instant::now()));
394 crate::core::heatmap::reset();
395}
396
397pub fn load_stats() -> GainSummary {
398 let store = load();
399 let input_saved = store
400 .total_input_tokens
401 .saturating_sub(store.total_output_tokens);
402 GainSummary {
403 total_saved: input_saved,
404 total_calls: store.total_commands,
405 }
406}
407
408#[allow(clippy::too_many_arguments)]
409pub fn record_cep_session(
410 score: u32,
411 cache_hits: u64,
412 cache_reads: u64,
413 tokens_original: u64,
414 tokens_compressed: u64,
415 modes: &HashMap<String, u64>,
416 tool_calls: u64,
417 complexity: &str,
418) {
419 let mut guard = STATS_BUFFER
420 .lock()
421 .unwrap_or_else(std::sync::PoisonError::into_inner);
422 if guard.is_none() {
423 let disk = io::load_from_disk();
424 *guard = Some((disk.clone(), disk, Instant::now()));
425 }
426 let Some((store, baseline, last_flush)) = guard.as_mut() else {
427 return;
428 };
429
430 apply_cep_snapshot(
431 &mut store.cep,
432 std::process::id(),
433 score,
434 cache_hits,
435 cache_reads,
436 tokens_original,
437 tokens_compressed,
438 modes,
439 tool_calls,
440 complexity,
441 );
442
443 maybe_flush(store, baseline, last_flush);
444 drop(guard);
445 record_for_project(|project| {
446 apply_cep_snapshot(
447 &mut project.cep,
448 std::process::id(),
449 score,
450 cache_hits,
451 cache_reads,
452 tokens_original,
453 tokens_compressed,
454 modes,
455 tool_calls,
456 complexity,
457 );
458 });
459}
460
461#[allow(clippy::too_many_arguments)]
471fn apply_cep_snapshot(
472 cep: &mut CepStats,
473 pid: u32,
474 score: u32,
475 cache_hits: u64,
476 cache_reads: u64,
477 tokens_original: u64,
478 tokens_compressed: u64,
479 modes: &HashMap<String, u64>,
480 tool_calls: u64,
481 complexity: &str,
482) {
483 let prev_original = cep.last_session_original.unwrap_or(0);
484 let prev_compressed = cep.last_session_compressed.unwrap_or(0);
485 let prev_cache_hits = cep.last_session_cache_hits.unwrap_or(0);
486 let prev_cache_reads = cep.last_session_cache_reads.unwrap_or(0);
487 let is_same_session = cep.last_session_pid == Some(pid);
488
489 if is_same_session {
490 cep.total_tokens_original += tokens_original.saturating_sub(prev_original);
491 cep.total_tokens_compressed += tokens_compressed.saturating_sub(prev_compressed);
492 cep.total_cache_hits += cache_hits.saturating_sub(prev_cache_hits);
493 cep.total_cache_reads += cache_reads.saturating_sub(prev_cache_reads);
494 } else {
495 cep.sessions += 1;
496 cep.total_cache_hits += cache_hits;
497 cep.total_cache_reads += cache_reads;
498 cep.total_tokens_original += tokens_original;
499 cep.total_tokens_compressed += tokens_compressed;
500
501 for (mode, count) in modes {
502 *cep.modes.entry(mode.clone()).or_insert(0) += count;
503 }
504 }
505
506 cep.last_session_pid = Some(pid);
507 cep.last_session_original = Some(tokens_original);
508 cep.last_session_compressed = Some(tokens_compressed);
509 cep.last_session_cache_hits = Some(cache_hits);
510 cep.last_session_cache_reads = Some(cache_reads);
511
512 let cache_hit_rate = if cache_reads > 0 {
513 (cache_hits as f64 / cache_reads as f64 * 100.0).round() as u32
514 } else {
515 0
516 };
517
518 let compression_rate = if tokens_original > 0 {
519 ((tokens_original - tokens_compressed) as f64 / tokens_original as f64 * 100.0).round()
520 as u32
521 } else {
522 0
523 };
524
525 let total_modes = 6u32;
526 let mode_diversity =
527 ((modes.len() as f64 / total_modes as f64).min(1.0) * 100.0).round() as u32;
528
529 let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
530
531 cep.scores.push(CepSessionSnapshot {
532 timestamp: chrono::Local::now().to_rfc3339(),
533 score,
534 cache_hit_rate,
535 mode_diversity,
536 compression_rate,
537 tool_calls,
538 tokens_saved,
539 complexity: complexity.to_string(),
540 });
541
542 if cep.scores.len() > 100 {
543 cep.scores.drain(..cep.scores.len() - 100);
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550
551 fn make_store(commands: u64, input: u64, output: u64) -> StatsStore {
552 StatsStore {
553 total_commands: commands,
554 total_input_tokens: input,
555 total_output_tokens: output,
556 ..Default::default()
557 }
558 }
559
560 #[test]
565 fn a_flush_with_nothing_new_does_not_rewrite_stats() {
566 let dir = crate::core::data_dir::isolated_data_dir();
567 record("ctx_read", 100, 40);
568 let stats_path = dir.path().join("stats.json");
569 let identity = || {
570 let meta = std::fs::metadata(&stats_path).expect("stats.json written");
571 #[cfg(unix)]
572 let ino = {
573 use std::os::unix::fs::MetadataExt;
574 meta.ino()
575 };
576 #[cfg(not(unix))]
577 let ino = 0;
578 (ino, meta.modified().unwrap())
579 };
580 let first = identity();
581 std::thread::sleep(std::time::Duration::from_millis(20));
582 flush();
583 flush();
584 assert_eq!(
585 identity(),
586 first,
587 "a flush with nothing new rewrote stats.json"
588 );
589 assert_eq!(load().total_commands, io::load_from_disk().total_commands);
590 }
591
592 #[test]
597 fn corrupt_stats_file_is_quarantined_not_silently_reset() {
598 let dir = crate::core::data_dir::isolated_data_dir();
599 let stats_path = dir.path().join("stats.json");
600 let quarantine = dir.path().join("stats.json.corrupt");
601 let truncated = r#"{"total_commands": 15677, "total_input_tok"#;
602 std::fs::write(&stats_path, truncated).unwrap();
603
604 let loaded = io::load_from_disk();
605 assert_eq!(loaded.total_commands, 0, "fresh store after corruption");
606 assert!(
607 !stats_path.exists(),
608 "corrupt file must be moved aside, not left to be overwritten"
609 );
610 assert_eq!(
611 std::fs::read_to_string(&quarantine).unwrap(),
612 truncated,
613 "quarantine preserves the corrupt bytes verbatim for recovery"
614 );
615
616 std::fs::write(&stats_path, "{ newer corruption").unwrap();
618 let loaded = io::load_from_disk();
619 assert_eq!(loaded.total_commands, 0);
620 assert_eq!(
621 std::fs::read_to_string(&quarantine).unwrap(),
622 truncated,
623 "the OLDER quarantine wins — it is closest to the lost history"
624 );
625
626 let healthy = make_store(42, 9000, 1000);
628 std::fs::write(&stats_path, serde_json::to_string(&healthy).unwrap()).unwrap();
629 assert_eq!(io::load_from_disk().total_commands, 42);
630 }
631
632 #[test]
635 fn each_project_keeps_its_own_counters() {
636 let _dir = crate::core::data_dir::isolated_data_dir();
637 let projects = tempfile::tempdir().unwrap();
638 let base = crate::core::pathutil::safe_canonicalize_or_self(projects.path());
639 let alpha = base.join("alpha");
640 let beta = base.join("beta");
641 let worktree = base.join("alpha-wt");
642 std::fs::create_dir_all(alpha.join(".git/worktrees/wt")).unwrap();
643 std::fs::create_dir_all(beta.join(".git")).unwrap();
644 std::fs::create_dir_all(&worktree).unwrap();
645 std::fs::write(
646 worktree.join(".git"),
647 format!("gitdir: {}\n", alpha.join(".git/worktrees/wt").display()),
648 )
649 .unwrap();
650 for (folder, calls) in [(&alpha, 2), (&worktree, 1), (&beta, 1)] {
651 crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
652 std::cell::RefCell::new(Some(folder.clone())),
653 || {
654 for _ in 0..calls {
655 record_for_project(|s| apply_record(s, "ctx_read", 100, 40, 0));
656 }
657 },
658 );
659 }
660
661 let scope =
662 crate::core::savings_ledger::ProjectScope::from_sessions(&alpha, std::iter::empty());
663 let mine = load_for_project(&scope);
664 assert_eq!(
665 mine.total_commands, 3,
666 "the worktree counts for its project"
667 );
668 assert_eq!(mine.commands["ctx_read"].input_tokens, 300);
669 let all = load_all_projects();
670 assert_eq!(all.len(), 2);
671 assert_eq!(
672 all.iter().map(|(_, s)| s.total_commands).sum::<u64>(),
673 4,
674 "the projects add up"
675 );
676 }
677
678 #[test]
679 fn aggregate_for_display_is_noop_without_siblings() {
680 let primary = make_store(7, 1000, 250);
683 let agg = aggregate_for_display(primary.clone(), &[]);
684 assert_eq!(agg.total_commands, 7);
685 assert_eq!(agg.total_input_tokens, 1000);
686 assert_eq!(agg.total_output_tokens, 250);
687 }
688
689 #[test]
690 fn aggregate_for_display_sums_split_dirs() {
691 let primary = make_store(0, 0, 0);
695 let mcp_dir = make_store(12, 8000, 1200);
696 let legacy_dir = make_store(3, 500, 100);
697
698 let agg = aggregate_for_display(primary, &[mcp_dir, legacy_dir]);
699
700 assert_eq!(agg.total_commands, 15, "12 (mcp) + 3 (legacy)");
701 assert_eq!(agg.total_input_tokens, 8500);
702 assert_eq!(agg.total_output_tokens, 1300);
703 let saved = agg
704 .total_input_tokens
705 .saturating_sub(agg.total_output_tokens);
706 assert_eq!(saved, 7200, "savings surface despite an empty primary dir");
707 }
708
709 #[test]
710 fn apply_deltas_merges_mcp_and_shell() {
711 let baseline = make_store(0, 0, 0);
712 let mut current = make_store(0, 0, 0);
713 current.total_commands = 5;
714 current.total_input_tokens = 1000;
715 current.total_output_tokens = 200;
716 current.commands.insert(
717 "ctx_read".to_string(),
718 CommandStats {
719 count: 5,
720 input_tokens: 1000,
721 output_tokens: 200,
722 },
723 );
724 current
725 .command_classes
726 .insert("ctx_read".into(), TrafficClass::Compressible);
727
728 let mut disk = make_store(20, 500, 490);
729 disk.commands.insert(
730 "echo".to_string(),
731 CommandStats {
732 count: 20,
733 input_tokens: 500,
734 output_tokens: 490,
735 },
736 );
737
738 let merged = io::apply_deltas(&disk, ¤t, &baseline);
739
740 assert_eq!(merged.total_commands, 25);
741 assert_eq!(merged.total_input_tokens, 1500);
742 assert_eq!(merged.total_output_tokens, 690);
743 assert_eq!(merged.commands["ctx_read"].count, 5);
744 assert_eq!(merged.commands["echo"].count, 20);
745 assert_eq!(
746 merged.command_classes["ctx_read"],
747 TrafficClass::Compressible
748 );
749 }
750
751 #[test]
752 fn apply_deltas_incremental_flush() {
753 let baseline = make_store(10, 200, 100);
754 let current = make_store(15, 700, 300);
755
756 let disk = make_store(30, 600, 500);
757
758 let merged = io::apply_deltas(&disk, ¤t, &baseline);
759
760 assert_eq!(merged.total_commands, 35);
761 assert_eq!(merged.total_input_tokens, 1100);
762 assert_eq!(merged.total_output_tokens, 700);
763 }
764
765 #[test]
766 fn apply_deltas_merges_stream_counters_without_replaying_baseline() {
767 let mut baseline = StatsStore {
768 first_inject_tokens_saved: 1_000,
769 reread_tokens_saved: 2_000,
770 active_tool_result_tokens_saved: 500,
771 last_tool_result_turn: 8,
772 stream_tracked_results: 2,
773 ..StatsStore::default()
774 };
775 let mut current = baseline.clone();
776 current.first_inject_tokens_saved = 1_400;
777 current.reread_tokens_saved = 2_900;
778 current.active_tool_result_tokens_saved = 700;
779 current.last_tool_result_turn = 10;
780 current.stream_tracked_results = 3;
781 let disk = StatsStore {
782 first_inject_tokens_saved: 5_000,
783 reread_tokens_saved: 7_000,
784 active_tool_result_tokens_saved: 1_000,
785 last_tool_result_turn: 9,
786 stream_tracked_results: 10,
787 ..StatsStore::default()
788 };
789
790 let merged = io::apply_deltas(&disk, ¤t, &baseline);
791 assert_eq!(merged.first_inject_tokens_saved, 5_400);
792 assert_eq!(merged.reread_tokens_saved, 7_900);
793 assert_eq!(merged.active_tool_result_tokens_saved, 1_200);
794 assert_eq!(merged.last_tool_result_turn, 10);
795 assert_eq!(merged.stream_tracked_results, 11);
796
797 baseline.first_inject_tokens_saved = current.first_inject_tokens_saved;
798 let no_replay = io::apply_deltas(&merged, ¤t, &baseline);
799 assert_eq!(no_replay.first_inject_tokens_saved, 5_400);
800 }
801
802 #[test]
803 fn merge_and_save_keeps_delta_when_lock_is_busy() {
804 let dir = crate::core::data_dir::isolated_data_dir();
805 let baseline = make_store(10, 200, 100);
806 let current = make_store(11, 300, 120);
807 let lock_path = dir.path().join(".stats.lock");
808 std::fs::write(&lock_path, "busy").unwrap();
809
810 assert!(io::merge_and_save(¤t, &baseline).is_none());
811
812 std::fs::remove_file(lock_path).unwrap();
813 let merged = io::merge_and_save(¤t, &baseline).unwrap();
814 assert_eq!(merged.total_commands, 1);
815 assert_eq!(merged.total_input_tokens, 100);
816 assert_eq!(merged.total_output_tokens, 20);
817 }
818
819 #[test]
820 fn apply_deltas_preserves_disk_commands() {
821 let baseline = make_store(0, 0, 0);
822 let mut current = make_store(2, 100, 50);
823 current.commands.insert(
824 "ctx_read".to_string(),
825 CommandStats {
826 count: 2,
827 input_tokens: 100,
828 output_tokens: 50,
829 },
830 );
831
832 let mut disk = make_store(10, 300, 280);
833 disk.commands.insert(
834 "echo".to_string(),
835 CommandStats {
836 count: 8,
837 input_tokens: 200,
838 output_tokens: 200,
839 },
840 );
841 disk.commands.insert(
842 "ctx_read".to_string(),
843 CommandStats {
844 count: 3,
845 input_tokens: 150,
846 output_tokens: 80,
847 },
848 );
849
850 let merged = io::apply_deltas(&disk, ¤t, &baseline);
851
852 assert_eq!(merged.commands["echo"].count, 8);
853 assert_eq!(merged.commands["ctx_read"].count, 5);
854 assert_eq!(merged.commands["ctx_read"].input_tokens, 250);
855 }
856
857 #[test]
858 fn merge_daily_combines_same_date() {
859 let baseline_daily = vec![];
860 let current_daily = vec![DayStats {
861 date: "2026-04-18".to_string(),
862 commands: 5,
863 input_tokens: 1000,
864 output_tokens: 200,
865 version: "3.7.0".to_string(),
866 compressible_input_tokens: 0,
867 compressible_output_tokens: 0,
868 }];
869 let mut merged_daily = vec![DayStats {
870 date: "2026-04-18".to_string(),
871 commands: 20,
872 input_tokens: 500,
873 output_tokens: 490,
874 version: String::new(),
875 compressible_input_tokens: 0,
876 compressible_output_tokens: 0,
877 }];
878
879 io::merge_daily(&mut merged_daily, ¤t_daily, &baseline_daily);
880
881 assert_eq!(merged_daily.len(), 1);
882 assert_eq!(merged_daily[0].commands, 25);
883 assert_eq!(merged_daily[0].input_tokens, 1500);
884 assert_eq!(merged_daily[0].version, "3.7.0");
886 }
887
888 #[test]
889 fn cep_snapshot_seeds_new_session() {
890 let mut cep = CepStats::default();
891 let modes = HashMap::from([("full".to_string(), 3)]);
892 apply_cep_snapshot(&mut cep, 100, 80, 5, 10, 1000, 200, &modes, 4, "Medium");
893 assert_eq!(cep.sessions, 1);
894 assert_eq!(cep.total_cache_hits, 5);
895 assert_eq!(cep.total_cache_reads, 10);
896 assert_eq!(cep.total_tokens_original, 1000);
897 assert_eq!(cep.total_tokens_compressed, 200);
898 assert_eq!(cep.scores.len(), 1);
899 }
900
901 #[test]
902 fn cep_snapshot_same_pid_accumulates_cache_delta() {
903 let mut cep = CepStats::default();
907 let modes = HashMap::new();
908 apply_cep_snapshot(&mut cep, 100, 80, 2, 4, 500, 100, &modes, 2, "Low");
909 apply_cep_snapshot(&mut cep, 100, 85, 9, 20, 1500, 300, &modes, 6, "Low");
911
912 assert_eq!(cep.sessions, 1, "same PID must not start a new session");
913 assert_eq!(cep.total_cache_hits, 9, "2 + delta(9-2)");
914 assert_eq!(cep.total_cache_reads, 20, "4 + delta(20-4)");
915 assert_eq!(cep.total_tokens_original, 1500);
916 assert_eq!(cep.total_tokens_compressed, 300);
917 assert_eq!(cep.scores.len(), 2);
918 }
919
920 #[test]
921 fn cep_snapshot_new_pid_starts_fresh_session() {
922 let mut cep = CepStats::default();
923 let modes = HashMap::new();
924 apply_cep_snapshot(&mut cep, 100, 80, 5, 10, 1000, 200, &modes, 4, "Medium");
925 apply_cep_snapshot(&mut cep, 200, 80, 3, 6, 800, 150, &modes, 4, "Medium");
926 assert_eq!(cep.sessions, 2);
927 assert_eq!(
928 cep.total_cache_hits, 8,
929 "5 (session 1) + 3 (session 2, fresh)"
930 );
931 assert_eq!(cep.total_cache_reads, 16);
932 }
933}