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