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