Skip to main content

warden/reports/
summary.rs

1//! `warden report summary` — totals for the period, broken down by day.
2
3use crate::output::{Cell, Report, Table};
4use crate::store::Scanner;
5
6use super::{count, day_of, rollup, scan, ReportCtx, ReportError, Totals};
7
8pub fn build(scanner: &Scanner, ctx: &ReportCtx) -> Result<Report, ReportError> {
9    let scanned = scan(scanner, ctx)?;
10    let by_day = rollup(&scanned.events, &ctx.pricing, |event| day_of(event.ts));
11
12    let mut table = Table::new([
13        "day",
14        "sessions",
15        "requests",
16        "in",
17        "out",
18        "cache r",
19        "est. cost",
20    ]);
21    let mut rows = Vec::new();
22    let mut grand = Totals::default();
23
24    // Chronological, not heaviest-first: a summary is read as a timeline.
25    for (day, totals) in &by_day {
26        let mut row = vec![
27            Cell::text(day),
28            count(totals.sessions.len() as u64),
29            count(totals.requests),
30        ];
31        row.extend(totals.tail_cells());
32        table.push(row);
33
34        let mut json = serde_json::Map::new();
35        json.insert("day".into(), serde_json::json!(day));
36        json.insert("sessions".into(), serde_json::json!(totals.sessions.len()));
37        totals.write_json(&mut json);
38        rows.push(serde_json::Value::Object(json));
39
40        grand.merge(totals);
41    }
42
43    if !by_day.is_empty() {
44        let mut total = vec![
45            Cell::text("total"),
46            count(grand.sessions.len() as u64),
47            count(grand.requests),
48        ];
49        total.extend(grand.tail_cells());
50        table.push(total);
51    }
52
53    let mut notes = scanned.notes;
54    notes.push("the final row totals the period; daily session counts overlap when a session spans midnight, so they sum to more than the period total");
55    Ok(Report::new("summary", ctx.window, table)
56        .with_json_rows(rows)
57        .with_notes(notes.finish()))
58}
59
60#[cfg(test)]
61mod tests {
62    use super::super::testkit::*;
63    use super::*;
64    use crate::cli::TimeWindow;
65
66    fn report() -> Report {
67        let (_dir, paths) = store(&[
68            priced(used("a", ms(2026, 8, 3, 10), "acme", "m", 100, 20), 1.0),
69            priced(used("b", ms(2026, 8, 3, 11), "acme", "m", 50, 10), 0.5),
70            priced(used("c", ms(2026, 8, 4, 10), "acme", "m", 10, 2), 0.25),
71        ]);
72        build(
73            &Scanner::new(paths),
74            &ReportCtx::new(TimeWindow::all(), None, true),
75        )
76        .unwrap()
77    }
78
79    #[test]
80    fn breaks_the_period_down_by_day_in_order() {
81        let rendered = report().table.render(crate::output::Style::plain());
82        let lines: Vec<&str> = rendered.lines().collect();
83        assert!(lines[0].starts_with("DAY"));
84        assert!(lines[1].starts_with("2026-08-03"), "{rendered}");
85        assert!(lines[2].starts_with("2026-08-04"), "{rendered}");
86        assert!(lines[3].starts_with("total"), "{rendered}");
87    }
88
89    #[test]
90    fn the_total_row_sums_the_days() {
91        let report = report();
92        assert_eq!(report.json_rows.len(), 2);
93        assert_eq!(report.json_rows[0]["input_tok"], 150);
94        assert_eq!(report.json_rows[1]["input_tok"], 10);
95        assert_eq!(report.json_rows[0]["cost_est"], 1.5);
96        assert!(report
97            .table
98            .render(crate::output::Style::plain())
99            .contains("$1.75 ~"));
100    }
101}