Skip to main content

cmd_usage/arkuse/
render.rs

1// arkuse's presentation: a watch frame, a one-shot dashboard, a status line, and
2// JSON.
3//
4// Reuses cmduse-core's pure formatters (compact/pct/duration) and cmduse's own
5// palette, gauge and sparkline, so all three binaries read the same. ARK's unit
6// is AFP — the plan's own currency — so no row prints `money()`'s `$`; yuan
7// appears only where a price is meant (1 AFP = ¥0.002 at every tier).
8use crate::arkuse::ark;
9use crate::arkuse::window::{Report, Totals};
10use crate::report_render::Fmt;
11use cmduse_core::{compact, duration};
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    fn cyan_on(&self, text: String) -> String {
68        format!("{}{text}{}", self.cyan, self.reset)
69    }
70
71    fn dim_on(&self, text: String) -> String {
72        format!("{}{text}{}", self.dim, self.reset)
73    }
74
75    fn bold_on(&self, text: String) -> String {
76        format!("{}{text}{}", self.bold, self.reset)
77    }
78
79    /// Severity for a used/limit percentage — cmduse's thresholds.
80    fn for_pct(&self, pct_used: f64) -> &'static str {
81        if !self.colour {
82            ""
83        } else if pct_used >= 90.0 {
84            self.red
85        } else if pct_used >= 70.0 {
86            self.yellow
87        } else {
88            self.green
89        }
90    }
91
92    /// Pct + gauge, exactly cmduse's `render::bar`; empty when colour is off so
93    /// the plain frame keeps its script-friendly column layout.
94    fn bar(&self, used: f64, cap: f64) -> String {
95        if self.colour {
96            crate::render::bar(used, cap, self.bar_width)
97        } else {
98            String::new()
99        }
100    }
101}
102
103/// AFP with thousands separators and no decimals. AFP is a countable quota, not
104/// a currency: a tenth of a point is below the control plane's own noise (it
105/// reports fractions, the console floors them).
106pub fn afp(value: f64) -> String {
107    let rounded = value.round() as i64;
108    let digits = rounded.unsigned_abs().to_string();
109    let mut out = String::new();
110    for (i, ch) in digits.chars().enumerate() {
111        if i > 0 && (digits.len() - i).is_multiple_of(3) {
112            out.push(',');
113        }
114        out.push(ch);
115    }
116    if rounded < 0 {
117        format!("-{out}")
118    } else {
119        out
120    }
121}
122
123/// Yuan, for prices only — never for metered usage, which is AFP.
124pub fn yuan(value: f64) -> String {
125    format!("¥{:.2}", (value * 100.0).round() / 100.0)
126}
127
128/// A percentage exactly as the control plane published it. ARK serves fractions
129/// (`29.756678`), so this keeps one decimal; local percentages keep `pct`'s own
130/// formatting.
131fn given_pct(value: f64) -> String {
132    if (value - value.round()).abs() < 1e-9 {
133        format!("{}", value.round() as i64)
134    } else {
135        format!("{value:.1}")
136    }
137}
138
139/// `YYYY-MM-DD` for a Unix day number.
140pub fn date_of(day: i64) -> String {
141    let z = day + 719_468;
142    let era = z.div_euclid(146_097);
143    let doe = z.rem_euclid(146_097);
144    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
145    let y = yoe + era * 400;
146    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
147    let mp = (5 * doy + 2) / 153;
148    let d = doy - (153 * mp + 2) / 5 + 1;
149    let m = if mp < 10 { mp + 3 } else { mp - 9 };
150    let year = if m <= 2 { y + 1 } else { y };
151    format!("{year:04}-{m:02}-{d:02}")
152}
153
154fn shorten(path: &str) -> String {
155    match std::env::var("HOME") {
156        Ok(home) if path.starts_with(&home) => format!("~{}", &path[home.len()..]),
157        _ => path.to_string(),
158    }
159}
160
161/// The account meter's window boundary: its reset instant and the window's
162/// length, so the row can measure elapsed share and pace on the server's clock.
163fn meter_reset(meter: &crate::arkuse::api::Meter, dur_secs: Option<u64>) -> Option<(i64, u64)> {
164    match (meter.reset_ms(), dur_secs) {
165        (Some(at), Some(dur)) if dur > 0 => Some((at, dur)),
166        _ => None,
167    }
168}
169
170#[allow(clippy::too_many_arguments)]
171/// One window row's inputs. The control plane's meters and the local estimate
172/// both go through this one builder, so the two branches cannot drift apart in
173/// which fields a row carries or in the order it carries them.
174///
175/// Unlike Go, ARK publishes the quota itself (`total`), so the row can print a
176/// true `used / quota` pair and a pace against it. The local branch has no
177/// quota — it shows AFP alone, never a denominator this store cannot know.
178struct RowSpec<'a> {
179    label: &'a str,
180    /// This store's own totals for the window.
181    used: &'a Totals,
182    /// The account's meter, when it answered.
183    meter: Option<&'a crate::arkuse::api::Meter>,
184    /// The window's reset instant and its length in seconds.
185    reset: Option<(i64, u64)>,
186    /// Whether an even-burn figure belongs on this row: the monthly window alone
187    /// is a budget, where "what even burn would have spent by now" is a fair
188    /// comparison; the throttles above it would read as a spend target.
189    pro_rata: bool,
190}
191
192fn window_line(ink: &Ink, spec: &RowSpec<'_>, now_ms: i64, catalog: &ark::Catalog) -> String {
193    let now_secs = (now_ms / 1000).max(0) as u64;
194    let reset_at = spec.reset.map(|(at, _)| at as f64);
195    let dur_secs = spec.reset.map(|(_, dur)| dur);
196    let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
197    let reset = spec
198        .reset
199        .map(|(at, _)| duration(((at - now_ms) / 1000).max(0) as u64))
200        .unwrap_or_default();
201    let reset_text = if reset.is_empty() {
202        String::new()
203    } else if ink.colour {
204        format!(" · resets in {reset}")
205    } else {
206        format!(" · resets {reset}")
207    };
208    let thru = elapsed
209        .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
210        .unwrap_or_default();
211
212    // Server branch: the quota is the server's own, so the pair and the pace are
213    // exact. Local branch: AFP alone.
214    let (body, tone) = match spec.meter {
215        Some(meter) => {
216            let pace = spec
217                .reset
218                .and_then(|(at, d)| {
219                    cmduse_core::pace_eta(Some(at as f64), d, meter.used, meter.quota, now_secs)
220                        .map(|secs| duration(secs as u64))
221                })
222                .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
223                .unwrap_or_default();
224            let by_now = match (spec.pro_rata, elapsed) {
225                (true, Some(p)) => format!(
226                    " · {}{} by now{}",
227                    ink.dim,
228                    afp(meter.quota * p / 100.0),
229                    ink.reset
230                ),
231                _ => String::new(),
232            };
233            let flag = if meter.exceeded() {
234                format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
235            } else if !meter.status.eq_ignore_ascii_case("ok") {
236                format!(" {}{}{}{}", ink.red, meter.status, ink.reset, "")
237            } else {
238                String::new()
239            };
240            let pct_tone = ink.for_pct(meter.percent);
241            let tail = format!("{reset_text}{flag}{pace}{thru}{by_now}");
242            let body = format!(
243                "{} / {} AFP · {}{}% of limit{} · {}{} local{}{tail}",
244                afp(meter.used),
245                afp(meter.quota),
246                pct_tone,
247                given_pct(meter.percent),
248                ink.reset,
249                ink.dim,
250                afp(spec.used.afp),
251                ink.reset,
252            );
253            (body, pct_tone)
254        }
255        None => {
256            let body = format!(
257                "{} AFP · {}{} · {} requests{}",
258                afp(spec.used.afp),
259                ink.dim,
260                yuan(catalog.yuan(spec.used.afp)),
261                compact(spec.used.requests),
262                ink.reset,
263            );
264            (format!("{body}{reset_text}{thru}"), ink.for_pct(0.0))
265        }
266    };
267    if ink.colour {
268        let bar = match spec.meter {
269            Some(meter) => format!("{} ", ink.bar(meter.percent, 100.0)),
270            None => String::new(),
271        };
272        format!(
273            " {}{:<8}{} {}{}{body}{}\n",
274            ink.bold, spec.label, ink.reset, bar, tone, ink.reset,
275        )
276    } else {
277        format!("  {:<8} {body}\n", spec.label)
278    }
279}
280
281/// One model's row: id, coefficient in force, AFP and requests this period, and
282/// its share of the period's AFP. No per-model limit exists on ARK — the quota is
283/// plan-level — so there is no USE column to invent.
284fn model_row(ink: &Ink, model: &crate::arkuse::window::ModelUsage, total_afp: f64) -> String {
285    let coef = format!("{:>5.2}", model.coef);
286    let coef_cell = if ink.colour {
287        format!("{}{coef}{}", ink.cyan, ink.reset)
288    } else {
289        coef
290    };
291    let share = if total_afp > 0.0 {
292        ink.dim_on(format!(" {:>5.1}%", model.month.afp / total_afp * 100.0))
293    } else {
294        String::new()
295    };
296    format!(
297        "{:<28} {coef_cell} {:>9} {:>7}{share}\n",
298        model.id,
299        afp(model.month.afp),
300        compact(model.month.requests),
301    )
302}
303
304/// `limit` rows to show; `usize::MAX` for all.
305pub fn render_text(
306    report: &Report,
307    db_path: &str,
308    rows: usize,
309    colour: bool,
310    bar_width: usize,
311) -> String {
312    let ink = Ink::new(colour).width(bar_width);
313    let catalog = ark::catalog();
314    let c = |text: String| ink.cyan_on(text);
315    let mut out = String::new();
316    let tier_label = catalog
317        .plan(&report.tier)
318        .map(|p| p.label.clone())
319        .unwrap_or_else(|| report.tier.clone());
320    out.push_str(&format!(
321        "{}Volcengine ARK usage{} — agent plan {}, from {}{}{}\n",
322        ink.bold,
323        ink.reset,
324        tier_label,
325        ink.dim,
326        shorten(db_path),
327        ink.reset,
328    ));
329    let end = report
330        .period_end_ms
331        .map(|ms| {
332            format!(
333                " · ends {}",
334                date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))
335            )
336        })
337        .unwrap_or_default();
338    let start = date_of((report.since_ms + report.offset_secs * 1000).div_euclid(86_400_000));
339    out.push_str(&format!(
340        "period: {start}{end} · {} requests · {} AFP ({}) · {} in / {} out tokens\n",
341        c(compact(report.month.requests)),
342        c(afp(report.month.afp)),
343        c(yuan(catalog.yuan(report.month.afp))),
344        c(compact(report.month.input)),
345        c(compact(report.month.output)),
346    ));
347    if let Some(plan) = catalog.plan(&report.tier) {
348        out.push_str(&format!(
349            "{}plan: {} {} · {} AFP/mo · {} · {} windows{}",
350            ink.dim,
351            plan.label,
352            yuan(plan.price),
353            afp(plan.afp),
354            catalog.currency,
355            if report.account.is_some() {
356                "metered by the control plane"
357            } else {
358                "local estimate"
359            },
360            ink.reset,
361        ));
362        out.push('\n');
363    }
364
365    match &report.account {
366        Some(account) => {
367            out.push_str(&format!(
368                "\n{}AGENT PLAN{} {}\n",
369                ink.bold,
370                ink.reset,
371                ink.dim_on(
372                    "(account meters — what the Volcengine console reports; AFP beside them is this store's own estimate)"
373                        .into()
374                ),
375            ));
376            let monthly_dur = report
377                .period_end_ms
378                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
379            let daily_local = report.daily.clone().unwrap_or_default();
380            let mut windows: Vec<(&str, &crate::arkuse::api::Meter, &Totals, Option<u64>, bool)> =
381                vec![(
382                    "5-hour",
383                    &account.five_hour,
384                    &report.five_hour,
385                    Some(crate::arkuse::window::FIVE_HOUR_SECS as u64),
386                    false,
387                )];
388            if let Some(daily) = &account.daily {
389                windows.push((
390                    "Daily",
391                    daily,
392                    &daily_local,
393                    Some(crate::arkuse::window::DAY_SECS as u64),
394                    false,
395                ));
396            }
397            windows.push((
398                "Weekly",
399                &account.weekly,
400                &report.weekly,
401                Some(crate::arkuse::window::WEEKLY_SECS as u64),
402                false,
403            ));
404            windows.push((
405                "Monthly",
406                &account.monthly,
407                &report.month,
408                monthly_dur,
409                true,
410            ));
411            for (label, meter, used, dur, pro_rata) in windows {
412                out.push_str(&window_line(
413                    &ink,
414                    &RowSpec {
415                        label,
416                        used,
417                        meter: Some(meter),
418                        reset: meter_reset(meter, dur),
419                        pro_rata,
420                    },
421                    report.now_ms,
422                    &catalog,
423                ));
424            }
425            // Reconciliation is the point of holding both sources: the meter is
426            // account-wide, the store is this machine, so a gap is either another
427            // client or an unmodelled item — worth printing, never hidden.
428            let mut notes: Vec<String> = Vec::new();
429            if let Some(delta) = report.reconcile() {
430                notes.push(format!("local vs meter {:+.1}%", delta));
431            }
432            if let Some(per) = report.afp_per_request() {
433                notes.push(format!("{} AFP/request", afp(per)));
434            }
435            if let Some(burn) = report.burn_per_day() {
436                notes.push(format!("{} AFP/day at this pace", afp(burn)));
437            }
438            if !notes.is_empty() {
439                out.push_str(&format!(
440                    "{}    {} · {}{}\n",
441                    ink.dim,
442                    account.profile,
443                    notes.join(" · "),
444                    ink.reset,
445                ));
446            }
447        }
448        None => {
449            out.push_str(&format!(
450                "\n{}AGENT PLAN{} {}\n",
451                ink.bold,
452                ink.reset,
453                ink.dim_on("(estimated locally: no control-plane answer, so AFP alone)".into()),
454            ));
455            if let Some(why) = &report.account_error {
456                out.push_str(&format!("{}   usage API: {why}{}\n", ink.dim, ink.reset));
457            }
458            let monthly_dur = report
459                .period_end_ms
460                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
461            out.push_str(&window_line(
462                &ink,
463                &RowSpec {
464                    label: "5-hour",
465                    used: &report.five_hour,
466                    meter: None,
467                    reset: None,
468                    pro_rata: false,
469                },
470                report.now_ms,
471                &catalog,
472            ));
473            out.push_str(&window_line(
474                &ink,
475                &RowSpec {
476                    label: "Weekly",
477                    used: &report.weekly,
478                    meter: None,
479                    reset: None,
480                    pro_rata: false,
481                },
482                report.now_ms,
483                &catalog,
484            ));
485            out.push_str(&window_line(
486                &ink,
487                &RowSpec {
488                    label: "Monthly",
489                    used: &report.month,
490                    meter: None,
491                    reset: report.period_end_ms.zip(monthly_dur),
492                    pro_rata: false,
493                },
494                report.now_ms,
495                &catalog,
496            ));
497        }
498    }
499
500    out.push_str(&format!(
501        "\n{}MODEL                            COEF       AFP     REQ  SHARE{}\n",
502        ink.bold, ink.reset
503    ));
504    let total_afp = report.month.afp;
505    for model in report.models.iter().take(rows) {
506        out.push_str(&model_row(&ink, model, total_afp));
507    }
508    if report.models.len() > rows {
509        out.push_str(&format!(
510            "{}\n",
511            ink.dim_on(format!("… {} more", report.models.len() - rows))
512        ));
513    }
514    // The subtlety that makes local AFP disagree with the meter: composition.
515    out.push_str(&format!(
516        "\n{}\n",
517        ink.dim_on(
518            "AFP = (input + cache_read + output) × coefficient / 10,000; the coefficient is the plan's own and a promo halves it until its date."
519                .into()
520        ),
521    ));
522    if report.account.is_some() {
523        out.push_str(&format!(
524            "{}\n",
525            ink.dim_on(
526                "The windows are the account's own cuts (5h block, Monday week, subscription month); the model AFP is this machine inside them."
527                    .into()
528            )
529        ));
530    } else {
531        out.push_str(&format!(
532            "{}\n",
533            ink.dim_on(
534                "Windows are rolling 5h / 7d / calendar month cuts, local only — not the control plane's anchors."
535                    .into()
536            )
537        ));
538    }
539    out
540}
541
542/// One watch frame: the dashboard, then an AFP-burst sparkline when the 5-hour
543/// figure has moved since the previous refresh. No trailing newline — the caller
544/// appends its own status line and `redraw_frame` (cmduse's) parks the cursor
545/// there, which is what lets the countdown rewrite a single line per second.
546pub fn watch_frame(
547    report: &Report,
548    db_path: &str,
549    rows: usize,
550    colour: bool,
551    history: &[f64],
552    interval_secs: u64,
553    bar_width: usize,
554) -> String {
555    let ink = Ink::new(colour);
556    let mut out = render_text(report, db_path, rows, colour, bar_width);
557    if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
558        out.push_str(&format!(
559            "{}AFP bursts ({}s samples){} {}\n",
560            ink.dim,
561            interval_secs,
562            ink.reset,
563            crate::render::sparkline(history),
564        ));
565    }
566    out.trim_end_matches('\n').to_string()
567}
568
569/// Minimal one-line dashboard for terminals too small for the full frame.
570pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
571    let ink = Ink::new(colour);
572    let catalog = ark::catalog();
573    let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
574        format!(
575            " {}{}{}",
576            ink.dim,
577            crate::render::sparkline(history),
578            ink.reset
579        )
580    } else {
581        String::new()
582    };
583    let window_share = match &report.account {
584        Some(account) => format!(
585            " · 5h {} · wk {} · mo {}",
586            ink.cyan_on(format!("{}%", given_pct(account.five_hour.percent))),
587            ink.cyan_on(format!("{}%", given_pct(account.weekly.percent))),
588            ink.cyan_on(format!("{}%", given_pct(account.monthly.percent))),
589        ),
590        None => format!(" · 5h {}", ink.cyan_on(afp(report.five_hour.afp))),
591    };
592    format!(
593        "{}ARK{} {} AFP ({}) month{window_share}{spark}",
594        ink.bold,
595        ink.reset,
596        ink.cyan_on(afp(report.month.afp)),
597        ink.cyan_on(yuan(catalog.yuan(report.month.afp))),
598    )
599}
600
601/// arkuse's statusline: cmduse's `{key}` template engine
602/// (`report_render::render_template`) resolved against this report's own
603/// placeholders.
604///
605/// Placeholders (case-insensitive):
606///   {plan} {tier} {period}                 tier id / period label
607///   {month} {month_pct} {month_bar}
608///   {five_hour} {five_hour_pct} {five_hour_bar}   (aliases {5h} {5h_pct} {5h_bar})
609///   {weekly} {weekly_pct} {weekly_bar}            (aliases {wk} {wk_pct} {wk_bar})
610///   {yuan} {yuan_month}                    this store's AFP in yuan
611///   {models}                               top model by period AFP
612pub fn statusline_template(
613    report: &Report,
614    plan_id: &str,
615    tpl: &str,
616    colours: bool,
617    ascii: bool,
618    bar_width: usize,
619) -> String {
620    let ink = Ink::new(colours);
621    let catalog = ark::catalog();
622    let account = report.account.as_ref();
623    let share_of = |meter: Option<&crate::arkuse::api::Meter>| match meter {
624        Some(meter) => format!("{}%", given_pct(meter.percent)),
625        None => "—".into(),
626    };
627    let share_bar = |meter: Option<&crate::arkuse::api::Meter>| match meter {
628        Some(meter) => crate::report_render::bar(meter.percent, bar_width, ascii, colours),
629        None => String::new(),
630    };
631    let cash = |value: f64| ink.cyan_on(afp(value));
632    let top = report
633        .models
634        .first()
635        .map(|m| m.id.clone())
636        .unwrap_or_else(|| "—".into());
637    let replacements: Vec<(&str, String)> = vec![
638        ("plan", plan_id.to_string()),
639        ("tier", plan_id.to_string()),
640        ("period", report.period_label.clone()),
641        ("month", cash(report.month.afp)),
642        ("month_pct", share_of(account.map(|a| &a.monthly))),
643        ("month_bar", share_bar(account.map(|a| &a.monthly))),
644        ("five_hour", cash(report.five_hour.afp)),
645        ("5h", cash(report.five_hour.afp)),
646        ("five_hour_pct", share_of(account.map(|a| &a.five_hour))),
647        ("5h_pct", share_of(account.map(|a| &a.five_hour))),
648        ("five_hour_bar", share_bar(account.map(|a| &a.five_hour))),
649        ("5h_bar", share_bar(account.map(|a| &a.five_hour))),
650        ("weekly", cash(report.weekly.afp)),
651        ("wk", cash(report.weekly.afp)),
652        ("weekly_pct", share_of(account.map(|a| &a.weekly))),
653        ("wk_pct", share_of(account.map(|a| &a.weekly))),
654        ("weekly_bar", share_bar(account.map(|a| &a.weekly))),
655        ("wk_bar", share_bar(account.map(|a| &a.weekly))),
656        ("yuan", yuan(catalog.yuan(report.month.afp))),
657        ("yuan_month", yuan(catalog.yuan(report.month.afp))),
658        ("models", top),
659    ];
660    crate::report_render::render_template(tpl, |key| {
661        let lower = key.to_ascii_lowercase();
662        replacements
663            .iter()
664            .find(|(name, _)| *name == lower)
665            .map(|(_, value)| value.clone())
666            .unwrap_or_default()
667    })
668}
669
670pub fn statusline_json(report: &Report, plan_id: &str) -> String {
671    let catalog = ark::catalog();
672    serde_json::json!({
673        "plan": plan_id,
674        "period": report.period_label,
675        "source": "opencode",
676        // The shared names a statusline consumer already reads; here they carry
677        // AFP, because AFP is this plan's currency.
678        "monthSpent": report.month.afp,
679        "monthCap": serde_json::Value::Null,
680        "fiveHourSpent": report.five_hour.afp,
681        "fiveHourCap": serde_json::Value::Null,
682        "weeklySpent": report.weekly.afp,
683        "weeklyCap": serde_json::Value::Null,
684        "model": report.models.first().map(|m| m.id.clone()),
685        "afp": {
686            "fiveHour": report.five_hour.afp,
687            "weekly": report.weekly.afp,
688            "monthly": report.month.afp,
689            "yuan": catalog.yuan(report.month.afp),
690        },
691        "account": report.account.as_ref().map(usage_json),
692        "accountError": report.account_error,
693        "periodEnd": report.period_end_ms,
694    })
695    .to_string()
696}
697
698/// One meter as JSON, with the server's own figures preserved — the percentage
699/// is the console's, not a recomputation.
700fn meter_json(meter: &crate::arkuse::api::Meter) -> serde_json::Value {
701    serde_json::json!({
702        "used": meter.used,
703        "total": meter.quota,
704        "percent": meter.percent,
705        "status": meter.status,
706        "resetsAt": meter.resets_at,
707        "resetAt": meter.reset_ms(),
708    })
709}
710
711/// Every window the control plane answered, keeping the plan's own shape — a
712/// text-only workload has no `daily` period, so that key is simply absent.
713fn usage_json(usage: &crate::arkuse::api::Usage) -> serde_json::Value {
714    serde_json::json!({
715        "profile": usage.profile,
716        "tier": usage.tier,
717        "fiveHour": meter_json(&usage.five_hour),
718        "daily": usage.daily.as_ref().map(meter_json),
719        "weekly": meter_json(&usage.weekly),
720        "monthly": meter_json(&usage.monthly),
721    })
722}
723
724pub fn render_json(report: &Report) -> String {
725    let totals = |t: &Totals| {
726        serde_json::json!({
727            "afp": t.afp,
728            "requests": t.requests,
729            "tokensIn": t.input,
730            "tokensOut": t.output,
731            "cacheRead": t.cache_read,
732            "cacheWrite": t.cache_write,
733        })
734    };
735    let models: serde_json::Map<String, serde_json::Value> = report
736        .models
737        .iter()
738        .map(|m| {
739            (
740                m.id.clone(),
741                serde_json::json!({
742                    "coef": m.coef,
743                    "month": totals(&m.month),
744                    "fiveHour": totals(&m.five_hour),
745                    "weekly": totals(&m.weekly),
746                }),
747            )
748        })
749        .collect();
750    serde_json::json!({
751        "source": "opencode",
752        "scope": "local",
753        "plan": report.tier,
754        "profile": report.profile,
755        "periodStart": report.since_ms,
756        "periodEndAt": report.period_end_ms,
757        "periodEnd": report.period_end_ms
758            .map(|ms| date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))),
759        "monthInferred": report.month_inferred,
760        "summary": {
761            "afp": report.month.afp,
762            "yuan": ark::catalog().yuan(report.month.afp),
763            "requests": report.month.requests,
764            "tokensIn": report.month.input,
765            "tokensOut": report.month.output,
766            "cacheRead": report.month.cache_read,
767        },
768        "fiveHour": totals(&report.five_hour),
769        "weekly": totals(&report.weekly),
770        "monthly": totals(&report.month),
771        "daily": report.daily.as_ref().map(totals),
772        "account": report.account.as_ref().map(|account| {
773            let mut body = serde_json::json!({
774                "profile": account.profile,
775                "tier": account.tier,
776                "fiveHour": meter_json(&account.five_hour),
777                "weekly": meter_json(&account.weekly),
778                "monthly": meter_json(&account.monthly),
779            });
780            if let Some(daily) = &account.daily {
781                body["daily"] = meter_json(daily);
782            }
783            body
784        }),
785        "accountError": report.account_error,
786        "reconciledPct": report.reconcile(),
787        "afpPerRequest": report.afp_per_request(),
788        "models": models,
789    })
790    .to_string()
791}
792
793/// The tier catalogue: what each plan costs, what quota it grants, and how that
794/// quota is split across the windows. The window figures are the plan's own
795/// fractions of its monthly AFP — the same table the console shows per tier.
796pub fn plans_text(colour: bool) -> String {
797    let ink = Ink::new(colour);
798    let catalog = ark::catalog();
799    let mut out = format!(
800        "{}{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}{}\n",
801        ink.bold, "TIER", "YUAN/MO", "AFP/MO", "5H AFP", "DAILY AFP", "WK AFP", ink.reset
802    );
803    for plan in &catalog.plans {
804        out.push_str(&format!(
805            "{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}\n",
806            plan.label,
807            ink.cyan_on(yuan(plan.price)),
808            ink.cyan_on(afp(plan.afp)),
809            afp(plan.afp * catalog.windows.five_hour),
810            afp(plan.afp * catalog.windows.daily),
811            afp(plan.afp * catalog.windows.weekly),
812        ));
813        if let Some(promo) = &plan.promo {
814            out.push_str(&format!(
815                "{}{} for {} months, until {}{}\n",
816                ink.dim,
817                yuan(promo.price),
818                promo.months.unwrap_or(1),
819                promo.until,
820                ink.reset,
821            ));
822        }
823    }
824    out.push_str(&format!(
825        "\n{}1 AFP = ¥{:.3} at every tier · 1,000 AFP = {}{}\n",
826        ink.dim,
827        catalog.afp_yuan,
828        yuan(catalog.yuan(1000.0)),
829        ink.reset,
830    ));
831    if !catalog.enterprise.is_empty() {
832        out.push_str(&format!(
833            "\n{}ENTERPRISE{} {}\n",
834            ink.bold,
835            ink.reset,
836            ink.dim_on(
837                "(seat-based; AFP is metered per seat and the personal usage API does not expose it)"
838                    .into()
839            ),
840        ));
841        for plan in &catalog.enterprise {
842            out.push_str(&format!(
843                "  {:<22} {}\n",
844                plan.label,
845                ink.cyan_on(format!("{}/seat/mo", yuan(plan.price))),
846            ));
847        }
848    }
849    if let Some(note) = catalog.notes.get("windows") {
850        out.push_str(&format!("\n{}{}{}\n", ink.dim, note, ink.reset));
851    }
852    out
853}
854
855pub fn plans_json() -> String {
856    let catalog = ark::catalog();
857    serde_json::json!({
858        "source": catalog.source,
859        "extractedAt": catalog.extracted_at,
860        "currency": catalog.currency,
861        "afpYuan": catalog.afp_yuan,
862        "windows": {
863            "fiveHour": catalog.windows.five_hour,
864            "daily": catalog.windows.daily,
865            "weekly": catalog.windows.weekly,
866            "monthly": catalog.windows.monthly,
867        },
868        "plans": catalog.plans.iter().map(|p| serde_json::json!({
869            "id": p.id,
870            "label": p.label,
871            "price": p.price,
872            "listPrice": p.list_price,
873            "afp": p.afp,
874            "promo": p.promo.as_ref().map(|promo| serde_json::json!({
875                "price": promo.price,
876                "until": promo.until,
877                "months": promo.months,
878            })),
879        })).collect::<Vec<_>>(),
880        "enterprise": catalog.enterprise.iter().map(|p| serde_json::json!({
881            "id": p.id,
882            "label": p.label,
883            "price": p.price,
884        })).collect::<Vec<_>>(),
885        "modelCount": catalog.models.len(),
886    })
887    .to_string()
888}
889
890/// `y` yuan per million tokens at a coefficient — the same quantity the docs
891/// publish per model, so a reader can check a coefficient without arithmetic:
892/// 1M tokens × coef / 10,000 = 100·coef AFP, and 1 AFP = ¥0.002.
893fn yuan_per_million(coef: f64, afp_yuan: f64) -> f64 {
894    1_000_000.0 * coef / 10_000.0 * afp_yuan
895}
896
897/// The model catalogue with its coefficient in force today. `gated` keeps only
898/// what `plan_id` grants; the table always says which tier a model needs, because
899/// the plan you hold changes what you can call.
900pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
901    let ink = Ink::new(colour);
902    let catalog = ark::catalog();
903    let now = crate::arkuse::window::now_ms();
904    let mut out = format!(
905        "{}{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}{}\n",
906        ink.bold, "MODEL", "KIND", "COEF", "YUAN/1M", "MIN", "GRANTED", ink.reset
907    );
908    for model in &catalog.models {
909        let granted = catalog.grants(plan_id, &model.min_plan);
910        if gated && !granted {
911            continue;
912        }
913        let coef = ark::effective_coef(model, now);
914        let promo = model.promo_coef.map(|_| {
915            format!(
916                " (promo until {})",
917                model.promo_until.as_deref().unwrap_or("?")
918            )
919        });
920        let coef_cell = ink.cyan_on(format!("{coef:.2}"));
921        out.push_str(&format!(
922            "{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}",
923            model.id,
924            model.kind,
925            coef_cell,
926            format!("{:.2}", yuan_per_million(coef, catalog.afp_yuan)),
927            model.min_plan,
928            if granted { "yes" } else { "no" },
929        ));
930        if let Some(promo) = promo {
931            out.push_str(&format!("{}{promo}{}", ink.dim, ink.reset));
932        }
933        out.push('\n');
934    }
935    out
936}
937
938/// One JSON object per line, mirroring `models_text` — the shape cmduse's own
939/// `models --json` uses, so a consumer pipes either binary the same way.
940pub fn models_json(plan_id: &str, gated: bool) -> String {
941    let catalog = ark::catalog();
942    let now = crate::arkuse::window::now_ms();
943    let mut lines: Vec<String> = Vec::new();
944    for model in &catalog.models {
945        let granted = catalog.grants(plan_id, &model.min_plan);
946        if gated && !granted {
947            continue;
948        }
949        let coef = ark::effective_coef(model, now);
950        lines.push(
951            serde_json::json!({
952                "id": model.id,
953                "name": model.name,
954                "kind": model.kind,
955                "coef": coef,
956                "baseCoef": model.coef,
957                "promoCoef": model.promo_coef,
958                "promoUntil": model.promo_until,
959                "yuanPerMillion": yuan_per_million(coef, catalog.afp_yuan),
960                "minPlan": model.min_plan,
961                "granted": granted,
962            })
963            .to_string(),
964        );
965    }
966    lines.join("\n")
967}
968
969/// One model's period record: what it cost in AFP, where that AFP sits in each
970/// window, and how much of the period it accounts for.
971pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
972    let ink = Ink::new(colour);
973    let Some(model) = report.models.iter().find(|m| m.id == id) else {
974        return format!("{id}: no usage in the period\n");
975    };
976    let row =
977        |label: &str, value: String| format!("  {}{value}\n", ink.dim_on(format!("{label:<10}")));
978    let mut out = format!(
979        "{} {}\n",
980        ink.bold_on(model.id.clone()),
981        ink.dim_on(format!("({})", model.provider)),
982    );
983    out.push_str(&row(
984        "coef",
985        ink.cyan_on(format!(
986            "{:.2} · {} per million tokens",
987            model.coef,
988            yuan(yuan_per_million(model.coef, ark::catalog().afp_yuan))
989        )),
990    ));
991    out.push_str(&row(
992        "month",
993        ink.cyan_on(format!(
994            "{} AFP ({}) · {} requests",
995            afp(model.month.afp),
996            yuan(ark::catalog().yuan(model.month.afp)),
997            model.month.requests
998        )),
999    ));
1000    out.push_str(&row(
1001        "5-hour",
1002        ink.cyan_on(format!("{} AFP", afp(model.five_hour.afp))),
1003    ));
1004    out.push_str(&row(
1005        "weekly",
1006        ink.cyan_on(format!("{} AFP", afp(model.weekly.afp))),
1007    ));
1008    if report.month.afp > 0.0 {
1009        out.push_str(&row(
1010            "share",
1011            ink.dim_on(format!(
1012                "{:.1}% of the period's AFP",
1013                model.month.afp / report.month.afp * 100.0
1014            )),
1015        ));
1016    }
1017    out
1018}
1019
1020/// Per-model report: `Json` mirrors cmduse's `model --json` envelope with AFP in
1021/// place of dollars; `Csv` is one row per model. `Table` is the caller's.
1022pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1023    let models: Vec<&crate::arkuse::window::ModelUsage> = report
1024        .models
1025        .iter()
1026        .filter(|m| id.is_none_or(|want| m.id == want))
1027        .collect();
1028    match fmt {
1029        Fmt::Json => {
1030            let map: serde_json::Map<String, serde_json::Value> = models
1031                .iter()
1032                .map(|m| {
1033                    (
1034                        m.id.clone(),
1035                        serde_json::json!({
1036                            "coef": m.coef,
1037                            "requests": m.month.requests,
1038                            "tokensIn": m.month.input,
1039                            "tokensOut": m.month.output,
1040                            "cacheRead": m.month.cache_read,
1041                            "afp": m.month.afp,
1042                            "yuan": ark::catalog().yuan(m.month.afp),
1043                        }),
1044                    )
1045                })
1046                .collect();
1047            serde_json::json!({
1048                "scope": "local",
1049                "source": "opencode",
1050                "sinceMs": report.since_ms,
1051                "models": map,
1052            })
1053            .to_string()
1054        }
1055        Fmt::Csv => {
1056            let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,afp\n");
1057            for m in models {
1058                out.push_str(&format!(
1059                    "{},{},{},{},{},{:.2}\n",
1060                    crate::report_render::csv_field(&m.id),
1061                    m.month.requests,
1062                    m.month.input,
1063                    m.month.output,
1064                    m.month.cache_read,
1065                    m.month.afp,
1066                ));
1067            }
1068            out
1069        }
1070        Fmt::Table => String::new(),
1071    }
1072}
1073
1074/// The period's per-model breakdown as a table — what `arkuse model` with no id
1075/// prints. Columns mirror the dashboard's model block so a reader can put the
1076/// two side by side.
1077pub fn models_period_text(report: &Report, limit: usize, colour: bool) -> String {
1078    let ink = Ink::new(colour);
1079    let mut out = format!(
1080        "{}{:<28} {:>5} {:>9} {:>7} {:>6}{}\n",
1081        ink.bold, "MODEL", "COEF", "AFP", "REQ", "SHARE", ink.reset
1082    );
1083    let total = report.month.afp;
1084    for model in report.models.iter().take(limit.min(report.models.len())) {
1085        let share = if total > 0.0 {
1086            ink.dim_on(format!("{:>5.1}%", model.month.afp / total * 100.0))
1087        } else {
1088            "     -".to_string()
1089        };
1090        out.push_str(&format!(
1091            "{:<28} {} {:>9} {:>7} {}\n",
1092            model.id,
1093            ink.cyan_on(format!("{:>5.2}", model.coef)),
1094            ink.cyan_on(format!("{:>9}", afp(model.month.afp))),
1095            ink.cyan_on(format!("{:>7}", compact(model.month.requests))),
1096            share,
1097        ));
1098    }
1099    if report.models.is_empty() {
1100        out.push_str(&format!(
1101            "{}\n",
1102            ink.dim_on("(no agent-plan usage in the period)".into())
1103        ));
1104    } else if total > 0.0 {
1105        out.push_str(&format!(
1106            "{:<28} {:>5} {:>9} {:>7} {:>6}\n",
1107            ink.bold_on("TOTAL".into()),
1108            "",
1109            afp(total),
1110            compact(report.month.requests),
1111            "100.0%",
1112        ));
1113    }
1114    out
1115}
1116
1117/// The store's numeric fields, in the shape every machine format in this repo
1118/// uses, with AFP standing where the other binaries put dollars.
1119fn totals_json(
1120    afp: f64,
1121    requests: u64,
1122    input: u64,
1123    output: u64,
1124    cache_read: u64,
1125) -> serde_json::Value {
1126    serde_json::json!({
1127        "requests": requests,
1128        "tokensIn": input,
1129        "tokensOut": output,
1130        "cacheRead": cache_read,
1131        "afp": afp,
1132    })
1133}
1134
1135/// One day/hour bucket. AFP is accumulated per row, because the coefficient is
1136/// a property of the model and of the day it ran (a promo can expire mid-window).
1137#[derive(Default, Clone, Copy)]
1138struct Bucket {
1139    afp: f64,
1140    requests: u64,
1141    input: u64,
1142    output: u64,
1143    cache_read: u64,
1144}
1145
1146impl Bucket {
1147    fn add(&mut self, catalog: &ark::Catalog, row: &crate::arkuse::db::Row) {
1148        self.requests += 1;
1149        self.input += row.input;
1150        self.output += row.output;
1151        self.cache_read += row.cache_read;
1152        self.afp += catalog
1153            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1154            .unwrap_or(0.0);
1155    }
1156
1157    fn merge(&mut self, other: &Bucket) {
1158        self.afp += other.afp;
1159        self.requests += other.requests;
1160        self.input += other.input;
1161        self.output += other.output;
1162        self.cache_read += other.cache_read;
1163    }
1164
1165    fn json(&self) -> serde_json::Value {
1166        totals_json(
1167            self.afp,
1168            self.requests,
1169            self.input,
1170            self.output,
1171            self.cache_read,
1172        )
1173    }
1174}
1175
1176/// AFP per bucket (day or hour), oldest first — `arkuse daily` / `arkuse hourly`.
1177#[allow(clippy::too_many_arguments)]
1178pub fn bucket_text(
1179    rows: &[crate::arkuse::db::Row],
1180    catalog: &ark::Catalog,
1181    now_ms: i64,
1182    lookback_ms: i64,
1183    size_ms: i64,
1184    tz_secs: i64,
1185    colour: bool,
1186    fmt: Fmt,
1187) -> String {
1188    // Buckets are cut in the display timezone: shift, bucket, then label.
1189    let shift = tz_secs * 1000;
1190    let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1191    for row in rows {
1192        let bucket = (row.at_ms + shift).div_euclid(size_ms);
1193        buckets.entry(bucket).or_default().add(catalog, row);
1194    }
1195    let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1196    let daily = size_ms >= crate::arkuse::window::DAY_SECS * 1000;
1197    let label = if daily { "day" } else { "hour" };
1198    let stamp = |bucket: i64| {
1199        if daily {
1200            date_of(bucket)
1201        } else {
1202            format!(
1203                "{} {:02}h",
1204                date_of(bucket.div_euclid(24)),
1205                bucket.rem_euclid(24)
1206            )
1207        }
1208    };
1209    let window: Vec<(i64, Bucket)> = buckets
1210        .range(start..)
1211        .map(|(bucket, b)| (*bucket, *b))
1212        .collect();
1213
1214    match fmt {
1215        Fmt::Json => {
1216            let mut keyed = serde_json::Map::new();
1217            let mut total = Bucket::default();
1218            for (bucket, b) in &window {
1219                keyed.insert(stamp(*bucket), b.json());
1220                total.merge(b);
1221            }
1222            let key = if daily { "days" } else { "hours" };
1223            serde_json::json!({
1224                "scope": "local",
1225                "source": "opencode",
1226                "total": total.json(),
1227                key: keyed,
1228            })
1229            .to_string()
1230        }
1231        Fmt::Csv => {
1232            let mut out = format!("{label},requests,tokens_in,tokens_out,cache_read,afp\n");
1233            let mut total = Bucket::default();
1234            for (bucket, b) in &window {
1235                out.push_str(&format!(
1236                    "{},{},{},{},{},{:.2}\n",
1237                    crate::report_render::csv_field(&stamp(*bucket)),
1238                    b.requests,
1239                    b.input,
1240                    b.output,
1241                    b.cache_read,
1242                    b.afp,
1243                ));
1244                total.merge(b);
1245            }
1246            out.push_str(&format!(
1247                "total,{},{},{},{},{:.2}\n",
1248                total.requests, total.input, total.output, total.cache_read, total.afp,
1249            ));
1250            out
1251        }
1252        Fmt::Table => {
1253            let ink = Ink::new(colour);
1254            let mut out = format!(
1255                "{}{label:<12} AFP      requests   tokens{}\n",
1256                ink.bold, ink.reset
1257            );
1258            for (bucket, b) in &window {
1259                out.push_str(&format!(
1260                    "{:<12} {} {} {}\n",
1261                    stamp(*bucket),
1262                    ink.cyan_on(format!("{:>9}", afp(b.afp))),
1263                    ink.cyan_on(format!("{:>12}", compact(b.requests))),
1264                    ink.cyan_on(format!("{:>9}", compact(b.input + b.output + b.cache_read))),
1265                ));
1266            }
1267            if out.lines().count() == 1 {
1268                out.push_str(&format!(
1269                    "{}\n",
1270                    ink.dim_on("(no agent-plan usage in the window)".into())
1271                ));
1272            }
1273            out
1274        }
1275    }
1276}
1277
1278/// One session's accumulating totals: `(afp, requests, tokens_in, tokens_out,
1279/// cache_read, last_ms, distinct models)`.
1280type SessionTotals<'a> = (
1281    f64,
1282    u64,
1283    u64,
1284    u64,
1285    u64,
1286    i64,
1287    std::collections::BTreeSet<&'a str>,
1288);
1289
1290/// AFP per session, newest first — the same columns as `ocuse session`, with AFP
1291/// in place of USD.
1292pub fn session_text(
1293    rows: &[crate::arkuse::db::Row],
1294    catalog: &ark::Catalog,
1295    limit: usize,
1296    colour: bool,
1297    fmt: Fmt,
1298) -> String {
1299    let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1300    for row in rows {
1301        let entry = by_session
1302            .entry(row.session.as_str())
1303            .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1304        entry.0 += catalog
1305            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1306            .unwrap_or(0.0);
1307        entry.1 += 1;
1308        entry.2 += row.input;
1309        entry.3 += row.output;
1310        entry.4 += row.cache_read;
1311        entry.5 = entry.5.max(row.at_ms);
1312        entry.6.insert(row.model.as_str());
1313    }
1314    let mut sessions: Vec<_> = by_session.into_iter().collect();
1315    sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1316    sessions.truncate(limit);
1317
1318    match fmt {
1319        Fmt::Json => {
1320            let mut keyed = serde_json::Map::new();
1321            for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1322                let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1323                body["lastMs"] = serde_json::json!(*last);
1324                body["models"] = serde_json::json!(models.len());
1325                keyed.insert((*id).to_string(), body);
1326            }
1327            serde_json::json!({
1328                "scope": "local",
1329                "source": "opencode",
1330                "sessions": keyed,
1331            })
1332            .to_string()
1333        }
1334        Fmt::Csv => {
1335            let mut out = String::from("session,requests,tokens_in,tokens_out,cache_read,afp\n");
1336            for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1337                out.push_str(&format!(
1338                    "{},{},{},{},{},{:.2}\n",
1339                    crate::report_render::csv_field(id),
1340                    requests,
1341                    input,
1342                    output,
1343                    cache_read,
1344                    cost,
1345                ));
1346            }
1347            out
1348        }
1349        Fmt::Table => {
1350            let ink = Ink::new(colour);
1351            let mut out = format!(
1352                "{}session                          last        models   AFP       req{}\n",
1353                ink.bold, ink.reset
1354            );
1355            for (id, (cost, requests, _, _, _, last, models)) in sessions {
1356                let short = id.rsplit('_').next().unwrap_or(id);
1357                out.push_str(&format!(
1358                    "{:<32} {:<11} {:>6} {} {:>6}\n",
1359                    short,
1360                    ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1361                    models.len(),
1362                    ink.cyan_on(format!("{:>9}", afp(cost))),
1363                    requests,
1364                ));
1365            }
1366            if out.lines().count() == 1 {
1367                out.push_str(&format!(
1368                    "{}\n",
1369                    ink.dim_on("(no sessions in the window)".into())
1370                ));
1371            }
1372            out
1373        }
1374    }
1375}
1376
1377#[cfg(test)]
1378mod tests {
1379    use super::*;
1380    use crate::arkuse::db::Row;
1381
1382    fn report() -> Report {
1383        let now = 1_800_000_000_000;
1384        let rows = vec![Row {
1385            at_ms: now - 60_000,
1386            session: "ses_a".into(),
1387            provider: "agent-plan".into(),
1388            model: "deepseek-v4.1-flash".into(),
1389            input: 10_000,
1390            output: 2_000,
1391            ..Default::default()
1392        }];
1393        crate::arkuse::window::build(&rows, &ark::catalog(), ark::DEFAULT_PLAN, now, None, None)
1394    }
1395
1396    #[test]
1397    fn text_names_the_model_and_its_afp() {
1398        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1399        assert!(text.contains("deepseek-v4.1-flash"), "{text}");
1400        assert!(text.contains("AFP"), "the plan's own unit: {text}");
1401        assert!(!text.contains('$'), "ARK never prices in dollars: {text}");
1402    }
1403
1404    #[test]
1405    fn the_tier_table_carries_every_personal_plan() {
1406        let text = plans_text(false);
1407        for plan in ark::catalog().plans {
1408            assert!(text.contains(&plan.label), "{} missing: {text}", plan.label);
1409        }
1410        let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1411        assert_eq!(
1412            json["plans"].as_array().unwrap().len(),
1413            ark::catalog().plans.len()
1414        );
1415    }
1416
1417    #[test]
1418    fn gated_models_are_filtered_by_the_tier() {
1419        let all = models_text("small", false, false);
1420        let gated = models_text("small", true, false);
1421        assert!(all.lines().count() >= gated.lines().count());
1422        assert!(all.contains("kimi-k3"), "{all}");
1423        assert!(!gated.contains("kimi-k3"), "{gated}");
1424    }
1425
1426    #[test]
1427    fn the_machine_formats_are_not_coloured() {
1428        let rows = vec![Row {
1429            at_ms: 1_800_000_000_000,
1430            session: "ses_a".into(),
1431            provider: "agent-plan".into(),
1432            model: "deepseek-v4.1-flash".into(),
1433            input: 1_000,
1434            ..Default::default()
1435        }];
1436        let catalog = ark::catalog();
1437        for fmt in [Fmt::Json, Fmt::Csv] {
1438            let daily = bucket_text(
1439                &rows,
1440                &catalog,
1441                1_800_000_000_000,
1442                86_400_000,
1443                86_400_000,
1444                0,
1445                true,
1446                fmt,
1447            );
1448            assert!(!daily.contains('\u{1b}'), "{fmt:?} leaked colour: {daily}");
1449        }
1450        let report = report();
1451        let json = model_report(&report, None, Fmt::Json);
1452        assert!(!json.contains('\u{1b}'));
1453        assert!(json.contains("deepseek-v4.1-flash"));
1454        let csv = model_report(&report, None, Fmt::Csv);
1455        assert!(csv.starts_with("model,requests,tokens_in,tokens_out,cache_read,afp"));
1456    }
1457}