1use crate::cli::TimeWindow;
9use crate::output::{Cell, Report, Table};
10use crate::store::Scanner;
11
12use crate::config::Pricing;
13
14use super::{count, round_money, scan, Cost, ReportCtx, ReportError, Totals};
15
16pub fn build(scanner: &Scanner, ctx: &ReportCtx) -> Result<Report, ReportError> {
17 let length = ctx
18 .window
19 .to_ms
20 .checked_sub(ctx.window.from_ms)
21 .filter(|len| *len > 0)
22 .ok_or(ReportError::NeedsWindow("compare"))?;
23 let previous_from = ctx
25 .window
26 .from_ms
27 .checked_sub(length)
28 .ok_or(ReportError::NeedsWindow("compare"))?;
29 let previous_window = TimeWindow::new(previous_from, ctx.window.from_ms);
30
31 let current = scan(scanner, ctx)?;
32 let previous = scan(scanner, &ctx.with_window(previous_window))?;
33
34 let now = fold(¤t.events, &ctx.pricing);
35 let before = fold(&previous.events, &ctx.pricing);
36
37 let mut table = Table::new(["metric", "this period", "previous", "delta", "change"]);
38 let mut rows = Vec::new();
39
40 let cost_partial = now.cost.is_partial() || before.cost.is_partial();
43
44 for metric in METRICS {
45 let (this, prev) = ((metric.value)(&now), (metric.value)(&before));
46 let partial = metric.kind == Kind::Money && cost_partial;
47 let cell = |value: Option<f64>| match (metric.kind, value) {
48 (Kind::Count, Some(v)) => count(v as u64),
49 (Kind::Money, Some(v)) if partial => Cell::money_partial(v),
50 (Kind::Money, Some(v)) => Cell::money_est(v),
51 (_, None) => Cell::Unsupported,
52 };
53
54 let delta = match (this, prev) {
55 (Some(a), Some(b)) => Some(a - b),
56 _ => None,
57 };
58 let change = match (this, prev) {
59 (Some(a), Some(b)) if b != 0.0 => Some((a - b) / b * 100.0),
60 _ => None,
61 };
62
63 table.push(vec![
64 Cell::text(metric.label),
65 cell(this),
66 cell(prev),
67 match (delta, metric.kind) {
68 (Some(d), Kind::Money) if partial => Cell::money_partial(d),
69 (Some(d), Kind::Money) => Cell::money_est(d),
70 (Some(d), Kind::Count) => Cell::Int(d as i64),
71 (None, _) => Cell::Unsupported,
72 },
73 match change {
74 Some(pct) => Cell::text(format!("{pct:+.1}%")),
75 None => Cell::Unsupported,
76 },
77 ]);
78
79 let number = |value: Option<f64>| match (metric.kind, value) {
80 (Kind::Count, Some(v)) => serde_json::json!(v as i64),
81 (Kind::Money, Some(v)) => serde_json::json!(round_money(v)),
82 (_, None) => serde_json::Value::Null,
83 };
84 rows.push(serde_json::json!({
85 "metric": metric.label,
86 "this_period": number(this),
87 "previous_period": number(prev),
88 "delta": number(delta),
89 "change_pct": change.map(|pct| (pct * 10.0).round() / 10.0),
90 "partial": partial,
91 }));
92 }
93
94 let mut notes = current.notes;
95 notes.merge(&previous.notes);
96 notes.push(format!(
97 "previous period is the {} immediately before this one, ending where it begins ({} to {})",
98 super::format_span(length),
99 iso(previous_window.from_ms),
100 iso(previous_window.to_ms),
101 ));
102 notes.push(
103 "change is blank where the previous period had nothing to divide by, and where a figure \
104 could not be derived in both periods",
105 );
106
107 Ok(Report::new("compare", ctx.window, table)
108 .with_json_rows(rows)
109 .with_notes(notes.finish()))
110}
111
112fn iso(ms: i64) -> String {
113 crate::output::iso8601_ms(ms).unwrap_or_else(|| "unbounded".to_string())
114}
115
116fn fold(events: &[crate::store::Event], pricing: &Pricing) -> Totals {
117 let mut totals = Totals::default();
118 for event in events {
119 totals.add(event, pricing);
120 }
121 totals
122}
123
124#[derive(Clone, Copy, PartialEq)]
125enum Kind {
126 Count,
127 Money,
128}
129
130struct Metric {
131 label: &'static str,
132 kind: Kind,
133 value: fn(&Totals) -> Option<f64>,
135}
136
137const METRICS: &[Metric] = &[
140 Metric {
141 label: "sessions",
142 kind: Kind::Count,
143 value: |t| Some(t.sessions.len() as f64),
144 },
145 Metric {
146 label: "requests",
147 kind: Kind::Count,
148 value: |t| Some(t.requests as f64),
149 },
150 Metric {
151 label: "input tok",
152 kind: Kind::Count,
153 value: |t| Some(t.input as f64),
154 },
155 Metric {
156 label: "output tok",
157 kind: Kind::Count,
158 value: |t| Some(t.output as f64),
159 },
160 Metric {
161 label: "cache read tok",
162 kind: Kind::Count,
163 value: |t| Some(t.cache_read as f64),
164 },
165 Metric {
166 label: "cache write tok",
167 kind: Kind::Count,
168 value: |t| Some(t.cache_write as f64),
169 },
170 Metric {
171 label: "total tok",
172 kind: Kind::Count,
173 value: |t| Some(t.total_tokens() as f64),
174 },
175 Metric {
176 label: "est. cost",
177 kind: Kind::Money,
178 value: |t| priced_total(t.cost),
179 },
180];
181
182fn priced_total(cost: Cost) -> Option<f64> {
183 (cost.priced > 0).then_some(cost.total)
184}
185
186#[cfg(test)]
187mod tests {
188 use super::super::testkit::*;
189 use super::*;
190 use crate::output::{Style, UNSUPPORTED};
191
192 fn window() -> TimeWindow {
193 TimeWindow::new(ms(2026, 8, 4, 0), ms(2026, 8, 5, 0))
194 }
195
196 fn report(events: &[crate::store::Event]) -> Report {
197 let (_dir, paths) = store(events);
198 build(&Scanner::new(paths), &ReportCtx::new(window(), None, true)).unwrap()
199 }
200
201 fn two_days() -> Vec<crate::store::Event> {
202 vec![
203 priced(used("y", ms(2026, 8, 3, 10), "acme", "m", 100, 10), 1.0),
204 priced(used("t1", ms(2026, 8, 4, 10), "acme", "m", 150, 10), 2.0),
205 ]
206 }
207
208 #[test]
209 fn compares_day_against_the_day_before() {
210 let report = report(&two_days());
211 let input = row(&report, "input tok");
212 assert_eq!(input["this_period"], 150);
213 assert_eq!(input["previous_period"], 100);
214 assert_eq!(input["delta"], 50);
215 assert_eq!(input["change_pct"], 50.0);
216
217 let cost = row(&report, "est. cost");
218 assert_eq!(cost["this_period"], 2.0);
219 assert_eq!(cost["delta"], 1.0);
220 assert!(report.table.render(Style::plain()).contains("+50.0%"));
221 }
222
223 fn row<'a>(report: &'a Report, metric: &str) -> &'a serde_json::Value {
224 report
225 .json_rows
226 .iter()
227 .find(|row| row["metric"] == metric)
228 .expect("metric present")
229 }
230
231 #[test]
232 fn an_unbounded_period_is_an_error_that_names_the_fix() {
233 let (_dir, paths) = store(&two_days());
234 let err = build(
235 &Scanner::new(paths),
236 &ReportCtx::new(TimeWindow::all(), None, true),
237 )
238 .unwrap_err();
239 let msg = err.to_string();
240 assert!(msg.contains("--since"), "{msg}");
241 }
242
243 #[test]
244 fn an_empty_previous_period_blanks_the_change_instead_of_dividing_by_zero() {
245 let report = report(&[priced(
246 used("t", ms(2026, 8, 4, 10), "acme", "m", 150, 10),
247 2.0,
248 )]);
249 let input = row(&report, "input tok");
250 assert_eq!(input["previous_period"], 0);
251 assert!(input["change_pct"].is_null());
252 assert!(report.table.render(Style::plain()).contains(UNSUPPORTED));
253 }
254
255 #[test]
256 fn unpriced_cost_never_reads_as_spend_dropping_to_zero() {
257 let report = report(&[used("t", ms(2026, 8, 4, 10), "acme", "m", 150, 10)]);
258 let cost = row(&report, "est. cost");
259 assert!(cost["this_period"].is_null());
260 assert!(cost["delta"].is_null());
261 }
262}