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