use std::path::Path;
use crate::json_util;
const MAX_HANDLERS: usize = 64;
#[derive(Debug, Clone, Default)]
pub struct HandlerStats {
pub names: Vec<String>,
pub calls: Vec<u64>,
pub in_tokens: Vec<u64>,
pub out_tokens: Vec<u64>,
}
#[derive(Debug, Clone)]
pub struct HandlerRow {
pub name: String,
pub calls: u64,
pub in_tokens: u64,
pub out_tokens: u64,
pub savings_bp: u32,
}
impl HandlerStats {
pub fn load(sessions_dir: &Path) -> Self {
let path = sessions_dir.join("handler_stats.json");
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
if size == 0 || size > crate::memory::MAX_FILE_BYTES {
return Self::default();
}
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return Self::default(),
};
let map = json_util::extract_all(&content);
let names = json_util::map_str_array(&map, "names");
let calls = json_util::map_u64_array(&map, "calls");
let in_tk = json_util::map_u64_array(&map, "in_tokens");
let out_tk = json_util::map_u64_array(&map, "out_tokens");
let n = names.len().min(calls.len()).min(in_tk.len()).min(out_tk.len());
Self {
names: names.into_iter().take(n).collect(),
calls: calls.into_iter().take(n).collect(),
in_tokens: in_tk.into_iter().take(n).collect(),
out_tokens: out_tk.into_iter().take(n).collect(),
}
}
pub fn save(&self, sessions_dir: &Path) {
let _ = std::fs::create_dir_all(sessions_dir);
let path = sessions_dir.join("handler_stats.json");
let tmp = path.with_extension("json.tmp");
let json = format!(
"{{\"names\":{},\"calls\":{},\"in_tokens\":{},\"out_tokens\":{}}}",
json_util::str_array(&self.names),
json_util::u64_array(&self.calls),
json_util::u64_array(&self.in_tokens),
json_util::u64_array(&self.out_tokens),
);
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
if let Ok(mut f) = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)
{
let _ = f.write_all(json.as_bytes());
}
}
#[cfg(not(unix))]
{
let _ = std::fs::write(&tmp, &json);
}
let _ = std::fs::rename(&tmp, &path);
}
pub fn record(&mut self, name: &str, in_tk: u64, out_tk: u64) {
if name.is_empty() {
return;
}
if let Some(idx) = self.names.iter().position(|n| n == name) {
self.calls[idx] = self.calls[idx].saturating_add(1);
self.in_tokens[idx] = self.in_tokens[idx].saturating_add(in_tk);
self.out_tokens[idx] = self.out_tokens[idx].saturating_add(out_tk);
return;
}
if self.names.len() >= MAX_HANDLERS {
return; }
self.names.push(name.to_string());
self.calls.push(1);
self.in_tokens.push(in_tk);
self.out_tokens.push(out_tk);
}
pub fn rows(&self) -> Vec<HandlerRow> {
let mut rows: Vec<HandlerRow> = (0..self.names.len())
.map(|i| {
let in_tk = self.in_tokens[i];
let out_tk = self.out_tokens[i];
let savings_bp = if in_tk > 0 {
let saved = in_tk.saturating_sub(out_tk);
((saved.saturating_mul(10_000)) / in_tk).min(10_000) as u32
} else {
0
};
HandlerRow {
name: self.names[i].clone(),
calls: self.calls[i],
in_tokens: in_tk,
out_tokens: out_tk,
savings_bp,
}
})
.collect();
rows.sort_by(|a, b| b.calls.cmp(&a.calls));
rows
}
}
pub fn format_table(stats: &HandlerStats) -> String {
let rows = stats.rows();
if rows.is_empty() {
return "(no handler stats recorded yet — run a few commands first)".to_string();
}
let mut out = String::from("squeez handler stats (cumulative, cross-session)\n");
out.push_str("name calls in_tok out_tok saved\n");
for r in &rows {
out.push_str(&format!(
"{:<16} {:>5} {:>8} {:>8} {:>4}%\n",
truncate(&r.name, 16),
r.calls,
r.in_tokens,
r.out_tokens,
r.savings_bp / 100,
));
}
let under: Vec<&HandlerRow> = rows.iter().filter(|r| r.calls >= 5 && r.savings_bp < 1_000).collect();
let over: Vec<&HandlerRow> = rows.iter().filter(|r| r.calls >= 5 && r.savings_bp >= 9_000).collect();
if !under.is_empty() {
out.push_str("\nunder-performers (savings <10%, ≥5 calls): ");
out.push_str(&under.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", "));
out.push('\n');
}
if !over.is_empty() {
out.push_str("over-performers (savings ≥90%, ≥5 calls): ");
out.push_str(&over.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", "));
out.push('\n');
}
out
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"squeez_hs_{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.subsec_nanos()
))
}
#[test]
fn record_inserts_new_handler() {
let mut s = HandlerStats::default();
s.record("git", 1000, 100);
assert_eq!(s.names, vec!["git"]);
assert_eq!(s.calls, vec![1]);
assert_eq!(s.in_tokens, vec![1000]);
assert_eq!(s.out_tokens, vec![100]);
}
#[test]
fn record_accumulates_existing_handler() {
let mut s = HandlerStats::default();
s.record("cargo", 500, 50);
s.record("cargo", 700, 70);
assert_eq!(s.calls, vec![2]);
assert_eq!(s.in_tokens, vec![1200]);
assert_eq!(s.out_tokens, vec![120]);
}
#[test]
fn rows_sorted_by_calls_desc() {
let mut s = HandlerStats::default();
s.record("a", 1, 1);
s.record("b", 1, 1);
s.record("b", 1, 1);
s.record("c", 1, 1);
s.record("c", 1, 1);
s.record("c", 1, 1);
let rows = s.rows();
assert_eq!(rows[0].name, "c");
assert_eq!(rows[1].name, "b");
assert_eq!(rows[2].name, "a");
}
#[test]
fn savings_bp_computed_correctly() {
let mut s = HandlerStats::default();
s.record("nine", 1000, 100); s.record("zero", 1000, 1000); s.record("full", 100, 0); let rows = s.rows();
let by_name = |n: &str| rows.iter().find(|r| r.name == n).unwrap().savings_bp;
assert_eq!(by_name("nine"), 9_000);
assert_eq!(by_name("zero"), 0);
assert_eq!(by_name("full"), 10_000);
}
#[test]
fn round_trip_persistence() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let mut s = HandlerStats::default();
s.record("git", 1000, 200);
s.record("git", 500, 100);
s.record("cargo", 5000, 800);
s.save(&dir);
let loaded = HandlerStats::load(&dir);
assert_eq!(loaded.names, s.names);
assert_eq!(loaded.calls, s.calls);
assert_eq!(loaded.in_tokens, s.in_tokens);
assert_eq!(loaded.out_tokens, s.out_tokens);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_missing_file_is_empty() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let loaded = HandlerStats::load(&dir);
assert!(loaded.names.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn cap_at_max_handlers() {
let mut s = HandlerStats::default();
for i in 0..(MAX_HANDLERS + 10) {
s.record(&format!("h{}", i), 100, 10);
}
assert_eq!(s.names.len(), MAX_HANDLERS);
}
#[test]
fn format_table_calls_out_under_and_over_performers() {
let mut s = HandlerStats::default();
for _ in 0..5 {
s.record("bad", 100, 100);
}
for _ in 0..5 {
s.record("good", 1000, 50);
}
for _ in 0..4 {
s.record("rare", 1000, 50);
}
let table = format_table(&s);
assert!(table.contains("under-performers"));
assert!(table.contains("bad"));
assert!(table.contains("over-performers"));
assert!(table.contains("good"));
assert!(!table.contains("under-performers") || !table.split('\n').any(|l| l.starts_with("under-performers") && l.contains("rare")));
}
#[test]
fn empty_stats_format_message() {
let s = HandlerStats::default();
assert!(format_table(&s).contains("no handler stats"));
}
}