1use crate::ocuse::window::{ModelUsage, Report, Totals};
9use crate::ocuse::zen;
10use crate::report_render::Fmt;
11use cmduse_core::{compact, duration, money, pct};
12
13pub const DEFAULT_BAR_WIDTH: usize = 20;
16
17pub struct Ink {
20 pub colour: bool,
21 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 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 {
70 format!("{}{text}{}", self.cyan, self.reset)
71 }
72
73 fn dim_on(&self, text: String) -> String {
75 format!("{}{text}{}", self.dim, self.reset)
76 }
77
78 fn bold_on(&self, text: String) -> String {
79 format!("{}{text}{}", self.bold, self.reset)
80 }
81
82 fn for_pct(&self, pct_used: f64) -> &'static str {
84 if !self.colour {
85 ""
86 } else if pct_used >= 90.0 {
87 self.red
88 } else if pct_used >= 70.0 {
89 self.yellow
90 } else {
91 self.green
92 }
93 }
94
95 fn bar_label(&self, pct_used: f64, label: &str) -> String {
101 if !self.colour {
102 return String::new();
103 }
104 format!(
105 "{}{label:>5}%{} {}",
106 self.for_pct(pct_used),
107 self.reset,
108 crate::render::gauge(pct_used, 100.0, self.bar_width),
109 )
110 }
111}
112
113#[allow(clippy::too_many_arguments)]
114struct RowSpec<'a> {
125 label: &'a str,
126 used: &'a Totals,
128 reset: Option<(i64, u64)>,
132 share_pct: Option<f64>,
136 status: Option<&'a str>,
139 pro_rata: bool,
144}
145
146fn window_line(ink: &Ink, spec: &RowSpec<'_>, now_ms: i64) -> String {
147 let reset = spec
148 .reset
149 .map(|(at, _)| duration(((at - now_ms) / 1000).max(0) as u64))
150 .unwrap_or_default();
151 let reset_text = if reset.is_empty() {
152 String::new()
153 } else if ink.colour {
154 format!(" · resets in {reset}")
155 } else {
156 format!(" · resets {reset}")
157 };
158 let now_secs = (now_ms / 1000).max(0) as u64;
163 let reset_at = spec.reset.map(|(at, _)| at as f64);
164 let dur_secs = spec.reset.map(|(_, dur)| dur);
165 let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
166 let share = spec.share_pct.map(|p| p.max(0.0));
167 let cap = share.and_then(|p| implied_cap(spec.used.cost, p));
172 let pace = cap
173 .and_then(|cap| {
174 dur_secs.and_then(|d| {
175 cmduse_core::pace_eta(reset_at, d, spec.used.cost, cap, now_secs)
176 .map(|secs| duration(secs as u64))
177 })
178 })
179 .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
180 .unwrap_or_default();
181 let thru = elapsed
182 .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
183 .unwrap_or_default();
184 let by_now = match (spec.pro_rata, elapsed, cap) {
185 (true, Some(p), Some(cap)) => format!(
186 " · {}{} by now{}",
187 ink.dim,
188 money(cap * p / 100.0),
189 ink.reset
190 ),
191 _ => String::new(),
192 };
193 let label = spec.label;
194 let flag = if share.is_some_and(|p| p >= 100.0) {
199 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
200 } else if spec.status.is_some_and(|s| !s.eq_ignore_ascii_case("ok")) {
201 format!(
202 " {}{}{}{}",
203 ink.red,
204 spec.status.unwrap_or_default(),
205 ink.reset,
206 ""
207 )
208 } else {
209 String::new()
210 };
211 let paren = match (share, ink.colour) {
217 (Some(_), true) => String::new(),
221 (Some(p), false) => format!(" · {}% of limit", given_pct(p)),
222 (None, _) => String::new(),
223 };
224 let body = format!(
227 "{}{paren}{reset_text}{flag}{pace}{thru}{by_now}",
228 money(spec.used.cost)
229 );
230 if ink.colour {
231 let bar = match share {
235 Some(p) => format!("{} ", ink.bar_label(p, &given_pct(p))),
236 None => String::new(),
237 };
238 format!(
239 " {}{label:<8}{} {}{}{body}{}\n",
240 ink.bold, ink.reset, bar, ink.dim, ink.reset,
241 )
242 } else {
243 format!(" {label:<8} {body}\n")
246 }
247}
248
249fn given_pct(value: f64) -> String {
256 if (value - value.round()).abs() < f64::EPSILON {
257 format!("{}", value.round() as i64)
258 } else {
259 format!("{:.1}", value)
260 }
261}
262
263fn implied_cap(spend: f64, share_pct: f64) -> Option<f64> {
269 (share_pct >= 0.05).then(|| spend * 100.0 / share_pct)
270}
271
272fn meter_reset(meter: &crate::ocuse::api::Meter, dur_secs: Option<u64>) -> Option<(i64, u64)> {
276 match (meter.reset_ms(), dur_secs) {
277 (Some(at), Some(dur)) if dur > 0 => Some((at, dur)),
278 _ => None,
279 }
280}
281
282fn exceeded(model: &ModelUsage) -> bool {
286 crate::ocuse::window::over_window(model).is_some()
287}
288
289fn top_models(ink: &Ink, report: &Report, count: usize, name_w: usize) -> String {
299 let tokens = |model: &ModelUsage| model.month.input + model.month.output;
300 let total_cost = report.month.cost;
301 let total_tokens: u64 = report.models.iter().map(tokens).sum();
302 if report.models.is_empty() || (total_cost <= 0.0 && total_tokens == 0) {
303 return String::new();
304 }
305 let lead = format!("TOP {count} MODELS");
308 let label_w = name_w.saturating_sub(lead.chars().count() + 1);
309 let mut out = format!(
310 "\n{}{}{}{} {:<label_w$} {} {} {}\n",
311 ink.bold,
312 lead,
313 ink.reset,
314 ink.dim,
315 "by period spend",
316 ink.dim_on(format!("{:>10}", "MONEY")),
317 ink.dim_on(format!("{:>8}", "SHARE")),
318 ink.dim_on(format!("{:>10} {:>8}", "TOKENS", "SHARE")),
319 );
320 for model in report.models.iter().take(count) {
321 let money_cell = ink.cyan_on(format!(
322 "{:>10} {:>8}",
323 money(model.month.cost),
324 pct(model.month.cost, total_cost),
325 ));
326 out.push_str(&format!(
327 "{:<name_w$} {money_cell} {:>10} {:>8}\n",
328 model.id,
329 compact(tokens(model)),
330 pct(tokens(model) as f64, total_tokens as f64),
331 name_w = name_w,
332 ));
333 }
334 out
335}
336
337fn model_row(ink: &Ink, model: &ModelUsage, name_w: usize) -> String {
341 let limit = match model.limit {
342 Some(limit) => money(limit),
343 None => ink.dim_on("—".into()),
344 };
345 let use_fraction = model.month_use();
346 let use_text = use_fraction
347 .map(|fraction| pct(fraction * 100.0, 100.0))
348 .unwrap_or_else(|| "—".into());
349 let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
351 let use_cell = match use_fraction {
352 Some(fraction) => {
353 let tone = ink.for_pct(fraction * 100.0);
354 if tone.is_empty() {
355 format!("{use_text:>7}")
356 } else {
357 format!("{tone}{use_text:>7}{}", ink.reset)
358 }
359 }
360 None => ink.dim_on(format!("{use_text:>7}")),
361 };
362 let flag = if exceeded(model) {
365 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
366 } else {
367 String::new()
368 };
369 format!(
370 "{:<name_w$}{flag} {:>7} {spent} {use_cell} {} {} {:>7}\n",
371 model.id,
372 limit,
373 window_cell(ink, model, model.five_hour.cost, 0.20),
375 window_cell(ink, model, model.weekly.cost, 0.50),
376 compact(model.month.requests),
377 name_w = name_w,
378 )
379}
380
381fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
384 let text = format!("{:>8}", money(cost));
385 match model.limit.filter(|limit| *limit > 0.0) {
386 Some(limit) => {
387 let tone = ink.for_pct(cost / (limit * share) * 100.0);
388 if tone.is_empty() {
389 text
390 } else {
391 format!("{tone}{text}{}", ink.reset)
392 }
393 }
394 None => ink.dim_on(text),
395 }
396}
397
398pub fn render_text(
400 report: &Report,
401 db_path: &str,
402 rows: usize,
403 colour: bool,
404 bar_width: usize,
405) -> String {
406 let ink = Ink::new(colour).width(bar_width);
407 let c = |text: String| ink.cyan_on(text);
408 let mut out = String::new();
409 out.push_str(&format!(
410 "{}OpenCode usage{} — go + zen, from {}{}{}\n",
411 ink.bold,
412 ink.reset,
413 ink.dim,
414 shorten(db_path),
415 ink.reset,
416 ));
417 let end = report
421 .period_end_ms
422 .map(|ms| format!(" · ends {}", date_of(ms / 86_400_000)))
423 .unwrap_or_default();
424 let used = report
430 .account
431 .as_ref()
432 .map(|account| format!(" · {}% of limit", c(given_pct(account.monthly.percent))))
433 .unwrap_or_default();
434 out.push_str(&format!(
435 "period: {}{end} · {} requests · {} · {} in / {} out tokens{used}\n",
436 report.period_label,
437 c(compact(report.month.requests)),
438 c(money(report.month.cost)),
439 c(compact(report.month.input)),
440 c(compact(report.month.output)),
441 ));
442
443 match &report.account {
448 Some(account) => {
449 out.push_str(&format!(
450 "\n{}GO{} {}\n",
451 ink.bold,
452 ink.reset,
453 ink.dim_on(
454 "(account meters — what opencode.ai/console reports; the $ is this store's spend priced on the documented peak/off-peak schedule)"
455 .into()
456 ),
457 ));
458 let monthly_dur = report
464 .period_end_ms
465 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
466 let rows = [
467 (
468 "5-hour",
469 &account.rolling,
470 &report.five_hour,
471 Some(crate::ocuse::window::FIVE_HOUR_SECS as u64),
472 ),
473 (
474 "Weekly",
475 &account.weekly,
476 &report.weekly,
477 Some(crate::ocuse::window::WEEKLY_SECS as u64),
478 ),
479 ("Monthly", &account.monthly, &report.month, monthly_dur),
480 ];
481 for (label, meter, used, dur_secs) in rows {
482 out.push_str(&window_line(
483 &ink,
484 &RowSpec {
485 label,
486 used,
487 reset: meter_reset(meter, dur_secs),
488 share_pct: Some(meter.percent),
489 status: Some(meter.status.as_str()),
490 pro_rata: label == "Monthly",
493 },
494 report.now_ms,
495 ));
496 }
497 }
498 None if report.month.requests > 0 => {
503 let monthly_dur = report
508 .period_end_ms
509 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
510 out.push_str(&format!(
511 "\n{}GO{} {}\n",
512 ink.bold,
513 ink.reset,
514 ink.dim_on(
515 "(estimated locally: limits are per model, so no pool: spend alone)".into()
516 ),
517 ));
518 if let Some(why) = &report.account_error {
522 out.push_str(&format!("{} usage API: {why}{}\n", ink.dim, ink.reset));
523 }
524 out.push_str(&window_line(
525 &ink,
526 &RowSpec {
527 label: "5-hour",
528 used: &report.five_hour,
529 reset: None,
530 share_pct: None,
531 status: None,
532 pro_rata: false,
533 },
534 report.now_ms,
535 ));
536 out.push_str(&window_line(
537 &ink,
538 &RowSpec {
539 label: "Weekly",
540 used: &report.weekly,
541 reset: None,
542 share_pct: None,
543 status: None,
544 pro_rata: false,
545 },
546 report.now_ms,
547 ));
548 out.push_str(&window_line(
549 &ink,
550 &RowSpec {
551 label: "Monthly",
552 used: &report.month,
553 reset: report.period_end_ms.zip(monthly_dur),
554 share_pct: None,
555 status: None,
556 pro_rata: false,
559 },
560 report.now_ms,
561 ));
562 }
563 None => {}
564 }
565
566 let name_w = report
570 .models
571 .iter()
572 .take(rows)
573 .map(|model| model.id.chars().count())
574 .max()
575 .unwrap_or(0)
576 .max(28);
577 out.push_str(&top_models(&ink, report, 3, name_w));
578 out.push_str(&format!(
582 "\n{}{:<name_w$} {:>7} {:>10} {:>7} {:>8} {:>8} {:>7}{}\n",
583 ink.bold,
584 "MODEL",
585 "LIMIT",
586 "SPENT",
587 "USE",
588 "5H",
589 "WK",
590 "REQ",
591 ink.reset,
592 name_w = name_w
593 ));
594 for model in report.models.iter().take(rows) {
595 out.push_str(&model_row(&ink, model, name_w));
596 }
597 if report.models.len() > rows {
598 out.push_str(&format!(
599 "{}\n",
600 ink.dim_on(format!("… {} more", report.models.len() - rows))
601 ));
602 }
603 out.push_str(&format!(
604 "\n{}\n",
605 ink.dim_on(
606 "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
607 .into()
608 )
609 ));
610 if report.account.is_some() {
616 out.push_str(&format!(
617 "{}\n",
618 ink.dim_on(
619 "5H/WK columns: this model's own spend inside the account's windows (the same cuts its percentages use)."
620 .into()
621 )
622 ));
623 } else {
624 out.push_str(&format!(
625 "{}\n",
626 ink.dim_on(
627 "5H/WK columns: this model's own spend over the last 5h / 7 days (local, not the account's windows)."
628 .into()
629 )
630 ));
631 }
632 out
633}
634
635pub fn watch_frame(
640 report: &Report,
641 db_path: &str,
642 rows: usize,
643 colour: bool,
644 history: &[f64],
645 interval_secs: u64,
646 bar_width: usize,
647) -> String {
648 let ink = Ink::new(colour);
649 let mut out = render_text(report, db_path, rows, colour, bar_width);
650 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
651 out.push_str(&format!(
652 "{}spend bursts ({}s samples){} {}\n",
653 ink.dim,
654 interval_secs,
655 ink.reset,
656 crate::render::sparkline(history),
657 ));
658 }
659 out.trim_end_matches('\n').to_string()
660}
661
662pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
667 let ink = Ink::new(colour);
668 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
669 format!(
670 " {}{}{}",
671 ink.dim,
672 crate::render::sparkline(history),
673 ink.reset
674 )
675 } else {
676 String::new()
677 };
678 let window_share = match &report.account {
683 Some(account) => format!(
684 " · 5h {} · wk {}",
685 ink.cyan_on(pct(account.rolling.percent, 100.0)),
686 ink.cyan_on(pct(account.weekly.percent, 100.0)),
687 ),
688 None => format!(" · 5h {}", ink.cyan_on(money(report.five_hour.cost))),
689 };
690 format!(
691 "{}oc{} {} month{window_share}{spark}",
692 ink.bold,
693 ink.reset,
694 ink.cyan_on(money(report.month.cost)),
695 )
696}
697
698pub fn statusline_template(
712 report: &Report,
713 plan_id: &str,
714 tpl: &str,
715 colours: bool,
716 ascii: bool,
717 bar_width: usize,
718) -> String {
719 let ink = Ink::new(colours);
720 let cash = |v: f64| ink.cyan_on(money(v));
721 let account = report.account.as_ref();
728 let share_of = |meter: Option<&crate::ocuse::api::Meter>| match meter {
729 Some(meter) => pct(meter.percent, 100.0),
730 None => "—".into(),
731 };
732 let share_bar = |meter: Option<&crate::ocuse::api::Meter>| match meter {
733 Some(meter) => crate::report_render::bar(meter.percent, bar_width, ascii, colours),
734 None => String::new(),
735 };
736 let top_model = || {
737 report
738 .models
739 .first()
740 .map(|m| m.id.clone())
741 .unwrap_or_else(|| "—".into())
742 };
743 crate::report_render::render_template(tpl, |key| match key {
744 "plan" => plan_id.to_string(),
745 "period" => report.period_label.clone(),
746 "month" => cash(report.month.cost),
747 "month_pct" => share_of(account.map(|a| &a.monthly)),
748 "month_bar" => share_bar(account.map(|a| &a.monthly)),
749 "five_hour" | "5h" => cash(report.five_hour.cost),
750 "five_hour_pct" | "5h_pct" => share_of(account.map(|a| &a.rolling)),
751 "five_hour_bar" | "5h_bar" => share_bar(account.map(|a| &a.rolling)),
752 "weekly" | "wk" => cash(report.weekly.cost),
753 "weekly_pct" | "wk_pct" => share_of(account.map(|a| &a.weekly)),
754 "weekly_bar" | "wk_bar" => share_bar(account.map(|a| &a.weekly)),
755 "models" => top_model(),
756 _ => String::new(),
757 })
758}
759
760pub fn statusline_json(report: &Report, plan_id: &str) -> String {
766 serde_json::json!({
767 "plan": plan_id,
768 "period": report.period_label,
769 "monthSpent": report.month.cost,
770 "monthCap": serde_json::Value::Null,
771 "fiveHourSpent": report.five_hour.cost,
772 "fiveHourCap": serde_json::Value::Null,
773 "weeklySpent": report.weekly.cost,
774 "weeklyCap": serde_json::Value::Null,
775 "model": report.models.first().map(|m| m.id.clone()),
776 })
777 .to_string()
778}
779
780pub fn render_json(report: &Report) -> String {
782 let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
783 let model = |m: &ModelUsage| {
784 serde_json::json!({
785 "id": m.id,
786 "provider": m.provider,
787 "limit": m.limit,
788 "month": totals(&m.month),
789 "fiveHour": totals(&m.five_hour),
790 "weekly": totals(&m.weekly),
791 })
792 };
793 let meter = |m: &crate::ocuse::api::Meter| {
797 serde_json::json!({
798 "status": m.status,
799 "percent": m.percent,
800 "resetsAt": m.reset_ms(),
801 })
802 };
803 serde_json::to_string_pretty(&serde_json::json!({
804 "source": "opencode.db",
805 "generatedAt": report.now_ms,
806 "periodStart": report.since_ms,
807 "periodStartAt": report.since_ms,
812 "periodEndAt": report.period_end_ms,
813 "periodEnd": report
816 .period_end_ms
817 .map(|ms| date_of(ms.div_euclid(86_400_000))),
818 "periodInferred": report.month_inferred,
819 "summary": totals(&report.month),
823 "totals": {
824 "month": totals(&report.month),
825 "fiveHour": totals(&report.five_hour),
826 "weekly": totals(&report.weekly),
827 },
828 "account": report.account.as_ref().map(|a| serde_json::json!({
831 "rolling": meter(&a.rolling),
832 "weekly": meter(&a.weekly),
833 "monthly": meter(&a.monthly),
834 })),
835 "accountError": report.account_error,
840 "models": report.models.iter().map(model).collect::<Vec<_>>(),
841 }))
842 .expect("json")
843}
844
845fn shorten(path: &str) -> String {
846 match std::env::var("HOME") {
847 Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
848 _ => path.to_string(),
849 }
850}
851
852pub fn plans_text(colour: bool) -> String {
859 let ink = Ink::new(colour);
860 let catalog = crate::ocuse::zen::catalog();
861 let mut out = format!(
862 "{}OpenCode plans{} {}\n",
863 ink.bold,
864 ink.reset,
865 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
866 );
867 out.push_str(&format!(
868 "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
869 ink.dim,
870 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
871 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
872 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
873 ink.reset,
874 ));
875 for plan in catalog.go.plans.values() {
876 out.push_str(&format!(
877 "\n{}{}{} — {}/month\n",
878 ink.bold,
879 plan.label,
880 ink.reset,
881 ink.cyan_on(format!("${:.2}", plan.price_usd)),
882 ));
883 out.push_str(&format!(
885 "{}{:<28} {:>7} {:>6} {:>6} {:>8}{}\n",
886 ink.bold, "MODEL", "LIMIT", "IN", "OUT", "CACHE", ink.reset
887 ));
888 for model in &catalog.go.models {
889 let rate = model.variants.first();
890 let limit = plan
891 .allowance_by_model
892 .get(&model.id)
893 .map(|limit| cmduse_core::money(*limit))
894 .unwrap_or_else(|| "—".into());
895 out.push_str(&format!(
896 "{:<28} {} {} {} {}\n",
897 model.id,
898 ink.cyan_on(format!("{limit:>7}")),
899 ink.cyan_on(format!(
900 "{:>6}",
901 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
902 )),
903 ink.cyan_on(format!(
904 "{:>6}",
905 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
906 )),
907 ink.cyan_on(format!(
908 "{:>8}",
909 rate.map(|r| format!("${}", r.cache_read))
910 .unwrap_or_default()
911 )),
912 ));
913 }
914 }
915 out.push_str(&format!(
916 "\n{}\n",
917 ink.dim_on(format!(
918 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
919 catalog.zen.models.len()
920 ))
921 ));
922 out
923}
924
925pub fn plans_json() -> String {
929 let catalog = crate::ocuse::zen::catalog();
930 let plans: Vec<serde_json::Value> = catalog
931 .go
932 .plans
933 .iter()
934 .map(|(id, plan)| {
935 serde_json::json!({
936 "id": id,
937 "label": plan.label,
938 "priceUsd": plan.price_usd,
939 "allowanceByModel": &plan.allowance_by_model,
940 })
941 })
942 .collect();
943 serde_json::json!({
944 "extractedAt": catalog.extracted_at,
945 "windows": {
946 "fiveHour": catalog.go.window_share.five_hour,
947 "weekly": catalog.go.window_share.weekly,
948 "monthly": catalog.go.window_share.monthly,
949 },
950 "plans": plans,
951 "models": catalog.go.models.iter().map(rate_json).collect::<Vec<_>>(),
952 "zen": catalog.zen.models.iter().map(rate_json).collect::<Vec<_>>(),
953 })
954 .to_string()
955}
956
957fn rate_json(model: &zen::Model) -> serde_json::Value {
959 let rate = model.variants.first();
960 serde_json::json!({
961 "id": model.id,
962 "name": model.name,
963 "input": rate.map(|r| r.input),
964 "output": rate.map(|r| r.output),
965 "cacheRead": rate.map(|r| r.cache_read),
966 "cacheWrite": rate.map(|r| r.cache_write),
967 })
968}
969
970struct ModelRow {
973 id: String,
974 name: String,
975 input: Option<f64>,
976 output: Option<f64>,
977 cache_read: Option<f64>,
978 limit: Option<f64>,
979}
980
981fn model_rows(plan_id: &str, gated: bool) -> Vec<ModelRow> {
984 let catalog = crate::ocuse::zen::catalog();
985 let plan = catalog.plan(plan_id);
986 let mut rows: Vec<ModelRow> = catalog
987 .go
988 .models
989 .iter()
990 .map(|m| {
991 let rate = m.variants.first();
992 ModelRow {
993 id: m.id.clone(),
994 name: m.name.clone(),
995 input: rate.map(|r| r.input),
996 output: rate.map(|r| r.output),
997 cache_read: rate.map(|r| r.cache_read),
998 limit: plan.and_then(|p| p.allowance_by_model.get(&m.id).copied()),
999 }
1000 })
1001 .collect();
1002 if gated {
1003 rows.retain(|r| r.limit.is_some());
1004 }
1005 rows
1006}
1007
1008pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
1012 let ink = Ink::new(colour);
1013 let catalog = crate::ocuse::zen::catalog();
1014 let label = catalog
1015 .plan(plan_id)
1016 .map(|p| p.label.clone())
1017 .unwrap_or_else(|| plan_id.to_string());
1018 let granted = catalog
1019 .plan(plan_id)
1020 .map(|p| p.allowance_by_model.len())
1021 .unwrap_or(0);
1022 let rows = model_rows(plan_id, gated);
1023 let mut out = format!(
1024 "{}{label} models{} {}\n",
1025 ink.bold,
1026 ink.reset,
1027 ink.dim_on(format!(
1028 "(plan {plan_id} · {granted}/{} granted)",
1029 catalog.go.models.len()
1030 )),
1031 );
1032 out.push_str(&format!(
1033 "{}{:<30} {:<28} {:>7} {:>7} {:>7} {:>8} {:>7}{}\n",
1034 ink.bold, "MODEL", "ID", "IN", "OUT", "CACHE", "LIMIT", "GRANTED", ink.reset
1035 ));
1036 let rate_cell = |v: Option<f64>| match v {
1037 Some(v) => ink.cyan_on(format!("{:>7}", format!("${v}"))),
1038 None => ink.dim_on(format!("{:>7}", "—")),
1039 };
1040 for r in &rows {
1041 let limit = match r.limit {
1042 Some(l) => ink.cyan_on(format!("{:>8}", cmduse_core::money(l))),
1043 None => ink.dim_on(format!("{:>8}", "—")),
1044 };
1045 let granted = if r.limit.is_some() { "yes" } else { "no" };
1046 out.push_str(&format!(
1047 "{:<30} {:<28} {} {} {} {} {:>7}\n",
1048 r.name,
1049 r.id,
1050 rate_cell(r.input),
1051 rate_cell(r.output),
1052 rate_cell(r.cache_read),
1053 limit,
1054 granted,
1055 ));
1056 }
1057 out
1058}
1059
1060pub fn models_json(plan_id: &str, gated: bool) -> String {
1064 let mut out = String::new();
1065 for r in model_rows(plan_id, gated) {
1066 out.push_str(
1067 &serde_json::json!({
1068 "id": r.id,
1069 "name": r.name,
1070 "input": r.input,
1071 "output": r.output,
1072 "cacheRead": r.cache_read,
1073 "limit": r.limit,
1074 "granted": r.limit.is_some(),
1075 })
1076 .to_string(),
1077 );
1078 out.push('\n');
1079 }
1080 out
1081}
1082
1083pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1084 let ink = Ink::new(colour);
1085 let Some(model) = report.models.iter().find(|m| m.id == id) else {
1086 return format!("{id}: no usage in the period\n");
1087 };
1088 let limit = model
1089 .limit
1090 .map(cmduse_core::money)
1091 .unwrap_or_else(|| "—".into());
1092 let row =
1093 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
1094 let mut out = format!(
1095 "{} {}\n",
1096 ink.bold_on(model.id.clone()),
1097 ink.dim_on(format!("({})", model.provider)),
1098 );
1099 out.push_str(&row("limit", ink.cyan_on(limit)));
1100 out.push_str(&row(
1101 "month",
1102 ink.cyan_on(format!(
1103 "{} · {} requests",
1104 cmduse_core::money(model.month.cost),
1105 model.month.requests
1106 )),
1107 ));
1108 out.push_str(&row(
1109 "5-hour",
1110 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
1111 ));
1112 out.push_str(&row(
1113 "weekly",
1114 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
1115 ));
1116 if report.month.cost > 0.0 {
1119 out.push_str(&row(
1120 "share",
1121 ink.dim_on(format!(
1122 "{:.1}% of period spend",
1123 model.month.cost / report.month.cost * 100.0
1124 )),
1125 ));
1126 }
1127 out
1128}
1129
1130fn totals_json(
1135 cost: f64,
1136 requests: u64,
1137 input: u64,
1138 output: u64,
1139 cache_read: u64,
1140) -> serde_json::Value {
1141 serde_json::json!({
1142 "requests": requests,
1143 "tokensIn": input,
1144 "tokensOut": output,
1145 "cacheRead": cache_read,
1146 "costUsd": cost,
1147 })
1148}
1149
1150#[derive(Default, Clone, Copy)]
1153struct Bucket {
1154 cost: f64,
1155 requests: u64,
1156 input: u64,
1157 output: u64,
1158 cache_read: u64,
1159}
1160
1161impl Bucket {
1162 fn add(&mut self, row: &crate::ocuse::db::Row) {
1163 self.cost += row.cost_usd;
1164 self.requests += 1;
1165 self.input += row.input;
1166 self.output += row.output;
1167 self.cache_read += row.cache_read;
1168 }
1169
1170 fn json(&self) -> serde_json::Value {
1171 totals_json(
1172 self.cost,
1173 self.requests,
1174 self.input,
1175 self.output,
1176 self.cache_read,
1177 )
1178 }
1179}
1180
1181pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1189 let models: Vec<&ModelUsage> = report
1190 .models
1191 .iter()
1192 .filter(|m| id.is_none_or(|want| m.id == want))
1193 .collect();
1194 match fmt {
1195 Fmt::Json => {
1196 let map: serde_json::Map<String, serde_json::Value> = models
1197 .iter()
1198 .map(|m| {
1199 let mut body = totals_json(
1200 m.month.cost,
1201 m.month.requests,
1202 m.month.input,
1203 m.month.output,
1204 m.month.cache_read,
1205 );
1206 body["limit"] = serde_json::json!(m.limit);
1207 (m.id.clone(), body)
1208 })
1209 .collect();
1210 serde_json::json!({
1211 "scope": "local",
1212 "source": "opencode",
1213 "sinceMs": report.since_ms,
1214 "models": map,
1215 })
1216 .to_string()
1217 }
1218 Fmt::Csv => {
1219 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1220 for m in models {
1221 out.push_str(&format!(
1222 "{},{},{},{},{},{:.2}\n",
1223 crate::report_render::csv_field(&m.id),
1224 m.month.requests,
1225 m.month.input,
1226 m.month.output,
1227 m.month.cache_read,
1228 m.month.cost,
1229 ));
1230 }
1231 out
1232 }
1233 Fmt::Table => String::new(), }
1235}
1236
1237pub fn bucket_text(
1245 rows: &[crate::ocuse::db::Row],
1246 now_ms: i64,
1247 lookback_ms: i64,
1248 size_ms: i64,
1249 tz_secs: i64,
1250 colour: bool,
1251 fmt: Fmt,
1252) -> String {
1253 let shift = tz_secs * 1000;
1255 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1256 for row in rows {
1257 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1258 buckets.entry(bucket).or_default().add(row);
1259 }
1260 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1261 let daily = size_ms >= 86_400_000;
1262 let label = if daily { "day" } else { "hour" };
1263 let stamp = |bucket: i64| {
1264 if daily {
1265 date_of(bucket)
1266 } else {
1267 format!(
1268 "{} {:02}h",
1269 date_of(bucket.div_euclid(24)),
1270 bucket.rem_euclid(24)
1271 )
1272 }
1273 };
1274 let window: Vec<(i64, Bucket)> = buckets
1275 .range(start..)
1276 .map(|(bucket, b)| (*bucket, *b))
1277 .collect();
1278
1279 match fmt {
1280 Fmt::Json => {
1281 let mut keyed = serde_json::Map::new();
1282 let mut total = Bucket::default();
1283 for (bucket, b) in &window {
1284 keyed.insert(stamp(*bucket), b.json());
1285 total.add_bucket(b);
1286 }
1287 let key = if daily { "days" } else { "hours" };
1288 serde_json::json!({
1289 "scope": "local",
1290 "source": "opencode",
1291 "total": total.json(),
1292 key: keyed,
1293 })
1294 .to_string()
1295 }
1296 Fmt::Csv => {
1297 let head = if daily { "day" } else { "hour" };
1298 let mut out = format!("{head},requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1299 let mut total = Bucket::default();
1300 for (bucket, b) in &window {
1301 out.push_str(&format!(
1302 "{},{},{},{},{},{:.2}\n",
1303 crate::report_render::csv_field(&stamp(*bucket)),
1304 b.requests,
1305 b.input,
1306 b.output,
1307 b.cache_read,
1308 b.cost,
1309 ));
1310 total.add_bucket(b);
1311 }
1312 out.push_str(&format!(
1313 "total,{},{},{},{},{:.2}\n",
1314 total.requests, total.input, total.output, total.cache_read, total.cost,
1315 ));
1316 out
1317 }
1318 Fmt::Table => {
1319 let ink = Ink::new(colour);
1320 let mut out = format!(
1321 "{}{label:<12} spend requests tokens{}\n",
1322 ink.bold, ink.reset
1323 );
1324 for (bucket, b) in &window {
1325 out.push_str(&format!(
1326 "{:<12} {} {} {}\n",
1327 stamp(*bucket),
1328 ink.cyan_on(format!("{:>9}", cmduse_core::money(b.cost))),
1329 ink.cyan_on(format!("{:>12}", cmduse_core::compact(b.requests))),
1330 ink.cyan_on(format!(
1331 "{:>9}",
1332 cmduse_core::compact(b.input + b.output + b.cache_read)
1333 )),
1334 ));
1335 }
1336 if out.lines().count() == 1 {
1337 out.push_str(&format!(
1338 "{}\n",
1339 ink.dim_on("(no OpenCode usage in the window)".into())
1340 ));
1341 }
1342 out
1343 }
1344 }
1345}
1346
1347type SessionTotals<'a> = (
1350 f64,
1351 u64,
1352 u64,
1353 u64,
1354 u64,
1355 i64,
1356 std::collections::BTreeSet<&'a str>,
1357);
1358
1359pub fn session_text(
1364 rows: &[crate::ocuse::db::Row],
1365 limit: usize,
1366 colour: bool,
1367 fmt: Fmt,
1368) -> String {
1369 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1370 for row in rows {
1371 let entry = by_session
1372 .entry(row.session.as_str())
1373 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1374 entry.0 += row.cost_usd;
1375 entry.1 += 1;
1376 entry.2 += row.input;
1377 entry.3 += row.output;
1378 entry.4 += row.cache_read;
1379 entry.5 = entry.5.max(row.at_ms);
1380 entry.6.insert(row.model.as_str());
1381 }
1382 let mut sessions: Vec<_> = by_session.into_iter().collect();
1383 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1384
1385 match fmt {
1386 Fmt::Json => {
1387 let mut keyed = serde_json::Map::new();
1388 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1389 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1390 body["lastMs"] = serde_json::json!(*last);
1391 body["models"] = serde_json::json!(models.len());
1392 keyed.insert((*id).to_string(), body);
1393 }
1394 serde_json::json!({
1395 "scope": "local",
1396 "source": "opencode",
1397 "sessions": keyed,
1398 })
1399 .to_string()
1400 }
1401 Fmt::Csv => {
1402 let mut out =
1403 String::from("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1404 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1405 out.push_str(&format!(
1406 "{},{},{},{},{},{:.2}\n",
1407 crate::report_render::csv_field(id),
1408 requests,
1409 input,
1410 output,
1411 cache_read,
1412 cost,
1413 ));
1414 }
1415 out
1416 }
1417 Fmt::Table => {
1418 let ink = Ink::new(colour);
1419 let mut out = format!(
1420 "{}session last models spend req{}\n",
1421 ink.bold, ink.reset
1422 );
1423 for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1424 let short = id.rsplit('_').next().unwrap_or(*id);
1425 out.push_str(&format!(
1426 "{:<32} {:<11} {:>6} {} {:>6}\n",
1427 short,
1428 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1429 models.len(),
1430 ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
1431 requests,
1432 ));
1433 }
1434 if sessions.len() > limit {
1435 out.push_str(&format!(
1436 "{}\n",
1437 ink.dim_on(format!("… {} more", sessions.len() - limit))
1438 ));
1439 }
1440 if out.lines().count() == 1 {
1441 out.push_str(&format!(
1442 "{}\n",
1443 ink.dim_on("(no sessions in the window)".into())
1444 ));
1445 }
1446 out
1447 }
1448 }
1449}
1450
1451impl Bucket {
1452 fn add_bucket(&mut self, other: &Bucket) {
1454 self.cost += other.cost;
1455 self.requests += other.requests;
1456 self.input += other.input;
1457 self.output += other.output;
1458 self.cache_read += other.cache_read;
1459 }
1460}
1461
1462pub fn date_of(day: i64) -> String {
1465 cmduse_core::dates::civil_from_days(day)
1466}
1467
1468#[cfg(test)]
1469mod tests {
1470 use super::*;
1471 use crate::ocuse::db::Row;
1472
1473 fn report() -> Report {
1474 let now = 1_800_000_000_000;
1475 let rows = vec![Row {
1476 at_ms: now - 60_000,
1477 provider: "opencode-go".into(),
1478 model: "glm-5.3-flash".into(),
1479 cost_usd: 30.0,
1480 ..Default::default()
1481 }];
1482 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None)
1483 }
1484
1485 #[test]
1486 fn text_shows_the_model_against_its_limit() {
1487 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1488 assert!(text.contains("glm-5.3-flash"), "{text}");
1489 assert!(text.contains("$60"), "the model's monthly limit: {text}");
1490 assert!(
1491 text.contains("50.0%"),
1492 "the model table keeps its own USE: {text}"
1493 );
1494 }
1495
1496 fn cell_ends(line: &str) -> Vec<usize> {
1498 let mut ends = Vec::new();
1499 let mut cursor = 0;
1500 for cell in line.split_whitespace() {
1501 let start = line[cursor..].find(cell).expect("cell in line") + cursor;
1502 cursor = start + cell.len();
1503 ends.push(cursor);
1504 }
1505 ends
1506 }
1507
1508 #[test]
1511 fn the_model_table_header_aligns_with_its_rows() {
1512 let now = 1_800_000_000_000;
1513 let rows = vec![Row {
1516 at_ms: now - 60_000,
1517 provider: "opencode-go".into(),
1518 model: "glm-5.3-flash".into(),
1519 cost_usd: 5.0,
1520 ..Default::default()
1521 }];
1522 let report =
1523 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
1524 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1525 let mut lines = text.lines().skip_while(|line| !line.starts_with("MODEL "));
1526 let header = lines.next().expect("the table header");
1527 let row = lines
1528 .find(|line| line.starts_with("glm-5.3-flash"))
1529 .expect("the model row");
1530 let header_ends = cell_ends(header);
1531 assert_eq!(
1532 header_ends[1..],
1533 cell_ends(row)[1..header_ends.len()],
1534 "header columns must end where their cells do:\n{header}\n{row}"
1535 );
1536 }
1537
1538 #[test]
1540 fn the_models_table_header_aligns_with_its_rows() {
1541 let text = models_text(zen::DEFAULT_PLAN, false, false);
1542 let mut lines = text.lines().skip_while(|line| !line.starts_with("MODEL "));
1543 let header = lines.next().expect("the table header");
1544 let row = lines
1547 .find(|line| line.starts_with("DeepSeek V4 Flash "))
1548 .expect("the model row");
1549 let header_ends = cell_ends(header);
1550 let row_ends = cell_ends(row);
1551 assert_eq!(
1555 &header_ends[header_ends.len() - 5..],
1556 &row_ends[row_ends.len() - 5..],
1557 "header columns must end where their cells do:\n{header}\n{row}"
1558 );
1559 }
1560
1561 fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
1562 crate::ocuse::api::Meter {
1563 status: status.into(),
1564 percent,
1565 resets_at: Some(resets_at.into()),
1566 }
1567 }
1568
1569 fn report_with_account() -> Report {
1576 let mut report = report();
1577 report.account = Some(crate::ocuse::api::Usage {
1578 rolling: meter("ok", 5.0, "2027-01-15T12:31:00.000Z"),
1579 weekly: meter("ok", 59.0, "2027-01-18T00:00:00.000Z"),
1580 monthly: meter("ok", 29.0, "2027-02-15T08:00:00.000Z"),
1581 });
1582 report.period_end_ms = Some(1_802_678_400_000); report.since_ms = crate::ocuse::window::prev_month(1_802_678_400_000);
1584 report
1585 }
1586
1587 #[test]
1588 fn the_window_rows_come_from_the_account_when_it_answered() {
1589 let text = render_text(
1590 &report_with_account(),
1591 "/tmp/opencode.db",
1592 10,
1593 false,
1594 DEFAULT_BAR_WIDTH,
1595 );
1596 assert!(text.contains("· 5% of limit"), "{text}");
1599 assert!(text.contains("· 59% of limit"), "{text}");
1600 assert!(text.contains("· 29% of limit"), "{text}");
1601 assert!(text.contains("account meters"), "{text}");
1602 assert!(
1603 text.contains("resets 4h 31m"),
1604 "the 5h block's own end: {text}"
1605 );
1606 assert!(text.contains(".00 · 5% of limit"), "{text}");
1612 assert!(text.contains(".00 · 29% of limit"), "{text}");
1613 assert!(text.contains("of limit"), "{text}");
1614 assert!(text.contains("elapsed"), "{text}");
1619 assert_eq!(text.matches("by now").count(), 1, "{text}");
1620 assert!(text.contains("the same cuts its percentages use"), "{text}");
1623 }
1624
1625 #[test]
1632 fn the_pair_is_the_ceiling_the_share_implies() {
1633 assert_eq!(implied_cap(30.0, 5.0), Some(600.0));
1635 assert_eq!(implied_cap(30.0, 59.0), Some(50.847457627118644));
1636 assert_eq!(implied_cap(20.45, 0.0), None);
1638 assert_eq!(implied_cap(20.45, 0.04), None);
1639 assert_eq!(implied_cap(20.45, 0.1), Some(20450.0));
1640 assert_eq!(implied_cap(0.0, 1.0), Some(0.0));
1641 }
1642
1643 #[test]
1648 fn the_gauge_carries_the_share_and_the_body_does_not_repeat_it() {
1649 let colour = render_text(
1650 &report_with_account(),
1651 "/tmp/opencode.db",
1652 10,
1653 true,
1654 DEFAULT_BAR_WIDTH,
1655 );
1656 let five = colour
1657 .lines()
1658 .find(|line| line.contains("5-hour"))
1659 .expect("a 5-hour row");
1660 assert!(!five.contains("of limit"), "no repeated share: {five}");
1661 assert!(
1662 five.contains(" 5%"),
1663 "the endpoint's whole figure: {five}"
1664 );
1665 let plain = render_text(
1666 &report_with_account(),
1667 "/tmp/opencode.db",
1668 10,
1669 false,
1670 DEFAULT_BAR_WIDTH,
1671 );
1672 assert!(plain.contains("· 5% of limit"), "{plain}");
1673 }
1674
1675 #[test]
1677 fn a_reachable_cap_gets_the_pace_line() {
1678 let mut report = report_with_account();
1679 if let Some(account) = &mut report.account {
1680 account.rolling = meter("ok", 90.0, "2027-01-15T12:00:00.000Z");
1683 }
1684 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1685 assert!(text.contains("on pace to hit cap in"), "{text}");
1686 }
1687
1688 #[test]
1689 fn without_an_account_the_rows_say_they_are_local() {
1690 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1691 assert!(text.contains("estimated locally"), "{text}");
1692 assert!(text.contains("no pool"), "no aggregate ceiling: {text}");
1693 assert!(!text.contains("of limit"), "{text}");
1695 assert!(!text.contains("resets"), "{text}");
1696 assert!(!text.contains("elapsed"), "{text}");
1697 assert!(!text.contains("account meters"), "{text}");
1699 assert!(
1700 !text.contains("the same cuts its percentages use"),
1701 "{text}"
1702 );
1703 assert!(text.contains("local, not the account's windows"), "{text}");
1706 }
1707
1708 #[test]
1709 fn an_over_limit_meter_says_so_before_its_countdown() {
1710 let mut report = report_with_account();
1711 if let Some(account) = &mut report.account {
1712 account.rolling = meter("rate_limited", 100.0, "2027-01-15T12:31:00.000Z");
1713 }
1714 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1715 assert!(text.contains("LIMIT EXCEEDED"), "{text}");
1716 }
1717
1718 #[test]
1719 fn json_carries_the_account_meters_and_null_without_them() {
1720 let parsed: serde_json::Value =
1721 serde_json::from_str(&render_json(&report_with_account())).unwrap();
1722 assert_eq!(parsed["account"]["rolling"]["percent"], 5.0);
1723 assert_eq!(parsed["account"]["weekly"]["percent"], 59.0);
1724 assert_eq!(parsed["account"]["monthly"]["status"], "ok");
1725 let monthly = cmduse_core::dates::parse_iso_utc("2027-02-15T08:00:00.000Z").unwrap();
1728 assert_eq!(parsed["account"]["monthly"]["resetsAt"], monthly as i64);
1729
1730 let parsed: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1733 assert_eq!(parsed["account"], serde_json::Value::Null);
1734 }
1735
1736 #[test]
1737 fn statusline_prefers_the_accounts_share_over_the_local_estimate() {
1738 let tpl = "{five_hour_pct} {weekly_pct} {month_pct} {five_hour}";
1739 let account = statusline_template(
1740 &report_with_account(),
1741 "go",
1742 tpl,
1743 false,
1744 false,
1745 DEFAULT_BAR_WIDTH,
1746 );
1747 assert!(account.starts_with("5.0% 59.0% 29.0%"), "{account}");
1748 assert!(account.contains("$30.00"), "{account}");
1750
1751 let local = statusline_template(&report(), "go", tpl, false, false, DEFAULT_BAR_WIDTH);
1754 assert!(!local.starts_with("5.0%"), "{local}");
1755 }
1756
1757 #[test]
1758 fn json_and_statusline_carry_the_totals() {
1759 let json = render_json(&report());
1760 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1761 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
1762 assert_eq!(parsed["summary"], parsed["totals"]["month"]);
1766 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
1767 let line = statusline_template(&report(), "go", "{plan} {models}", false, false, 20);
1768 assert_eq!(line, "go glm-5.3-flash");
1769 }
1770
1771 #[test]
1772 fn json_carries_the_period_bounds() {
1773 let mut r = report();
1776 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1777 let parsed: serde_json::Value = serde_json::from_str(&render_json(&r)).unwrap();
1778 assert_eq!(parsed["periodStart"], r.since_ms);
1779 assert_eq!(parsed["periodStartAt"], r.since_ms);
1780 assert_eq!(parsed["periodEndAt"], r.period_end_ms.unwrap());
1781
1782 let mut all = report();
1785 all.period_end_ms = None;
1786 let parsed: serde_json::Value = serde_json::from_str(&render_json(&all)).unwrap();
1787 assert_eq!(parsed["periodEndAt"], serde_json::Value::Null);
1788 assert_eq!(parsed["periodStart"], all.since_ms);
1789 assert!(parsed.get("periodInferred").is_some());
1790 }
1791
1792 #[test]
1798 fn every_renderer_respects_the_colour_flag() {
1799 let mut report = report();
1800 report.period_end_ms = Some(crate::ocuse::window::next_month(report.since_ms));
1803 let rows = vec![Row {
1804 at_ms: report.now_ms - 60_000,
1805 session: "ses_fixture".into(),
1806 provider: "opencode-go".into(),
1807 model: "glm-5.3-flash".into(),
1808 cost_usd: 30.0,
1809 ..Default::default()
1810 }];
1811 let now = report.now_ms;
1812
1813 let cases: Vec<(&str, String, String)> = vec![
1814 (
1815 "render_text",
1816 render_text(&report, "/tmp/oc.db", 10, false, DEFAULT_BAR_WIDTH),
1817 render_text(&report, "/tmp/oc.db", 10, true, DEFAULT_BAR_WIDTH),
1818 ),
1819 (
1820 "statusline_template",
1821 statusline_template(
1822 &report,
1823 "go",
1824 "{period} {month} {month_pct}",
1825 false,
1826 false,
1827 20,
1828 ),
1829 statusline_template(
1830 &report,
1831 "go",
1832 "{period} {month} {month_pct}",
1833 true,
1834 false,
1835 20,
1836 ),
1837 ),
1838 (
1839 "compact_line",
1840 compact_line(&report, &[], false),
1841 compact_line(&report, &[], true),
1842 ),
1843 ("plans_text", plans_text(false), plans_text(true)),
1844 (
1845 "model_text",
1846 model_text(&report, "glm-5.3-flash", false),
1847 model_text(&report, "glm-5.3-flash", true),
1848 ),
1849 (
1850 "bucket_text/day",
1851 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Table),
1852 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Table),
1853 ),
1854 (
1855 "bucket_text/hour",
1856 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Table),
1857 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Table),
1858 ),
1859 (
1860 "session_text",
1861 session_text(&rows, 20, false, Fmt::Table),
1862 session_text(&rows, 20, true, Fmt::Table),
1863 ),
1864 (
1865 "models_text",
1866 models_text("go", false, false),
1867 models_text("go", false, true),
1868 ),
1869 ];
1870 for (name, plain, coloured) in cases {
1871 assert!(
1872 !plain.contains('\x1b'),
1873 "{name} leaked escapes when off: {plain}"
1874 );
1875 assert!(
1876 coloured.contains('\x1b'),
1877 "{name} rendered nothing coloured when on: {coloured}"
1878 );
1879 }
1880
1881 let flagged: Vec<(&str, String, String)> = vec![
1884 (
1885 "bucket_text/day csv",
1886 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv),
1887 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Csv),
1888 ),
1889 (
1890 "bucket_text/hour json",
1891 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Json),
1892 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Json),
1893 ),
1894 (
1895 "session_text csv",
1896 session_text(&rows, 20, false, Fmt::Csv),
1897 session_text(&rows, 20, true, Fmt::Csv),
1898 ),
1899 (
1900 "session_text json",
1901 session_text(&rows, 20, false, Fmt::Json),
1902 session_text(&rows, 20, true, Fmt::Json),
1903 ),
1904 ];
1905 for (name, off, on) in flagged {
1906 assert!(
1907 !off.contains('\x1b'),
1908 "{name} leaked escapes with colour off: {off}"
1909 );
1910 assert!(
1911 !on.contains('\x1b'),
1912 "{name} must stay uncoloured with colour on: {on}"
1913 );
1914 }
1915 for (name, out) in [
1916 ("model_report json", model_report(&report, None, Fmt::Json)),
1917 ("model_report csv", model_report(&report, None, Fmt::Csv)),
1918 ("plans_json", plans_json()),
1919 ("statusline_json", statusline_json(&report, "go")),
1920 ("models_json", models_json("go", false)),
1921 ] {
1922 assert!(
1923 !out.contains('\x1b'),
1924 "{name} must never be coloured: {out}"
1925 );
1926 }
1927 }
1928
1929 #[test]
1930 fn session_rows_cap_the_table_and_never_the_machine_formats() {
1931 let now = 1_800_000_000_000;
1935 let rows: Vec<Row> = (0..25)
1936 .map(|i| Row {
1937 at_ms: now - i as i64 * 60_000,
1938 provider: "opencode-go".into(),
1939 model: "glm-5.3-flash".into(),
1940 session: format!("ses_{i:02}"),
1941 cost_usd: 0.1,
1942 ..Default::default()
1943 })
1944 .collect();
1945 let table = session_text(&rows, 2, false, Fmt::Table);
1946 assert!(table.contains("… 23 more"), "{table}");
1947 assert_eq!(
1948 table.lines().count(),
1949 4,
1950 "header + 2 rows + the tail: {table}"
1951 );
1952 let json: serde_json::Value =
1953 serde_json::from_str(&session_text(&rows, 2, false, Fmt::Json)).unwrap();
1954 assert_eq!(json["sessions"].as_object().unwrap().len(), 25);
1955 let csv = session_text(&rows, 2, false, Fmt::Csv);
1956 assert_eq!(csv.lines().count(), 26, "header + every session: {csv}");
1957 }
1958
1959 #[test]
1960 fn bucket_reports_carry_the_documented_columns() {
1961 let now = 1_800_000_000_000;
1962 let rows = vec![Row {
1963 at_ms: now - 60_000,
1964 provider: "opencode-go".into(),
1965 model: "glm-5.3-flash".into(),
1966 cost_usd: 1.5,
1967 input: 10,
1968 output: 20,
1969 cache_read: 30,
1970 cache_write: 40,
1971 ..Default::default()
1972 }];
1973 let csv = bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv);
1974 assert!(
1975 csv.starts_with("day,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1976 "{csv}"
1977 );
1978 assert!(!csv.contains("cache_write"), "{csv}");
1980 assert!(csv.contains(",10,20,30,1.50\n"), "{csv}");
1981 assert!(csv.trim_end().ends_with("total,1,10,20,30,1.50"), "{csv}");
1982
1983 let json: serde_json::Value = serde_json::from_str(&bucket_text(
1984 &rows,
1985 now,
1986 7 * 86_400_000,
1987 86_400_000,
1988 0,
1989 false,
1990 Fmt::Json,
1991 ))
1992 .unwrap();
1993 assert_eq!(json["total"]["requests"], 1);
1994 assert_eq!(json["total"]["cacheRead"], 30);
1995 assert_eq!(json["days"].as_object().unwrap().len(), 1);
1996 }
1997
1998 #[test]
1999 fn session_report_json_and_csv_agree_with_the_store() {
2000 let now = 1_800_000_000_000;
2001 let rows = vec![
2002 Row {
2003 at_ms: now - 60_000,
2004 session: "ses_first".into(),
2005 model: "glm-5.3-flash".into(),
2006 cost_usd: 2.0,
2007 input: 5,
2008 output: 6,
2009 cache_read: 7,
2010 ..Default::default()
2011 },
2012 Row {
2013 at_ms: now - 30_000,
2014 session: "ses_second".into(),
2015 model: "kimi-k3".into(),
2016 cost_usd: 1.0,
2017 ..Default::default()
2018 },
2019 ];
2020 let json: serde_json::Value =
2021 serde_json::from_str(&session_text(&rows, 20, false, Fmt::Json)).unwrap();
2022 assert_eq!(json["sessions"]["ses_first"]["tokensIn"], 5);
2023 assert_eq!(json["sessions"]["ses_first"]["models"], 1);
2024 assert_eq!(json["sessions"]["ses_second"]["costUsd"], 1.0);
2025 let csv = session_text(&rows, 20, false, Fmt::Csv);
2026 assert!(
2027 csv.starts_with("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
2028 "{csv}"
2029 );
2030 assert!(csv.contains("ses_second,1,0,0,0,1.00\n"), "{csv}");
2031 }
2032
2033 #[test]
2034 fn model_report_projects_the_report_models() {
2035 let json: serde_json::Value =
2036 serde_json::from_str(&model_report(&report(), None, Fmt::Json)).unwrap();
2037 assert_eq!(json["source"], "opencode");
2038 assert_eq!(json["models"]["glm-5.3-flash"]["costUsd"], 30.0);
2039 assert_eq!(json["models"]["glm-5.3-flash"]["limit"], 60.0);
2040 let csv = model_report(&report(), None, Fmt::Csv);
2041 assert!(csv.contains("glm-5.3-flash,1,0,0,0,30.00\n"), "{csv}");
2042 assert_eq!(
2044 model_report(&report(), Some("nope"), Fmt::Csv),
2045 "model,requests,tokens_in,tokens_out,cache_read,cost_usd\n"
2046 );
2047 }
2048
2049 #[test]
2050 fn catalogue_json_carries_both_plans_and_windows() {
2051 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
2052 assert_eq!(json["windows"]["fiveHour"], 0.2);
2053 assert_eq!(json["windows"]["monthly"], 1.0);
2054 assert_eq!(json["plans"][0]["id"], "go");
2055 assert_eq!(json["plans"][0]["allowanceByModel"]["glm-5.3-flash"], 60.0);
2056 assert_eq!(json["plans"][1]["priceUsd"], 40.0);
2057 assert!(!json["models"].as_array().unwrap().is_empty());
2058 assert!(!json["zen"].as_array().unwrap().is_empty());
2059 }
2060
2061 #[test]
2062 fn models_lists_the_plan_and_marks_granted() {
2063 let all = models_text("go", false, false);
2065 assert!(all.contains("glm-5.3-flash"), "{all}");
2066 assert!(all.contains("GRANTED"), "{all}");
2067 let gated = models_text("go", true, false);
2069 for line in gated.lines().skip(2) {
2070 assert!(
2071 line.trim_end().ends_with("yes"),
2072 "ungranted row kept: {line}"
2073 );
2074 }
2075 let every: Vec<serde_json::Value> = models_json("go", false)
2077 .lines()
2078 .map(|l| serde_json::from_str(l).unwrap())
2079 .collect();
2080 assert!(every.iter().any(|m| m["granted"] == false));
2081 assert!(every
2082 .iter()
2083 .any(|m| m["id"] == "glm-5.3-flash" && m["limit"] == 60.0));
2084 let only: Vec<serde_json::Value> = models_json("go", true)
2085 .lines()
2086 .map(|l| serde_json::from_str(l).unwrap())
2087 .collect();
2088 assert!(!only.is_empty());
2089 assert!(only.iter().all(|m| m["granted"] == true));
2090 }
2091
2092 #[test]
2093 fn statusline_json_mirrors_the_template_numbers() {
2094 let json: serde_json::Value =
2095 serde_json::from_str(&statusline_json(&report(), "go")).unwrap();
2096 assert_eq!(json["plan"], "go");
2097 assert_eq!(json["period"], "calendar month");
2098 assert_eq!(json["monthSpent"], 30.0);
2099 assert_eq!(json["monthCap"], serde_json::Value::Null);
2102 assert_eq!(json["fiveHourCap"], serde_json::Value::Null);
2103 assert_eq!(json["weeklyCap"], serde_json::Value::Null);
2104 assert_eq!(json["model"], "glm-5.3-flash");
2105 }
2106
2107 #[test]
2108 fn plain_frames_carry_no_escapes() {
2109 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2110 assert!(
2111 !text.contains('\x1b'),
2112 "piped output must stay plain: {text}"
2113 );
2114 assert!(text.contains("$30.00"), "{text}");
2117 }
2118
2119 #[test]
2123 fn monthly_row_carries_the_real_period_metrics() {
2124 let mut r = report();
2125 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
2126 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2127
2128 let monthly = text
2129 .lines()
2130 .find(|line| line.trim_start().starts_with("Monthly"))
2131 .expect("the monthly row");
2132 assert!(monthly.contains("resets"), "{monthly}");
2133 assert!(
2134 monthly.contains("window") && monthly.contains("% elapsed"),
2135 "the real period gives an elapsed share: {monthly}"
2136 );
2137
2138 for label in ["5-hour", "Weekly"] {
2139 let row = text
2140 .lines()
2141 .find(|line| line.trim_start().starts_with(label))
2142 .unwrap_or_else(|| panic!("the {label} row"));
2143 assert!(!row.contains("elapsed"), "{label} has no boundary: {row}");
2144 assert!(!row.contains("resets"), "{label} has no boundary: {row}");
2145 }
2146 }
2147
2148 #[test]
2151 fn period_line_splits_tokens_and_shows_the_end_date() {
2152 let mut r = report();
2153 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
2154 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2155 let header = text
2156 .lines()
2157 .find(|line| line.starts_with("period:"))
2158 .expect("the period line");
2159 assert!(
2160 header.contains(" in / "),
2161 "token split like cmduse: {header}"
2162 );
2163 assert!(header.contains(" out tokens"), "{header}");
2164 assert!(
2165 header.contains(&date_of(r.period_end_ms.unwrap() / 86_400_000)),
2166 "the resolved end date: {header}"
2167 );
2168
2169 let no_end = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2171 let header = no_end
2172 .lines()
2173 .find(|line| line.starts_with("period:"))
2174 .expect("the period line");
2175 assert!(!header.contains("ends"), "{header}");
2176 }
2177
2178 #[test]
2179 fn an_over_cap_window_is_flagged_in_both_modes() {
2180 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2182 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
2183 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
2184 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
2185 assert!(
2186 coloured.contains(crate::render::RED),
2187 "the flag and the gauge are red: {coloured}"
2188 );
2189 }
2190
2191 #[test]
2192 fn a_model_over_any_of_its_own_windows_is_flagged() {
2193 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2196 let row = plain
2199 .lines()
2200 .skip_while(|line| !line.starts_with("MODEL "))
2201 .find(|line| line.starts_with("glm-5.3-flash"))
2202 .expect("the model row");
2203 assert!(row.contains("LIMIT EXCEEDED"), "{row}");
2204 assert!(!row.trim_end().ends_with('%'), "no trailing share: {row}");
2207 assert!(row.trim_end().ends_with(char::is_numeric), "{row}");
2208 }
2209
2210 #[test]
2214 fn period_line_carries_the_account_monthly_share() {
2215 let with = render_text(
2216 &report_with_account(),
2217 "/tmp/opencode.db",
2218 10,
2219 false,
2220 DEFAULT_BAR_WIDTH,
2221 );
2222 let header = with
2223 .lines()
2224 .find(|line| line.starts_with("period:"))
2225 .expect("the period line");
2226 assert!(header.contains("% of limit"), "{header}");
2227 assert!(
2228 header.contains("29%"),
2229 "the fixture's monthly meter: {header}"
2230 );
2231
2232 let without = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2233 let header = without
2234 .lines()
2235 .find(|line| line.starts_with("period:"))
2236 .expect("the period line");
2237 assert!(
2238 !header.contains("of limit"),
2239 "no account, no share: {header}"
2240 );
2241 }
2242
2243 #[test]
2246 fn top_models_state_their_share_of_money_and_tokens() {
2247 let now = 1_800_000_000_000;
2251 let rows = vec![Row {
2252 at_ms: now - 60_000,
2253 provider: "opencode-go".into(),
2254 model: "glm-5.3-flash".into(),
2255 input: 1_200_000,
2256 output: 400_000,
2257 ..Default::default()
2258 }];
2259 let report =
2260 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
2261 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2262 let block = text
2263 .lines()
2264 .skip_while(|line| !line.starts_with("TOP 3 MODELS"))
2265 .take_while(|line| !line.starts_with("MODEL "))
2266 .collect::<Vec<_>>()
2267 .join("\n");
2268 assert!(block.contains("glm-5.3-flash"), "{block}");
2269 assert!(
2270 block.contains("MONEY") && block.contains("TOKENS"),
2271 "{block}"
2272 );
2273 assert_eq!(block.matches("100.0%").count(), 2, "{block}");
2275 assert!(!text.contains("of period spend"), "{text}");
2277 }
2278
2279 #[test]
2280 fn a_model_within_its_limits_is_not_flagged() {
2281 let now = 1_800_000_000_000;
2282 let rows = vec![Row {
2284 at_ms: now - 60_000,
2285 provider: "opencode-go".into(),
2286 model: "glm-5.3-flash".into(),
2287 cost_usd: 5.0,
2288 ..Default::default()
2289 }];
2290 let report =
2291 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
2292 for colour in [false, true] {
2293 let text = render_text(&report, "/tmp/opencode.db", 10, colour, DEFAULT_BAR_WIDTH);
2294 assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
2295 }
2296 }
2297
2298 #[test]
2299 fn the_model_detail_spells_out_the_share() {
2300 let detail = model_text(&report(), "glm-5.3-flash", false);
2301 assert!(detail.contains("100.0% of period spend"), "{detail}");
2302 let empty = crate::ocuse::window::build(
2304 &[],
2305 &zen::catalog(),
2306 zen::DEFAULT_PLAN,
2307 1_800_000_000_000,
2308 None,
2309 None,
2310 );
2311 assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
2312 }
2313
2314 #[test]
2315 fn colour_frames_get_the_palette_and_a_gauge() {
2316 let text = render_text(
2321 &report_with_account(),
2322 "/tmp/opencode.db",
2323 10,
2324 true,
2325 DEFAULT_BAR_WIDTH,
2326 );
2327 assert!(text.contains(crate::render::BOLD), "{text}");
2328 assert!(text.contains(crate::render::CYAN), "{text}");
2329 assert!(
2330 text.contains(crate::render::GREEN),
2331 "the 50% USE cell is green: {text}"
2332 );
2333 assert!(
2334 text.contains('━') && text.contains('╱'),
2335 "the account share's gauge bar: {text}"
2336 );
2337 assert!(text.contains("resets in"), "{text}");
2338 }
2339
2340 #[test]
2341 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
2342 let frame = watch_frame(
2343 &report(),
2344 "/tmp/opencode.db",
2345 10,
2346 true,
2347 &[0.0, 1.5, 0.0],
2348 10,
2349 DEFAULT_BAR_WIDTH,
2350 );
2351 assert!(
2352 !frame.ends_with('\n'),
2353 "the caller's status line owns the last row"
2354 );
2355 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
2356 assert!(
2357 frame.contains('█'),
2358 "a nonzero delta shows as a tall bar: {frame}"
2359 );
2360 }
2361
2362 #[test]
2363 fn idle_history_omits_the_sparkline() {
2364 let all_idle = watch_frame(
2365 &report(),
2366 "/tmp/opencode.db",
2367 10,
2368 true,
2369 &[0.0, 0.0],
2370 10,
2371 DEFAULT_BAR_WIDTH,
2372 );
2373 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
2374 let first_frame = watch_frame(
2375 &report(),
2376 "/tmp/opencode.db",
2377 10,
2378 true,
2379 &[],
2380 10,
2381 DEFAULT_BAR_WIDTH,
2382 );
2383 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
2384 }
2385
2386 #[test]
2390 fn statusline_resolves_every_placeholder() {
2391 let r = report();
2392 let line = |tpl: &str| statusline_template(&r, "go", tpl, false, false, 20);
2393
2394 assert_eq!(line("{plan}"), "go");
2396 assert_eq!(line("{period}"), "calendar month");
2397 assert_eq!(line("{month}"), "$30.00");
2399 assert_eq!(line("{five_hour}"), "$30.00");
2400 assert_eq!(line("{weekly}"), "$30.00");
2401 assert_eq!(line("{models}"), "glm-5.3-flash");
2403 assert_eq!(line("{month_pct}"), "—");
2407 assert_eq!(line("{month_bar}"), "");
2408 assert_eq!(line("{five_hour_pct}"), "—");
2409 assert_eq!(line("{weekly_pct}"), "—");
2410
2411 let default = line("{period} {month} · 5h {five_hour} · wk {weekly}");
2413 assert_eq!(default, "calendar month $30.00 · 5h $30.00 · wk $30.00");
2414
2415 assert_eq!(line("{plan} {bogus} | tail"), "go | tail");
2417 assert_eq!(line("{plan} {oops"), "go {oops");
2419 assert!(!line("{plan} {month} {month_pct} {month_bar}").contains('\x1b'));
2421 }
2422
2423 #[test]
2426 fn statusline_without_caps_degrades_gracefully() {
2427 let empty = crate::ocuse::window::build(
2428 &[],
2429 &zen::catalog(),
2430 zen::DEFAULT_PLAN,
2431 1_800_000_000_000,
2432 None,
2433 None,
2434 );
2435 let line = statusline_template(&empty, "go", "{month_pct} {models}", false, false, 20);
2436 assert_eq!(line, "— —");
2437 }
2438
2439 #[test]
2441 fn compact_line_is_one_line_with_inline_sparkline() {
2442 let plain = compact_line(&report(), &[], true);
2443 assert_eq!(plain.lines().count(), 1, "{plain}");
2444 assert!(!plain.ends_with('\n'), "{plain}");
2445 let bursty = compact_line(&report(), &[0.0, 1.5], true);
2446 assert!(bursty.contains('█'), "nonzero delta shows: {bursty}");
2447 assert!(!compact_line(&report(), &[], false).contains('\x1b'));
2449 assert!(compact_line(&report(), &[], true).contains(crate::render::BOLD));
2450 }
2451}