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