Skip to main content

cmd_usage/ocuse/
render.rs

1// ocuse's presentation: a watch frame, a one-shot dashboard, a status line, and JSON.
2//
3// Reuses cmduse-core's pure formatters (money/compact/pct/duration) and cmduse's
4// own palette, gauge and sparkline, so both tools read the same. Go's allowance
5// is per model — not per account — so the header's window lines sum the caps of
6// the models actually used this month and the table shows each model against its
7// own limit; that distinction is the whole point of the Go plan.
8use crate::ocuse::window::{ModelUsage, Report, Totals};
9use crate::ocuse::zen;
10use crate::report_render::Fmt;
11use cmduse_core::{compact, duration, money, pct};
12
13/// Gauge width for the window bars when neither config nor `--bar-width` says
14/// otherwise — cmduse's own default.
15pub const DEFAULT_BAR_WIDTH: usize = 20;
16
17/// ANSI styles — cmduse's palette, or empty strings when colour is off, so a
18/// piped run never leaks SGR escapes.
19pub struct Ink {
20    pub colour: bool,
21    /// Gauge width for the window bars (config / `--bar-width`).
22    pub bar_width: usize,
23    pub bold: &'static str,
24    pub dim: &'static str,
25    pub reset: &'static str,
26    pub cyan: &'static str,
27    pub green: &'static str,
28    pub yellow: &'static str,
29    pub red: &'static str,
30}
31
32impl Ink {
33    pub fn new(colour: bool) -> Self {
34        if colour {
35            Self {
36                colour,
37                bar_width: DEFAULT_BAR_WIDTH,
38                bold: crate::render::BOLD,
39                dim: crate::render::DIM,
40                reset: crate::render::RESET,
41                cyan: crate::render::CYAN,
42                green: crate::render::GREEN,
43                yellow: crate::render::YELLOW,
44                red: crate::render::RED,
45            }
46        } else {
47            Self {
48                colour,
49                bar_width: DEFAULT_BAR_WIDTH,
50                bold: "",
51                dim: "",
52                reset: "",
53                cyan: "",
54                green: "",
55                yellow: "",
56                red: "",
57            }
58        }
59    }
60
61    /// Override the gauge width (config / `--bar-width`); chains after `new`.
62    pub fn width(mut self, width: usize) -> Self {
63        self.bar_width = width;
64        self
65    }
66
67    /// Colour a value without disturbing the caller's column padding: pad the
68    /// text first, then wrap it.
69    fn cyan_on(&self, text: String) -> String {
70        format!("{}{text}{}", self.cyan, self.reset)
71    }
72
73    /// Secondary information — cmduse's `DIM`.
74    fn dim_on(&self, text: String) -> String {
75        format!("{}{text}{}", self.dim, self.reset)
76    }
77
78    fn bold_on(&self, text: String) -> String {
79        format!("{}{text}{}", self.bold, self.reset)
80    }
81
82    /// Severity for a used/limit percentage — cmduse's thresholds.
83    fn for_pct(&self, pct_used: f64) -> &'static str {
84        if !self.colour {
85            ""
86        } else if pct_used >= 90.0 {
87            self.red
88        } else if pct_used >= 70.0 {
89            self.yellow
90        } else {
91            self.green
92        }
93    }
94
95    /// The share plus cmduse's gauge, and the row's only percentage in colour
96    /// mode — the body drops its own `x% of limit` so the figure is not printed
97    /// twice. The label is passed in because the endpoint's own percentage is
98    /// printed verbatim (`53`, never `53.0`). Empty when colour is off, so the
99    /// plain frame keeps its script-friendly layout and its own share.
100    fn bar_label(&self, pct_used: f64, label: &str) -> String {
101        if !self.colour {
102            return String::new();
103        }
104        format!(
105            "{}{label:>5}%{} {}",
106            self.for_pct(pct_used),
107            self.reset,
108            crate::render::gauge(pct_used, 100.0, self.bar_width),
109        )
110    }
111}
112
113#[allow(clippy::too_many_arguments)]
114/// One window row's inputs. The account's own meters and the local estimate both
115/// go through this one builder, so the two branches cannot drift apart in which
116/// fields a window row carries or in the order it carries them.
117///
118/// There is deliberately no cap *field* here: Go grants limits per model, so no
119/// account-wide pool exists to divide by, and summing the per-model limits into
120/// one ceiling read as a budget the console does not have (it meters each window
121/// on its own basis) — that is where `$215` came from. The money pair comes back
122/// the honest way instead: the server's own share back-solves the ceiling it is
123/// metering against, so `used / implied` divides to exactly the share beside it.
124struct RowSpec<'a> {
125    label: &'a str,
126    /// This store's own totals for the window.
127    used: &'a Totals,
128    /// The window's reset instant and its length in seconds. `None` on the local
129    /// estimate's rolling 5h/weekly windows: those are cut at `now - window`, so
130    /// they have no boundary to measure an elapsed share against.
131    reset: Option<(i64, u64)>,
132    /// The account's own share of the limit, when it answered — the console's
133    /// figure, and the reason the endpoint is read at all. `None` on the local
134    /// estimate, whose rows then show spend alone.
135    share_pct: Option<f64>,
136    /// The account's own verdict: a status it does not call `ok` rides beside
137    /// the limit flag.
138    status: Option<&'a str>,
139    /// Whether an even-burn figure belongs on this row. True for the monthly
140    /// window alone: that one is a budget, so "what even burn would have spent by
141    /// now" is a fair comparison, where the 5-hour and weekly throttles would
142    /// read the same number as a spend target.
143    pro_rata: bool,
144}
145
146fn window_line(ink: &Ink, spec: &RowSpec<'_>, now_ms: i64) -> String {
147    let reset = spec
148        .reset
149        .map(|(at, _)| duration(((at - now_ms) / 1000).max(0) as u64))
150        .unwrap_or_default();
151    let reset_text = if reset.is_empty() {
152        String::new()
153    } else if ink.colour {
154        format!(" · resets in {reset}")
155    } else {
156        format!(" · resets {reset}")
157    };
158    // cmduse's field order is importance order, so a clipped watch frame keeps
159    // the actionable parts: limit flag, pace warning, elapsed share, then the
160    // informational pro-rata figure. Percentages come from core (one decimal);
161    // duration (never rel_time) formats the ETA.
162    let now_secs = (now_ms / 1000).max(0) as u64;
163    let reset_at = spec.reset.map(|(at, _)| at as f64);
164    let dur_secs = spec.reset.map(|(_, dur)| dur);
165    let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
166    let share = spec.share_pct.map(|p| p.max(0.0));
167    // The ceiling the server's own share implies, in this store's dollars. No
168    // pool is invented here: the console's percentage *is* the fraction of the
169    // ceiling it meters against, so `spend / share` is the ceiling, and the pair
170    // below divides to exactly the percentage printed beside it.
171    let cap = share.and_then(|p| implied_cap(spec.used.cost, p));
172    let pace = cap
173        .and_then(|cap| {
174            dur_secs.and_then(|d| {
175                cmduse_core::pace_eta(reset_at, d, spec.used.cost, cap, now_secs)
176                    .map(|secs| duration(secs as u64))
177            })
178        })
179        .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
180        .unwrap_or_default();
181    let thru = elapsed
182        .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
183        .unwrap_or_default();
184    let by_now = match (spec.pro_rata, elapsed, cap) {
185        (true, Some(p), Some(cap)) => format!(
186            " · {}{} by now{}",
187            ink.dim,
188            money(cap * p / 100.0),
189            ink.reset
190        ),
191        _ => String::new(),
192    };
193    let label = spec.label;
194    // The account's own share is labelled, because the money beside it is this
195    // store's spend and the two need not divide to each other (the server
196    // meters higher). Same placement cmduse uses for the flag: on the window
197    // it belongs to.
198    let flag = if share.is_some_and(|p| p >= 100.0) {
199        format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
200    } else if spec.status.is_some_and(|s| !s.eq_ignore_ascii_case("ok")) {
201        format!(
202            " {}{}{}{}",
203            ink.red,
204            spec.status.unwrap_or_default(),
205            ink.reset,
206            ""
207        )
208    } else {
209        String::new()
210    };
211    // No denominator is printed. `cap` is spent ÷ share, so printing it offered
212    // a ceiling of ~$40 beside a documented $80 monthly limit and read as "my
213    // limit is $40" — the console meters at roughly twice the cost this store
214    // records, so the two bases never meet. It stays a local value for the pace
215    // and the even-burn figure, both of which are ratios in this store's dollars.
216    let paren = match (share, ink.colour) {
217        // The gauge carries the share in colour mode; repeating it here read as
218        // `5% ━━━━━ 12.34 · 5% of limit`. The plain frame has no gauge, so it
219        // keeps the figure.
220        (Some(_), true) => String::new(),
221        (Some(p), false) => format!(" · {}% of limit", given_pct(p)),
222        (None, _) => String::new(),
223    };
224    // Composed in pieces: the pair and the pro-rata figure are conditional, and
225    // one format string for both branches is a placeholder count waiting to drift.
226    let body = format!(
227        "{}{paren}{reset_text}{flag}{pace}{thru}{by_now}",
228        money(spec.used.cost)
229    );
230    if ink.colour {
231        // The gauge bar belongs to the share that gates: the account's own
232        // when it answered. The local estimate has no share, so its rows
233        // carry no bar — spend alone, never a gauge against a pool.
234        let bar = match share {
235            Some(p) => format!("{} ", ink.bar_label(p, &given_pct(p))),
236            None => String::new(),
237        };
238        format!(
239            " {}{label:<8}{} {}{}{body}{}\n",
240            ink.bold, ink.reset, bar, ink.dim, ink.reset,
241        )
242    } else {
243        // The plain frame is the script-facing one: same field order,
244        // no SGR (the Ink helpers are empty strings when colour is off).
245        format!("  {label:<8} {body}\n")
246    }
247}
248
249/// A percentage exactly as the endpoint published it.
250///
251/// The server sends integers (`"percent":53`), so `{:.1}` invented a decimal the
252/// console never reports — and a tenth implies a precision its rounding does not
253/// have. Local, computed percentages keep `pct`'s one decimal; this is only for
254/// the values we are handed.
255fn given_pct(value: f64) -> String {
256    if (value - value.round()).abs() < f64::EPSILON {
257        format!("{}", value.round() as i64)
258    } else {
259        format!("{:.1}", value)
260    }
261}
262
263/// The ceiling a share implies, in this store's dollars: `spend / share`.
264///
265/// Kept for the pace and even-burn figures only — never printed, because it
266/// contradicts the documented allowance. `None` on a zero share, which would
267/// divide by it.
268fn implied_cap(spend: f64, share_pct: f64) -> Option<f64> {
269    (share_pct >= 0.05).then(|| spend * 100.0 / share_pct)
270}
271
272/// The account meter's own window boundary, when the endpoint gave one: its
273/// reset instant together with the window's length, so the row can measure an
274/// elapsed share and a pace against the server's clock rather than a guess.
275fn meter_reset(meter: &crate::ocuse::api::Meter, dur_secs: Option<u64>) -> Option<(i64, u64)> {
276    match (meter.reset_ms(), dur_secs) {
277        (Some(at), Some(dur)) if dur > 0 => Some((at, dur)),
278        _ => None,
279    }
280}
281
282/// Is any of this model's windows over its share of the limit? Go meters per
283/// model *and* per window, so being over the 5-hour cap counts like being over
284/// the month. One verdict, shared with the watch loop's cap notification.
285fn exceeded(model: &ModelUsage) -> bool {
286    crate::ocuse::window::over_window(model).is_some()
287}
288
289fn model_row(ink: &Ink, model: &ModelUsage, total_cost: f64) -> String {
290    let limit = match model.limit {
291        Some(limit) => money(limit),
292        None => ink.dim_on("—".into()),
293    };
294    let use_fraction = model.month_use();
295    let use_text = use_fraction
296        .map(|fraction| pct(fraction * 100.0, 100.0))
297        .unwrap_or_else(|| "—".into());
298    // Pad before colouring: SGR escapes would otherwise count as columns.
299    let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
300    let use_cell = match use_fraction {
301        Some(fraction) => {
302            let tone = ink.for_pct(fraction * 100.0);
303            if tone.is_empty() {
304                format!("{use_text:>7}")
305            } else {
306                format!("{tone}{use_text:>7}{}", ink.reset)
307            }
308        }
309        None => ink.dim_on(format!("{use_text:>7}")),
310    };
311    // Actionable before informational (cmduse's ordering): the flag sits beside
312    // the model so a clipped frame keeps it; the share is the first thing to go.
313    let flag = if exceeded(model) {
314        format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
315    } else {
316        String::new()
317    };
318    let share = if total_cost > 0.0 {
319        ink.dim_on(format!(" {:>5.1}%", model.month.cost / total_cost * 100.0))
320    } else {
321        String::new()
322    };
323    format!(
324        "{:<28}{flag} {:>7} {spent} {use_cell} {} {} {:>7}{share}\n",
325        model.id,
326        limit,
327        // Go windows are per model too: 5h = 20% of the limit, weekly = 50%.
328        window_cell(ink, model, model.five_hour.cost, 0.20),
329        window_cell(ink, model, model.weekly.cost, 0.50),
330        compact(model.month.requests),
331    )
332}
333
334/// One rolling-window column, coloured against that model's own share of the
335/// limit; models with no limit (free, or Zen-only) are dimmed instead.
336fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
337    let text = format!("{:>8}", money(cost));
338    match model.limit.filter(|limit| *limit > 0.0) {
339        Some(limit) => {
340            let tone = ink.for_pct(cost / (limit * share) * 100.0);
341            if tone.is_empty() {
342                text
343            } else {
344                format!("{tone}{text}{}", ink.reset)
345            }
346        }
347        None => ink.dim_on(text),
348    }
349}
350
351/// `limit` rows to show; `usize::MAX` for all.
352pub fn render_text(
353    report: &Report,
354    db_path: &str,
355    rows: usize,
356    colour: bool,
357    bar_width: usize,
358) -> String {
359    let ink = Ink::new(colour).width(bar_width);
360    let c = |text: String| ink.cyan_on(text);
361    let mut out = String::new();
362    out.push_str(&format!(
363        "{}OpenCode usage{} — go + zen, from {}{}{}\n",
364        ink.bold,
365        ink.reset,
366        ink.dim,
367        shorten(db_path),
368        ink.reset,
369    ));
370    // cmduse prints `Success N%` from its account API; opencode's own store
371    // records no per-request success/error signal, so there is no rate to read
372    // here — omitted rather than synthesised (source-forced).
373    let end = report
374        .period_end_ms
375        .map(|ms| format!(" · ends {}", date_of(ms / 86_400_000)))
376        .unwrap_or_default();
377    out.push_str(&format!(
378        "period: {}{end} · {} requests · {} · {} in / {} out tokens\n",
379        report.period_label,
380        c(compact(report.month.requests)),
381        c(money(report.month.cost)),
382        c(compact(report.month.input)),
383        c(compact(report.month.output)),
384    ));
385
386    // The three window rows. When the account answered, they are its own meters
387    // — the only view that agrees with opencode.ai/console, and the reason this
388    // endpoint is read at all: the store cannot reproduce the server's window
389    // anchors or its price basis (see `ocuse::api`).
390    match &report.account {
391        Some(account) => {
392            out.push_str(&format!(
393                "\n{}GO{} {}\n",
394                ink.bold,
395                ink.reset,
396                ink.dim_on(
397                    "(account meters — what opencode.ai/console reports; the $ is this store's spend priced on the documented peak/off-peak schedule)"
398                        .into()
399                ),
400            ));
401            // Every field that is true stays on the row: this store's money, the
402            // ceiling the server's own share implies, the share labelled beside
403            // it, its reset instant, and the elapsed share measured against the
404            // server's clock. No account-wide pool is invented here — summing the
405            // per-model limits into one ceiling is where the old `$215` came from.
406            let monthly_dur = report
407                .period_end_ms
408                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
409            let rows = [
410                (
411                    "5-hour",
412                    &account.rolling,
413                    &report.five_hour,
414                    Some(crate::ocuse::window::FIVE_HOUR_SECS as u64),
415                ),
416                (
417                    "Weekly",
418                    &account.weekly,
419                    &report.weekly,
420                    Some(crate::ocuse::window::WEEKLY_SECS as u64),
421                ),
422                ("Monthly", &account.monthly, &report.month, monthly_dur),
423            ];
424            for (label, meter, used, dur_secs) in rows {
425                out.push_str(&window_line(
426                    &ink,
427                    &RowSpec {
428                        label,
429                        used,
430                        reset: meter_reset(meter, dur_secs),
431                        share_pct: Some(meter.percent),
432                        status: Some(meter.status.as_str()),
433                        // The monthly window is the budget; the two throttles
434                        // above it would read a pro-rata figure as a target.
435                        pro_rata: label == "Monthly",
436                    },
437                    report.now_ms,
438                ));
439            }
440        }
441        // No Go key, no network, or a key without the subscription: the store's
442        // own spend, which says so. Limits are per model, so the rows carry
443        // no pool to divide by -- spend alone, with the Monthly row's real
444        // reset and elapsed share. They can disagree with the account.
445        None if report.month.requests > 0 => {
446            // Only the monthly period has a boundary of its own (`period_end_ms`).
447            // The rolling 5h/weekly windows are cut at `now - window`, so they get
448            // no reset, no elapsed share and no pace — the fabricated `now + window`
449            // countdown that used to sit here moved on every frame.
450            let monthly_dur = report
451                .period_end_ms
452                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
453            out.push_str(&format!(
454                "\n{}GO{} {}\n",
455                ink.bold,
456                ink.reset,
457                ink.dim_on(
458                    "(estimated locally: limits are per model, so no pool: spend alone)".into()
459                ),
460            ));
461            // Why the console's own numbers are missing, when there is a reason:
462            // "estimated locally" alone cannot tell a missing key from an expired
463            // one, and only the second is something a person can act on.
464            if let Some(why) = &report.account_error {
465                out.push_str(&format!("{}   usage API: {why}{}\n", ink.dim, ink.reset));
466            }
467            out.push_str(&window_line(
468                &ink,
469                &RowSpec {
470                    label: "5-hour",
471                    used: &report.five_hour,
472                    reset: None,
473                    share_pct: None,
474                    status: None,
475                    pro_rata: false,
476                },
477                report.now_ms,
478            ));
479            out.push_str(&window_line(
480                &ink,
481                &RowSpec {
482                    label: "Weekly",
483                    used: &report.weekly,
484                    reset: None,
485                    share_pct: None,
486                    status: None,
487                    pro_rata: false,
488                },
489                report.now_ms,
490            ));
491            out.push_str(&window_line(
492                &ink,
493                &RowSpec {
494                    label: "Monthly",
495                    used: &report.month,
496                    reset: report.period_end_ms.zip(monthly_dur),
497                    share_pct: None,
498                    status: None,
499                    // No account share, so no ceiling to pro-rate: this row keeps
500                    // its real reset and elapsed share and nothing invented.
501                    pro_rata: false,
502                },
503                report.now_ms,
504            ));
505        }
506        None => {}
507    }
508
509    out.push_str(&format!(
510        "\n{}MODEL                            LIMIT      SPENT     USE       5H       WK     REQ{}\n",
511        ink.bold, ink.reset
512    ));
513    let total_cost = report.month.cost;
514    for model in report.models.iter().take(rows) {
515        out.push_str(&model_row(&ink, model, total_cost));
516    }
517    if report.models.len() > rows {
518        out.push_str(&format!(
519            "{}\n",
520            ink.dim_on(format!("… {} more", report.models.len() - rows))
521        ));
522    }
523    out.push_str(&format!(
524        "\n{}\n",
525        ink.dim_on(
526            "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
527                .into()
528        )
529    ));
530    // The GO block above is the account's own metering. These two columns are
531    // per model, so say whose they are: with an account answer they are cut at
532    // the server's own window bounds (the same block and Monday-anchored week its
533    // percentages describe), and only without one do they fall back to this
534    // machine's rolling cuts.
535    if report.account.is_some() {
536        out.push_str(&format!(
537            "{}\n",
538            ink.dim_on(
539                "5H/WK columns: this model's own spend inside the account's windows (the same cuts its percentages use)."
540                    .into()
541            )
542        ));
543    } else {
544        out.push_str(&format!(
545            "{}\n",
546            ink.dim_on(
547                "5H/WK columns: this model's own spend over the last 5h / 7 days (local, not the account's windows)."
548                    .into()
549            )
550        ));
551    }
552    out
553}
554
555/// One watch frame: the dashboard, then a burst sparkline when the 5-hour spend
556/// has moved since the previous refresh. No trailing newline — the caller
557/// appends its own status line and `redraw_frame` (cmduse's) parks the cursor
558/// there, which is what lets the countdown rewrite a single line per second.
559pub fn watch_frame(
560    report: &Report,
561    db_path: &str,
562    rows: usize,
563    colour: bool,
564    history: &[f64],
565    interval_secs: u64,
566    bar_width: usize,
567) -> String {
568    let ink = Ink::new(colour);
569    let mut out = render_text(report, db_path, rows, colour, bar_width);
570    if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
571        out.push_str(&format!(
572            "{}spend bursts ({}s samples){} {}\n",
573            ink.dim,
574            interval_secs,
575            ink.reset,
576            crate::render::sparkline(history),
577        ));
578    }
579    out.trim_end_matches('\n').to_string()
580}
581
582/// Minimal one-line dashboard for terminals too small for the full frame:
583/// period spend, rolling windows, and the burst sparkline inlined when active.
584/// No trailing newline — the caller appends its own status line, exactly like
585/// `watch_frame`.
586pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
587    let ink = Ink::new(colour);
588    let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
589        format!(
590            " {}{}{}",
591            ink.dim,
592            crate::render::sparkline(history),
593            ink.reset
594        )
595    } else {
596        String::new()
597    };
598    // The 5-hour figure is the one a narrow terminal shows, and the store's
599    // rolling 5h is not the server's block: prefer the account's own share
600    // whenever the endpoint answered (see `ocuse::api`). The month spend stays
601    // money — it is a budget, not a throttle.
602    let window_share = match &report.account {
603        Some(account) => format!(
604            " · 5h {} · wk {}",
605            ink.cyan_on(pct(account.rolling.percent, 100.0)),
606            ink.cyan_on(pct(account.weekly.percent, 100.0)),
607        ),
608        None => format!(" · 5h {}", ink.cyan_on(money(report.five_hour.cost))),
609    };
610    format!(
611        "{}oc{} {} month{window_share}{spark}",
612        ink.bold,
613        ink.reset,
614        ink.cyan_on(money(report.month.cost)),
615    )
616}
617
618/// ocuse's statusline: cmduse's `{key}` template engine (`report_render::
619/// render_template`) resolved against this report's own placeholders, so both
620/// binaries are driven by `statusline_template` in their config.
621///
622/// Placeholders (case-insensitive):
623///   {plan} {period}                plan id / period label
624///   {month} {month_pct} {month_bar}
625///   {five_hour} {five_hour_pct} {five_hour_bar}   (aliases {5h} {5h_pct} {5h_bar})
626///   {weekly} {weekly_pct} {weekly_bar}            (aliases {wk} {wk_pct} {wk_bar})
627///   {models}                       top model by period spend
628///   | or newline                    literal separators
629/// Unknown placeholders are dropped. Percentages are one decimal; money reuses
630/// `cmduse_core::money`; bars reuse `report_render::bar` (ascii/colour aware).
631pub fn statusline_template(
632    report: &Report,
633    plan_id: &str,
634    tpl: &str,
635    colours: bool,
636    ascii: bool,
637    bar_width: usize,
638) -> String {
639    let ink = Ink::new(colours);
640    let cash = |v: f64| ink.cyan_on(money(v));
641    // The account's own share outranks the local one wherever both exist: it is
642    // what opencode.ai/console shows. Money tokens stay local either way — the
643    // endpoint publishes shares and instants, not dollars. Without an account
644    // there is no pool to divide by (Go grants limits per model), so the share
645    // placeholders degrade to the money-less dash instead of a ratio against a
646    // summed ceiling that reads as a budget the console does not have.
647    let account = report.account.as_ref();
648    let share_of = |meter: Option<&crate::ocuse::api::Meter>| match meter {
649        Some(meter) => pct(meter.percent, 100.0),
650        None => "—".into(),
651    };
652    let share_bar = |meter: Option<&crate::ocuse::api::Meter>| match meter {
653        Some(meter) => crate::report_render::bar(meter.percent, bar_width, ascii, colours),
654        None => String::new(),
655    };
656    let top_model = || {
657        report
658            .models
659            .first()
660            .map(|m| m.id.clone())
661            .unwrap_or_else(|| "—".into())
662    };
663    crate::report_render::render_template(tpl, |key| match key {
664        "plan" => plan_id.to_string(),
665        "period" => report.period_label.clone(),
666        "month" => cash(report.month.cost),
667        "month_pct" => share_of(account.map(|a| &a.monthly)),
668        "month_bar" => share_bar(account.map(|a| &a.monthly)),
669        "five_hour" | "5h" => cash(report.five_hour.cost),
670        "five_hour_pct" | "5h_pct" => share_of(account.map(|a| &a.rolling)),
671        "five_hour_bar" | "5h_bar" => share_bar(account.map(|a| &a.rolling)),
672        "weekly" | "wk" => cash(report.weekly.cost),
673        "weekly_pct" | "wk_pct" => share_of(account.map(|a| &a.weekly)),
674        "weekly_bar" | "wk_bar" => share_bar(account.map(|a| &a.weekly)),
675        "models" => top_model(),
676        _ => String::new(),
677    })
678}
679
680/// `ocuse statusline --json`: the same numbers the template resolves, in one
681/// object — plan and period label, period/month spend, the two rolling windows,
682/// and the top model. The `*Cap` keys are retired (always null): Go grants
683/// limits per model, so no account-wide pool exists, and the summed ceiling
684/// they carried read as a budget the console does not have.
685pub fn statusline_json(report: &Report, plan_id: &str) -> String {
686    serde_json::json!({
687        "plan": plan_id,
688        "period": report.period_label,
689        "monthSpent": report.month.cost,
690        "monthCap": serde_json::Value::Null,
691        "fiveHourSpent": report.five_hour.cost,
692        "fiveHourCap": serde_json::Value::Null,
693        "weeklySpent": report.weekly.cost,
694        "weeklyCap": serde_json::Value::Null,
695        "model": report.models.first().map(|m| m.id.clone()),
696    })
697    .to_string()
698}
699
700/// Machine-readable projection: totals plus the per-model breakdown.
701pub fn render_json(report: &Report) -> String {
702    let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
703    let model = |m: &ModelUsage| {
704        serde_json::json!({
705            "id": m.id,
706            "provider": m.provider,
707            "limit": m.limit,
708            "month": totals(&m.month),
709            "fiveHour": totals(&m.five_hour),
710            "weekly": totals(&m.weekly),
711        })
712    };
713    // The account's own meters, when the Go usage endpoint answered. Consumers
714    // (the sidebar's Go block among them) should prefer these over the local
715    // `totals`, which cannot reproduce the server's anchors or price basis.
716    let meter = |m: &crate::ocuse::api::Meter| {
717        serde_json::json!({
718            "status": m.status,
719            "percent": m.percent,
720            "resetsAt": m.reset_ms(),
721        })
722    };
723    serde_json::to_string_pretty(&serde_json::json!({
724        "source": "opencode.db",
725        "generatedAt": report.now_ms,
726        "periodStart": report.since_ms,
727        // Period bounds in epoch ms, the same units as cmduse's `-1 --json`
728        // (`periodStartAt`/`periodEndAt`) — `periodStart` stays for the plugin's
729        // existing read. `periodEndAt` is null when the period has no boundary
730        // (`--window all`).
731        "periodStartAt": report.since_ms,
732        "periodEndAt": report.period_end_ms,
733        // The family's display date (`cmduse` and `arkuse` both carry it);
734        // `null` when the period has no boundary, as `periodEndAt` is.
735        "periodEnd": report
736            .period_end_ms
737            .map(|ms| date_of(ms.div_euclid(86_400_000))),
738        "periodInferred": report.month_inferred,
739        // The period's own aggregate under the name `cmduse` and `arkuse` use
740        // (`summary`); `totals` stays the existing shape, and its `month` member
741        // is exactly this figure, so the two cannot disagree.
742        "summary": totals(&report.month),
743        "totals": {
744            "month": totals(&report.month),
745            "fiveHour": totals(&report.five_hour),
746            "weekly": totals(&report.weekly),
747        },
748        // `null` when no Go key, no network, or a key without the subscription:
749        // the caller then has only `totals`, which is the local estimate.
750        "account": report.account.as_ref().map(|a| serde_json::json!({
751            "rolling": meter(&a.rolling),
752            "weekly": meter(&a.weekly),
753            "monthly": meter(&a.monthly),
754        })),
755        // Why `account` is null, when that is worth saying — `HTTP 401
756        // api_key_expired`, `no Go key`, a transport fault. `null` when the
757        // endpoint answered, and for `--no-api`. Consumers otherwise show the
758        // local estimate without being able to tell the three apart.
759        "accountError": report.account_error,
760        "models": report.models.iter().map(model).collect::<Vec<_>>(),
761    }))
762    .expect("json")
763}
764
765fn shorten(path: &str) -> String {
766    match std::env::var("HOME") {
767        Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
768        _ => path.to_string(),
769    }
770}
771
772// ---- catalogues and history tables (shared with the MCP server) -------------
773
774/// The catalogue: what each product costs and (for Go) what each plan allows.
775///
776/// Both Go plans are listed because the docs define two — Go and Go Plus — that
777/// share one token price list but grant each model a different monthly limit.
778pub fn plans_text(colour: bool) -> String {
779    let ink = Ink::new(colour);
780    let catalog = crate::ocuse::zen::catalog();
781    let mut out = format!(
782        "{}OpenCode plans{} {}\n",
783        ink.bold,
784        ink.reset,
785        ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
786    );
787    out.push_str(&format!(
788        "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
789        ink.dim,
790        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
791        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
792        ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
793        ink.reset,
794    ));
795    for plan in catalog.go.plans.values() {
796        out.push_str(&format!(
797            "\n{}{}{} — {}/month\n",
798            ink.bold,
799            plan.label,
800            ink.reset,
801            ink.cyan_on(format!("${:.2}", plan.price_usd)),
802        ));
803        out.push_str(&format!(
804            "{}MODEL                            LIMIT    IN     OUT    CACHE{}\n",
805            ink.bold, ink.reset
806        ));
807        for model in &catalog.go.models {
808            let rate = model.variants.first();
809            let limit = plan
810                .allowance_by_model
811                .get(&model.id)
812                .map(|limit| cmduse_core::money(*limit))
813                .unwrap_or_else(|| "—".into());
814            out.push_str(&format!(
815                "{:<28} {} {} {} {}\n",
816                model.id,
817                ink.cyan_on(format!("{limit:>7}")),
818                ink.cyan_on(format!(
819                    "{:>6}",
820                    rate.map(|r| format!("${}", r.input)).unwrap_or_default()
821                )),
822                ink.cyan_on(format!(
823                    "{:>6}",
824                    rate.map(|r| format!("${}", r.output)).unwrap_or_default()
825                )),
826                ink.cyan_on(format!(
827                    "{:>8}",
828                    rate.map(|r| format!("${}", r.cache_read))
829                        .unwrap_or_default()
830                )),
831            ));
832        }
833    }
834    out.push_str(&format!(
835        "\n{}\n",
836        ink.dim_on(format!(
837            "ZEN — pay-as-you-go, {} models priced per 1M tokens",
838            catalog.zen.models.len()
839        ))
840    ));
841    out
842}
843
844/// The Go/Zen catalogue as one JSON object — the machine twin of `plans_text`:
845/// both plans (label, price, per-model monthly allowances), the shared window
846/// shares, and the Go and Zen rate tables.
847pub fn plans_json() -> String {
848    let catalog = crate::ocuse::zen::catalog();
849    let plans: Vec<serde_json::Value> = catalog
850        .go
851        .plans
852        .iter()
853        .map(|(id, plan)| {
854            serde_json::json!({
855                "id": id,
856                "label": plan.label,
857                "priceUsd": plan.price_usd,
858                "allowanceByModel": &plan.allowance_by_model,
859            })
860        })
861        .collect();
862    serde_json::json!({
863        "extractedAt": catalog.extracted_at,
864        "windows": {
865            "fiveHour": catalog.go.window_share.five_hour,
866            "weekly": catalog.go.window_share.weekly,
867            "monthly": catalog.go.window_share.monthly,
868        },
869        "plans": plans,
870        "models": catalog.go.models.iter().map(rate_json).collect::<Vec<_>>(),
871        "zen": catalog.zen.models.iter().map(rate_json).collect::<Vec<_>>(),
872    })
873    .to_string()
874}
875
876/// One catalogue row's shared `$`/1M rates, taken from its base (first) variant.
877fn rate_json(model: &zen::Model) -> serde_json::Value {
878    let rate = model.variants.first();
879    serde_json::json!({
880        "id": model.id,
881        "name": model.name,
882        "input": rate.map(|r| r.input),
883        "output": rate.map(|r| r.output),
884        "cacheRead": rate.map(|r| r.cache_read),
885        "cacheWrite": rate.map(|r| r.cache_write),
886    })
887}
888
889/// One `ocuse models` row: a catalogued Go model, its rates, and the selected
890/// plan's monthly limit (`None` = the plan does not grant it).
891struct ModelRow {
892    id: String,
893    name: String,
894    input: Option<f64>,
895    output: Option<f64>,
896    cache_read: Option<f64>,
897    limit: Option<f64>,
898}
899
900/// The Go catalogue joined with the plan's allowances; `gated` keeps only the
901/// models the plan grants (present in `allowanceByModel`).
902fn model_rows(plan_id: &str, gated: bool) -> Vec<ModelRow> {
903    let catalog = crate::ocuse::zen::catalog();
904    let plan = catalog.plan(plan_id);
905    let mut rows: Vec<ModelRow> = catalog
906        .go
907        .models
908        .iter()
909        .map(|m| {
910            let rate = m.variants.first();
911            ModelRow {
912                id: m.id.clone(),
913                name: m.name.clone(),
914                input: rate.map(|r| r.input),
915                output: rate.map(|r| r.output),
916                cache_read: rate.map(|r| r.cache_read),
917                limit: plan.and_then(|p| p.allowance_by_model.get(&m.id).copied()),
918            }
919        })
920        .collect();
921    if gated {
922        rows.retain(|r| r.limit.is_some());
923    }
924    rows
925}
926
927/// `ocuse models`: the Go catalogue for one plan — name, id, `$`/1M in / out /
928/// cache-read, and that plan's monthly limit. Without `gated`, every catalogued
929/// model is listed and the `GRANTED` column marks which the plan allows.
930pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
931    let ink = Ink::new(colour);
932    let catalog = crate::ocuse::zen::catalog();
933    let label = catalog
934        .plan(plan_id)
935        .map(|p| p.label.clone())
936        .unwrap_or_else(|| plan_id.to_string());
937    let granted = catalog
938        .plan(plan_id)
939        .map(|p| p.allowance_by_model.len())
940        .unwrap_or(0);
941    let rows = model_rows(plan_id, gated);
942    let mut out = format!(
943        "{}{label} models{} {}\n",
944        ink.bold,
945        ink.reset,
946        ink.dim_on(format!(
947            "(plan {plan_id} · {granted}/{} granted)",
948            catalog.go.models.len()
949        )),
950    );
951    out.push_str(&format!(
952        "{}{:<30} {:<28} {:>7} {:>7} {:>7} {:>8} {:>7}{}\n",
953        ink.bold, "MODEL", "ID", "IN", "OUT", "CACHE", "LIMIT", "GRANTED", ink.reset
954    ));
955    let rate_cell = |v: Option<f64>| match v {
956        Some(v) => ink.cyan_on(format!("{:>7}", format!("${v}"))),
957        None => ink.dim_on(format!("{:>7}", "—")),
958    };
959    for r in &rows {
960        let limit = match r.limit {
961            Some(l) => ink.cyan_on(format!("{:>8}", cmduse_core::money(l))),
962            None => ink.dim_on(format!("{:>8}", "—")),
963        };
964        let granted = if r.limit.is_some() { "yes" } else { "no" };
965        out.push_str(&format!(
966            "{:<30} {:<28} {} {} {} {} {:>7}\n",
967            r.name,
968            r.id,
969            rate_cell(r.input),
970            rate_cell(r.output),
971            rate_cell(r.cache_read),
972            limit,
973            granted,
974        ));
975    }
976    out
977}
978
979/// `ocuse models --json`: one JSON object per line (matching cmduse's
980/// `models --json`), each model's id, name, `$`/1M rates, plan limit (null
981/// when not granted) and the `granted` verdict.
982pub fn models_json(plan_id: &str, gated: bool) -> String {
983    let mut out = String::new();
984    for r in model_rows(plan_id, gated) {
985        out.push_str(
986            &serde_json::json!({
987                "id": r.id,
988                "name": r.name,
989                "input": r.input,
990                "output": r.output,
991                "cacheRead": r.cache_read,
992                "limit": r.limit,
993                "granted": r.limit.is_some(),
994            })
995            .to_string(),
996        );
997        out.push('\n');
998    }
999    out
1000}
1001
1002pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1003    let ink = Ink::new(colour);
1004    let Some(model) = report.models.iter().find(|m| m.id == id) else {
1005        return format!("{id}: no usage in the period\n");
1006    };
1007    let limit = model
1008        .limit
1009        .map(cmduse_core::money)
1010        .unwrap_or_else(|| "—".into());
1011    let row =
1012        |label: &str, value: String| format!("  {}{value}\n", ink.dim_on(format!("{label:<10}")));
1013    let mut out = format!(
1014        "{} {}\n",
1015        ink.bold_on(model.id.clone()),
1016        ink.dim_on(format!("({})", model.provider)),
1017    );
1018    out.push_str(&row("limit", ink.cyan_on(limit)));
1019    out.push_str(&row(
1020        "month",
1021        ink.cyan_on(format!(
1022            "{} · {} requests",
1023            cmduse_core::money(model.month.cost),
1024            model.month.requests
1025        )),
1026    ));
1027    out.push_str(&row(
1028        "5-hour",
1029        ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
1030    ));
1031    out.push_str(&row(
1032        "weekly",
1033        ink.cyan_on(cmduse_core::money(model.weekly.cost)),
1034    ));
1035    // The dashboard's share column needs the period total; here it is the same
1036    // fact spelled out. Absent when the period has no spend to divide by.
1037    if report.month.cost > 0.0 {
1038        out.push_str(&row(
1039            "share",
1040            ink.dim_on(format!(
1041                "{:.1}% of period spend",
1042                model.month.cost / report.month.cost * 100.0
1043            )),
1044        ));
1045    }
1046    out
1047}
1048
1049/// Per-key JSON totals — the same field vocabulary cmduse's
1050/// `report_render::totals_json` uses. `cacheWrite` is deliberately absent:
1051/// ocuse's `window::Totals` carries no cache-write (the store's rows do, but
1052/// nothing aggregates them into a report), so no column is invented for it.
1053fn totals_json(
1054    cost: f64,
1055    requests: u64,
1056    input: u64,
1057    output: u64,
1058    cache_read: u64,
1059) -> serde_json::Value {
1060    serde_json::json!({
1061        "requests": requests,
1062        "tokensIn": input,
1063        "tokensOut": output,
1064        "cacheRead": cache_read,
1065        "costUsd": cost,
1066    })
1067}
1068
1069/// One day/hour bucket's totals, kept apart per column so the CSV/JSON forms do
1070/// not have to re-derive them from a packed tuple.
1071#[derive(Default, Clone, Copy)]
1072struct Bucket {
1073    cost: f64,
1074    requests: u64,
1075    input: u64,
1076    output: u64,
1077    cache_read: u64,
1078}
1079
1080impl Bucket {
1081    fn add(&mut self, row: &crate::ocuse::db::Row) {
1082        self.cost += row.cost_usd;
1083        self.requests += 1;
1084        self.input += row.input;
1085        self.output += row.output;
1086        self.cache_read += row.cache_read;
1087    }
1088
1089    fn json(&self) -> serde_json::Value {
1090        totals_json(
1091            self.cost,
1092            self.requests,
1093            self.input,
1094            self.output,
1095            self.cache_read,
1096        )
1097    }
1098}
1099
1100/// Per-model report — `ocuse model` on the whole period (or one id). The
1101/// machine formats project the report's models: `Json` mirrors cmduse's
1102/// `model --json` envelope (`{source, sinceMs, models:{id: totals}}`, with the
1103/// Go limit added), `Csv` is one row per model
1104/// (`model,requests,tokens_in,tokens_out,cache_read,cost_usd`). The `Table` form
1105/// is the caller's — the dashboard (no id) or the single-model detail (with an
1106/// id) — which needs the db path and row cap, so it is rendered there.
1107pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1108    let models: Vec<&ModelUsage> = report
1109        .models
1110        .iter()
1111        .filter(|m| id.is_none_or(|want| m.id == want))
1112        .collect();
1113    match fmt {
1114        Fmt::Json => {
1115            let map: serde_json::Map<String, serde_json::Value> = models
1116                .iter()
1117                .map(|m| {
1118                    let mut body = totals_json(
1119                        m.month.cost,
1120                        m.month.requests,
1121                        m.month.input,
1122                        m.month.output,
1123                        m.month.cache_read,
1124                    );
1125                    body["limit"] = serde_json::json!(m.limit);
1126                    (m.id.clone(), body)
1127                })
1128                .collect();
1129            serde_json::json!({
1130                "scope": "local",
1131                "source": "opencode",
1132                "sinceMs": report.since_ms,
1133                "models": map,
1134            })
1135            .to_string()
1136        }
1137        Fmt::Csv => {
1138            let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1139            for m in models {
1140                out.push_str(&format!(
1141                    "{},{},{},{},{},{:.2}\n",
1142                    crate::report_render::csv_field(&m.id),
1143                    m.month.requests,
1144                    m.month.input,
1145                    m.month.output,
1146                    m.month.cache_read,
1147                    m.month.cost,
1148                ));
1149            }
1150            out
1151        }
1152        Fmt::Table => String::new(), // the caller renders the table form
1153    }
1154}
1155
1156/// Spend per bucket (day or hour), oldest first — `cmduse daily`/`hourly`.
1157/// `lookback_ms` is the read window and `size_ms` the bucket width (which also
1158/// names the first CSV/JSON column: `day` for a ≥24h bucket, else `hour`). The
1159/// `Csv` columns are that key plus the store's numeric fields
1160/// (`…,requests,tokens_in,tokens_out,cache_read,cost_usd`; no cache-write), the
1161/// `Json` form `{scope,source,total,days|hours}`, and `Table` is the original
1162/// framing but for `Ink`'s colour. `Json`/`Csv` are never coloured.
1163pub fn bucket_text(
1164    rows: &[crate::ocuse::db::Row],
1165    now_ms: i64,
1166    lookback_ms: i64,
1167    size_ms: i64,
1168    tz_secs: i64,
1169    colour: bool,
1170    fmt: Fmt,
1171) -> String {
1172    // Buckets are cut in the display timezone: shift, bucket, then label.
1173    let shift = tz_secs * 1000;
1174    let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1175    for row in rows {
1176        let bucket = (row.at_ms + shift).div_euclid(size_ms);
1177        buckets.entry(bucket).or_default().add(row);
1178    }
1179    let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1180    let daily = size_ms >= 86_400_000;
1181    let label = if daily { "day" } else { "hour" };
1182    let stamp = |bucket: i64| {
1183        if daily {
1184            date_of(bucket)
1185        } else {
1186            format!(
1187                "{} {:02}h",
1188                date_of(bucket.div_euclid(24)),
1189                bucket.rem_euclid(24)
1190            )
1191        }
1192    };
1193    let window: Vec<(i64, Bucket)> = buckets
1194        .range(start..)
1195        .map(|(bucket, b)| (*bucket, *b))
1196        .collect();
1197
1198    match fmt {
1199        Fmt::Json => {
1200            let mut keyed = serde_json::Map::new();
1201            let mut total = Bucket::default();
1202            for (bucket, b) in &window {
1203                keyed.insert(stamp(*bucket), b.json());
1204                total.add_bucket(b);
1205            }
1206            let key = if daily { "days" } else { "hours" };
1207            serde_json::json!({
1208                "scope": "local",
1209                "source": "opencode",
1210                "total": total.json(),
1211                key: keyed,
1212            })
1213            .to_string()
1214        }
1215        Fmt::Csv => {
1216            let head = if daily { "day" } else { "hour" };
1217            let mut out = format!("{head},requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1218            let mut total = Bucket::default();
1219            for (bucket, b) in &window {
1220                out.push_str(&format!(
1221                    "{},{},{},{},{},{:.2}\n",
1222                    crate::report_render::csv_field(&stamp(*bucket)),
1223                    b.requests,
1224                    b.input,
1225                    b.output,
1226                    b.cache_read,
1227                    b.cost,
1228                ));
1229                total.add_bucket(b);
1230            }
1231            out.push_str(&format!(
1232                "total,{},{},{},{},{:.2}\n",
1233                total.requests, total.input, total.output, total.cache_read, total.cost,
1234            ));
1235            out
1236        }
1237        Fmt::Table => {
1238            let ink = Ink::new(colour);
1239            let mut out = format!(
1240                "{}{label:<12} spend     requests   tokens{}\n",
1241                ink.bold, ink.reset
1242            );
1243            for (bucket, b) in &window {
1244                out.push_str(&format!(
1245                    "{:<12} {} {} {}\n",
1246                    stamp(*bucket),
1247                    ink.cyan_on(format!("{:>9}", cmduse_core::money(b.cost))),
1248                    ink.cyan_on(format!("{:>12}", cmduse_core::compact(b.requests))),
1249                    ink.cyan_on(format!(
1250                        "{:>9}",
1251                        cmduse_core::compact(b.input + b.output + b.cache_read)
1252                    )),
1253                ));
1254            }
1255            if out.lines().count() == 1 {
1256                out.push_str(&format!(
1257                    "{}\n",
1258                    ink.dim_on("(no OpenCode usage in the window)".into())
1259                ));
1260            }
1261            out
1262        }
1263    }
1264}
1265
1266/// One session's accumulating totals: `(cost, requests, tokens_in, tokens_out,
1267/// cache_read, last_ms, distinct models)`.
1268type SessionTotals<'a> = (
1269    f64,
1270    u64,
1271    u64,
1272    u64,
1273    u64,
1274    i64,
1275    std::collections::BTreeSet<&'a str>,
1276);
1277
1278/// Spend per session, newest first. The `Csv` form is one row per session
1279/// (`session,requests,tokens_in,tokens_out,cache_read,cost_usd`), `Json` a
1280/// `{scope,source,sessions}` object whose entries add the last-used instant and
1281/// the distinct-model count; `Table` keeps its own (last, models) columns.
1282pub fn session_text(
1283    rows: &[crate::ocuse::db::Row],
1284    limit: usize,
1285    colour: bool,
1286    fmt: Fmt,
1287) -> String {
1288    let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1289    for row in rows {
1290        let entry = by_session
1291            .entry(row.session.as_str())
1292            .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1293        entry.0 += row.cost_usd;
1294        entry.1 += 1;
1295        entry.2 += row.input;
1296        entry.3 += row.output;
1297        entry.4 += row.cache_read;
1298        entry.5 = entry.5.max(row.at_ms);
1299        entry.6.insert(row.model.as_str());
1300    }
1301    let mut sessions: Vec<_> = by_session.into_iter().collect();
1302    sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1303
1304    match fmt {
1305        Fmt::Json => {
1306            let mut keyed = serde_json::Map::new();
1307            for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1308                let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1309                body["lastMs"] = serde_json::json!(*last);
1310                body["models"] = serde_json::json!(models.len());
1311                keyed.insert((*id).to_string(), body);
1312            }
1313            serde_json::json!({
1314                "scope": "local",
1315                "source": "opencode",
1316                "sessions": keyed,
1317            })
1318            .to_string()
1319        }
1320        Fmt::Csv => {
1321            let mut out =
1322                String::from("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1323            for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1324                out.push_str(&format!(
1325                    "{},{},{},{},{},{:.2}\n",
1326                    crate::report_render::csv_field(id),
1327                    requests,
1328                    input,
1329                    output,
1330                    cache_read,
1331                    cost,
1332                ));
1333            }
1334            out
1335        }
1336        Fmt::Table => {
1337            let ink = Ink::new(colour);
1338            let mut out = format!(
1339                "{}session                          last        models   spend     req{}\n",
1340                ink.bold, ink.reset
1341            );
1342            for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1343                let short = id.rsplit('_').next().unwrap_or(*id);
1344                out.push_str(&format!(
1345                    "{:<32} {:<11} {:>6} {} {:>6}\n",
1346                    short,
1347                    ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1348                    models.len(),
1349                    ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
1350                    requests,
1351                ));
1352            }
1353            if sessions.len() > limit {
1354                out.push_str(&format!(
1355                    "{}\n",
1356                    ink.dim_on(format!("… {} more", sessions.len() - limit))
1357                ));
1358            }
1359            if out.lines().count() == 1 {
1360                out.push_str(&format!(
1361                    "{}\n",
1362                    ink.dim_on("(no sessions in the window)".into())
1363                ));
1364            }
1365            out
1366        }
1367    }
1368}
1369
1370impl Bucket {
1371    /// Fold another bucket's totals in (for the report's `total` row/entry).
1372    fn add_bucket(&mut self, other: &Bucket) {
1373        self.cost += other.cost;
1374        self.requests += other.requests;
1375        self.input += other.input;
1376        self.output += other.output;
1377        self.cache_read += other.cache_read;
1378    }
1379}
1380
1381/// `YYYY-MM-DD` for a Unix day number — `cmduse_core::dates::civil_from_days`,
1382/// so the three binaries cannot drift on the calendar.
1383pub fn date_of(day: i64) -> String {
1384    cmduse_core::dates::civil_from_days(day)
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389    use super::*;
1390    use crate::ocuse::db::Row;
1391
1392    fn report() -> Report {
1393        let now = 1_800_000_000_000;
1394        let rows = vec![Row {
1395            at_ms: now - 60_000,
1396            provider: "opencode-go".into(),
1397            model: "glm-5.3-flash".into(),
1398            cost_usd: 30.0,
1399            ..Default::default()
1400        }];
1401        crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None)
1402    }
1403
1404    #[test]
1405    fn text_shows_the_model_against_its_limit() {
1406        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1407        assert!(text.contains("glm-5.3-flash"), "{text}");
1408        assert!(text.contains("$60"), "the model's monthly limit: {text}");
1409        assert!(
1410            text.contains("50.0%"),
1411            "the model table keeps its own USE: {text}"
1412        );
1413    }
1414
1415    fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
1416        crate::ocuse::api::Meter {
1417            status: status.into(),
1418            percent,
1419            resets_at: Some(resets_at.into()),
1420        }
1421    }
1422
1423    /// The same report with the account's own meters attached — the shape a Go
1424    /// key produces. Reset instants sit after `now` (2027-01-15T08:00:00Z), so
1425    /// the countdowns render rather than clamp to zero. The period bounds mirror
1426    /// what `cli.rs` derives from the monthly reset (`prev_month`), so the
1427    /// Monthly row carries the same elapsed share and pro-rata figure a live
1428    /// dashboard shows.
1429    fn report_with_account() -> Report {
1430        let mut report = report();
1431        report.account = Some(crate::ocuse::api::Usage {
1432            rolling: meter("ok", 5.0, "2027-01-15T12:31:00.000Z"),
1433            weekly: meter("ok", 59.0, "2027-01-18T00:00:00.000Z"),
1434            monthly: meter("ok", 29.0, "2027-02-15T08:00:00.000Z"),
1435        });
1436        report.period_end_ms = Some(1_802_678_400_000); // 2027-02-15T08:00:00Z
1437        report.since_ms = crate::ocuse::window::prev_month(1_802_678_400_000);
1438        report
1439    }
1440
1441    #[test]
1442    fn the_window_rows_come_from_the_account_when_it_answered() {
1443        let text = render_text(
1444            &report_with_account(),
1445            "/tmp/opencode.db",
1446            10,
1447            false,
1448            DEFAULT_BAR_WIDTH,
1449        );
1450        // The console's three shares, and its reset instants. Whole percents, as
1451        // the endpoint publishes them: `"percent":53`, never `53.0`.
1452        assert!(text.contains("· 5% of limit"), "{text}");
1453        assert!(text.contains("· 59% of limit"), "{text}");
1454        assert!(text.contains("· 29% of limit"), "{text}");
1455        assert!(text.contains("account meters"), "{text}");
1456        assert!(
1457            text.contains("resets 4h 31m"),
1458            "the 5h block's own end: {text}"
1459        );
1460        // Every field that is true stays on the row: this store's own money and
1461        // the server's share labelled beside it. No denominator is printed — it
1462        // would have to be `spend / share`, which contradicted the documented
1463        // allowance ($40 next to an $80 limit) because the console meters at
1464        // roughly twice the cost this store records.
1465        assert!(text.contains(".00 · 5% of limit"), "{text}");
1466        assert!(text.contains(".00 · 29% of limit"), "{text}");
1467        assert!(text.contains("of limit"), "{text}");
1468        // The monthly window measures against the period bounds, like cmduse's:
1469        // an elapsed share, the server's reset, and the even-burn figure — which
1470        // only the budget window gets, because the two throttles above it would
1471        // read it as a spend target.
1472        assert!(text.contains("elapsed"), "{text}");
1473        assert_eq!(text.matches("by now").count(), 1, "{text}");
1474        // …and the per-model columns are named as the same cuts, not a second
1475        // measurement of a different window.
1476        assert!(text.contains("the same cuts its percentages use"), "{text}");
1477    }
1478
1479    /// The denominator is the server's own percentage read backwards: whatever
1480    /// this store spent, divided by the share the console prints, is the ceiling
1481    /// that share is metering against. A share too small to imply one (the
1482    /// endpoint rounds to one decimal, and sends 0.0 for a window it meters on
1483    /// another basis) leaves the row with the share alone, never a division by
1484    /// it.
1485    #[test]
1486    fn the_pair_is_the_ceiling_the_share_implies() {
1487        // The $30 fixture spend at each of the fixture's shares.
1488        assert_eq!(implied_cap(30.0, 5.0), Some(600.0));
1489        assert_eq!(implied_cap(30.0, 59.0), Some(50.847457627118644));
1490        // Under the display's precision there is no ceiling to print.
1491        assert_eq!(implied_cap(20.45, 0.0), None);
1492        assert_eq!(implied_cap(20.45, 0.04), None);
1493        assert_eq!(implied_cap(20.45, 0.1), Some(20450.0));
1494        assert_eq!(implied_cap(0.0, 1.0), Some(0.0));
1495    }
1496
1497    /// The colour frame prints the share once. It used to read
1498    /// `5% ━━━━━━ $30.00 · 5% of limit`: the gauge and the body each printed the
1499    /// same percentage, and the gauge's own `{:.1}` also turned the endpoint's
1500    /// whole `5` into `5.0`. The plain frame has no gauge, so it keeps the figure.
1501    #[test]
1502    fn the_gauge_carries_the_share_and_the_body_does_not_repeat_it() {
1503        let colour = render_text(
1504            &report_with_account(),
1505            "/tmp/opencode.db",
1506            10,
1507            true,
1508            DEFAULT_BAR_WIDTH,
1509        );
1510        let five = colour
1511            .lines()
1512            .find(|line| line.contains("5-hour"))
1513            .expect("a 5-hour row");
1514        assert!(!five.contains("of limit"), "no repeated share: {five}");
1515        assert!(
1516            five.contains("    5%"),
1517            "the endpoint's whole figure: {five}"
1518        );
1519        let plain = render_text(
1520            &report_with_account(),
1521            "/tmp/opencode.db",
1522            10,
1523            false,
1524            DEFAULT_BAR_WIDTH,
1525        );
1526        assert!(plain.contains("· 5% of limit"), "{plain}");
1527    }
1528
1529    /// A pace line needs a cap the burn can actually reach before the reset.
1530    #[test]
1531    fn a_reachable_cap_gets_the_pace_line() {
1532        let mut report = report_with_account();
1533        if let Some(account) = &mut report.account {
1534            // 90% of the ceiling this store's $30 implies ($33.33), one hour into
1535            // a 5-hour window with four left: the burn reaches it well inside.
1536            account.rolling = meter("ok", 90.0, "2027-01-15T12:00:00.000Z");
1537        }
1538        let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1539        assert!(text.contains("on pace to hit cap in"), "{text}");
1540    }
1541
1542    #[test]
1543    fn without_an_account_the_rows_say_they_are_local() {
1544        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1545        assert!(text.contains("estimated locally"), "{text}");
1546        assert!(text.contains("no pool"), "no aggregate ceiling: {text}");
1547        // Spend alone: no pair, no share, no countdown without a boundary.
1548        assert!(!text.contains("of limit"), "{text}");
1549        assert!(!text.contains("resets"), "{text}");
1550        assert!(!text.contains("elapsed"), "{text}");
1551        // Nothing claims to be the account's own view.
1552        assert!(!text.contains("account meters"), "{text}");
1553        assert!(
1554            !text.contains("the same cuts its percentages use"),
1555            "{text}"
1556        );
1557        // Without an answer the columns keep saying they are this machine's own
1558        // rolling cuts, since there is no server window to align them to.
1559        assert!(text.contains("local, not the account's windows"), "{text}");
1560    }
1561
1562    #[test]
1563    fn an_over_limit_meter_says_so_before_its_countdown() {
1564        let mut report = report_with_account();
1565        if let Some(account) = &mut report.account {
1566            account.rolling = meter("rate_limited", 100.0, "2027-01-15T12:31:00.000Z");
1567        }
1568        let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1569        assert!(text.contains("LIMIT EXCEEDED"), "{text}");
1570    }
1571
1572    #[test]
1573    fn json_carries_the_account_meters_and_null_without_them() {
1574        let parsed: serde_json::Value =
1575            serde_json::from_str(&render_json(&report_with_account())).unwrap();
1576        assert_eq!(parsed["account"]["rolling"]["percent"], 5.0);
1577        assert_eq!(parsed["account"]["weekly"]["percent"], 59.0);
1578        assert_eq!(parsed["account"]["monthly"]["status"], "ok");
1579        // `resetsAt` in epoch ms (the same units as `periodEndAt`), not the ISO
1580        // string the endpoint sent; `api::tests` pins the literal itself.
1581        let monthly = cmduse_core::dates::parse_iso_utc("2027-02-15T08:00:00.000Z").unwrap();
1582        assert_eq!(parsed["account"]["monthly"]["resetsAt"], monthly as i64);
1583
1584        // No account: the key is present and null, so a consumer can tell the
1585        // difference between "no key" and "zero".
1586        let parsed: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1587        assert_eq!(parsed["account"], serde_json::Value::Null);
1588    }
1589
1590    #[test]
1591    fn statusline_prefers_the_accounts_share_over_the_local_estimate() {
1592        let tpl = "{five_hour_pct} {weekly_pct} {month_pct} {five_hour}";
1593        let account = statusline_template(
1594            &report_with_account(),
1595            "go",
1596            tpl,
1597            false,
1598            false,
1599            DEFAULT_BAR_WIDTH,
1600        );
1601        assert!(account.starts_with("5.0% 59.0% 29.0%"), "{account}");
1602        // Money stays local either way: the endpoint publishes shares, not dollars.
1603        assert!(account.contains("$30.00"), "{account}");
1604
1605        // Without an account the same tokens resolve against the store, which is
1606        // what makes the fallback worth having.
1607        let local = statusline_template(&report(), "go", tpl, false, false, DEFAULT_BAR_WIDTH);
1608        assert!(!local.starts_with("5.0%"), "{local}");
1609    }
1610
1611    #[test]
1612    fn json_and_statusline_carry_the_totals() {
1613        let json = render_json(&report());
1614        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1615        assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
1616        // `summary` is the period's own aggregate under the name cmduse and
1617        // arkuse use; it is the same object as `totals.month`, so the two keys
1618        // can never disagree.
1619        assert_eq!(parsed["summary"], parsed["totals"]["month"]);
1620        assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
1621        let line = statusline_template(&report(), "go", "{plan} {models}", false, false, 20);
1622        assert_eq!(line, "go glm-5.3-flash");
1623    }
1624
1625    #[test]
1626    fn json_carries_the_period_bounds() {
1627        // The default/month report has an end resolved from its own start, and
1628        // `periodStartAt` is the alias of the existing `periodStart`.
1629        let mut r = report();
1630        r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1631        let parsed: serde_json::Value = serde_json::from_str(&render_json(&r)).unwrap();
1632        assert_eq!(parsed["periodStart"], r.since_ms);
1633        assert_eq!(parsed["periodStartAt"], r.since_ms);
1634        assert_eq!(parsed["periodEndAt"], r.period_end_ms.unwrap());
1635
1636        // `--window all` resolves no end: the key is present and null, and the
1637        // old keys are unchanged.
1638        let mut all = report();
1639        all.period_end_ms = None;
1640        let parsed: serde_json::Value = serde_json::from_str(&render_json(&all)).unwrap();
1641        assert_eq!(parsed["periodEndAt"], serde_json::Value::Null);
1642        assert_eq!(parsed["periodStart"], all.since_ms);
1643        assert!(parsed.get("periodInferred").is_some());
1644    }
1645
1646    /// Every renderer, both ways round: piped/non-tty output must stay free of
1647    /// SGR escapes, and every one of them must colour when colour is on. This is
1648    /// the guard for the bug where only `render_text` ever got a colour flag, so
1649    /// `daily` / `hourly` / `session` / `plans` / `model` / `statusline` printed
1650    /// plain regardless of the terminal.
1651    #[test]
1652    fn every_renderer_respects_the_colour_flag() {
1653        let mut report = report();
1654        // Exercise the monthly row's real period (reset, elapsed, by-now) both
1655        // ways round, so a colour leak into the new plain fields fails here too.
1656        report.period_end_ms = Some(crate::ocuse::window::next_month(report.since_ms));
1657        let rows = vec![Row {
1658            at_ms: report.now_ms - 60_000,
1659            session: "ses_fixture".into(),
1660            provider: "opencode-go".into(),
1661            model: "glm-5.3-flash".into(),
1662            cost_usd: 30.0,
1663            ..Default::default()
1664        }];
1665        let now = report.now_ms;
1666
1667        let cases: Vec<(&str, String, String)> = vec![
1668            (
1669                "render_text",
1670                render_text(&report, "/tmp/oc.db", 10, false, DEFAULT_BAR_WIDTH),
1671                render_text(&report, "/tmp/oc.db", 10, true, DEFAULT_BAR_WIDTH),
1672            ),
1673            (
1674                "statusline_template",
1675                statusline_template(
1676                    &report,
1677                    "go",
1678                    "{period} {month} {month_pct}",
1679                    false,
1680                    false,
1681                    20,
1682                ),
1683                statusline_template(
1684                    &report,
1685                    "go",
1686                    "{period} {month} {month_pct}",
1687                    true,
1688                    false,
1689                    20,
1690                ),
1691            ),
1692            (
1693                "compact_line",
1694                compact_line(&report, &[], false),
1695                compact_line(&report, &[], true),
1696            ),
1697            ("plans_text", plans_text(false), plans_text(true)),
1698            (
1699                "model_text",
1700                model_text(&report, "glm-5.3-flash", false),
1701                model_text(&report, "glm-5.3-flash", true),
1702            ),
1703            (
1704                "bucket_text/day",
1705                bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Table),
1706                bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Table),
1707            ),
1708            (
1709                "bucket_text/hour",
1710                bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Table),
1711                bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Table),
1712            ),
1713            (
1714                "session_text",
1715                session_text(&rows, 20, false, Fmt::Table),
1716                session_text(&rows, 20, true, Fmt::Table),
1717            ),
1718            (
1719                "models_text",
1720                models_text("go", false, false),
1721                models_text("go", false, true),
1722            ),
1723        ];
1724        for (name, plain, coloured) in cases {
1725            assert!(
1726                !plain.contains('\x1b'),
1727                "{name} leaked escapes when off: {plain}"
1728            );
1729            assert!(
1730                coloured.contains('\x1b'),
1731                "{name} rendered nothing coloured when on: {coloured}"
1732            );
1733        }
1734
1735        // Machine formats must ignore the colour flag entirely: no escapes
1736        // whether it is off or on.
1737        let flagged: Vec<(&str, String, String)> = vec![
1738            (
1739                "bucket_text/day csv",
1740                bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv),
1741                bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Csv),
1742            ),
1743            (
1744                "bucket_text/hour json",
1745                bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Json),
1746                bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Json),
1747            ),
1748            (
1749                "session_text csv",
1750                session_text(&rows, 20, false, Fmt::Csv),
1751                session_text(&rows, 20, true, Fmt::Csv),
1752            ),
1753            (
1754                "session_text json",
1755                session_text(&rows, 20, false, Fmt::Json),
1756                session_text(&rows, 20, true, Fmt::Json),
1757            ),
1758        ];
1759        for (name, off, on) in flagged {
1760            assert!(
1761                !off.contains('\x1b'),
1762                "{name} leaked escapes with colour off: {off}"
1763            );
1764            assert!(
1765                !on.contains('\x1b'),
1766                "{name} must stay uncoloured with colour on: {on}"
1767            );
1768        }
1769        for (name, out) in [
1770            ("model_report json", model_report(&report, None, Fmt::Json)),
1771            ("model_report csv", model_report(&report, None, Fmt::Csv)),
1772            ("plans_json", plans_json()),
1773            ("statusline_json", statusline_json(&report, "go")),
1774            ("models_json", models_json("go", false)),
1775        ] {
1776            assert!(
1777                !out.contains('\x1b'),
1778                "{name} must never be coloured: {out}"
1779            );
1780        }
1781    }
1782
1783    #[test]
1784    fn session_rows_cap_the_table_and_never_the_machine_formats() {
1785        // `--rows` is a display cap (the CLI passes it straight in). JSON/CSV
1786        // must keep every session: a consumer that reads a short report as the
1787        // whole window is worse than a long table.
1788        let now = 1_800_000_000_000;
1789        let rows: Vec<Row> = (0..25)
1790            .map(|i| Row {
1791                at_ms: now - i as i64 * 60_000,
1792                provider: "opencode-go".into(),
1793                model: "glm-5.3-flash".into(),
1794                session: format!("ses_{i:02}"),
1795                cost_usd: 0.1,
1796                ..Default::default()
1797            })
1798            .collect();
1799        let table = session_text(&rows, 2, false, Fmt::Table);
1800        assert!(table.contains("… 23 more"), "{table}");
1801        assert_eq!(
1802            table.lines().count(),
1803            4,
1804            "header + 2 rows + the tail: {table}"
1805        );
1806        let json: serde_json::Value =
1807            serde_json::from_str(&session_text(&rows, 2, false, Fmt::Json)).unwrap();
1808        assert_eq!(json["sessions"].as_object().unwrap().len(), 25);
1809        let csv = session_text(&rows, 2, false, Fmt::Csv);
1810        assert_eq!(csv.lines().count(), 26, "header + every session: {csv}");
1811    }
1812
1813    #[test]
1814    fn bucket_reports_carry_the_documented_columns() {
1815        let now = 1_800_000_000_000;
1816        let rows = vec![Row {
1817            at_ms: now - 60_000,
1818            provider: "opencode-go".into(),
1819            model: "glm-5.3-flash".into(),
1820            cost_usd: 1.5,
1821            input: 10,
1822            output: 20,
1823            cache_read: 30,
1824            cache_write: 40,
1825            ..Default::default()
1826        }];
1827        let csv = bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv);
1828        assert!(
1829            csv.starts_with("day,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1830            "{csv}"
1831        );
1832        // cache-write is absent from the columns by design; the total row closes.
1833        assert!(!csv.contains("cache_write"), "{csv}");
1834        assert!(csv.contains(",10,20,30,1.50\n"), "{csv}");
1835        assert!(csv.trim_end().ends_with("total,1,10,20,30,1.50"), "{csv}");
1836
1837        let json: serde_json::Value = serde_json::from_str(&bucket_text(
1838            &rows,
1839            now,
1840            7 * 86_400_000,
1841            86_400_000,
1842            0,
1843            false,
1844            Fmt::Json,
1845        ))
1846        .unwrap();
1847        assert_eq!(json["total"]["requests"], 1);
1848        assert_eq!(json["total"]["cacheRead"], 30);
1849        assert_eq!(json["days"].as_object().unwrap().len(), 1);
1850    }
1851
1852    #[test]
1853    fn session_report_json_and_csv_agree_with_the_store() {
1854        let now = 1_800_000_000_000;
1855        let rows = vec![
1856            Row {
1857                at_ms: now - 60_000,
1858                session: "ses_first".into(),
1859                model: "glm-5.3-flash".into(),
1860                cost_usd: 2.0,
1861                input: 5,
1862                output: 6,
1863                cache_read: 7,
1864                ..Default::default()
1865            },
1866            Row {
1867                at_ms: now - 30_000,
1868                session: "ses_second".into(),
1869                model: "kimi-k3".into(),
1870                cost_usd: 1.0,
1871                ..Default::default()
1872            },
1873        ];
1874        let json: serde_json::Value =
1875            serde_json::from_str(&session_text(&rows, 20, false, Fmt::Json)).unwrap();
1876        assert_eq!(json["sessions"]["ses_first"]["tokensIn"], 5);
1877        assert_eq!(json["sessions"]["ses_first"]["models"], 1);
1878        assert_eq!(json["sessions"]["ses_second"]["costUsd"], 1.0);
1879        let csv = session_text(&rows, 20, false, Fmt::Csv);
1880        assert!(
1881            csv.starts_with("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1882            "{csv}"
1883        );
1884        assert!(csv.contains("ses_second,1,0,0,0,1.00\n"), "{csv}");
1885    }
1886
1887    #[test]
1888    fn model_report_projects_the_report_models() {
1889        let json: serde_json::Value =
1890            serde_json::from_str(&model_report(&report(), None, Fmt::Json)).unwrap();
1891        assert_eq!(json["source"], "opencode");
1892        assert_eq!(json["models"]["glm-5.3-flash"]["costUsd"], 30.0);
1893        assert_eq!(json["models"]["glm-5.3-flash"]["limit"], 60.0);
1894        let csv = model_report(&report(), None, Fmt::Csv);
1895        assert!(csv.contains("glm-5.3-flash,1,0,0,0,30.00\n"), "{csv}");
1896        // An id that never ran yields just the header, not a stray row.
1897        assert_eq!(
1898            model_report(&report(), Some("nope"), Fmt::Csv),
1899            "model,requests,tokens_in,tokens_out,cache_read,cost_usd\n"
1900        );
1901    }
1902
1903    #[test]
1904    fn catalogue_json_carries_both_plans_and_windows() {
1905        let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1906        assert_eq!(json["windows"]["fiveHour"], 0.2);
1907        assert_eq!(json["windows"]["monthly"], 1.0);
1908        assert_eq!(json["plans"][0]["id"], "go");
1909        assert_eq!(json["plans"][0]["allowanceByModel"]["glm-5.3-flash"], 60.0);
1910        assert_eq!(json["plans"][1]["priceUsd"], 40.0);
1911        assert!(!json["models"].as_array().unwrap().is_empty());
1912        assert!(!json["zen"].as_array().unwrap().is_empty());
1913    }
1914
1915    #[test]
1916    fn models_lists_the_plan_and_marks_granted() {
1917        // The ungated table lists every catalogued model and marks the granted.
1918        let all = models_text("go", false, false);
1919        assert!(all.contains("glm-5.3-flash"), "{all}");
1920        assert!(all.contains("GRANTED"), "{all}");
1921        // The gated list keeps only what the plan grants.
1922        let gated = models_text("go", true, false);
1923        for line in gated.lines().skip(2) {
1924            assert!(
1925                line.trim_end().ends_with("yes"),
1926                "ungranted row kept: {line}"
1927            );
1928        }
1929        // JSON: one object per line, and gated drops the ungranted.
1930        let every: Vec<serde_json::Value> = models_json("go", false)
1931            .lines()
1932            .map(|l| serde_json::from_str(l).unwrap())
1933            .collect();
1934        assert!(every.iter().any(|m| m["granted"] == false));
1935        assert!(every
1936            .iter()
1937            .any(|m| m["id"] == "glm-5.3-flash" && m["limit"] == 60.0));
1938        let only: Vec<serde_json::Value> = models_json("go", true)
1939            .lines()
1940            .map(|l| serde_json::from_str(l).unwrap())
1941            .collect();
1942        assert!(!only.is_empty());
1943        assert!(only.iter().all(|m| m["granted"] == true));
1944    }
1945
1946    #[test]
1947    fn statusline_json_mirrors_the_template_numbers() {
1948        let json: serde_json::Value =
1949            serde_json::from_str(&statusline_json(&report(), "go")).unwrap();
1950        assert_eq!(json["plan"], "go");
1951        assert_eq!(json["period"], "calendar month");
1952        assert_eq!(json["monthSpent"], 30.0);
1953        // No account-wide pool exists (limits are per model), so the cap keys
1954        // stay null; the per-model limits live on the models array.
1955        assert_eq!(json["monthCap"], serde_json::Value::Null);
1956        assert_eq!(json["fiveHourCap"], serde_json::Value::Null);
1957        assert_eq!(json["weeklyCap"], serde_json::Value::Null);
1958        assert_eq!(json["model"], "glm-5.3-flash");
1959    }
1960
1961    #[test]
1962    fn plain_frames_carry_no_escapes() {
1963        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1964        assert!(
1965            !text.contains('\x1b'),
1966            "piped output must stay plain: {text}"
1967        );
1968        // The header rows carry no gauge and no share without an account; the
1969        // plain frame keeps the money the colour frame shows.
1970        assert!(text.contains("$30.00"), "{text}");
1971    }
1972
1973    /// The monthly row is the only one with a real period end, so it alone gains
1974    /// the reset and the elapsed share; the rolling windows have no boundary
1975    /// and must not invent one. Nothing divides by a pool: limits are per model.
1976    #[test]
1977    fn monthly_row_carries_the_real_period_metrics() {
1978        let mut r = report();
1979        r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1980        let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1981
1982        let monthly = text
1983            .lines()
1984            .find(|line| line.trim_start().starts_with("Monthly"))
1985            .expect("the monthly row");
1986        assert!(monthly.contains("resets"), "{monthly}");
1987        assert!(
1988            monthly.contains("window") && monthly.contains("% elapsed"),
1989            "the real period gives an elapsed share: {monthly}"
1990        );
1991
1992        for label in ["5-hour", "Weekly"] {
1993            let row = text
1994                .lines()
1995                .find(|line| line.trim_start().starts_with(label))
1996                .unwrap_or_else(|| panic!("the {label} row"));
1997            assert!(!row.contains("elapsed"), "{label} has no boundary: {row}");
1998            assert!(!row.contains("resets"), "{label} has no boundary: {row}");
1999        }
2000    }
2001
2002    /// The period header splits tokens like cmduse and names the resolved end
2003    /// date when the period has one (`--window all` has none).
2004    #[test]
2005    fn period_line_splits_tokens_and_shows_the_end_date() {
2006        let mut r = report();
2007        r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
2008        let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2009        let header = text
2010            .lines()
2011            .find(|line| line.starts_with("period:"))
2012            .expect("the period line");
2013        assert!(
2014            header.contains(" in / "),
2015            "token split like cmduse: {header}"
2016        );
2017        assert!(header.contains(" out tokens"), "{header}");
2018        assert!(
2019            header.contains(&date_of(r.period_end_ms.unwrap() / 86_400_000)),
2020            "the resolved end date: {header}"
2021        );
2022
2023        // No end resolved: no date is invented.
2024        let no_end = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2025        let header = no_end
2026            .lines()
2027            .find(|line| line.starts_with("period:"))
2028            .expect("the period line");
2029        assert!(!header.contains("ends"), "{header}");
2030    }
2031
2032    #[test]
2033    fn an_over_cap_window_is_flagged_in_both_modes() {
2034        // 30 spent against a 5-hour cap of 20% of the $60 limit = $12.
2035        let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2036        assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
2037        let coloured = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
2038        assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
2039        assert!(
2040            coloured.contains(crate::render::RED),
2041            "the flag and the gauge are red: {coloured}"
2042        );
2043    }
2044
2045    #[test]
2046    fn a_model_over_any_of_its_own_windows_is_flagged() {
2047        // The fixture is at 50% of the month but over the 5-hour share, and Go
2048        // meters per model per window: the row must say so, not just glow red.
2049        let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2050        let row = plain
2051            .lines()
2052            .find(|line| line.starts_with("glm-5.3-flash"))
2053            .expect("the model row");
2054        assert!(row.contains("LIMIT EXCEEDED"), "{row}");
2055        // Share last, flag early: the informational column is clipped first.
2056        assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
2057    }
2058
2059    #[test]
2060    fn a_model_within_its_limits_is_not_flagged() {
2061        let now = 1_800_000_000_000;
2062        // $5 against a $60 limit: every window is comfortably inside its share.
2063        let rows = vec![Row {
2064            at_ms: now - 60_000,
2065            provider: "opencode-go".into(),
2066            model: "glm-5.3-flash".into(),
2067            cost_usd: 5.0,
2068            ..Default::default()
2069        }];
2070        let report =
2071            crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
2072        for colour in [false, true] {
2073            let text = render_text(&report, "/tmp/opencode.db", 10, colour, DEFAULT_BAR_WIDTH);
2074            assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
2075        }
2076    }
2077
2078    #[test]
2079    fn the_model_detail_spells_out_the_share() {
2080        let detail = model_text(&report(), "glm-5.3-flash", false);
2081        assert!(detail.contains("100.0% of period spend"), "{detail}");
2082        // A period with nothing to divide by omits the row rather than printing NaN.
2083        let empty = crate::ocuse::window::build(
2084            &[],
2085            &zen::catalog(),
2086            zen::DEFAULT_PLAN,
2087            1_800_000_000_000,
2088            None,
2089            None,
2090        );
2091        assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
2092    }
2093
2094    #[test]
2095    fn colour_frames_get_the_palette_and_a_gauge() {
2096        // Gauge bars exist only where a share gates: the account's own rows.
2097        // The local estimate divides by no pool, so its rows carry no bar —
2098        // spend alone, never a gauge against a fiction. Palette assertions live
2099        // on the model table, whose per-model limits are real.
2100        let text = render_text(
2101            &report_with_account(),
2102            "/tmp/opencode.db",
2103            10,
2104            true,
2105            DEFAULT_BAR_WIDTH,
2106        );
2107        assert!(text.contains(crate::render::BOLD), "{text}");
2108        assert!(text.contains(crate::render::CYAN), "{text}");
2109        assert!(
2110            text.contains(crate::render::GREEN),
2111            "the 50% USE cell is green: {text}"
2112        );
2113        assert!(
2114            text.contains('━') && text.contains('╱'),
2115            "the account share's gauge bar: {text}"
2116        );
2117        assert!(text.contains("resets in"), "{text}");
2118    }
2119
2120    #[test]
2121    fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
2122        let frame = watch_frame(
2123            &report(),
2124            "/tmp/opencode.db",
2125            10,
2126            true,
2127            &[0.0, 1.5, 0.0],
2128            10,
2129            DEFAULT_BAR_WIDTH,
2130        );
2131        assert!(
2132            !frame.ends_with('\n'),
2133            "the caller's status line owns the last row"
2134        );
2135        assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
2136        assert!(
2137            frame.contains('█'),
2138            "a nonzero delta shows as a tall bar: {frame}"
2139        );
2140    }
2141
2142    #[test]
2143    fn idle_history_omits_the_sparkline() {
2144        let all_idle = watch_frame(
2145            &report(),
2146            "/tmp/opencode.db",
2147            10,
2148            true,
2149            &[0.0, 0.0],
2150            10,
2151            DEFAULT_BAR_WIDTH,
2152        );
2153        assert!(!all_idle.contains("spend bursts"), "{all_idle}");
2154        let first_frame = watch_frame(
2155            &report(),
2156            "/tmp/opencode.db",
2157            10,
2158            true,
2159            &[],
2160            10,
2161            DEFAULT_BAR_WIDTH,
2162        );
2163        assert!(!first_frame.contains("spend bursts"), "{first_frame}");
2164    }
2165
2166    /// Every statusline placeholder resolves, unknown ones are dropped, and
2167    /// colours off means no SGR at all. The default template is the one the
2168    /// config ships, so it must render end to end.
2169    #[test]
2170    fn statusline_resolves_every_placeholder() {
2171        let r = report();
2172        let line = |tpl: &str| statusline_template(&r, "go", tpl, false, false, 20);
2173
2174        // {plan} {period} are plain text
2175        assert_eq!(line("{plan}"), "go");
2176        assert_eq!(line("{period}"), "calendar month");
2177        // money placeholders reuse cmduse_core::money
2178        assert_eq!(line("{month}"), "$30.00");
2179        assert_eq!(line("{five_hour}"), "$30.00");
2180        assert_eq!(line("{weekly}"), "$30.00");
2181        // {models} is the top model by spend
2182        assert_eq!(line("{models}"), "glm-5.3-flash");
2183        // without an account there is no pool to divide by (limits are per
2184        // model), so the share placeholders degrade to the dash; the account's
2185        // own shares resolve when it answers (pinned by the account test above)
2186        assert_eq!(line("{month_pct}"), "—");
2187        assert_eq!(line("{month_bar}"), "");
2188        assert_eq!(line("{five_hour_pct}"), "—");
2189        assert_eq!(line("{weekly_pct}"), "—");
2190
2191        // the default template resolves as a whole
2192        let default = line("{period} {month} · 5h {five_hour} · wk {weekly}");
2193        assert_eq!(default, "calendar month $30.00 · 5h $30.00 · wk $30.00");
2194
2195        // unknown placeholders drop; text and separators pass through
2196        assert_eq!(line("{plan} {bogus} | tail"), "go  | tail");
2197        // unclosed brace is emitted verbatim
2198        assert_eq!(line("{plan} {oops"), "go {oops");
2199        // with colours off there is no escape anywhere
2200        assert!(!line("{plan} {month} {month_pct} {month_bar}").contains('\x1b'));
2201    }
2202
2203    /// A period with no spend resolves no shares either way, so the pct
2204    /// placeholders degrade to the money-less dash instead of NaN.
2205    #[test]
2206    fn statusline_without_caps_degrades_gracefully() {
2207        let empty = crate::ocuse::window::build(
2208            &[],
2209            &zen::catalog(),
2210            zen::DEFAULT_PLAN,
2211            1_800_000_000_000,
2212            None,
2213            None,
2214        );
2215        let line = statusline_template(&empty, "go", "{month_pct} {models}", false, false, 20);
2216        assert_eq!(line, "— —");
2217    }
2218
2219    /// The one-line compact frame is a single line and inlines the sparkline.
2220    #[test]
2221    fn compact_line_is_one_line_with_inline_sparkline() {
2222        let plain = compact_line(&report(), &[], true);
2223        assert_eq!(plain.lines().count(), 1, "{plain}");
2224        assert!(!plain.ends_with('\n'), "{plain}");
2225        let bursty = compact_line(&report(), &[0.0, 1.5], true);
2226        assert!(bursty.contains('█'), "nonzero delta shows: {bursty}");
2227        // colour off ⇒ no escapes; colour on ⇒ the palette appears
2228        assert!(!compact_line(&report(), &[], false).contains('\x1b'));
2229        assert!(compact_line(&report(), &[], true).contains(crate::render::BOLD));
2230    }
2231}