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/// A locally cut window's boundary: the oldest row still inside the cut plus the
171/// window's length, matching the Go side's rolling reset. `None` when the window
172/// holds no rows, because `now + length` is not a reset the data supports.
173fn rolling(report: &Report, totals: &Totals, dur_secs: i64) -> Option<(i64, u64)> {
174    report
175        .rolling_reset(totals, dur_secs)
176        .map(|at| (at, dur_secs as u64))
177}
178
179#[allow(clippy::too_many_arguments)]
180/// One window row's inputs. The control plane's meters and the local estimate
181/// both go through this one builder, so the two branches cannot drift apart in
182/// which fields a row carries or in the order it carries them.
183///
184/// Unlike Go, ARK publishes the quota itself (`total`), so the row can print a
185/// true `used / quota` pair and a pace against it. The local branch has no
186/// quota — it shows AFP alone, never a denominator this store cannot know.
187struct RowSpec<'a> {
188    label: &'a str,
189    /// This store's own totals for the window.
190    used: &'a Totals,
191    /// The account's meter, when it answered.
192    meter: Option<&'a crate::arkuse::api::Meter>,
193    /// The window's reset instant and its length in seconds.
194    reset: Option<(i64, u64)>,
195    /// The plan's published quota for this window, used only when no meter
196    /// answered — ARK publishes the split, so the local estimate can print the
197    /// same `used / quota` pair the console does instead of AFP alone.
198    quota: Option<f64>,
199    /// Whether an even-burn figure belongs on this row: the monthly window alone
200    /// is a budget, where "what even burn would have spent by now" is a fair
201    /// comparison; the throttles above it would read as a spend target.
202    pro_rata: bool,
203}
204
205fn window_line(ink: &Ink, spec: &RowSpec<'_>, now_ms: i64, catalog: &ark::Catalog) -> String {
206    let now_secs = (now_ms / 1000).max(0) as u64;
207    let reset_at = spec.reset.map(|(at, _)| at as f64);
208    let dur_secs = spec.reset.map(|(_, dur)| dur);
209    let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
210    let reset = spec
211        .reset
212        .map(|(at, _)| duration(((at - now_ms) / 1000).max(0) as u64))
213        .unwrap_or_default();
214    let reset_text = if reset.is_empty() {
215        String::new()
216    } else if ink.colour {
217        format!(" · resets in {reset}")
218    } else {
219        format!(" · resets {reset}")
220    };
221    let thru = elapsed
222        .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
223        .unwrap_or_default();
224    // This store's AFP against the plan's published quota: the local branch's
225    // percentage. It under-reads the console (the meter counts every client and
226    // bills at its own coefficient), which is why the row says `est.`.
227    let local_pct = spec
228        .quota
229        .filter(|quota| *quota > 0.0)
230        .map(|quota| spec.used.afp / quota * 100.0);
231
232    // Server branch: the quota is the server's own, so the pair and the pace are
233    // exact. Local branch: the catalogue's quota, labelled as the estimate it is.
234    let (body, tone, pct) = match spec.meter {
235        Some(meter) => {
236            let pace = spec
237                .reset
238                .and_then(|(at, d)| {
239                    cmduse_core::pace_eta(Some(at as f64), d, meter.used, meter.quota, now_secs)
240                        .map(|secs| duration(secs as u64))
241                })
242                .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
243                .unwrap_or_default();
244            let by_now = match (spec.pro_rata, elapsed) {
245                (true, Some(p)) => format!(
246                    " · {}{} by now{}",
247                    ink.dim,
248                    afp(meter.quota * p / 100.0),
249                    ink.reset
250                ),
251                _ => String::new(),
252            };
253            let flag = if meter.exceeded() {
254                format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
255            } else if !meter.status.eq_ignore_ascii_case("ok") {
256                format!(" {}{}{}{}", ink.red, meter.status, ink.reset, "")
257            } else {
258                String::new()
259            };
260            let pct_tone = ink.for_pct(meter.percent);
261            let tail = format!("{reset_text}{flag}{pace}{thru}{by_now}");
262            let body = format!(
263                "{} / {} AFP · {}{}% of limit{} · {}{} local{}{tail}",
264                afp(meter.used),
265                afp(meter.quota),
266                pct_tone,
267                given_pct(meter.percent),
268                ink.reset,
269                ink.dim,
270                afp(spec.used.afp),
271                ink.reset,
272            );
273            (body, pct_tone, Some(meter.percent))
274        }
275        None => {
276            let quota = spec.quota.filter(|quota| *quota > 0.0);
277            let head = match (quota, local_pct) {
278                (Some(quota), Some(p)) => format!(
279                    "{} / {} AFP · {}{p:.1}% of limit (est.){}",
280                    afp(spec.used.afp),
281                    afp(quota),
282                    ink.for_pct(p),
283                    ink.reset,
284                ),
285                _ => format!("{} AFP", afp(spec.used.afp)),
286            };
287            let pace = match (spec.reset, quota, local_pct) {
288                (Some((at, d)), Some(quota), Some(_)) => {
289                    cmduse_core::pace_eta(Some(at as f64), d, spec.used.afp, quota, now_secs)
290                        .map(|secs| {
291                            format!(
292                                " · {}on pace to hit cap in {}{}",
293                                ink.yellow,
294                                duration(secs as u64),
295                                ink.reset
296                            )
297                        })
298                        .unwrap_or_default()
299                }
300                _ => String::new(),
301            };
302            let by_now = match (spec.pro_rata, quota, elapsed) {
303                (true, Some(quota), Some(p)) => {
304                    format!(
305                        " · {}{} by now{}",
306                        ink.dim,
307                        afp(quota * p / 100.0),
308                        ink.reset
309                    )
310                }
311                _ => String::new(),
312            };
313            let body = format!(
314                "{head} · {}{} · {} requests{}{pace}{reset_text}{thru}{by_now}",
315                ink.dim,
316                yuan(catalog.yuan(spec.used.afp)),
317                compact(spec.used.requests),
318                ink.reset,
319            );
320            (body, ink.for_pct(local_pct.unwrap_or(0.0)), local_pct)
321        }
322    };
323    if ink.colour {
324        let bar = pct
325            .map(|p| format!("{} ", ink.bar(p, 100.0)))
326            .unwrap_or_default();
327        format!(
328            " {}{:<8}{} {}{}{body}{}\n",
329            ink.bold, spec.label, ink.reset, bar, tone, ink.reset,
330        )
331    } else {
332        format!("  {:<8} {body}\n", spec.label)
333    }
334}
335
336/// One model's row: id, coefficient in force, AFP and requests this period, and
337/// its share of the period's AFP. No per-model limit exists on ARK — the quota is
338/// plan-level — so there is no USE column to invent.
339fn model_row(ink: &Ink, model: &crate::arkuse::window::ModelUsage, total_afp: f64) -> String {
340    let coef = format!("{:>5.2}", model.coef);
341    let coef_cell = if ink.colour {
342        format!("{}{coef}{}", ink.cyan, ink.reset)
343    } else {
344        coef
345    };
346    let share = if total_afp > 0.0 {
347        ink.dim_on(format!(" {:>5.1}%", model.month.afp / total_afp * 100.0))
348    } else {
349        String::new()
350    };
351    format!(
352        "{:<28} {coef_cell} {:>9} {:>7}{share}\n",
353        model.id,
354        afp(model.month.afp),
355        compact(model.month.requests),
356    )
357}
358
359/// `limit` rows to show; `usize::MAX` for all.
360pub fn render_text(
361    report: &Report,
362    db_path: &str,
363    rows: usize,
364    colour: bool,
365    bar_width: usize,
366) -> String {
367    let ink = Ink::new(colour).width(bar_width);
368    let catalog = ark::catalog();
369    let c = |text: String| ink.cyan_on(text);
370    let mut out = String::new();
371    let tier_label = catalog
372        .plan(&report.tier)
373        .map(|p| p.label.clone())
374        .unwrap_or_else(|| report.tier.clone());
375    out.push_str(&format!(
376        "{}Volcengine ARK usage{} — agent plan {}, from {}{}{}\n",
377        ink.bold,
378        ink.reset,
379        tier_label,
380        ink.dim,
381        shorten(db_path),
382        ink.reset,
383    ));
384    let end = report
385        .period_end_ms
386        .map(|ms| {
387            format!(
388                " · ends {}",
389                date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))
390            )
391        })
392        .unwrap_or_default();
393    let start = date_of((report.since_ms + report.offset_secs * 1000).div_euclid(86_400_000));
394    out.push_str(&format!(
395        "period: {start}{end} · {} requests · {} AFP ({}) · {} in / {} out tokens\n",
396        c(compact(report.month.requests)),
397        c(afp(report.month.afp)),
398        c(yuan(catalog.yuan(report.month.afp))),
399        c(compact(report.month.input)),
400        c(compact(report.month.output)),
401    ));
402    if let Some(plan) = catalog.plan(&report.tier) {
403        out.push_str(&format!(
404            "{}plan: {} {} · {} AFP/mo · {} · {} windows{}",
405            ink.dim,
406            plan.label,
407            yuan(plan.price),
408            afp(plan.afp),
409            catalog.currency,
410            if report.account.is_some() {
411                "metered by the control plane"
412            } else {
413                "local estimate"
414            },
415            ink.reset,
416        ));
417        out.push('\n');
418    }
419
420    match &report.account {
421        Some(account) => {
422            out.push_str(&format!(
423                "\n{}AGENT PLAN{} {}\n",
424                ink.bold,
425                ink.reset,
426                ink.dim_on(
427                    "(account meters — what the Volcengine console reports; AFP beside them is this store's own estimate)"
428                        .into()
429                ),
430            ));
431            let monthly_dur = report
432                .period_end_ms
433                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
434            let daily_local = report.daily.clone().unwrap_or_default();
435            let mut windows: Vec<(&str, &crate::arkuse::api::Meter, &Totals, Option<u64>, bool)> =
436                vec![(
437                    "5-hour",
438                    &account.five_hour,
439                    &report.five_hour,
440                    Some(crate::arkuse::window::FIVE_HOUR_SECS as u64),
441                    false,
442                )];
443            if let Some(daily) = &account.daily {
444                windows.push((
445                    "Daily",
446                    daily,
447                    &daily_local,
448                    Some(crate::arkuse::window::DAY_SECS as u64),
449                    false,
450                ));
451            }
452            windows.push((
453                "Weekly",
454                &account.weekly,
455                &report.weekly,
456                Some(crate::arkuse::window::WEEKLY_SECS as u64),
457                false,
458            ));
459            windows.push((
460                "Monthly",
461                &account.monthly,
462                &report.month,
463                monthly_dur,
464                true,
465            ));
466            for (label, meter, used, dur, pro_rata) in windows {
467                // A just-reset block answers without `reset_at` (measured
468                // 2026-10-20: `{"label":"5h","total":10000,"percent":0}`), so
469                // fall back to the store's own rolling boundary rather than
470                // drop the countdown the row is asked for.
471                let reset = meter_reset(meter, dur)
472                    .or_else(|| dur.and_then(|dur| rolling(report, used, dur as i64)));
473                out.push_str(&window_line(
474                    &ink,
475                    &RowSpec {
476                        label,
477                        used,
478                        meter: Some(meter),
479                        reset,
480                        quota: None,
481                        pro_rata,
482                    },
483                    report.now_ms,
484                    &catalog,
485                ));
486            }
487            // Reconciliation is the point of holding both sources: the meter is
488            // account-wide, the store is this machine, so a gap is either another
489            // client or an unmodelled item — worth printing, never hidden.
490            let mut notes: Vec<String> = Vec::new();
491            if let Some(delta) = report.reconcile() {
492                notes.push(format!("local vs meter {:+.1}%", delta));
493            }
494            if let Some(per) = report.afp_per_request() {
495                notes.push(format!("{} AFP/request", afp(per)));
496            }
497            if let Some(burn) = report.burn_per_day() {
498                notes.push(format!("{} AFP/day at this pace", afp(burn)));
499            }
500            if !notes.is_empty() {
501                out.push_str(&format!(
502                    "{}    {} · {}{}\n",
503                    ink.dim,
504                    account.profile,
505                    notes.join(" · "),
506                    ink.reset,
507                ));
508            }
509        }
510        None => {
511            out.push_str(&format!(
512                "\n{}AGENT PLAN{} {}\n",
513                ink.bold,
514                ink.reset,
515                ink.dim_on("(estimated locally: no control-plane answer, so AFP alone)".into()),
516            ));
517            if let Some(why) = &report.account_error {
518                out.push_str(&format!("{}   usage API: {why}{}\n", ink.dim, ink.reset));
519            }
520            let monthly_dur = report
521                .period_end_ms
522                .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
523            // No meter: the catalogue's published split stands in for the
524            // quota, and the reset comes from the oldest row still inside the
525            // rolling cut — never a fabricated `now + length`.
526            let five_reset = rolling(
527                report,
528                &report.five_hour,
529                crate::arkuse::window::FIVE_HOUR_SECS,
530            );
531            let week_reset = rolling(report, &report.weekly, crate::arkuse::window::WEEKLY_SECS);
532            out.push_str(&window_line(
533                &ink,
534                &RowSpec {
535                    label: "5-hour",
536                    used: &report.five_hour,
537                    meter: None,
538                    reset: five_reset,
539                    quota: report.window_quota(&catalog, catalog.windows.five_hour),
540                    pro_rata: false,
541                },
542                report.now_ms,
543                &catalog,
544            ));
545            out.push_str(&window_line(
546                &ink,
547                &RowSpec {
548                    label: "Weekly",
549                    used: &report.weekly,
550                    meter: None,
551                    reset: week_reset,
552                    quota: report.window_quota(&catalog, catalog.windows.weekly),
553                    pro_rata: false,
554                },
555                report.now_ms,
556                &catalog,
557            ));
558            out.push_str(&window_line(
559                &ink,
560                &RowSpec {
561                    label: "Monthly",
562                    used: &report.month,
563                    meter: None,
564                    reset: report.period_end_ms.zip(monthly_dur),
565                    quota: report.window_quota(&catalog, catalog.windows.monthly),
566                    pro_rata: true,
567                },
568                report.now_ms,
569                &catalog,
570            ));
571        }
572    }
573
574    out.push_str(&format!(
575        "\n{}MODEL                            COEF       AFP     REQ  SHARE{}\n",
576        ink.bold, ink.reset
577    ));
578    let total_afp = report.month.afp;
579    for model in report.models.iter().take(rows) {
580        out.push_str(&model_row(&ink, model, total_afp));
581    }
582    if report.models.len() > rows {
583        out.push_str(&format!(
584            "{}\n",
585            ink.dim_on(format!("… {} more", report.models.len() - rows))
586        ));
587    }
588    // The subtlety that makes local AFP disagree with the meter: composition.
589    out.push_str(&format!(
590        "\n{}\n",
591        ink.dim_on(
592            "AFP = (input + cache_read + output) × coefficient / 10,000; the coefficient is the plan's own and a promo halves it until its date."
593                .into()
594        ),
595    ));
596    if report.account.is_some() {
597        out.push_str(&format!(
598            "{}\n",
599            ink.dim_on(
600                "The windows are the account's own cuts (5h block, Monday week, subscription month); the model AFP is this machine inside them."
601                    .into()
602            )
603        ));
604    } else {
605        out.push_str(&format!(
606            "{}\n",
607            ink.dim_on(
608                "Windows are rolling 5h / 7d / calendar month cuts, local only — not the control plane's anchors."
609                    .into()
610            )
611        ));
612    }
613    out
614}
615
616/// One watch frame: the dashboard, then an AFP-burst sparkline when the 5-hour
617/// figure has moved since the previous refresh. No trailing newline — the caller
618/// appends its own status line and `redraw_frame` (cmduse's) parks the cursor
619/// there, which is what lets the countdown rewrite a single line per second.
620pub fn watch_frame(
621    report: &Report,
622    db_path: &str,
623    rows: usize,
624    colour: bool,
625    history: &[f64],
626    interval_secs: u64,
627    bar_width: usize,
628) -> String {
629    let ink = Ink::new(colour);
630    let mut out = render_text(report, db_path, rows, colour, bar_width);
631    if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
632        out.push_str(&format!(
633            "{}AFP bursts ({}s samples){} {}\n",
634            ink.dim,
635            interval_secs,
636            ink.reset,
637            crate::render::sparkline(history),
638        ));
639    }
640    out.trim_end_matches('\n').to_string()
641}
642
643/// Minimal one-line dashboard for terminals too small for the full frame.
644pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
645    let ink = Ink::new(colour);
646    let catalog = ark::catalog();
647    let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
648        format!(
649            " {}{}{}",
650            ink.dim,
651            crate::render::sparkline(history),
652            ink.reset
653        )
654    } else {
655        String::new()
656    };
657    let window_share = match &report.account {
658        Some(account) => format!(
659            " · 5h {} · wk {} · mo {}",
660            ink.cyan_on(format!("{}%", given_pct(account.five_hour.percent))),
661            ink.cyan_on(format!("{}%", given_pct(account.weekly.percent))),
662            ink.cyan_on(format!("{}%", given_pct(account.monthly.percent))),
663        ),
664        None => format!(" · 5h {}", ink.cyan_on(afp(report.five_hour.afp))),
665    };
666    format!(
667        "{}ARK{} {} AFP ({}) month{window_share}{spark}",
668        ink.bold,
669        ink.reset,
670        ink.cyan_on(afp(report.month.afp)),
671        ink.cyan_on(yuan(catalog.yuan(report.month.afp))),
672    )
673}
674
675/// arkuse's statusline: cmduse's `{key}` template engine
676/// (`report_render::render_template`) resolved against this report's own
677/// placeholders.
678///
679/// Placeholders (case-insensitive):
680///   {plan} {tier} {period}                 tier id / period label
681///   {month} {month_pct} {month_bar}
682///   {five_hour} {five_hour_pct} {five_hour_bar}   (aliases {5h} {5h_pct} {5h_bar})
683///   {weekly} {weekly_pct} {weekly_bar}            (aliases {wk} {wk_pct} {wk_bar})
684///   {yuan} {yuan_month}                    this store's AFP in yuan
685///   {models}                               top model by period AFP
686pub fn statusline_template(
687    report: &Report,
688    plan_id: &str,
689    tpl: &str,
690    colours: bool,
691    ascii: bool,
692    bar_width: usize,
693) -> String {
694    let ink = Ink::new(colours);
695    let catalog = ark::catalog();
696    let account = report.account.as_ref();
697    let quota_of = |share: f64| report.window_quota(&catalog, share);
698    // The console's own percentage when it answered, else this store's AFP
699    // against the plan's published split — never a dash for a window we can
700    // measure ourselves. The plan's quota is public, so this is not a guess.
701    let pct_of = |meter: Option<&crate::arkuse::api::Meter>, used: f64, share: f64| match meter {
702        Some(meter) => Some(meter.percent),
703        None => quota_of(share)
704            .filter(|quota| *quota > 0.0)
705            .map(|quota| used / quota * 100.0),
706    };
707    let share_of = |pct: Option<f64>| match pct {
708        Some(pct) => format!("{}%", given_pct(pct)),
709        None => "—".into(),
710    };
711    let share_bar = |pct: Option<f64>| match pct {
712        Some(pct) => crate::report_render::bar(pct, bar_width, ascii, colours),
713        None => String::new(),
714    };
715    // A window's boundary: the server's reset, else the oldest row still inside
716    // the rolling cut plus the window's length.
717    let reset_of =
718        |meter: Option<&crate::arkuse::api::Meter>, totals: &Totals, dur: i64| match meter
719            .and_then(|m| m.reset_ms())
720        {
721            Some(at) => Some((at, dur as u64)),
722            None => report.rolling_reset(totals, dur).map(|at| (at, dur as u64)),
723        };
724    let eta_of = |reset: Option<(i64, u64)>, used: f64, quota: Option<f64>| -> Option<String> {
725        let (at, dur) = reset?;
726        let quota = quota?;
727        let secs = cmduse_core::pace_eta(
728            Some(at as f64),
729            dur,
730            used,
731            quota,
732            (report.now_ms / 1000).max(0) as u64,
733        )?;
734        Some(format!("on pace to hit cap in {}", duration(secs as u64)))
735    };
736    let h5_pct = pct_of(
737        account.map(|a| &a.five_hour),
738        report.five_hour.afp,
739        catalog.windows.five_hour,
740    );
741    let wk_pct = pct_of(
742        account.map(|a| &a.weekly),
743        report.weekly.afp,
744        catalog.windows.weekly,
745    );
746    let month_pct = pct_of(
747        account.map(|a| &a.monthly),
748        report.month.afp,
749        catalog.windows.monthly,
750    );
751    let h5_eta = eta_of(
752        reset_of(
753            account.map(|a| &a.five_hour),
754            &report.five_hour,
755            crate::arkuse::window::FIVE_HOUR_SECS,
756        ),
757        report.five_hour.afp,
758        quota_of(catalog.windows.five_hour),
759    )
760    .unwrap_or_default();
761    let wk_eta = eta_of(
762        reset_of(
763            account.map(|a| &a.weekly),
764            &report.weekly,
765            crate::arkuse::window::WEEKLY_SECS,
766        ),
767        report.weekly.afp,
768        quota_of(catalog.windows.weekly),
769    )
770    .unwrap_or_default();
771    let cash = |value: f64| ink.cyan_on(afp(value));
772    let top = report
773        .models
774        .first()
775        .map(|m| m.id.clone())
776        .unwrap_or_else(|| "—".into());
777    let replacements: Vec<(&str, String)> = vec![
778        ("plan", plan_id.to_string()),
779        ("tier", plan_id.to_string()),
780        ("period", report.period_label.clone()),
781        ("month", cash(report.month.afp)),
782        ("month_pct", share_of(month_pct)),
783        ("month_bar", share_bar(month_pct)),
784        ("five_hour", cash(report.five_hour.afp)),
785        ("5h", cash(report.five_hour.afp)),
786        ("five_hour_pct", share_of(h5_pct)),
787        ("5h_pct", share_of(h5_pct)),
788        ("five_hour_bar", share_bar(h5_pct)),
789        ("5h_bar", share_bar(h5_pct)),
790        ("5h_eta", h5_eta),
791        ("weekly", cash(report.weekly.afp)),
792        ("wk", cash(report.weekly.afp)),
793        ("weekly_pct", share_of(wk_pct)),
794        ("wk_pct", share_of(wk_pct)),
795        ("weekly_bar", share_bar(wk_pct)),
796        ("wk_bar", share_bar(wk_pct)),
797        ("wk_eta", wk_eta),
798        ("yuan", yuan(catalog.yuan(report.month.afp))),
799        ("yuan_month", yuan(catalog.yuan(report.month.afp))),
800        ("models", top),
801    ];
802    crate::report_render::render_template(tpl, |key| {
803        let lower = key.to_ascii_lowercase();
804        replacements
805            .iter()
806            .find(|(name, _)| *name == lower)
807            .map(|(_, value)| value.clone())
808            .unwrap_or_default()
809    })
810}
811
812pub fn statusline_json(report: &Report, plan_id: &str) -> String {
813    let catalog = ark::catalog();
814    let quota_of = |share: f64| report.window_quota(&catalog, share);
815    serde_json::json!({
816        "plan": plan_id,
817        "period": report.period_label,
818        "source": "opencode",
819        // The shared names a statusline consumer already reads; here they carry
820        // AFP, because AFP is this plan's currency. The caps are the plan's
821        // published split of its monthly AFP, so a consumer never has to hold a
822        // table of its own.
823        "monthSpent": report.month.afp,
824        "monthCap": quota_of(catalog.windows.monthly),
825        "fiveHourSpent": report.five_hour.afp,
826        "fiveHourCap": quota_of(catalog.windows.five_hour),
827        "weeklySpent": report.weekly.afp,
828        "weeklyCap": quota_of(catalog.windows.weekly),
829        "model": report.models.first().map(|m| m.id.clone()),
830        "afp": {
831            "fiveHour": report.five_hour.afp,
832            "weekly": report.weekly.afp,
833            "monthly": report.month.afp,
834            "yuan": catalog.yuan(report.month.afp),
835        },
836        "account": report.account.as_ref().map(usage_json),
837        "accountError": report.account_error,
838        "periodEnd": report.period_end_ms,
839    })
840    .to_string()
841}
842
843/// One meter as JSON, with the server's own figures preserved — the percentage
844/// is the console's, not a recomputation.
845/// One window as this store measures it: AFP, the plan's published quota, the
846/// percentage against it, and the boundary its row counts down to.
847fn window_json(totals: &Totals, quota: Option<f64>, reset_ms: Option<i64>) -> serde_json::Value {
848    serde_json::json!({
849        "afp": totals.afp,
850        "requests": totals.requests,
851        "quota": quota,
852        "percent": quota
853            .filter(|quota| *quota > 0.0)
854            .map(|quota| totals.afp / quota * 100.0),
855        "resetAt": reset_ms,
856    })
857}
858
859fn meter_json(meter: &crate::arkuse::api::Meter) -> serde_json::Value {
860    serde_json::json!({
861        "used": meter.used,
862        "total": meter.quota,
863        "percent": meter.percent,
864        "status": meter.status,
865        "resetsAt": meter.resets_at,
866        "resetAt": meter.reset_ms(),
867    })
868}
869
870/// Every window the control plane answered, keeping the plan's own shape — a
871/// text-only workload has no `daily` period, so that key is simply absent.
872fn usage_json(usage: &crate::arkuse::api::Usage) -> serde_json::Value {
873    serde_json::json!({
874        "profile": usage.profile,
875        "tier": usage.tier,
876        "fiveHour": meter_json(&usage.five_hour),
877        "daily": usage.daily.as_ref().map(meter_json),
878        "weekly": meter_json(&usage.weekly),
879        "monthly": meter_json(&usage.monthly),
880    })
881}
882
883pub fn render_json(report: &Report) -> String {
884    let catalog = ark::catalog();
885    let totals = |t: &Totals| {
886        serde_json::json!({
887            "afp": t.afp,
888            "requests": t.requests,
889            "tokensIn": t.input,
890            "tokensOut": t.output,
891            "cacheRead": t.cache_read,
892            "cacheWrite": t.cache_write,
893        })
894    };
895    let models: serde_json::Map<String, serde_json::Value> = report
896        .models
897        .iter()
898        .map(|m| {
899            (
900                m.id.clone(),
901                serde_json::json!({
902                    "coef": m.coef,
903                    "month": totals(&m.month),
904                    "fiveHour": totals(&m.five_hour),
905                    "weekly": totals(&m.weekly),
906                }),
907            )
908        })
909        .collect();
910    serde_json::json!({
911        "source": "opencode",
912        "scope": "local",
913        "plan": report.tier,
914        "profile": report.profile,
915        "periodStart": report.since_ms,
916        "periodEndAt": report.period_end_ms,
917        "periodEnd": report.period_end_ms
918            .map(|ms| date_of((ms + report.offset_secs * 1000).div_euclid(86_400_000))),
919        "monthInferred": report.month_inferred,
920        "summary": {
921            "afp": report.month.afp,
922            "yuan": ark::catalog().yuan(report.month.afp),
923            "requests": report.month.requests,
924            "tokensIn": report.month.input,
925            "tokensOut": report.month.output,
926            "cacheRead": report.month.cache_read,
927        },
928        "fiveHour": totals(&report.five_hour),
929        "weekly": totals(&report.weekly),
930        "monthly": totals(&report.month),
931        "daily": report.daily.as_ref().map(totals),
932        // This store's view of each window against the plan's published quota —
933        // the same pair the dashboard prints, so a consumer without the account
934        // answer still gets a percentage and a boundary.
935        "windows": {
936            "fiveHour": window_json(
937                &report.five_hour,
938                report.window_quota(&catalog, catalog.windows.five_hour),
939                report.rolling_reset(&report.five_hour, crate::arkuse::window::FIVE_HOUR_SECS),
940            ),
941            "weekly": window_json(
942                &report.weekly,
943                report.window_quota(&catalog, catalog.windows.weekly),
944                report.rolling_reset(&report.weekly, crate::arkuse::window::WEEKLY_SECS),
945            ),
946            "monthly": window_json(
947                &report.month,
948                report.window_quota(&catalog, catalog.windows.monthly),
949                report.period_end_ms,
950            ),
951        },
952        "account": report.account.as_ref().map(|account| {
953            let mut body = serde_json::json!({
954                "profile": account.profile,
955                "tier": account.tier,
956                "fiveHour": meter_json(&account.five_hour),
957                "weekly": meter_json(&account.weekly),
958                "monthly": meter_json(&account.monthly),
959            });
960            if let Some(daily) = &account.daily {
961                body["daily"] = meter_json(daily);
962            }
963            body
964        }),
965        "accountError": report.account_error,
966        "reconciledPct": report.reconcile(),
967        "afpPerRequest": report.afp_per_request(),
968        "models": models,
969    })
970    .to_string()
971}
972
973/// The tier catalogue: what each plan costs, what quota it grants, and how that
974/// quota is split across the windows. The window figures are the plan's own
975/// fractions of its monthly AFP — the same table the console shows per tier.
976pub fn plans_text(colour: bool) -> String {
977    let ink = Ink::new(colour);
978    let catalog = ark::catalog();
979    let mut out = format!(
980        "{}{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}{}\n",
981        ink.bold, "TIER", "YUAN/MO", "AFP/MO", "5H AFP", "DAILY AFP", "WK AFP", ink.reset
982    );
983    for plan in &catalog.plans {
984        out.push_str(&format!(
985            "{:<8} {:>9} {:>9} {:>10} {:>10} {:>10}\n",
986            plan.label,
987            ink.cyan_on(yuan(plan.price)),
988            ink.cyan_on(afp(plan.afp)),
989            afp(plan.afp * catalog.windows.five_hour),
990            afp(plan.afp * catalog.windows.daily),
991            afp(plan.afp * catalog.windows.weekly),
992        ));
993        if let Some(promo) = &plan.promo {
994            out.push_str(&format!(
995                "{}{} for {} months, until {}{}\n",
996                ink.dim,
997                yuan(promo.price),
998                promo.months.unwrap_or(1),
999                promo.until,
1000                ink.reset,
1001            ));
1002        }
1003    }
1004    out.push_str(&format!(
1005        "\n{}1 AFP = ¥{:.3} at every tier · 1,000 AFP = {}{}\n",
1006        ink.dim,
1007        catalog.afp_yuan,
1008        yuan(catalog.yuan(1000.0)),
1009        ink.reset,
1010    ));
1011    if !catalog.enterprise.is_empty() {
1012        out.push_str(&format!(
1013            "\n{}ENTERPRISE{} {}\n",
1014            ink.bold,
1015            ink.reset,
1016            ink.dim_on(
1017                "(seat-based; AFP is metered per seat and the personal usage API does not expose it)"
1018                    .into()
1019            ),
1020        ));
1021        for plan in &catalog.enterprise {
1022            out.push_str(&format!(
1023                "  {:<22} {}\n",
1024                plan.label,
1025                ink.cyan_on(format!("{}/seat/mo", yuan(plan.price))),
1026            ));
1027        }
1028    }
1029    if let Some(note) = catalog.notes.get("windows") {
1030        out.push_str(&format!("\n{}{}{}\n", ink.dim, note, ink.reset));
1031    }
1032    out
1033}
1034
1035pub fn plans_json() -> String {
1036    let catalog = ark::catalog();
1037    serde_json::json!({
1038        "source": catalog.source,
1039        "extractedAt": catalog.extracted_at,
1040        "currency": catalog.currency,
1041        "afpYuan": catalog.afp_yuan,
1042        "windows": {
1043            "fiveHour": catalog.windows.five_hour,
1044            "daily": catalog.windows.daily,
1045            "weekly": catalog.windows.weekly,
1046            "monthly": catalog.windows.monthly,
1047        },
1048        "plans": catalog.plans.iter().map(|p| serde_json::json!({
1049            "id": p.id,
1050            "label": p.label,
1051            "price": p.price,
1052            "listPrice": p.list_price,
1053            "afp": p.afp,
1054            "promo": p.promo.as_ref().map(|promo| serde_json::json!({
1055                "price": promo.price,
1056                "until": promo.until,
1057                "months": promo.months,
1058            })),
1059        })).collect::<Vec<_>>(),
1060        "enterprise": catalog.enterprise.iter().map(|p| serde_json::json!({
1061            "id": p.id,
1062            "label": p.label,
1063            "price": p.price,
1064        })).collect::<Vec<_>>(),
1065        "modelCount": catalog.models.len(),
1066    })
1067    .to_string()
1068}
1069
1070/// `y` yuan per million tokens at a coefficient — the same quantity the docs
1071/// publish per model, so a reader can check a coefficient without arithmetic:
1072/// 1M tokens × coef / 10,000 = 100·coef AFP, and 1 AFP = ¥0.002.
1073fn yuan_per_million(coef: f64, afp_yuan: f64) -> f64 {
1074    1_000_000.0 * coef / 10_000.0 * afp_yuan
1075}
1076
1077/// The model catalogue with its coefficient in force today. `gated` keeps only
1078/// what `plan_id` grants; the table always says which tier a model needs, because
1079/// the plan you hold changes what you can call.
1080pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
1081    let ink = Ink::new(colour);
1082    let catalog = ark::catalog();
1083    let now = crate::arkuse::window::now_ms();
1084    let mut out = format!(
1085        "{}{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}{}\n",
1086        ink.bold, "MODEL", "KIND", "COEF", "YUAN/1M", "MIN", "GRANTED", ink.reset
1087    );
1088    for model in &catalog.models {
1089        let granted = catalog.grants(plan_id, &model.min_plan);
1090        if gated && !granted {
1091            continue;
1092        }
1093        let coef = ark::effective_coef(model, now);
1094        let promo = model.promo_coef.map(|_| {
1095            format!(
1096                " (promo until {})",
1097                model.promo_until.as_deref().unwrap_or("?")
1098            )
1099        });
1100        let coef_cell = ink.cyan_on(format!("{coef:.2}"));
1101        out.push_str(&format!(
1102            "{:<30} {:>6} {:>7} {:>10} {:>8} {:>8}",
1103            model.id,
1104            model.kind,
1105            coef_cell,
1106            format!("{:.2}", yuan_per_million(coef, catalog.afp_yuan)),
1107            model.min_plan,
1108            if granted { "yes" } else { "no" },
1109        ));
1110        if let Some(promo) = promo {
1111            out.push_str(&format!("{}{promo}{}", ink.dim, ink.reset));
1112        }
1113        out.push('\n');
1114    }
1115    out
1116}
1117
1118/// One JSON object per line, mirroring `models_text` — the shape cmduse's own
1119/// `models --json` uses, so a consumer pipes either binary the same way.
1120pub fn models_json(plan_id: &str, gated: bool) -> String {
1121    let catalog = ark::catalog();
1122    let now = crate::arkuse::window::now_ms();
1123    let mut lines: Vec<String> = Vec::new();
1124    for model in &catalog.models {
1125        let granted = catalog.grants(plan_id, &model.min_plan);
1126        if gated && !granted {
1127            continue;
1128        }
1129        let coef = ark::effective_coef(model, now);
1130        lines.push(
1131            serde_json::json!({
1132                "id": model.id,
1133                "name": model.name,
1134                "kind": model.kind,
1135                "coef": coef,
1136                "baseCoef": model.coef,
1137                "promoCoef": model.promo_coef,
1138                "promoUntil": model.promo_until,
1139                "yuanPerMillion": yuan_per_million(coef, catalog.afp_yuan),
1140                "minPlan": model.min_plan,
1141                "granted": granted,
1142            })
1143            .to_string(),
1144        );
1145    }
1146    lines.join("\n")
1147}
1148
1149/// One model's period record: what it cost in AFP, where that AFP sits in each
1150/// window, and how much of the period it accounts for.
1151pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1152    let ink = Ink::new(colour);
1153    let Some(model) = report.models.iter().find(|m| m.id == id) else {
1154        return format!("{id}: no usage in the period\n");
1155    };
1156    let row =
1157        |label: &str, value: String| format!("  {}{value}\n", ink.dim_on(format!("{label:<10}")));
1158    let mut out = format!(
1159        "{} {}\n",
1160        ink.bold_on(model.id.clone()),
1161        ink.dim_on(format!("({})", model.provider)),
1162    );
1163    out.push_str(&row(
1164        "coef",
1165        ink.cyan_on(format!(
1166            "{:.2} · {} per million tokens",
1167            model.coef,
1168            yuan(yuan_per_million(model.coef, ark::catalog().afp_yuan))
1169        )),
1170    ));
1171    out.push_str(&row(
1172        "month",
1173        ink.cyan_on(format!(
1174            "{} AFP ({}) · {} requests",
1175            afp(model.month.afp),
1176            yuan(ark::catalog().yuan(model.month.afp)),
1177            model.month.requests
1178        )),
1179    ));
1180    out.push_str(&row(
1181        "5-hour",
1182        ink.cyan_on(format!("{} AFP", afp(model.five_hour.afp))),
1183    ));
1184    out.push_str(&row(
1185        "weekly",
1186        ink.cyan_on(format!("{} AFP", afp(model.weekly.afp))),
1187    ));
1188    if report.month.afp > 0.0 {
1189        out.push_str(&row(
1190            "share",
1191            ink.dim_on(format!(
1192                "{:.1}% of the period's AFP",
1193                model.month.afp / report.month.afp * 100.0
1194            )),
1195        ));
1196    }
1197    out
1198}
1199
1200/// Per-model report: `Json` mirrors cmduse's `model --json` envelope with AFP in
1201/// place of dollars; `Csv` is one row per model. `Table` is the caller's.
1202pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1203    let models: Vec<&crate::arkuse::window::ModelUsage> = report
1204        .models
1205        .iter()
1206        .filter(|m| id.is_none_or(|want| m.id == want))
1207        .collect();
1208    match fmt {
1209        Fmt::Json => {
1210            let map: serde_json::Map<String, serde_json::Value> = models
1211                .iter()
1212                .map(|m| {
1213                    (
1214                        m.id.clone(),
1215                        serde_json::json!({
1216                            "coef": m.coef,
1217                            "requests": m.month.requests,
1218                            "tokensIn": m.month.input,
1219                            "tokensOut": m.month.output,
1220                            "cacheRead": m.month.cache_read,
1221                            "afp": m.month.afp,
1222                            "yuan": ark::catalog().yuan(m.month.afp),
1223                        }),
1224                    )
1225                })
1226                .collect();
1227            serde_json::json!({
1228                "scope": "local",
1229                "source": "opencode",
1230                "sinceMs": report.since_ms,
1231                "models": map,
1232            })
1233            .to_string()
1234        }
1235        Fmt::Csv => {
1236            let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,afp\n");
1237            for m in models {
1238                out.push_str(&format!(
1239                    "{},{},{},{},{},{:.2}\n",
1240                    crate::report_render::csv_field(&m.id),
1241                    m.month.requests,
1242                    m.month.input,
1243                    m.month.output,
1244                    m.month.cache_read,
1245                    m.month.afp,
1246                ));
1247            }
1248            out
1249        }
1250        Fmt::Table => String::new(),
1251    }
1252}
1253
1254/// The period's per-model breakdown as a table — what `arkuse model` with no id
1255/// prints. Columns mirror the dashboard's model block so a reader can put the
1256/// two side by side.
1257pub fn models_period_text(report: &Report, limit: usize, colour: bool) -> String {
1258    let ink = Ink::new(colour);
1259    let mut out = format!(
1260        "{}{:<28} {:>5} {:>9} {:>7} {:>6}{}\n",
1261        ink.bold, "MODEL", "COEF", "AFP", "REQ", "SHARE", ink.reset
1262    );
1263    let total = report.month.afp;
1264    for model in report.models.iter().take(limit.min(report.models.len())) {
1265        let share = if total > 0.0 {
1266            ink.dim_on(format!("{:>5.1}%", model.month.afp / total * 100.0))
1267        } else {
1268            "     -".to_string()
1269        };
1270        out.push_str(&format!(
1271            "{:<28} {} {:>9} {:>7} {}\n",
1272            model.id,
1273            ink.cyan_on(format!("{:>5.2}", model.coef)),
1274            ink.cyan_on(format!("{:>9}", afp(model.month.afp))),
1275            ink.cyan_on(format!("{:>7}", compact(model.month.requests))),
1276            share,
1277        ));
1278    }
1279    if report.models.is_empty() {
1280        out.push_str(&format!(
1281            "{}\n",
1282            ink.dim_on("(no agent-plan usage in the period)".into())
1283        ));
1284    } else if total > 0.0 {
1285        out.push_str(&format!(
1286            "{:<28} {:>5} {:>9} {:>7} {:>6}\n",
1287            ink.bold_on("TOTAL".into()),
1288            "",
1289            afp(total),
1290            compact(report.month.requests),
1291            "100.0%",
1292        ));
1293    }
1294    out
1295}
1296
1297/// The store's numeric fields, in the shape every machine format in this repo
1298/// uses, with AFP standing where the other binaries put dollars.
1299fn totals_json(
1300    afp: f64,
1301    requests: u64,
1302    input: u64,
1303    output: u64,
1304    cache_read: u64,
1305) -> serde_json::Value {
1306    serde_json::json!({
1307        "requests": requests,
1308        "tokensIn": input,
1309        "tokensOut": output,
1310        "cacheRead": cache_read,
1311        "afp": afp,
1312    })
1313}
1314
1315/// One day/hour bucket. AFP is accumulated per row, because the coefficient is
1316/// a property of the model and of the day it ran (a promo can expire mid-window).
1317#[derive(Default, Clone, Copy)]
1318struct Bucket {
1319    afp: f64,
1320    requests: u64,
1321    input: u64,
1322    output: u64,
1323    cache_read: u64,
1324}
1325
1326impl Bucket {
1327    fn add(&mut self, catalog: &ark::Catalog, row: &crate::arkuse::db::Row) {
1328        self.requests += 1;
1329        self.input += row.input;
1330        self.output += row.output;
1331        self.cache_read += row.cache_read;
1332        self.afp += catalog
1333            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1334            .unwrap_or(0.0);
1335    }
1336
1337    fn merge(&mut self, other: &Bucket) {
1338        self.afp += other.afp;
1339        self.requests += other.requests;
1340        self.input += other.input;
1341        self.output += other.output;
1342        self.cache_read += other.cache_read;
1343    }
1344
1345    fn json(&self) -> serde_json::Value {
1346        totals_json(
1347            self.afp,
1348            self.requests,
1349            self.input,
1350            self.output,
1351            self.cache_read,
1352        )
1353    }
1354}
1355
1356/// AFP per bucket (day or hour), oldest first — `arkuse daily` / `arkuse hourly`.
1357#[allow(clippy::too_many_arguments)]
1358pub fn bucket_text(
1359    rows: &[crate::arkuse::db::Row],
1360    catalog: &ark::Catalog,
1361    now_ms: i64,
1362    lookback_ms: i64,
1363    size_ms: i64,
1364    tz_secs: i64,
1365    colour: bool,
1366    fmt: Fmt,
1367) -> String {
1368    // Buckets are cut in the display timezone: shift, bucket, then label.
1369    let shift = tz_secs * 1000;
1370    let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1371    for row in rows {
1372        let bucket = (row.at_ms + shift).div_euclid(size_ms);
1373        buckets.entry(bucket).or_default().add(catalog, row);
1374    }
1375    let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1376    let daily = size_ms >= crate::arkuse::window::DAY_SECS * 1000;
1377    let label = if daily { "day" } else { "hour" };
1378    let stamp = |bucket: i64| {
1379        if daily {
1380            date_of(bucket)
1381        } else {
1382            format!(
1383                "{} {:02}h",
1384                date_of(bucket.div_euclid(24)),
1385                bucket.rem_euclid(24)
1386            )
1387        }
1388    };
1389    let window: Vec<(i64, Bucket)> = buckets
1390        .range(start..)
1391        .map(|(bucket, b)| (*bucket, *b))
1392        .collect();
1393
1394    match fmt {
1395        Fmt::Json => {
1396            let mut keyed = serde_json::Map::new();
1397            let mut total = Bucket::default();
1398            for (bucket, b) in &window {
1399                keyed.insert(stamp(*bucket), b.json());
1400                total.merge(b);
1401            }
1402            let key = if daily { "days" } else { "hours" };
1403            serde_json::json!({
1404                "scope": "local",
1405                "source": "opencode",
1406                "total": total.json(),
1407                key: keyed,
1408            })
1409            .to_string()
1410        }
1411        Fmt::Csv => {
1412            let mut out = format!("{label},requests,tokens_in,tokens_out,cache_read,afp\n");
1413            let mut total = Bucket::default();
1414            for (bucket, b) in &window {
1415                out.push_str(&format!(
1416                    "{},{},{},{},{},{:.2}\n",
1417                    crate::report_render::csv_field(&stamp(*bucket)),
1418                    b.requests,
1419                    b.input,
1420                    b.output,
1421                    b.cache_read,
1422                    b.afp,
1423                ));
1424                total.merge(b);
1425            }
1426            out.push_str(&format!(
1427                "total,{},{},{},{},{:.2}\n",
1428                total.requests, total.input, total.output, total.cache_read, total.afp,
1429            ));
1430            out
1431        }
1432        Fmt::Table => {
1433            let ink = Ink::new(colour);
1434            let mut out = format!(
1435                "{}{label:<12} AFP      requests   tokens{}\n",
1436                ink.bold, ink.reset
1437            );
1438            for (bucket, b) in &window {
1439                out.push_str(&format!(
1440                    "{:<12} {} {} {}\n",
1441                    stamp(*bucket),
1442                    ink.cyan_on(format!("{:>9}", afp(b.afp))),
1443                    ink.cyan_on(format!("{:>12}", compact(b.requests))),
1444                    ink.cyan_on(format!("{:>9}", compact(b.input + b.output + b.cache_read))),
1445                ));
1446            }
1447            if out.lines().count() == 1 {
1448                out.push_str(&format!(
1449                    "{}\n",
1450                    ink.dim_on("(no agent-plan usage in the window)".into())
1451                ));
1452            }
1453            out
1454        }
1455    }
1456}
1457
1458/// One session's accumulating totals: `(afp, requests, tokens_in, tokens_out,
1459/// cache_read, last_ms, distinct models)`.
1460type SessionTotals<'a> = (
1461    f64,
1462    u64,
1463    u64,
1464    u64,
1465    u64,
1466    i64,
1467    std::collections::BTreeSet<&'a str>,
1468);
1469
1470/// AFP per session, newest first — the same columns as `ocuse session`, with AFP
1471/// in place of USD.
1472pub fn session_text(
1473    rows: &[crate::arkuse::db::Row],
1474    catalog: &ark::Catalog,
1475    limit: usize,
1476    colour: bool,
1477    fmt: Fmt,
1478) -> String {
1479    let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1480    for row in rows {
1481        let entry = by_session
1482            .entry(row.session.as_str())
1483            .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1484        entry.0 += catalog
1485            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1486            .unwrap_or(0.0);
1487        entry.1 += 1;
1488        entry.2 += row.input;
1489        entry.3 += row.output;
1490        entry.4 += row.cache_read;
1491        entry.5 = entry.5.max(row.at_ms);
1492        entry.6.insert(row.model.as_str());
1493    }
1494    let mut sessions: Vec<_> = by_session.into_iter().collect();
1495    sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1496    sessions.truncate(limit);
1497
1498    match fmt {
1499        Fmt::Json => {
1500            let mut keyed = serde_json::Map::new();
1501            for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1502                let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1503                body["lastMs"] = serde_json::json!(*last);
1504                body["models"] = serde_json::json!(models.len());
1505                keyed.insert((*id).to_string(), body);
1506            }
1507            serde_json::json!({
1508                "scope": "local",
1509                "source": "opencode",
1510                "sessions": keyed,
1511            })
1512            .to_string()
1513        }
1514        Fmt::Csv => {
1515            let mut out = String::from("session,requests,tokens_in,tokens_out,cache_read,afp\n");
1516            for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1517                out.push_str(&format!(
1518                    "{},{},{},{},{},{:.2}\n",
1519                    crate::report_render::csv_field(id),
1520                    requests,
1521                    input,
1522                    output,
1523                    cache_read,
1524                    cost,
1525                ));
1526            }
1527            out
1528        }
1529        Fmt::Table => {
1530            let ink = Ink::new(colour);
1531            let mut out = format!(
1532                "{}session                          last        models   AFP       req{}\n",
1533                ink.bold, ink.reset
1534            );
1535            for (id, (cost, requests, _, _, _, last, models)) in sessions {
1536                let short = id.rsplit('_').next().unwrap_or(id);
1537                out.push_str(&format!(
1538                    "{:<32} {:<11} {:>6} {} {:>6}\n",
1539                    short,
1540                    ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1541                    models.len(),
1542                    ink.cyan_on(format!("{:>9}", afp(cost))),
1543                    requests,
1544                ));
1545            }
1546            if out.lines().count() == 1 {
1547                out.push_str(&format!(
1548                    "{}\n",
1549                    ink.dim_on("(no sessions in the window)".into())
1550                ));
1551            }
1552            out
1553        }
1554    }
1555}
1556
1557#[cfg(test)]
1558mod tests {
1559    use super::*;
1560    use crate::arkuse::db::Row;
1561
1562    fn report() -> Report {
1563        let now = 1_800_000_000_000;
1564        let rows = vec![Row {
1565            at_ms: now - 60_000,
1566            session: "ses_a".into(),
1567            provider: "agent-plan".into(),
1568            model: "deepseek-v4.1-flash".into(),
1569            input: 10_000,
1570            output: 2_000,
1571            ..Default::default()
1572        }];
1573        crate::arkuse::window::build(&rows, &ark::catalog(), ark::DEFAULT_PLAN, now, None, None)
1574    }
1575
1576    #[test]
1577    fn text_names_the_model_and_its_afp() {
1578        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1579        assert!(text.contains("deepseek-v4.1-flash"), "{text}");
1580        assert!(text.contains("AFP"), "the plan's own unit: {text}");
1581        assert!(!text.contains('$'), "ARK never prices in dollars: {text}");
1582    }
1583
1584    #[test]
1585    fn the_tier_table_carries_every_personal_plan() {
1586        let text = plans_text(false);
1587        for plan in ark::catalog().plans {
1588            assert!(text.contains(&plan.label), "{} missing: {text}", plan.label);
1589        }
1590        let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1591        assert_eq!(
1592            json["plans"].as_array().unwrap().len(),
1593            ark::catalog().plans.len()
1594        );
1595    }
1596
1597    #[test]
1598    fn gated_models_are_filtered_by_the_tier() {
1599        let all = models_text("small", false, false);
1600        let gated = models_text("small", true, false);
1601        assert!(all.lines().count() >= gated.lines().count());
1602        assert!(all.contains("kimi-k3"), "{all}");
1603        assert!(!gated.contains("kimi-k3"), "{gated}");
1604    }
1605
1606    /// A report measured against the control plane's own meters.
1607    fn metered(account: &crate::arkuse::api::Usage) -> Report {
1608        let now = 1_800_000_000_000;
1609        let rows = vec![Row {
1610            at_ms: now - 60_000,
1611            session: "ses_a".into(),
1612            provider: "agent-plan".into(),
1613            model: "deepseek-v4.1-flash".into(),
1614            input: 10_000,
1615            output: 2_000,
1616            ..Default::default()
1617        }];
1618        crate::arkuse::window::build(
1619            &rows,
1620            &ark::catalog(),
1621            ark::DEFAULT_PLAN,
1622            now,
1623            None,
1624            Some(account),
1625        )
1626    }
1627
1628    fn meter(used: f64, quota: f64, reset: Option<&str>) -> crate::arkuse::api::Meter {
1629        let percent = if quota > 0.0 {
1630            used / quota * 100.0
1631        } else {
1632            0.0
1633        };
1634        crate::arkuse::api::Meter::from_parts("ok", percent, used, quota, reset, None)
1635    }
1636
1637    #[test]
1638    fn a_local_row_still_reads_used_over_quota_and_a_reset() {
1639        // Without the control plane the plan's published split stands in for the
1640        // quota, so the row is never AFP alone — the parity the user expects.
1641        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1642        let five = text
1643            .lines()
1644            .find(|line| line.contains("5-hour"))
1645            .expect("a 5-hour row");
1646        assert!(
1647            five.contains("/ 10,000 AFP"),
1648            "the plan's own quota: {five}"
1649        );
1650        assert!(five.contains("% of limit (est.)"), "a percentage: {five}");
1651        let weekly = text
1652            .lines()
1653            .find(|line| line.contains("Weekly"))
1654            .expect("a weekly row");
1655        assert!(weekly.contains("resets"), "a countdown: {weekly}");
1656        assert!(weekly.contains("window "), "an elapsed share: {weekly}");
1657        let json: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1658        assert_eq!(json["windows"]["fiveHour"]["quota"], 10_000.0);
1659        assert!(json["windows"]["weekly"]["resetAt"].is_i64());
1660    }
1661
1662    #[test]
1663    fn a_meter_without_its_reset_falls_back_to_the_stores_boundary() {
1664        // The measured shape: a just-reset 5-hour block answers 0% with no
1665        // `reset_at`, and the row still owes the countdown.
1666        let account = crate::arkuse::api::Usage {
1667            five_hour: meter(0.0, 10_000.0, None),
1668            daily: None,
1669            weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1670            monthly: meter(2_975.67, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1671            tier: Some("medium".into()),
1672            profile: "agent-plan_cn-beijing_medium".into(),
1673        };
1674        let text = render_text(
1675            &metered(&account),
1676            "/tmp/opencode.db",
1677            10,
1678            false,
1679            DEFAULT_BAR_WIDTH,
1680        );
1681        let five = text
1682            .lines()
1683            .find(|line| line.contains("5-hour"))
1684            .expect("a 5-hour row");
1685        assert!(
1686            five.contains("0% of limit"),
1687            "the server's own share: {five}"
1688        );
1689        assert!(five.contains("resets"), "a derived countdown: {five}");
1690    }
1691
1692    #[test]
1693    fn the_machine_formats_are_not_coloured() {
1694        let rows = vec![Row {
1695            at_ms: 1_800_000_000_000,
1696            session: "ses_a".into(),
1697            provider: "agent-plan".into(),
1698            model: "deepseek-v4.1-flash".into(),
1699            input: 1_000,
1700            ..Default::default()
1701        }];
1702        let catalog = ark::catalog();
1703        for fmt in [Fmt::Json, Fmt::Csv] {
1704            let daily = bucket_text(
1705                &rows,
1706                &catalog,
1707                1_800_000_000_000,
1708                86_400_000,
1709                86_400_000,
1710                0,
1711                true,
1712                fmt,
1713            );
1714            assert!(!daily.contains('\u{1b}'), "{fmt:?} leaked colour: {daily}");
1715        }
1716        let report = report();
1717        let json = model_report(&report, None, Fmt::Json);
1718        assert!(!json.contains('\u{1b}'));
1719        assert!(json.contains("deepseek-v4.1-flash"));
1720        let csv = model_report(&report, None, Fmt::Csv);
1721        assert!(csv.starts_with("model,requests,tokens_in,tokens_out,cache_read,afp"));
1722    }
1723}