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