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