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