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