Skip to main content

context_bar_core/
report.rs

1//! Tabular usage/cost reports built from a [`UsageSnapshot`].
2//!
3//! This is the data layer behind the terminal CLI's `daily` / `weekly` /
4//! `monthly` / `session` / `--instances` / `--breakdown` verbs (and reusable
5//! by any other surface). It does no rendering and pulls in no terminal
6//! crates — it only reshapes the engine's already-priced buckets into rows.
7//!
8//! Cost-model note (see `docs/ai/COST_MODEL.md`): the **Total** column here is
9//! ccusage's "Total Tokens" = `input + output + cache_creation + cache_read`,
10//! which is DISTINCT from the Stats/HUD `tokens` total (`fresh_in + output`).
11//! Per-row `cost` is the engine's per-turn estimate, summed; we never re-price.
12
13use serde::Serialize;
14
15use crate::usage_signal::{AgentUsage, DailyInstance, SessionRecord, TimeBucket, UsageSnapshot};
16
17/// Which agents to include.
18#[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/// Time grouping for [`time_report`].
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum Period {
38    Daily,
39    Weekly,
40    Monthly,
41}
42
43/// Filtering options shared by the report builders.
44#[derive(Debug, Clone, Default)]
45pub struct ReportOptions {
46    /// Inclusive lower bound, normalized `YYYY-MM-DD` (or `None`).
47    pub since: Option<String>,
48    /// Inclusive upper bound, normalized `YYYY-MM-DD` (or `None`).
49    pub until: Option<String>,
50    pub agent: AgentFilter,
51}
52
53/// Role of a row in the rendered table.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
55#[serde(rename_all = "lowercase")]
56pub enum RowKind {
57    /// A period's aggregate ("All" across agents), or a standalone row.
58    Group,
59    /// A per-agent breakdown nested under a Group row.
60    Sub,
61    /// The grand-total row.
62    Total,
63}
64
65/// The four token buckets plus derived total and cost. Accumulates cleanly.
66#[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    /// ccusage "Total Tokens": all four buckets.
91    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/// One rendered row. Columns are selected per [`ReportKind`] by the renderer.
104#[derive(Debug, Clone, Serialize)]
105pub struct ReportRow {
106    /// Primary group label: date / `YYYY-Www` / `YYYY-MM` / session id.
107    pub label: String,
108    /// Secondary label: agent ("All"/"Claude"/"Codex"), project, or model.
109    pub sublabel: String,
110    /// Model ids relevant to this row (already de-synthetic'd), for the
111    /// Models column. Empty when not applicable.
112    pub models: Vec<String>,
113    /// Free-form extra cell (e.g. session start time, duration). Empty if unused.
114    pub extra: String,
115    #[serde(flatten)]
116    pub metrics: Metrics,
117    pub kind: RowKind,
118}
119
120/// What flavor of report this is — drives column selection in the renderer.
121#[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/// A complete report: rows + grand total + pricing provenance.
133#[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
142// ---- date helpers ---------------------------------------------------------
143
144/// Normalize a user date arg (`YYYYMMDD` or `YYYY-MM-DD`) to `YYYY-MM-DD`.
145pub 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
158/// ISO-week label `YYYY-Www`, matching the engine's `by_week` keys.
159fn 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
170/// Map a daily date to the key of the given period.
171fn 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
179/// Does a daily date fall within the inclusive [since, until] window?
180fn 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
194/// Range check for an aggregated period label, by mapping the since/until
195/// bounds into the same period space (so a week/month row survives if it
196/// overlaps the window).
197fn 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
227/// date/period-key -> set of model ids, from an agent's `by_day_project`.
228fn 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
245// ---- builders -------------------------------------------------------------
246
247/// Which engine bucket list to read for an agent at a given period.
248fn 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
256/// Build a daily/weekly/monthly report: per-period "All" rows with per-agent
257/// sub-rows, sorted chronologically, ending in a grand-total row.
258pub 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    // period label -> (claude metrics, codex metrics)
266    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    // Only collect models for the agents the filter includes, so an "All" row
285    // under e.g. `--agent codex` never lists Claude-only models.
286    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
351/// Per (date × project) report — the `better-ccusage daily --instances` view.
352pub 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
400/// Recent-sessions report (both agents merged, newest first).
401pub 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            // recent_sessions carry full timestamps; filter by the date part.
408            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    // Newest first.
438    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
449/// Per-model breakdown (global, both agents merged by model id).
450pub 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    // Highest cost first.
486    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        // One period -> All + Claude + Codex.
547        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        // Grand total equals the single period's All row.
555        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        // All + Claude only.
571        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        // Only the 13th survives -> All + Claude.
592        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        // 2026-05-29 falls in ISO week 22 (matches the engine's by_week labels).
632        assert_eq!(iso_week_key("2026-05-29").as_deref(), Some("2026-W22"));
633    }
634}