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        if report.models.len() > limit {
1310            out.push_str(&format!(
1311                "{}\n",
1312                ink.dim_on(format!("… {} more", report.models.len() - limit))
1313            ));
1314        }
1315        out.push_str(&format!(
1316            "{:<28} {:>5} {:>9} {:>7} {:>6}\n",
1317            ink.bold_on("TOTAL".into()),
1318            "",
1319            afp(total),
1320            compact(report.month.requests),
1321            "100.0%",
1322        ));
1323    }
1324    out
1325}
1326
1327/// The store's numeric fields, in the shape every machine format in this repo
1328/// uses, with AFP standing where the other binaries put dollars.
1329fn totals_json(
1330    afp: f64,
1331    requests: u64,
1332    input: u64,
1333    output: u64,
1334    cache_read: u64,
1335) -> serde_json::Value {
1336    serde_json::json!({
1337        "requests": requests,
1338        "tokensIn": input,
1339        "tokensOut": output,
1340        "cacheRead": cache_read,
1341        "afp": afp,
1342    })
1343}
1344
1345/// One day/hour bucket. AFP is accumulated per row, because the coefficient is
1346/// a property of the model and of the day it ran (a promo can expire mid-window).
1347#[derive(Default, Clone, Copy)]
1348struct Bucket {
1349    afp: f64,
1350    requests: u64,
1351    input: u64,
1352    output: u64,
1353    cache_read: u64,
1354}
1355
1356impl Bucket {
1357    fn add(&mut self, catalog: &ark::Catalog, row: &crate::arkuse::db::Row) {
1358        self.requests += 1;
1359        self.input += row.input;
1360        self.output += row.output;
1361        self.cache_read += row.cache_read;
1362        self.afp += catalog
1363            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1364            .unwrap_or(0.0);
1365    }
1366
1367    fn merge(&mut self, other: &Bucket) {
1368        self.afp += other.afp;
1369        self.requests += other.requests;
1370        self.input += other.input;
1371        self.output += other.output;
1372        self.cache_read += other.cache_read;
1373    }
1374
1375    fn json(&self) -> serde_json::Value {
1376        totals_json(
1377            self.afp,
1378            self.requests,
1379            self.input,
1380            self.output,
1381            self.cache_read,
1382        )
1383    }
1384}
1385
1386/// AFP per bucket (day or hour), oldest first — `arkuse daily` / `arkuse hourly`.
1387#[allow(clippy::too_many_arguments)]
1388pub fn bucket_text(
1389    rows: &[crate::arkuse::db::Row],
1390    catalog: &ark::Catalog,
1391    now_ms: i64,
1392    lookback_ms: i64,
1393    size_ms: i64,
1394    tz_secs: i64,
1395    colour: bool,
1396    fmt: Fmt,
1397) -> String {
1398    // Buckets are cut in the display timezone: shift, bucket, then label.
1399    let shift = tz_secs * 1000;
1400    let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1401    for row in rows {
1402        let bucket = (row.at_ms + shift).div_euclid(size_ms);
1403        buckets.entry(bucket).or_default().add(catalog, row);
1404    }
1405    let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1406    let daily = size_ms >= crate::arkuse::window::DAY_SECS * 1000;
1407    let label = if daily { "day" } else { "hour" };
1408    let stamp = |bucket: i64| {
1409        if daily {
1410            date_of(bucket)
1411        } else {
1412            format!(
1413                "{} {:02}h",
1414                date_of(bucket.div_euclid(24)),
1415                bucket.rem_euclid(24)
1416            )
1417        }
1418    };
1419    let window: Vec<(i64, Bucket)> = buckets
1420        .range(start..)
1421        .map(|(bucket, b)| (*bucket, *b))
1422        .collect();
1423
1424    match fmt {
1425        Fmt::Json => {
1426            let mut keyed = serde_json::Map::new();
1427            let mut total = Bucket::default();
1428            for (bucket, b) in &window {
1429                keyed.insert(stamp(*bucket), b.json());
1430                total.merge(b);
1431            }
1432            let key = if daily { "days" } else { "hours" };
1433            serde_json::json!({
1434                "scope": "local",
1435                "source": "opencode",
1436                "total": total.json(),
1437                key: keyed,
1438            })
1439            .to_string()
1440        }
1441        Fmt::Csv => {
1442            let mut out = format!("{label},requests,tokens_in,tokens_out,cache_read,afp\n");
1443            let mut total = Bucket::default();
1444            for (bucket, b) in &window {
1445                out.push_str(&format!(
1446                    "{},{},{},{},{},{:.2}\n",
1447                    crate::report_render::csv_field(&stamp(*bucket)),
1448                    b.requests,
1449                    b.input,
1450                    b.output,
1451                    b.cache_read,
1452                    b.afp,
1453                ));
1454                total.merge(b);
1455            }
1456            out.push_str(&format!(
1457                "total,{},{},{},{},{:.2}\n",
1458                total.requests, total.input, total.output, total.cache_read, total.afp,
1459            ));
1460            out
1461        }
1462        Fmt::Table => {
1463            let ink = Ink::new(colour);
1464            let mut out = format!(
1465                "{}{label:<12} AFP      requests   tokens{}\n",
1466                ink.bold, ink.reset
1467            );
1468            for (bucket, b) in &window {
1469                out.push_str(&format!(
1470                    "{:<12} {} {} {}\n",
1471                    stamp(*bucket),
1472                    ink.cyan_on(format!("{:>9}", afp(b.afp))),
1473                    ink.cyan_on(format!("{:>12}", compact(b.requests))),
1474                    ink.cyan_on(format!("{:>9}", compact(b.input + b.output + b.cache_read))),
1475                ));
1476            }
1477            if out.lines().count() == 1 {
1478                out.push_str(&format!(
1479                    "{}\n",
1480                    ink.dim_on("(no agent-plan usage in the window)".into())
1481                ));
1482            }
1483            out
1484        }
1485    }
1486}
1487
1488/// One session's accumulating totals: `(afp, requests, tokens_in, tokens_out,
1489/// cache_read, last_ms, distinct models)`.
1490type SessionTotals<'a> = (
1491    f64,
1492    u64,
1493    u64,
1494    u64,
1495    u64,
1496    i64,
1497    std::collections::BTreeSet<&'a str>,
1498);
1499
1500/// AFP per session, newest first — the same columns as `ocuse session`, with AFP
1501/// in place of USD.
1502pub fn session_text(
1503    rows: &[crate::arkuse::db::Row],
1504    catalog: &ark::Catalog,
1505    limit: usize,
1506    colour: bool,
1507    fmt: Fmt,
1508) -> String {
1509    let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1510    for row in rows {
1511        let entry = by_session
1512            .entry(row.session.as_str())
1513            .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1514        entry.0 += catalog
1515            .afp(&row.model, row.at_ms, row.input, row.cache_read, row.output)
1516            .unwrap_or(0.0);
1517        entry.1 += 1;
1518        entry.2 += row.input;
1519        entry.3 += row.output;
1520        entry.4 += row.cache_read;
1521        entry.5 = entry.5.max(row.at_ms);
1522        entry.6.insert(row.model.as_str());
1523    }
1524    let mut sessions: Vec<_> = by_session.into_iter().collect();
1525    sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1526
1527    match fmt {
1528        Fmt::Json => {
1529            let mut keyed = serde_json::Map::new();
1530            for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1531                let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1532                body["lastMs"] = serde_json::json!(*last);
1533                body["models"] = serde_json::json!(models.len());
1534                keyed.insert((*id).to_string(), body);
1535            }
1536            serde_json::json!({
1537                "scope": "local",
1538                "source": "opencode",
1539                "sessions": keyed,
1540            })
1541            .to_string()
1542        }
1543        Fmt::Csv => {
1544            let mut out = String::from("session,requests,tokens_in,tokens_out,cache_read,afp\n");
1545            for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1546                out.push_str(&format!(
1547                    "{},{},{},{},{},{:.2}\n",
1548                    crate::report_render::csv_field(id),
1549                    requests,
1550                    input,
1551                    output,
1552                    cache_read,
1553                    cost,
1554                ));
1555            }
1556            out
1557        }
1558        Fmt::Table => {
1559            let ink = Ink::new(colour);
1560            let mut out = format!(
1561                "{}session                          last        models   AFP       req{}\n",
1562                ink.bold, ink.reset
1563            );
1564            for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1565                let short = id.rsplit('_').next().unwrap_or(*id);
1566                out.push_str(&format!(
1567                    "{:<32} {:<11} {:>6} {} {:>6}\n",
1568                    short,
1569                    ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1570                    models.len(),
1571                    ink.cyan_on(format!("{:>9}", afp(*cost))),
1572                    requests,
1573                ));
1574            }
1575            if sessions.len() > limit {
1576                out.push_str(&format!(
1577                    "{}\n",
1578                    ink.dim_on(format!("… {} more", sessions.len() - limit))
1579                ));
1580            }
1581            if out.lines().count() == 1 {
1582                out.push_str(&format!(
1583                    "{}\n",
1584                    ink.dim_on("(no sessions in the window)".into())
1585                ));
1586            }
1587            out
1588        }
1589    }
1590}
1591
1592#[cfg(test)]
1593mod tests {
1594    use super::*;
1595    use crate::arkuse::db::Row;
1596
1597    fn report() -> Report {
1598        let now = 1_800_000_000_000;
1599        let rows = vec![Row {
1600            at_ms: now - 60_000,
1601            session: "ses_a".into(),
1602            provider: "agent-plan".into(),
1603            model: "deepseek-v4.1-flash".into(),
1604            input: 10_000,
1605            output: 2_000,
1606            ..Default::default()
1607        }];
1608        crate::arkuse::window::build(&rows, &ark::catalog(), ark::DEFAULT_PLAN, now, None, None)
1609    }
1610
1611    #[test]
1612    fn text_names_the_model_and_its_afp() {
1613        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1614        assert!(text.contains("deepseek-v4.1-flash"), "{text}");
1615        assert!(text.contains("AFP"), "the plan's own unit: {text}");
1616        assert!(!text.contains('$'), "ARK never prices in dollars: {text}");
1617    }
1618
1619    #[test]
1620    fn the_tier_table_carries_every_personal_plan() {
1621        let text = plans_text(false);
1622        for plan in ark::catalog().plans {
1623            assert!(text.contains(&plan.label), "{} missing: {text}", plan.label);
1624        }
1625        let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1626        assert_eq!(
1627            json["plans"].as_array().unwrap().len(),
1628            ark::catalog().plans.len()
1629        );
1630    }
1631
1632    #[test]
1633    fn gated_models_are_filtered_by_the_tier() {
1634        let all = models_text("small", false, false);
1635        let gated = models_text("small", true, false);
1636        assert!(all.lines().count() >= gated.lines().count());
1637        assert!(all.contains("kimi-k3"), "{all}");
1638        assert!(!gated.contains("kimi-k3"), "{gated}");
1639    }
1640
1641    /// A report measured against the control plane's own meters.
1642    fn metered(account: &crate::arkuse::api::Usage) -> Report {
1643        let now = 1_800_000_000_000;
1644        let rows = vec![Row {
1645            at_ms: now - 60_000,
1646            session: "ses_a".into(),
1647            provider: "agent-plan".into(),
1648            model: "deepseek-v4.1-flash".into(),
1649            input: 10_000,
1650            output: 2_000,
1651            ..Default::default()
1652        }];
1653        crate::arkuse::window::build(
1654            &rows,
1655            &ark::catalog(),
1656            ark::DEFAULT_PLAN,
1657            now,
1658            None,
1659            Some(account),
1660        )
1661    }
1662
1663    fn meter(used: f64, quota: f64, reset: Option<&str>) -> crate::arkuse::api::Meter {
1664        let percent = if quota > 0.0 {
1665            used / quota * 100.0
1666        } else {
1667            0.0
1668        };
1669        crate::arkuse::api::Meter::from_parts("ok", percent, used, quota, reset, None)
1670    }
1671
1672    #[test]
1673    fn a_local_row_still_reads_used_over_quota_and_a_reset() {
1674        // Without the control plane the plan's published split stands in for the
1675        // quota, so the row is never AFP alone — the parity the user expects.
1676        let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1677        let five = text
1678            .lines()
1679            .find(|line| line.contains("5-hour"))
1680            .expect("a 5-hour row");
1681        assert!(
1682            five.contains("/ 10,000 AFP"),
1683            "the plan's own quota: {five}"
1684        );
1685        assert!(five.contains("% of limit (est.)"), "a percentage: {five}");
1686        let weekly = text
1687            .lines()
1688            .find(|line| line.contains("Weekly"))
1689            .expect("a weekly row");
1690        assert!(weekly.contains("resets"), "a countdown: {weekly}");
1691        assert!(weekly.contains("window "), "an elapsed share: {weekly}");
1692        let json: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1693        assert_eq!(json["windows"]["fiveHour"]["quota"], 10_000.0);
1694        assert!(json["windows"]["weekly"]["resetAt"].is_i64());
1695    }
1696
1697    #[test]
1698    fn a_meter_without_its_reset_falls_back_to_the_stores_boundary() {
1699        // The measured shape: a just-reset 5-hour block answers 0% with no
1700        // `reset_at`, and the row still owes the countdown.
1701        let account = crate::arkuse::api::Usage {
1702            five_hour: meter(0.0, 10_000.0, None),
1703            daily: None,
1704            weekly: meter(2_975.67, 35_000.0, Some("2026-10-26T00:00:00+08:00")),
1705            monthly: meter(2_975.67, 100_000.0, Some("2026-11-19T23:59:59+08:00")),
1706            tier: Some("medium".into()),
1707            profile: "agent-plan_cn-beijing_medium".into(),
1708        };
1709        let text = render_text(
1710            &metered(&account),
1711            "/tmp/opencode.db",
1712            10,
1713            false,
1714            DEFAULT_BAR_WIDTH,
1715        );
1716        let five = text
1717            .lines()
1718            .find(|line| line.contains("5-hour"))
1719            .expect("a 5-hour row");
1720        assert!(
1721            five.contains("0% of limit"),
1722            "the server's own share: {five}"
1723        );
1724        assert!(five.contains("resets"), "a derived countdown: {five}");
1725    }
1726
1727    #[test]
1728    fn the_bar_carries_the_share_so_the_body_does_not_repeat_it() {
1729        // The row used to read `28.5% ━━━━━━━ 9,976 / 35,000 AFP · 28.5% of
1730        // limit`: one figure, twice, because the gauge and the body each printed
1731        // it. The gauge owns the percentage; the body prints the quota pair.
1732        let text = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1733        let weekly = text
1734            .lines()
1735            .find(|line| line.contains("Weekly"))
1736            .expect("a weekly row");
1737        assert!(!weekly.contains("of limit"), "no repeated share: {weekly}");
1738        assert!(weekly.contains("/ 35,000 AFP"), "the pair: {weekly}");
1739        assert!(
1740            weekly.contains("(est.)"),
1741            "still marked an estimate: {weekly}"
1742        );
1743    }
1744
1745    #[test]
1746    fn the_machine_formats_are_not_coloured() {
1747        let rows = vec![Row {
1748            at_ms: 1_800_000_000_000,
1749            session: "ses_a".into(),
1750            provider: "agent-plan".into(),
1751            model: "deepseek-v4.1-flash".into(),
1752            input: 1_000,
1753            ..Default::default()
1754        }];
1755        let catalog = ark::catalog();
1756        for fmt in [Fmt::Json, Fmt::Csv] {
1757            let daily = bucket_text(
1758                &rows,
1759                &catalog,
1760                1_800_000_000_000,
1761                86_400_000,
1762                86_400_000,
1763                0,
1764                true,
1765                fmt,
1766            );
1767            assert!(!daily.contains('\u{1b}'), "{fmt:?} leaked colour: {daily}");
1768        }
1769        let report = report();
1770        let json = model_report(&report, None, Fmt::Json);
1771        assert!(!json.contains('\u{1b}'));
1772        assert!(json.contains("deepseek-v4.1-flash"));
1773        let csv = model_report(&report, None, Fmt::Csv);
1774        assert!(csv.starts_with("model,requests,tokens_in,tokens_out,cache_read,afp"));
1775    }
1776}