1use serde::Serialize;
14
15use crate::usage_signal::{AgentUsage, DailyInstance, SessionRecord, TimeBucket, UsageSnapshot};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum AgentFilter {
20 #[default]
21 All,
22 Claude,
23 Codex,
24}
25
26impl AgentFilter {
27 fn includes_claude(self) -> bool {
28 matches!(self, AgentFilter::All | AgentFilter::Claude)
29 }
30 fn includes_codex(self) -> bool {
31 matches!(self, AgentFilter::All | AgentFilter::Codex)
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Period {
38 Daily,
39 Weekly,
40 Monthly,
41}
42
43#[derive(Debug, Clone, Default)]
45pub struct ReportOptions {
46 pub since: Option<String>,
48 pub until: Option<String>,
50 pub agent: AgentFilter,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "lowercase")]
56pub enum RowKind {
57 Group,
59 Sub,
61 Total,
63}
64
65#[derive(Debug, Clone, Default, Serialize)]
67pub struct Metrics {
68 pub input: u64,
69 pub output: u64,
70 pub cache_creation: u64,
71 pub cache_read: u64,
72 pub cost: f64,
73}
74
75impl Metrics {
76 fn add_bucket(&mut self, b: &TimeBucket) {
77 self.input += b.input;
78 self.output += b.output;
79 self.cache_creation += b.cache_creation;
80 self.cache_read += b.cache_read;
81 self.cost += b.cost;
82 }
83 fn add(&mut self, other: &Metrics) {
84 self.input += other.input;
85 self.output += other.output;
86 self.cache_creation += other.cache_creation;
87 self.cache_read += other.cache_read;
88 self.cost += other.cost;
89 }
90 pub fn total_tokens(&self) -> u64 {
92 self.input + self.output + self.cache_creation + self.cache_read
93 }
94 fn is_empty(&self) -> bool {
95 self.input == 0
96 && self.output == 0
97 && self.cache_creation == 0
98 && self.cache_read == 0
99 && self.cost == 0.0
100 }
101}
102
103#[derive(Debug, Clone, Serialize)]
105pub struct ReportRow {
106 pub label: String,
108 pub sublabel: String,
110 pub models: Vec<String>,
113 pub extra: String,
115 #[serde(flatten)]
116 pub metrics: Metrics,
117 pub kind: RowKind,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
122#[serde(rename_all = "lowercase")]
123pub enum ReportKind {
124 Daily,
125 Weekly,
126 Monthly,
127 Instances,
128 Session,
129 Model,
130}
131
132#[derive(Debug, Clone, Serialize)]
134pub struct Report {
135 pub kind: ReportKind,
136 pub rows: Vec<ReportRow>,
137 pub total: Metrics,
138 pub pricing_source: Option<String>,
139 pub pricing_is_estimate: bool,
140}
141
142pub fn normalize_date_arg(s: &str) -> Option<String> {
146 let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
147 if digits.len() == 8 {
148 Some(format!("{}-{}-{}", &digits[0..4], &digits[4..6], &digits[6..8]))
149 } else {
150 None
151 }
152}
153
154fn month_key(date: &str) -> String {
155 date.get(0..7).unwrap_or(date).to_string()
156}
157
158fn iso_week_key(date: &str) -> Option<String> {
160 let mut it = date.split('-');
161 let y: i32 = it.next()?.parse().ok()?;
162 let m: u8 = it.next()?.parse().ok()?;
163 let d: u8 = it.next()?.parse().ok()?;
164 let month = time::Month::try_from(m).ok()?;
165 let date = time::Date::from_calendar_date(y, month, d).ok()?;
166 let (iso_year, week, _) = date.to_iso_week_date();
167 Some(format!("{iso_year}-W{week:02}"))
168}
169
170fn period_key(period: Period, date: &str) -> String {
172 match period {
173 Period::Daily => date.to_string(),
174 Period::Weekly => iso_week_key(date).unwrap_or_else(|| date.to_string()),
175 Period::Monthly => month_key(date),
176 }
177}
178
179fn date_in_range(date: &str, opts: &ReportOptions) -> bool {
181 if let Some(s) = &opts.since {
182 if date < s.as_str() {
183 return false;
184 }
185 }
186 if let Some(u) = &opts.until {
187 if date > u.as_str() {
188 return false;
189 }
190 }
191 true
192}
193
194fn period_in_range(period: Period, label: &str, opts: &ReportOptions) -> bool {
198 if let Some(s) = &opts.since {
199 if label < period_key(period, s).as_str() {
200 return false;
201 }
202 }
203 if let Some(u) = &opts.until {
204 if label > period_key(period, u).as_str() {
205 return false;
206 }
207 }
208 true
209}
210
211fn clean_models(models: &[String]) -> Vec<String> {
212 models
213 .iter()
214 .filter(|m| !m.is_empty() && m.as_str() != "<synthetic>")
215 .cloned()
216 .collect()
217}
218
219fn merge_models(into: &mut Vec<String>, more: &[String]) {
220 for m in more {
221 if !into.contains(m) {
222 into.push(m.clone());
223 }
224 }
225}
226
227fn models_by_period(
229 instances: &[DailyInstance],
230 period: Period,
231 opts: &ReportOptions,
232) -> std::collections::BTreeMap<String, Vec<String>> {
233 let mut map: std::collections::BTreeMap<String, Vec<String>> = Default::default();
234 for inst in instances {
235 if !date_in_range(&inst.date, opts) {
236 continue;
237 }
238 let key = period_key(period, &inst.date);
239 let entry = map.entry(key).or_default();
240 merge_models(entry, &clean_models(&inst.models));
241 }
242 map
243}
244
245fn agent_buckets(a: &AgentUsage, period: Period) -> &[TimeBucket] {
249 match period {
250 Period::Daily => &a.by_day,
251 Period::Weekly => &a.by_week,
252 Period::Monthly => &a.by_month,
253 }
254}
255
256pub fn time_report(snap: &UsageSnapshot, period: Period, opts: &ReportOptions) -> Report {
259 let kind = match period {
260 Period::Daily => ReportKind::Daily,
261 Period::Weekly => ReportKind::Weekly,
262 Period::Monthly => ReportKind::Monthly,
263 };
264
265 let mut periods: std::collections::BTreeMap<String, (Metrics, Metrics)> = Default::default();
267 if opts.agent.includes_claude() {
268 for b in agent_buckets(&snap.claude, period) {
269 if !period_in_range(period, &b.date, opts) {
270 continue;
271 }
272 periods.entry(b.date.clone()).or_default().0.add_bucket(b);
273 }
274 }
275 if opts.agent.includes_codex() {
276 for b in agent_buckets(&snap.codex, period) {
277 if !period_in_range(period, &b.date, opts) {
278 continue;
279 }
280 periods.entry(b.date.clone()).or_default().1.add_bucket(b);
281 }
282 }
283
284 let claude_models = if opts.agent.includes_claude() {
287 models_by_period(&snap.claude.by_day_project, period, opts)
288 } else {
289 Default::default()
290 };
291 let codex_models = if opts.agent.includes_codex() {
292 models_by_period(&snap.codex.by_day_project, period, opts)
293 } else {
294 Default::default()
295 };
296
297 let mut rows = Vec::new();
298 let mut total = Metrics::default();
299
300 for (label, (claude, codex)) in &periods {
301 let mut all = Metrics::default();
302 all.add(claude);
303 all.add(codex);
304 if all.is_empty() {
305 continue;
306 }
307
308 let mut all_models = claude_models.get(label).cloned().unwrap_or_default();
309 merge_models(&mut all_models, &codex_models.get(label).cloned().unwrap_or_default());
310
311 rows.push(ReportRow {
312 label: label.clone(),
313 sublabel: "All".to_string(),
314 models: all_models,
315 extra: String::new(),
316 metrics: all.clone(),
317 kind: RowKind::Group,
318 });
319 if opts.agent.includes_claude() && !claude.is_empty() {
320 rows.push(ReportRow {
321 label: label.clone(),
322 sublabel: "Claude".to_string(),
323 models: claude_models.get(label).cloned().unwrap_or_default(),
324 extra: String::new(),
325 metrics: claude.clone(),
326 kind: RowKind::Sub,
327 });
328 }
329 if opts.agent.includes_codex() && !codex.is_empty() {
330 rows.push(ReportRow {
331 label: label.clone(),
332 sublabel: "Codex".to_string(),
333 models: codex_models.get(label).cloned().unwrap_or_default(),
334 extra: String::new(),
335 metrics: codex.clone(),
336 kind: RowKind::Sub,
337 });
338 }
339 total.add(&all);
340 }
341
342 Report {
343 kind,
344 rows,
345 total,
346 pricing_source: snap.pricing_source.clone(),
347 pricing_is_estimate: snap.pricing_is_estimate,
348 }
349}
350
351pub fn instances_report(snap: &UsageSnapshot, opts: &ReportOptions) -> Report {
353 let mut rows = Vec::new();
354 let mut total = Metrics::default();
355
356 let mut push_agent = |agent_label: &str, insts: &[DailyInstance]| {
357 for inst in insts {
358 if !date_in_range(&inst.date, opts) {
359 continue;
360 }
361 let m = Metrics {
362 input: inst.input,
363 output: inst.output,
364 cache_creation: inst.cache_creation,
365 cache_read: inst.cache_read,
366 cost: inst.cost,
367 };
368 if m.is_empty() {
369 continue;
370 }
371 total.add(&m);
372 rows.push(ReportRow {
373 label: inst.date.clone(),
374 sublabel: format!("{} · {}", agent_label, inst.project),
375 models: clean_models(&inst.models),
376 extra: String::new(),
377 metrics: m,
378 kind: RowKind::Group,
379 });
380 }
381 };
382 if opts.agent.includes_claude() {
383 push_agent("Claude", &snap.claude.by_day_project);
384 }
385 if opts.agent.includes_codex() {
386 push_agent("Codex", &snap.codex.by_day_project);
387 }
388
389 rows.sort_by(|a, b| a.label.cmp(&b.label).then(a.sublabel.cmp(&b.sublabel)));
390
391 Report {
392 kind: ReportKind::Instances,
393 rows,
394 total,
395 pricing_source: snap.pricing_source.clone(),
396 pricing_is_estimate: snap.pricing_is_estimate,
397 }
398}
399
400pub fn session_report(snap: &UsageSnapshot, opts: &ReportOptions) -> Report {
402 let mut rows = Vec::new();
403 let mut total = Metrics::default();
404
405 let mut collect = |agent_label: &str, sessions: &[SessionRecord]| {
406 for s in sessions {
407 let day = s.ended_at.get(0..10).unwrap_or("");
409 if !day.is_empty() && !date_in_range(day, opts) {
410 continue;
411 }
412 let m = Metrics {
413 input: s.input,
414 output: s.output,
415 cache_creation: s.cache_creation,
416 cache_read: s.cache_read,
417 cost: s.cost,
418 };
419 total.add(&m);
420 rows.push(ReportRow {
421 label: s.started_at.get(0..16).unwrap_or(&s.started_at).replace('T', " "),
422 sublabel: format!("{} · {}", agent_label, s.project),
423 models: clean_models(std::slice::from_ref(&s.model)),
424 extra: format!("{:.0}m", s.duration_minutes),
425 metrics: m,
426 kind: RowKind::Group,
427 });
428 }
429 };
430 if opts.agent.includes_claude() {
431 collect("Claude", &snap.claude.recent_sessions);
432 }
433 if opts.agent.includes_codex() {
434 collect("Codex", &snap.codex.recent_sessions);
435 }
436
437 rows.sort_by(|a, b| b.label.cmp(&a.label));
439
440 Report {
441 kind: ReportKind::Session,
442 rows,
443 total,
444 pricing_source: snap.pricing_source.clone(),
445 pricing_is_estimate: snap.pricing_is_estimate,
446 }
447}
448
449pub fn model_report(snap: &UsageSnapshot, opts: &ReportOptions) -> Report {
451 let mut by_model: std::collections::BTreeMap<String, Metrics> = Default::default();
452 let mut add = |buckets: &[crate::usage_signal::NamedBucket]| {
453 for b in buckets {
454 let e = by_model.entry(b.model.clone()).or_default();
455 e.input += b.input;
456 e.output += b.output;
457 e.cache_creation += b.cache_creation;
458 e.cache_read += b.cache_read;
459 e.cost += b.cost;
460 }
461 };
462 if opts.agent.includes_claude() {
463 add(&snap.claude.by_model);
464 }
465 if opts.agent.includes_codex() {
466 add(&snap.codex.by_model);
467 }
468
469 let mut total = Metrics::default();
470 let mut rows: Vec<ReportRow> = by_model
471 .into_iter()
472 .filter(|(_, m)| !m.is_empty())
473 .map(|(model, m)| {
474 total.add(&m);
475 ReportRow {
476 label: model.clone(),
477 sublabel: String::new(),
478 models: vec![model],
479 extra: String::new(),
480 metrics: m,
481 kind: RowKind::Group,
482 }
483 })
484 .collect();
485 rows.sort_by(|a, b| {
487 b.metrics
488 .cost
489 .partial_cmp(&a.metrics.cost)
490 .unwrap_or(std::cmp::Ordering::Equal)
491 });
492
493 Report {
494 kind: ReportKind::Model,
495 rows,
496 total,
497 pricing_source: snap.pricing_source.clone(),
498 pricing_is_estimate: snap.pricing_is_estimate,
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 fn bucket(date: &str, input: u64, output: u64, cc: u64, cr: u64, cost: f64) -> TimeBucket {
507 TimeBucket {
508 date: date.to_string(),
509 tokens: input + output,
510 sessions: 1,
511 input,
512 output,
513 cache_creation: cc,
514 cache_read: cr,
515 cost,
516 }
517 }
518
519 fn snap_with(claude_day: Vec<TimeBucket>, codex_day: Vec<TimeBucket>) -> UsageSnapshot {
520 let mut s = UsageSnapshot::default();
521 s.claude.by_day = claude_day;
522 s.codex.by_day = codex_day;
523 s.pricing_is_estimate = true;
524 s
525 }
526
527 #[test]
528 fn total_tokens_is_all_four_buckets() {
529 let m = Metrics {
530 input: 10,
531 output: 20,
532 cache_creation: 5,
533 cache_read: 100,
534 cost: 1.0,
535 };
536 assert_eq!(m.total_tokens(), 135);
537 }
538
539 #[test]
540 fn daily_all_row_sums_agents_and_totals_match() {
541 let snap = snap_with(
542 vec![bucket("2026-05-13", 100, 200, 10, 1000, 2.5)],
543 vec![bucket("2026-05-13", 50, 60, 0, 500, 1.0)],
544 );
545 let r = time_report(&snap, Period::Daily, &ReportOptions::default());
546 assert_eq!(r.rows.len(), 3);
548 let all = &r.rows[0];
549 assert_eq!(all.sublabel, "All");
550 assert_eq!(all.metrics.input, 150);
551 assert_eq!(all.metrics.output, 260);
552 assert_eq!(all.metrics.cache_read, 1500);
553 assert!((all.metrics.cost - 3.5).abs() < 1e-9);
554 assert_eq!(r.total.total_tokens(), all.metrics.total_tokens());
556 assert!((r.total.cost - 3.5).abs() < 1e-9);
557 }
558
559 #[test]
560 fn agent_filter_drops_codex() {
561 let snap = snap_with(
562 vec![bucket("2026-05-13", 100, 200, 10, 1000, 2.5)],
563 vec![bucket("2026-05-13", 50, 60, 0, 500, 1.0)],
564 );
565 let opts = ReportOptions {
566 agent: AgentFilter::Claude,
567 ..Default::default()
568 };
569 let r = time_report(&snap, Period::Daily, &opts);
570 assert_eq!(r.rows.len(), 2);
572 assert!((r.total.cost - 2.5).abs() < 1e-9);
573 }
574
575 #[test]
576 fn since_until_filters_dates() {
577 let snap = snap_with(
578 vec![
579 bucket("2026-05-12", 10, 10, 0, 0, 1.0),
580 bucket("2026-05-13", 10, 10, 0, 0, 1.0),
581 bucket("2026-05-14", 10, 10, 0, 0, 1.0),
582 ],
583 vec![],
584 );
585 let opts = ReportOptions {
586 since: Some("2026-05-13".into()),
587 until: Some("2026-05-13".into()),
588 ..Default::default()
589 };
590 let r = time_report(&snap, Period::Daily, &opts);
591 assert_eq!(r.rows.len(), 2);
593 assert!((r.total.cost - 1.0).abs() < 1e-9);
594 }
595
596 #[test]
597 fn agent_filter_excludes_other_agents_models() {
598 let mut snap = snap_with(
599 vec![bucket("2026-05-13", 10, 10, 0, 0, 1.0)],
600 vec![bucket("2026-05-13", 10, 10, 0, 0, 1.0)],
601 );
602 snap.claude.by_day_project = vec![DailyInstance {
603 date: "2026-05-13".into(),
604 models: vec!["claude-opus-4-8".into()],
605 ..Default::default()
606 }];
607 snap.codex.by_day_project = vec![DailyInstance {
608 date: "2026-05-13".into(),
609 models: vec!["gpt-5.5".into()],
610 ..Default::default()
611 }];
612 let opts = ReportOptions {
613 agent: AgentFilter::Codex,
614 ..Default::default()
615 };
616 let r = time_report(&snap, Period::Daily, &opts);
617 let all = &r.rows[0];
618 assert_eq!(all.sublabel, "All");
619 assert_eq!(all.models, vec!["gpt-5.5".to_string()]);
620 }
621
622 #[test]
623 fn normalize_date_arg_accepts_both_forms() {
624 assert_eq!(normalize_date_arg("20260513").as_deref(), Some("2026-05-13"));
625 assert_eq!(normalize_date_arg("2026-05-13").as_deref(), Some("2026-05-13"));
626 assert_eq!(normalize_date_arg("nope"), None);
627 }
628
629 #[test]
630 fn iso_week_key_matches_engine_format() {
631 assert_eq!(iso_week_key("2026-05-29").as_deref(), Some("2026-W22"));
633 }
634}