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