//! Statistics as CSV (one table) or XLSX (all tables, one sheet each). use serde_json::Value; use std::io::Write; /// A table: header row and rows of cells. pub struct Table { pub id: &'static str, pub title: String, pub rows: Vec>, } pub enum Cell { Text(String), Num(f64), } fn num(v: &Value, k: &str) -> Cell { Cell::Num(v[k].as_f64().unwrap_or(0.0)) } /// Column titles (English, German). fn columns(first: &str, de: bool) -> Vec { let t: [&str; 10] = if de { ["Anfragen", "Input-Tokens", "Cache gelesen", "Cache geschrieben", "Output-Tokens", "Fehler", "Ø Latenz (ms)", "Output (Tok/s)", "Prefill (Tok/s)", "Kosten"] } else { ["Requests", "Input tokens", "Cache read", "Cache write", "Output tokens", "Errors", "Avg TTFT (ms)", "Output (tok/s)", "Prefill (tok/s)", "Cost"] }; std::iter::once(first).chain(t).map(|s| Cell::Text(s.to_string())).collect() } fn agg_table(id: &'static str, title: &str, first: &str, key: &str, rows: &Value, de: bool) -> Table { let mut out = vec![columns(first, de)]; for r in rows.as_array().into_iter().flatten() { out.push(vec![ Cell::Text(r[key].as_str().unwrap_or("").to_string()), num(r, "requests"), num(r, "input_tokens"), num(r, "cache_read_tokens"), num(r, "cache_write_tokens"), num(r, "output_tokens"), num(r, "errors"), num(r, "avg_ttft_ms"), num(r, "output_tps"), num(r, "prefill_tps"), num(r, "cost"), ]); } Table { id, title: title.to_string(), rows: out } } /// All tables of a statistics result (as returned by `Store::global_stats`). pub fn tables(stats: &Value, de: bool) -> Vec { let (day, month, model, provider, agent, user, session) = if de { ("Tag", "Monat", "Modell", "Anbieter", "Modus", "Benutzer", "Chat") } else { ("Day", "Month", "Model", "Provider", "Agent", "User", "Chat") }; let mut out = vec![ agg_table("day", if de { "Pro Tag" } else { "Per day" }, day, "day", &stats["by_day"], de), agg_table("month", if de { "Pro Monat" } else { "Per month" }, month, "month", &stats["by_month"], de), agg_table("model", model, model, "model", &stats["by_model"], de), agg_table("provider", provider, provider, "provider", &stats["by_provider"], de), agg_table("agent", agent, agent, "agent", &stats["by_agent"], de), agg_table("user", user, user, "user", &stats["by_user"], de), agg_table("session", if de { "Teuerste Chats" } else { "Top chats" }, session, "title", &stats["top_sessions"], de), ]; // The grand total first, as a one-row table. let mut total = vec![columns(if de { "Zeitraum" } else { "Range" }, de)]; let t = &stats["total"]; total.push(vec![ Cell::Text(format!("{} – {}", date(stats["from"].as_i64().unwrap_or(0)), date(stats["to"].as_i64().unwrap_or(0)))), num(t, "requests"), num(t, "input_tokens"), num(t, "cache_read_tokens"), num(t, "cache_write_tokens"), num(t, "output_tokens"), num(t, "errors"), num(t, "avg_ttft_ms"), num(t, "output_tps"), num(t, "prefill_tps"), num(t, "cost"), ]); out.insert(0, Table { id: "total", title: (if de { "Gesamt" } else { "Total" }).to_string(), rows: total }); out } fn date(ms: i64) -> String { if ms <= 0 { return "-".into(); } chrono::DateTime::from_timestamp_millis(ms).map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default() } // --- CSV ----------------------------------------------------------------------------- /// CSV for spreadsheets: `;` and decimal comma in German, `,` and decimal point otherwise; /// UTF-8 with BOM (Excel). Cells starting with = + - @ are prefixed against formula injection. pub fn csv(table: &Table, de: bool) -> String { let sep = if de { ';' } else { ',' }; let mut out = String::from('\u{feff}'); for row in &table.rows { let line: Vec = row .iter() .map(|c| match c { Cell::Num(n) => { let s = if n.fract() == 0.0 { format!("{n:.0}") } else { format!("{n:.4}") }; if de { s.replace('.', ",") } else { s } } Cell::Text(t) => { let t = if t.starts_with(['=', '+', '-', '@', '\t', '\r']) { format!("'{t}") } else { t.clone() }; if t.contains([sep, '"', '\n', '\r']) { format!("\"{}\"", t.replace('"', "\"\"")) } else { t } } }) .collect(); out.push_str(&line.join(&sep.to_string())); out.push_str("\r\n"); } out } // --- XLSX ---------------------------------------------------------------------------- fn esc(s: &str) -> String { s.chars() .filter(|c| !c.is_control() || matches!(c, '\n' | '\t')) .map(|c| match c { '&' => "&".to_string(), '<' => "<".to_string(), '>' => ">".to_string(), '"' => """.to_string(), c => c.to_string(), }) .collect() } fn col_name(mut i: usize) -> String { let mut s = String::new(); loop { s.insert(0, (b'A' + (i % 26) as u8) as char); if i < 26 { break; } i = i / 26 - 1; } s } fn sheet_xml(t: &Table) -> String { let mut x = String::from(r#""#); for (r, row) in t.rows.iter().enumerate() { x.push_str(&format!(r#""#, r + 1)); for (c, cell) in row.iter().enumerate() { let at = format!("{}{}", col_name(c), r + 1); let style = if r == 0 { r#" s="1""# } else { "" }; match cell { // Text as inline strings: no shared string table needed. Cell::Text(s) => x.push_str(&format!(r#"{}"#, esc(s))), Cell::Num(n) => x.push_str(&format!(r#"{n}"#)), } } x.push_str(""); } x.push_str(""); x } /// Sheet names: at most 31 characters, none of []:*?/\ fn sheet_name(s: &str) -> String { s.chars().filter(|c| !"[]:*?/\\".contains(*c)).take(31).collect() } pub fn xlsx(tables: &[Table]) -> anyhow::Result> { let mut buf = std::io::Cursor::new(Vec::new()); { let mut z = zip::ZipWriter::new(&mut buf); let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); let mut put = |name: &str, body: &str| -> anyhow::Result<()> { z.start_file(name, opts)?; z.write_all(body.as_bytes())?; Ok(()) }; let mut types = String::from(r#""#); let mut sheets = String::new(); let mut rels = String::new(); for (i, t) in tables.iter().enumerate() { let n = i + 1; types.push_str(&format!(r#""#)); sheets.push_str(&format!(r#""#, esc(&sheet_name(&t.title)))); rels.push_str(&format!(r#""#)); } types.push_str(""); let styles_id = tables.len() + 1; rels.push_str(&format!(r#""#)); put("[Content_Types].xml", &types)?; put("_rels/.rels", r#""#)?; put("xl/workbook.xml", &format!(r#"{sheets}"#))?; put("xl/_rels/workbook.xml.rels", &format!(r#"{rels}"#))?; // Style 1 = bold header. put("xl/styles.xml", r#""#)?; for (i, t) in tables.iter().enumerate() { put(&format!("xl/worksheets/sheet{}.xml", i + 1), &sheet_xml(t))?; } z.finish()?; } Ok(buf.into_inner()) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn sample() -> Value { json!({ "from": 0, "to": 1_700_000_000_000i64, "total": {"requests": 3, "cost": 1.5}, "by_day": [{"day": "2026-01-02", "requests": 2, "cost": 1.25, "input_tokens": 10}], "by_month": [], "by_model": [{"model": "=evil, \"x\"", "requests": 1}], "by_provider": [], "by_agent": [], "by_user": [], "top_sessions": [] }) } #[test] fn csv_uses_locale_and_blocks_formulas() { let tabs = tables(&sample(), true); let day = tabs.iter().find(|t| t.id == "day").unwrap(); let c = csv(day, true); assert!(c.starts_with('\u{feff}')); assert!(c.contains("2026-01-02;2;10;0;0;0;0;0;0;0;1,2500"), "{c}"); let model = csv(tabs.iter().find(|t| t.id == "model").unwrap(), false); assert!(model.contains("\"'=evil, \"\"x\"\"\""), "{model}"); } #[test] fn xlsx_is_a_zip_with_every_sheet() { let tabs = tables(&sample(), false); let bytes = xlsx(&tabs).unwrap(); let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); for i in 1..=tabs.len() { assert!(z.by_name(&format!("xl/worksheets/sheet{i}.xml")).is_ok()); } let mut wb = String::new(); std::io::Read::read_to_string(&mut z.by_name("xl/workbook.xml").unwrap(), &mut wb).unwrap(); assert!(wb.contains("Per day") && wb.contains("Total")); assert_eq!(col_name(0), "A"); assert_eq!(col_name(26), "AA"); } }