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