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
82fn maybe_flush(store: &mut StatsStore, baseline: &mut StatsStore, last_flush: &mut Instant) {
83 if last_flush.elapsed().as_secs() >= FLUSH_INTERVAL_SECS
84 && let Some(merged) = io::merge_and_save(store, baseline)
85 {
86 *store = merged.clone();
87 *baseline = merged;
88 *last_flush = Instant::now();
89 }
90}
91
92pub fn flush() {
93 let mut guard = STATS_BUFFER
94 .lock()
95 .unwrap_or_else(std::sync::PoisonError::into_inner);
96 if let Some((ref mut store, ref mut baseline, ref mut last_flush)) = *guard
97 && let Some(merged) = io::merge_and_save(store, baseline)
98 {
99 *store = merged.clone();
100 *baseline = merged;
101 *last_flush = Instant::now();
102 }
103}
104
105pub fn flush_if_due() {
109 let due = {
110 let guard = STATS_BUFFER
111 .lock()
112 .unwrap_or_else(std::sync::PoisonError::into_inner);
113 match guard.as_ref() {
114 Some((_, _, last_flush)) => last_flush.elapsed().as_secs() >= FLUSH_INTERVAL_SECS,
115 None => false,
116 }
117 };
118 if due {
119 flush();
120 }
121}
122
123pub fn adjust_savings(command: &str, over_report_delta: i64) {
129 let mut guard = STATS_BUFFER
130 .lock()
131 .unwrap_or_else(std::sync::PoisonError::into_inner);
132 let Some((store, baseline, last_flush)) = guard.as_mut() else {
133 return;
134 };
135 let cmd_key = format::normalize_command(command);
136 let stream_tracked = classify_command(&cmd_key) == TrafficClass::Compressible;
137 if over_report_delta > 0 {
138 let adj = over_report_delta as u64;
139 store.total_output_tokens = store.total_output_tokens.saturating_add(adj);
140 if let Some(cmd) = store.commands.get_mut(&cmd_key) {
141 cmd.output_tokens = cmd.output_tokens.saturating_add(adj);
142 }
143 if stream_tracked {
144 store.first_inject_tokens_saved = store.first_inject_tokens_saved.saturating_sub(adj);
145 store.active_tool_result_tokens_saved =
146 store.active_tool_result_tokens_saved.saturating_sub(adj);
147 }
148 } else {
149 let adj = over_report_delta.unsigned_abs();
150 store.total_output_tokens = store.total_output_tokens.saturating_sub(adj);
151 if let Some(cmd) = store.commands.get_mut(&cmd_key) {
152 cmd.output_tokens = cmd.output_tokens.saturating_sub(adj);
153 }
154 if stream_tracked {
155 store.first_inject_tokens_saved = store.first_inject_tokens_saved.saturating_add(adj);
156 if store.last_tool_result_turn > 0 {
157 store.active_tool_result_tokens_saved =
158 store.active_tool_result_tokens_saved.saturating_add(adj);
159 }
160 }
161 }
162 if let Some(merged) = io::merge_and_save(store, baseline) {
163 *store = merged.clone();
164 *baseline = merged;
165 *last_flush = Instant::now();
166 }
167}
168
169pub fn record(command: &str, input_tokens: usize, output_tokens: usize) {
170 record_at_turn(command, input_tokens, output_tokens, 0);
171}
172
173pub fn record_at_turn(command: &str, input_tokens: usize, output_tokens: usize, turn: u64) {
176 let mut guard = STATS_BUFFER
177 .lock()
178 .unwrap_or_else(std::sync::PoisonError::into_inner);
179 if guard.is_none() {
180 let disk = io::load_from_disk();
181 *guard = Some((disk.clone(), disk, Instant::now()));
182 }
183 let Some((store, baseline, last_flush)) = guard.as_mut() else {
184 return;
185 };
186
187 let now = chrono::Local::now();
189 let today = now.format("%Y-%m-%d").to_string();
190 let timestamp = now.to_rfc3339();
191
192 store.total_commands = store.total_commands.saturating_add(1);
193 store.total_input_tokens = store.total_input_tokens.saturating_add(input_tokens as u64);
194 store.total_output_tokens = store
195 .total_output_tokens
196 .saturating_add(output_tokens as u64);
197
198 if store.first_use.is_none() {
199 store.first_use = Some(timestamp.clone());
200 }
201 store.last_use = Some(timestamp);
202
203 let cmd_key = format::normalize_command(command);
204 if classify_command(&cmd_key) == TrafficClass::Compressible {
205 let saved = input_tokens.saturating_sub(output_tokens) as u64;
206 store.record_tool_result_savings(saved, turn);
207 }
208 store
209 .command_classes
210 .insert(cmd_key.clone(), classify_command(&cmd_key));
211 let entry = store.commands.entry(cmd_key).or_default();
212 entry.count = entry.count.saturating_add(1);
213 entry.input_tokens = entry.input_tokens.saturating_add(input_tokens as u64);
214 entry.output_tokens = entry.output_tokens.saturating_add(output_tokens as u64);
215
216 let current_version = env!("CARGO_PKG_VERSION").to_string();
217 if let Some(day) = store.daily.last_mut() {
218 if day.date == today {
219 day.commands = day.commands.saturating_add(1);
220 day.input_tokens = day.input_tokens.saturating_add(input_tokens as u64);
221 day.output_tokens = day.output_tokens.saturating_add(output_tokens as u64);
222 day.version = current_version;
225 } else {
226 store.daily.push(DayStats {
227 date: today,
228 commands: 1,
229 input_tokens: input_tokens as u64,
230 output_tokens: output_tokens as u64,
231 version: current_version,
232 });
233 }
234 } else {
235 store.daily.push(DayStats {
236 date: today,
237 commands: 1,
238 input_tokens: input_tokens as u64,
239 output_tokens: output_tokens as u64,
240 version: current_version,
241 });
242 }
243
244 if store.daily.len() > MAX_DAILY_HISTORY_DAYS {
245 store
246 .daily
247 .drain(..store.daily.len() - MAX_DAILY_HISTORY_DAYS);
248 }
249
250 if let Some(merged) = io::merge_and_save(store, baseline) {
254 *store = merged.clone();
255 *baseline = merged;
256 *last_flush = Instant::now();
257 }
258}
259
260pub fn reset_cep() {
261 let mut guard = STATS_BUFFER
262 .lock()
263 .unwrap_or_else(std::sync::PoisonError::into_inner);
264 let mut store = io::load_from_disk();
265 store.cep = CepStats::default();
266 io::locked_write(&store);
267 *guard = Some((store.clone(), store, Instant::now()));
268}
269
270pub fn reset_all() {
271 let mut guard = STATS_BUFFER
272 .lock()
273 .unwrap_or_else(std::sync::PoisonError::into_inner);
274 let store = StatsStore::default();
275 io::locked_write(&store);
276 *guard = Some((store.clone(), store, Instant::now()));
277 crate::core::heatmap::reset();
278}
279
280pub fn load_stats() -> GainSummary {
281 let store = load();
282 let input_saved = store
283 .total_input_tokens
284 .saturating_sub(store.total_output_tokens);
285 GainSummary {
286 total_saved: input_saved,
287 total_calls: store.total_commands,
288 }
289}
290
291#[allow(clippy::too_many_arguments)]
292pub fn record_cep_session(
293 score: u32,
294 cache_hits: u64,
295 cache_reads: u64,
296 tokens_original: u64,
297 tokens_compressed: u64,
298 modes: &HashMap<String, u64>,
299 tool_calls: u64,
300 complexity: &str,
301) {
302 let mut guard = STATS_BUFFER
303 .lock()
304 .unwrap_or_else(std::sync::PoisonError::into_inner);
305 if guard.is_none() {
306 let disk = io::load_from_disk();
307 *guard = Some((disk.clone(), disk, Instant::now()));
308 }
309 let Some((store, baseline, last_flush)) = guard.as_mut() else {
310 return;
311 };
312
313 apply_cep_snapshot(
314 &mut store.cep,
315 std::process::id(),
316 score,
317 cache_hits,
318 cache_reads,
319 tokens_original,
320 tokens_compressed,
321 modes,
322 tool_calls,
323 complexity,
324 );
325
326 maybe_flush(store, baseline, last_flush);
327}
328
329#[allow(clippy::too_many_arguments)]
339fn apply_cep_snapshot(
340 cep: &mut CepStats,
341 pid: u32,
342 score: u32,
343 cache_hits: u64,
344 cache_reads: u64,
345 tokens_original: u64,
346 tokens_compressed: u64,
347 modes: &HashMap<String, u64>,
348 tool_calls: u64,
349 complexity: &str,
350) {
351 let prev_original = cep.last_session_original.unwrap_or(0);
352 let prev_compressed = cep.last_session_compressed.unwrap_or(0);
353 let prev_cache_hits = cep.last_session_cache_hits.unwrap_or(0);
354 let prev_cache_reads = cep.last_session_cache_reads.unwrap_or(0);
355 let is_same_session = cep.last_session_pid == Some(pid);
356
357 if is_same_session {
358 cep.total_tokens_original += tokens_original.saturating_sub(prev_original);
359 cep.total_tokens_compressed += tokens_compressed.saturating_sub(prev_compressed);
360 cep.total_cache_hits += cache_hits.saturating_sub(prev_cache_hits);
361 cep.total_cache_reads += cache_reads.saturating_sub(prev_cache_reads);
362 } else {
363 cep.sessions += 1;
364 cep.total_cache_hits += cache_hits;
365 cep.total_cache_reads += cache_reads;
366 cep.total_tokens_original += tokens_original;
367 cep.total_tokens_compressed += tokens_compressed;
368
369 for (mode, count) in modes {
370 *cep.modes.entry(mode.clone()).or_insert(0) += count;
371 }
372 }
373
374 cep.last_session_pid = Some(pid);
375 cep.last_session_original = Some(tokens_original);
376 cep.last_session_compressed = Some(tokens_compressed);
377 cep.last_session_cache_hits = Some(cache_hits);
378 cep.last_session_cache_reads = Some(cache_reads);
379
380 let cache_hit_rate = if cache_reads > 0 {
381 (cache_hits as f64 / cache_reads as f64 * 100.0).round() as u32
382 } else {
383 0
384 };
385
386 let compression_rate = if tokens_original > 0 {
387 ((tokens_original - tokens_compressed) as f64 / tokens_original as f64 * 100.0).round()
388 as u32
389 } else {
390 0
391 };
392
393 let total_modes = 6u32;
394 let mode_diversity =
395 ((modes.len() as f64 / total_modes as f64).min(1.0) * 100.0).round() as u32;
396
397 let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
398
399 cep.scores.push(CepSessionSnapshot {
400 timestamp: chrono::Local::now().to_rfc3339(),
401 score,
402 cache_hit_rate,
403 mode_diversity,
404 compression_rate,
405 tool_calls,
406 tokens_saved,
407 complexity: complexity.to_string(),
408 });
409
410 if cep.scores.len() > 100 {
411 cep.scores.drain(..cep.scores.len() - 100);
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 fn make_store(commands: u64, input: u64, output: u64) -> StatsStore {
420 StatsStore {
421 total_commands: commands,
422 total_input_tokens: input,
423 total_output_tokens: output,
424 ..Default::default()
425 }
426 }
427
428 #[test]
433 fn corrupt_stats_file_is_quarantined_not_silently_reset() {
434 let dir = crate::core::data_dir::isolated_data_dir();
435 let stats_path = dir.path().join("stats.json");
436 let quarantine = dir.path().join("stats.json.corrupt");
437 let truncated = r#"{"total_commands": 15677, "total_input_tok"#;
438 std::fs::write(&stats_path, truncated).unwrap();
439
440 let loaded = io::load_from_disk();
441 assert_eq!(loaded.total_commands, 0, "fresh store after corruption");
442 assert!(
443 !stats_path.exists(),
444 "corrupt file must be moved aside, not left to be overwritten"
445 );
446 assert_eq!(
447 std::fs::read_to_string(&quarantine).unwrap(),
448 truncated,
449 "quarantine preserves the corrupt bytes verbatim for recovery"
450 );
451
452 std::fs::write(&stats_path, "{ newer corruption").unwrap();
454 let loaded = io::load_from_disk();
455 assert_eq!(loaded.total_commands, 0);
456 assert_eq!(
457 std::fs::read_to_string(&quarantine).unwrap(),
458 truncated,
459 "the OLDER quarantine wins — it is closest to the lost history"
460 );
461
462 let healthy = make_store(42, 9000, 1000);
464 std::fs::write(&stats_path, serde_json::to_string(&healthy).unwrap()).unwrap();
465 assert_eq!(io::load_from_disk().total_commands, 42);
466 }
467
468 #[test]
469 fn aggregate_for_display_is_noop_without_siblings() {
470 let primary = make_store(7, 1000, 250);
473 let agg = aggregate_for_display(primary.clone(), &[]);
474 assert_eq!(agg.total_commands, 7);
475 assert_eq!(agg.total_input_tokens, 1000);
476 assert_eq!(agg.total_output_tokens, 250);
477 }
478
479 #[test]
480 fn aggregate_for_display_sums_split_dirs() {
481 let primary = make_store(0, 0, 0);
485 let mcp_dir = make_store(12, 8000, 1200);
486 let legacy_dir = make_store(3, 500, 100);
487
488 let agg = aggregate_for_display(primary, &[mcp_dir, legacy_dir]);
489
490 assert_eq!(agg.total_commands, 15, "12 (mcp) + 3 (legacy)");
491 assert_eq!(agg.total_input_tokens, 8500);
492 assert_eq!(agg.total_output_tokens, 1300);
493 let saved = agg
494 .total_input_tokens
495 .saturating_sub(agg.total_output_tokens);
496 assert_eq!(saved, 7200, "savings surface despite an empty primary dir");
497 }
498
499 #[test]
500 fn apply_deltas_merges_mcp_and_shell() {
501 let baseline = make_store(0, 0, 0);
502 let mut current = make_store(0, 0, 0);
503 current.total_commands = 5;
504 current.total_input_tokens = 1000;
505 current.total_output_tokens = 200;
506 current.commands.insert(
507 "ctx_read".to_string(),
508 CommandStats {
509 count: 5,
510 input_tokens: 1000,
511 output_tokens: 200,
512 },
513 );
514 current
515 .command_classes
516 .insert("ctx_read".into(), TrafficClass::Compressible);
517
518 let mut disk = make_store(20, 500, 490);
519 disk.commands.insert(
520 "echo".to_string(),
521 CommandStats {
522 count: 20,
523 input_tokens: 500,
524 output_tokens: 490,
525 },
526 );
527
528 let merged = io::apply_deltas(&disk, ¤t, &baseline);
529
530 assert_eq!(merged.total_commands, 25);
531 assert_eq!(merged.total_input_tokens, 1500);
532 assert_eq!(merged.total_output_tokens, 690);
533 assert_eq!(merged.commands["ctx_read"].count, 5);
534 assert_eq!(merged.commands["echo"].count, 20);
535 assert_eq!(
536 merged.command_classes["ctx_read"],
537 TrafficClass::Compressible
538 );
539 }
540
541 #[test]
542 fn apply_deltas_incremental_flush() {
543 let baseline = make_store(10, 200, 100);
544 let current = make_store(15, 700, 300);
545
546 let disk = make_store(30, 600, 500);
547
548 let merged = io::apply_deltas(&disk, ¤t, &baseline);
549
550 assert_eq!(merged.total_commands, 35);
551 assert_eq!(merged.total_input_tokens, 1100);
552 assert_eq!(merged.total_output_tokens, 700);
553 }
554
555 #[test]
556 fn apply_deltas_merges_stream_counters_without_replaying_baseline() {
557 let mut baseline = StatsStore {
558 first_inject_tokens_saved: 1_000,
559 reread_tokens_saved: 2_000,
560 active_tool_result_tokens_saved: 500,
561 last_tool_result_turn: 8,
562 stream_tracked_results: 2,
563 ..StatsStore::default()
564 };
565 let mut current = baseline.clone();
566 current.first_inject_tokens_saved = 1_400;
567 current.reread_tokens_saved = 2_900;
568 current.active_tool_result_tokens_saved = 700;
569 current.last_tool_result_turn = 10;
570 current.stream_tracked_results = 3;
571 let disk = StatsStore {
572 first_inject_tokens_saved: 5_000,
573 reread_tokens_saved: 7_000,
574 active_tool_result_tokens_saved: 1_000,
575 last_tool_result_turn: 9,
576 stream_tracked_results: 10,
577 ..StatsStore::default()
578 };
579
580 let merged = io::apply_deltas(&disk, ¤t, &baseline);
581 assert_eq!(merged.first_inject_tokens_saved, 5_400);
582 assert_eq!(merged.reread_tokens_saved, 7_900);
583 assert_eq!(merged.active_tool_result_tokens_saved, 1_200);
584 assert_eq!(merged.last_tool_result_turn, 10);
585 assert_eq!(merged.stream_tracked_results, 11);
586
587 baseline.first_inject_tokens_saved = current.first_inject_tokens_saved;
588 let no_replay = io::apply_deltas(&merged, ¤t, &baseline);
589 assert_eq!(no_replay.first_inject_tokens_saved, 5_400);
590 }
591
592 #[test]
593 fn merge_and_save_keeps_delta_when_lock_is_busy() {
594 let dir = crate::core::data_dir::isolated_data_dir();
595 let baseline = make_store(10, 200, 100);
596 let current = make_store(11, 300, 120);
597 let lock_path = dir.path().join(".stats.lock");
598 std::fs::write(&lock_path, "busy").unwrap();
599
600 assert!(io::merge_and_save(¤t, &baseline).is_none());
601
602 std::fs::remove_file(lock_path).unwrap();
603 let merged = io::merge_and_save(¤t, &baseline).unwrap();
604 assert_eq!(merged.total_commands, 1);
605 assert_eq!(merged.total_input_tokens, 100);
606 assert_eq!(merged.total_output_tokens, 20);
607 }
608
609 #[test]
610 fn apply_deltas_preserves_disk_commands() {
611 let baseline = make_store(0, 0, 0);
612 let mut current = make_store(2, 100, 50);
613 current.commands.insert(
614 "ctx_read".to_string(),
615 CommandStats {
616 count: 2,
617 input_tokens: 100,
618 output_tokens: 50,
619 },
620 );
621
622 let mut disk = make_store(10, 300, 280);
623 disk.commands.insert(
624 "echo".to_string(),
625 CommandStats {
626 count: 8,
627 input_tokens: 200,
628 output_tokens: 200,
629 },
630 );
631 disk.commands.insert(
632 "ctx_read".to_string(),
633 CommandStats {
634 count: 3,
635 input_tokens: 150,
636 output_tokens: 80,
637 },
638 );
639
640 let merged = io::apply_deltas(&disk, ¤t, &baseline);
641
642 assert_eq!(merged.commands["echo"].count, 8);
643 assert_eq!(merged.commands["ctx_read"].count, 5);
644 assert_eq!(merged.commands["ctx_read"].input_tokens, 250);
645 }
646
647 #[test]
648 fn merge_daily_combines_same_date() {
649 let baseline_daily = vec![];
650 let current_daily = vec![DayStats {
651 date: "2026-04-18".to_string(),
652 commands: 5,
653 input_tokens: 1000,
654 output_tokens: 200,
655 version: "3.7.0".to_string(),
656 }];
657 let mut merged_daily = vec![DayStats {
658 date: "2026-04-18".to_string(),
659 commands: 20,
660 input_tokens: 500,
661 output_tokens: 490,
662 version: String::new(),
663 }];
664
665 io::merge_daily(&mut merged_daily, ¤t_daily, &baseline_daily);
666
667 assert_eq!(merged_daily.len(), 1);
668 assert_eq!(merged_daily[0].commands, 25);
669 assert_eq!(merged_daily[0].input_tokens, 1500);
670 assert_eq!(merged_daily[0].version, "3.7.0");
672 }
673
674 #[test]
675 fn cep_snapshot_seeds_new_session() {
676 let mut cep = CepStats::default();
677 let modes = HashMap::from([("full".to_string(), 3)]);
678 apply_cep_snapshot(&mut cep, 100, 80, 5, 10, 1000, 200, &modes, 4, "Medium");
679 assert_eq!(cep.sessions, 1);
680 assert_eq!(cep.total_cache_hits, 5);
681 assert_eq!(cep.total_cache_reads, 10);
682 assert_eq!(cep.total_tokens_original, 1000);
683 assert_eq!(cep.total_tokens_compressed, 200);
684 assert_eq!(cep.scores.len(), 1);
685 }
686
687 #[test]
688 fn cep_snapshot_same_pid_accumulates_cache_delta() {
689 let mut cep = CepStats::default();
693 let modes = HashMap::new();
694 apply_cep_snapshot(&mut cep, 100, 80, 2, 4, 500, 100, &modes, 2, "Low");
695 apply_cep_snapshot(&mut cep, 100, 85, 9, 20, 1500, 300, &modes, 6, "Low");
697
698 assert_eq!(cep.sessions, 1, "same PID must not start a new session");
699 assert_eq!(cep.total_cache_hits, 9, "2 + delta(9-2)");
700 assert_eq!(cep.total_cache_reads, 20, "4 + delta(20-4)");
701 assert_eq!(cep.total_tokens_original, 1500);
702 assert_eq!(cep.total_tokens_compressed, 300);
703 assert_eq!(cep.scores.len(), 2);
704 }
705
706 #[test]
707 fn cep_snapshot_new_pid_starts_fresh_session() {
708 let mut cep = CepStats::default();
709 let modes = HashMap::new();
710 apply_cep_snapshot(&mut cep, 100, 80, 5, 10, 1000, 200, &modes, 4, "Medium");
711 apply_cep_snapshot(&mut cep, 200, 80, 3, 6, 800, 150, &modes, 4, "Medium");
712 assert_eq!(cep.sessions, 2);
713 assert_eq!(
714 cep.total_cache_hits, 8,
715 "5 (session 1) + 3 (session 2, fresh)"
716 );
717 assert_eq!(cep.total_cache_reads, 16);
718 }
719}