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 = 30;
16
17pub fn load() -> StatsStore {
18 let guard = STATS_BUFFER
19 .lock()
20 .unwrap_or_else(std::sync::PoisonError::into_inner);
21 if let Some((ref current, ref baseline, _)) = *guard {
22 let disk = io::load_from_disk();
23 return io::apply_deltas(&disk, current, baseline);
24 }
25 drop(guard);
26 io::load_from_disk()
27}
28
29pub fn save(store: &StatsStore) {
30 io::locked_write(store);
31}
32
33fn maybe_flush(store: &mut StatsStore, baseline: &mut StatsStore, last_flush: &mut Instant) {
34 if last_flush.elapsed().as_secs() >= FLUSH_INTERVAL_SECS {
35 let merged = io::merge_and_save(store, baseline);
36 *store = merged.clone();
37 *baseline = merged;
38 *last_flush = Instant::now();
39 }
40}
41
42pub fn flush() {
43 let mut guard = STATS_BUFFER
44 .lock()
45 .unwrap_or_else(std::sync::PoisonError::into_inner);
46 if let Some((ref mut store, ref mut baseline, ref mut last_flush)) = *guard {
47 let merged = io::merge_and_save(store, baseline);
48 *store = merged.clone();
49 *baseline = merged;
50 *last_flush = Instant::now();
51 }
52}
53
54pub fn adjust_savings(command: &str, over_report_delta: i64) {
57 let mut guard = STATS_BUFFER
58 .lock()
59 .unwrap_or_else(std::sync::PoisonError::into_inner);
60 let Some((store, _, _)) = guard.as_mut() else {
61 return;
62 };
63 if over_report_delta > 0 {
64 let adj = over_report_delta as u64;
65 store.total_output_tokens = store.total_output_tokens.saturating_add(adj);
66 if let Some(cmd) = store.commands.get_mut(command) {
67 cmd.output_tokens = cmd.output_tokens.saturating_add(adj);
68 }
69 } else {
70 let adj = over_report_delta.unsigned_abs();
71 store.total_output_tokens = store.total_output_tokens.saturating_sub(adj);
72 if let Some(cmd) = store.commands.get_mut(command) {
73 cmd.output_tokens = cmd.output_tokens.saturating_sub(adj);
74 }
75 }
76}
77
78pub fn record(command: &str, input_tokens: usize, output_tokens: usize) {
79 let mut guard = STATS_BUFFER
80 .lock()
81 .unwrap_or_else(std::sync::PoisonError::into_inner);
82 if guard.is_none() {
83 let disk = io::load_from_disk();
84 *guard = Some((disk.clone(), disk, Instant::now()));
85 }
86 let Some((store, baseline, last_flush)) = guard.as_mut() else {
87 return;
88 };
89
90 let is_first_command = store.total_commands == baseline.total_commands;
91 let now = chrono::Local::now();
92 let today = now.format("%Y-%m-%d").to_string();
93 let timestamp = now.to_rfc3339();
94
95 store.total_commands = store.total_commands.saturating_add(1);
96 store.total_input_tokens = store.total_input_tokens.saturating_add(input_tokens as u64);
97 store.total_output_tokens = store
98 .total_output_tokens
99 .saturating_add(output_tokens as u64);
100
101 if store.first_use.is_none() {
102 store.first_use = Some(timestamp.clone());
103 }
104 store.last_use = Some(timestamp);
105
106 let cmd_key = format::normalize_command(command);
107 let entry = store.commands.entry(cmd_key).or_default();
108 entry.count = entry.count.saturating_add(1);
109 entry.input_tokens = entry.input_tokens.saturating_add(input_tokens as u64);
110 entry.output_tokens = entry.output_tokens.saturating_add(output_tokens as u64);
111
112 if let Some(day) = store.daily.last_mut() {
113 if day.date == today {
114 day.commands = day.commands.saturating_add(1);
115 day.input_tokens = day.input_tokens.saturating_add(input_tokens as u64);
116 day.output_tokens = day.output_tokens.saturating_add(output_tokens as u64);
117 } else {
118 store.daily.push(DayStats {
119 date: today,
120 commands: 1,
121 input_tokens: input_tokens as u64,
122 output_tokens: output_tokens as u64,
123 });
124 }
125 } else {
126 store.daily.push(DayStats {
127 date: today,
128 commands: 1,
129 input_tokens: input_tokens as u64,
130 output_tokens: output_tokens as u64,
131 });
132 }
133
134 if store.daily.len() > 90 {
135 store.daily.drain(..store.daily.len() - 90);
136 }
137
138 if is_first_command {
139 let merged = io::merge_and_save(store, baseline);
140 *store = merged.clone();
141 *baseline = merged;
142 *last_flush = Instant::now();
143 } else {
144 maybe_flush(store, baseline, last_flush);
145 }
146}
147
148pub fn reset_cep() {
149 let mut guard = STATS_BUFFER
150 .lock()
151 .unwrap_or_else(std::sync::PoisonError::into_inner);
152 let mut store = io::load_from_disk();
153 store.cep = CepStats::default();
154 io::locked_write(&store);
155 *guard = Some((store.clone(), store, Instant::now()));
156}
157
158pub fn reset_all() {
159 let mut guard = STATS_BUFFER
160 .lock()
161 .unwrap_or_else(std::sync::PoisonError::into_inner);
162 let store = StatsStore::default();
163 io::locked_write(&store);
164 *guard = Some((store.clone(), store, Instant::now()));
165 crate::core::heatmap::reset();
166}
167
168pub fn load_stats() -> GainSummary {
169 let store = load();
170 let input_saved = store
171 .total_input_tokens
172 .saturating_sub(store.total_output_tokens);
173 GainSummary {
174 total_saved: input_saved,
175 total_calls: store.total_commands,
176 }
177}
178
179#[allow(clippy::too_many_arguments)]
180pub fn record_cep_session(
181 score: u32,
182 cache_hits: u64,
183 cache_reads: u64,
184 tokens_original: u64,
185 tokens_compressed: u64,
186 modes: &HashMap<String, u64>,
187 tool_calls: u64,
188 complexity: &str,
189) {
190 let mut guard = STATS_BUFFER
191 .lock()
192 .unwrap_or_else(std::sync::PoisonError::into_inner);
193 if guard.is_none() {
194 let disk = io::load_from_disk();
195 *guard = Some((disk.clone(), disk, Instant::now()));
196 }
197 let Some((store, baseline, last_flush)) = guard.as_mut() else {
198 return;
199 };
200
201 let cep = &mut store.cep;
202
203 let pid = std::process::id();
204 let prev_original = cep.last_session_original.unwrap_or(0);
205 let prev_compressed = cep.last_session_compressed.unwrap_or(0);
206 let is_same_session = cep.last_session_pid == Some(pid);
207
208 if is_same_session {
209 let delta_original = tokens_original.saturating_sub(prev_original);
210 let delta_compressed = tokens_compressed.saturating_sub(prev_compressed);
211 cep.total_tokens_original += delta_original;
212 cep.total_tokens_compressed += delta_compressed;
213 } else {
214 cep.sessions += 1;
215 cep.total_cache_hits += cache_hits;
216 cep.total_cache_reads += cache_reads;
217 cep.total_tokens_original += tokens_original;
218 cep.total_tokens_compressed += tokens_compressed;
219
220 for (mode, count) in modes {
221 *cep.modes.entry(mode.clone()).or_insert(0) += count;
222 }
223 }
224
225 cep.last_session_pid = Some(pid);
226 cep.last_session_original = Some(tokens_original);
227 cep.last_session_compressed = Some(tokens_compressed);
228
229 let cache_hit_rate = if cache_reads > 0 {
230 (cache_hits as f64 / cache_reads as f64 * 100.0).round() as u32
231 } else {
232 0
233 };
234
235 let compression_rate = if tokens_original > 0 {
236 ((tokens_original - tokens_compressed) as f64 / tokens_original as f64 * 100.0).round()
237 as u32
238 } else {
239 0
240 };
241
242 let total_modes = 6u32;
243 let mode_diversity =
244 ((modes.len() as f64 / total_modes as f64).min(1.0) * 100.0).round() as u32;
245
246 let tokens_saved = tokens_original.saturating_sub(tokens_compressed);
247
248 cep.scores.push(CepSessionSnapshot {
249 timestamp: chrono::Local::now().to_rfc3339(),
250 score,
251 cache_hit_rate,
252 mode_diversity,
253 compression_rate,
254 tool_calls,
255 tokens_saved,
256 complexity: complexity.to_string(),
257 });
258
259 if cep.scores.len() > 100 {
260 cep.scores.drain(..cep.scores.len() - 100);
261 }
262
263 maybe_flush(store, baseline, last_flush);
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 fn make_store(commands: u64, input: u64, output: u64) -> StatsStore {
271 StatsStore {
272 total_commands: commands,
273 total_input_tokens: input,
274 total_output_tokens: output,
275 ..Default::default()
276 }
277 }
278
279 #[test]
280 fn apply_deltas_merges_mcp_and_shell() {
281 let baseline = make_store(0, 0, 0);
282 let mut current = make_store(0, 0, 0);
283 current.total_commands = 5;
284 current.total_input_tokens = 1000;
285 current.total_output_tokens = 200;
286 current.commands.insert(
287 "ctx_read".to_string(),
288 CommandStats {
289 count: 5,
290 input_tokens: 1000,
291 output_tokens: 200,
292 },
293 );
294
295 let mut disk = make_store(20, 500, 490);
296 disk.commands.insert(
297 "echo".to_string(),
298 CommandStats {
299 count: 20,
300 input_tokens: 500,
301 output_tokens: 490,
302 },
303 );
304
305 let merged = io::apply_deltas(&disk, ¤t, &baseline);
306
307 assert_eq!(merged.total_commands, 25);
308 assert_eq!(merged.total_input_tokens, 1500);
309 assert_eq!(merged.total_output_tokens, 690);
310 assert_eq!(merged.commands["ctx_read"].count, 5);
311 assert_eq!(merged.commands["echo"].count, 20);
312 }
313
314 #[test]
315 fn apply_deltas_incremental_flush() {
316 let baseline = make_store(10, 200, 100);
317 let current = make_store(15, 700, 300);
318
319 let disk = make_store(30, 600, 500);
320
321 let merged = io::apply_deltas(&disk, ¤t, &baseline);
322
323 assert_eq!(merged.total_commands, 35);
324 assert_eq!(merged.total_input_tokens, 1100);
325 assert_eq!(merged.total_output_tokens, 700);
326 }
327
328 #[test]
329 fn apply_deltas_preserves_disk_commands() {
330 let baseline = make_store(0, 0, 0);
331 let mut current = make_store(2, 100, 50);
332 current.commands.insert(
333 "ctx_read".to_string(),
334 CommandStats {
335 count: 2,
336 input_tokens: 100,
337 output_tokens: 50,
338 },
339 );
340
341 let mut disk = make_store(10, 300, 280);
342 disk.commands.insert(
343 "echo".to_string(),
344 CommandStats {
345 count: 8,
346 input_tokens: 200,
347 output_tokens: 200,
348 },
349 );
350 disk.commands.insert(
351 "ctx_read".to_string(),
352 CommandStats {
353 count: 3,
354 input_tokens: 150,
355 output_tokens: 80,
356 },
357 );
358
359 let merged = io::apply_deltas(&disk, ¤t, &baseline);
360
361 assert_eq!(merged.commands["echo"].count, 8);
362 assert_eq!(merged.commands["ctx_read"].count, 5);
363 assert_eq!(merged.commands["ctx_read"].input_tokens, 250);
364 }
365
366 #[test]
367 fn merge_daily_combines_same_date() {
368 let baseline_daily = vec![];
369 let current_daily = vec![DayStats {
370 date: "2026-04-18".to_string(),
371 commands: 5,
372 input_tokens: 1000,
373 output_tokens: 200,
374 }];
375 let mut merged_daily = vec![DayStats {
376 date: "2026-04-18".to_string(),
377 commands: 20,
378 input_tokens: 500,
379 output_tokens: 490,
380 }];
381
382 io::merge_daily(&mut merged_daily, ¤t_daily, &baseline_daily);
383
384 assert_eq!(merged_daily.len(), 1);
385 assert_eq!(merged_daily[0].commands, 25);
386 assert_eq!(merged_daily[0].input_tokens, 1500);
387 }
388
389 #[test]
390 fn format_pct_1dp_normal() {
391 assert_eq!(format::format_pct_1dp(50.0), "50.0%");
392 assert_eq!(format::format_pct_1dp(100.0), "100.0%");
393 assert_eq!(format::format_pct_1dp(33.333), "33.3%");
394 }
395
396 #[test]
397 fn format_pct_1dp_small_values() {
398 assert_eq!(format::format_pct_1dp(0.0), "0.0%");
399 assert_eq!(format::format_pct_1dp(0.05), "<0.1%");
400 assert_eq!(format::format_pct_1dp(0.09), "<0.1%");
401 assert_eq!(format::format_pct_1dp(0.1), "0.1%");
402 assert_eq!(format::format_pct_1dp(0.5), "0.5%");
403 }
404
405 #[test]
406 fn format_savings_pct_zero_input() {
407 assert_eq!(format::format_savings_pct(0, 0), "0.0%");
408 assert_eq!(format::format_savings_pct(100, 0), "n/a");
409 }
410
411 #[test]
412 fn format_savings_pct_normal() {
413 assert_eq!(format::format_savings_pct(50, 100), "50.0%");
414 assert_eq!(format::format_savings_pct(1, 10000), "<0.1%");
415 }
416}