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