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 let extra: Vec<&str> = report
412 .clients
413 .iter()
414 .map(String::as_str)
415 .filter(|client| *client != crate::stores::OPENCODE)
416 .collect();
417 let extra = if extra.is_empty() {
418 String::new()
419 } else {
420 format!(" + {}", extra.join(", "))
421 };
422 out.push_str(&format!(
423 "{}OpenCode usage{} — go + zen, from {}{}{}{}\n",
424 ink.bold,
425 ink.reset,
426 ink.dim,
427 shorten(db_path),
428 extra,
429 ink.reset,
430 ));
431 let end = report
435 .period_end_ms
436 .map(|ms| format!(" · ends {}", date_of(ms / 86_400_000)))
437 .unwrap_or_default();
438 let used = report
444 .account
445 .as_ref()
446 .map(|account| format!(" · {}% of limit", c(given_pct(account.monthly.percent))))
447 .unwrap_or_default();
448 out.push_str(&format!(
449 "period: {}{end} · {} requests · {} · {} in / {} out tokens{used}\n",
450 report.period_label,
451 c(compact(report.month.requests)),
452 c(money(report.month.cost)),
453 c(compact(report.month.input)),
454 c(compact(report.month.output)),
455 ));
456
457 match &report.account {
462 Some(account) => {
463 out.push_str(&format!(
464 "\n{}GO{} {}\n",
465 ink.bold,
466 ink.reset,
467 ink.dim_on(
468 "(account meters — what opencode.ai/console reports; the $ is this store's spend priced on the documented peak/off-peak schedule)"
469 .into()
470 ),
471 ));
472 let monthly_dur = report
478 .period_end_ms
479 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
480 let rows = [
481 (
482 "5-hour",
483 &account.rolling,
484 &report.five_hour,
485 Some(crate::ocuse::window::FIVE_HOUR_SECS as u64),
486 ),
487 (
488 "Weekly",
489 &account.weekly,
490 &report.weekly,
491 Some(crate::ocuse::window::WEEKLY_SECS as u64),
492 ),
493 ("Monthly", &account.monthly, &report.month, monthly_dur),
494 ];
495 for (label, meter, used, dur_secs) in rows {
496 out.push_str(&window_line(
497 &ink,
498 &RowSpec {
499 label,
500 used,
501 reset: meter_reset(meter, dur_secs),
502 share_pct: Some(meter.percent),
503 status: Some(meter.status.as_str()),
504 pro_rata: label == "Monthly",
507 },
508 report.now_ms,
509 ));
510 }
511 }
512 None if report.month.requests > 0 => {
517 let monthly_dur = report
522 .period_end_ms
523 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
524 out.push_str(&format!(
525 "\n{}GO{} {}\n",
526 ink.bold,
527 ink.reset,
528 ink.dim_on(
529 "(estimated locally: limits are per model, so no pool: spend alone)".into()
530 ),
531 ));
532 if let Some(why) = &report.account_error {
536 out.push_str(&format!("{} usage API: {why}{}\n", ink.dim, ink.reset));
537 }
538 out.push_str(&window_line(
539 &ink,
540 &RowSpec {
541 label: "5-hour",
542 used: &report.five_hour,
543 reset: None,
544 share_pct: None,
545 status: None,
546 pro_rata: false,
547 },
548 report.now_ms,
549 ));
550 out.push_str(&window_line(
551 &ink,
552 &RowSpec {
553 label: "Weekly",
554 used: &report.weekly,
555 reset: None,
556 share_pct: None,
557 status: None,
558 pro_rata: false,
559 },
560 report.now_ms,
561 ));
562 out.push_str(&window_line(
563 &ink,
564 &RowSpec {
565 label: "Monthly",
566 used: &report.month,
567 reset: report.period_end_ms.zip(monthly_dur),
568 share_pct: None,
569 status: None,
570 pro_rata: false,
573 },
574 report.now_ms,
575 ));
576 }
577 None => {}
578 }
579
580 let name_w = report
584 .models
585 .iter()
586 .take(rows)
587 .map(|model| model.id.chars().count())
588 .max()
589 .unwrap_or(0)
590 .max(28);
591 out.push_str(&top_models(&ink, report, 3, name_w));
592 out.push_str(&format!(
596 "\n{}{:<name_w$} {:>7} {:>10} {:>7} {:>8} {:>8} {:>7}{}\n",
597 ink.bold,
598 "MODEL",
599 "LIMIT",
600 "SPENT",
601 "USE",
602 "5H",
603 "WK",
604 "REQ",
605 ink.reset,
606 name_w = name_w
607 ));
608 for model in report.models.iter().take(rows) {
609 out.push_str(&model_row(&ink, model, name_w));
610 }
611 if report.models.len() > rows {
612 out.push_str(&format!(
613 "{}\n",
614 ink.dim_on(format!("… {} more", report.models.len() - rows))
615 ));
616 }
617 out.push_str(&format!(
618 "\n{}\n",
619 ink.dim_on(
620 "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
621 .into()
622 )
623 ));
624 if report.account.is_some() {
630 out.push_str(&format!(
631 "{}\n",
632 ink.dim_on(
633 "5H/WK columns: this model's own spend inside the account's windows (the same cuts its percentages use)."
634 .into()
635 )
636 ));
637 } else {
638 out.push_str(&format!(
639 "{}\n",
640 ink.dim_on(
641 "5H/WK columns: this model's own spend over the last 5h / 7 days (local, not the account's windows)."
642 .into()
643 )
644 ));
645 }
646 out
647}
648
649pub fn watch_frame(
654 report: &Report,
655 db_path: &str,
656 rows: usize,
657 colour: bool,
658 history: &[f64],
659 interval_secs: u64,
660 bar_width: usize,
661) -> String {
662 let ink = Ink::new(colour);
663 let mut out = render_text(report, db_path, rows, colour, bar_width);
664 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
665 out.push_str(&format!(
666 "{}spend bursts ({}s samples){} {}\n",
667 ink.dim,
668 interval_secs,
669 ink.reset,
670 crate::render::sparkline(history),
671 ));
672 }
673 out.trim_end_matches('\n').to_string()
674}
675
676pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
681 let ink = Ink::new(colour);
682 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
683 format!(
684 " {}{}{}",
685 ink.dim,
686 crate::render::sparkline(history),
687 ink.reset
688 )
689 } else {
690 String::new()
691 };
692 let window_share = match &report.account {
697 Some(account) => format!(
698 " · 5h {} · wk {}",
699 ink.cyan_on(pct(account.rolling.percent, 100.0)),
700 ink.cyan_on(pct(account.weekly.percent, 100.0)),
701 ),
702 None => format!(" · 5h {}", ink.cyan_on(money(report.five_hour.cost))),
703 };
704 format!(
705 "{}oc{} {} month{window_share}{spark}",
706 ink.bold,
707 ink.reset,
708 ink.cyan_on(money(report.month.cost)),
709 )
710}
711
712pub fn statusline_template(
726 report: &Report,
727 plan_id: &str,
728 tpl: &str,
729 colours: bool,
730 ascii: bool,
731 bar_width: usize,
732) -> String {
733 let ink = Ink::new(colours);
734 let cash = |v: f64| ink.cyan_on(money(v));
735 let account = report.account.as_ref();
742 let share_of = |meter: Option<&crate::ocuse::api::Meter>| match meter {
743 Some(meter) => pct(meter.percent, 100.0),
744 None => "—".into(),
745 };
746 let share_bar = |meter: Option<&crate::ocuse::api::Meter>| match meter {
747 Some(meter) => crate::report_render::bar(meter.percent, bar_width, ascii, colours),
748 None => String::new(),
749 };
750 let top_model = || {
751 report
752 .models
753 .first()
754 .map(|m| m.id.clone())
755 .unwrap_or_else(|| "—".into())
756 };
757 crate::report_render::render_template(tpl, |key| match key {
758 "plan" => plan_id.to_string(),
759 "period" => report.period_label.clone(),
760 "month" => cash(report.month.cost),
761 "month_pct" => share_of(account.map(|a| &a.monthly)),
762 "month_bar" => share_bar(account.map(|a| &a.monthly)),
763 "five_hour" | "5h" => cash(report.five_hour.cost),
764 "five_hour_pct" | "5h_pct" => share_of(account.map(|a| &a.rolling)),
765 "five_hour_bar" | "5h_bar" => share_bar(account.map(|a| &a.rolling)),
766 "weekly" | "wk" => cash(report.weekly.cost),
767 "weekly_pct" | "wk_pct" => share_of(account.map(|a| &a.weekly)),
768 "weekly_bar" | "wk_bar" => share_bar(account.map(|a| &a.weekly)),
769 "models" => top_model(),
770 _ => String::new(),
771 })
772}
773
774pub fn statusline_json(report: &Report, plan_id: &str) -> String {
780 serde_json::json!({
781 "plan": plan_id,
782 "period": report.period_label,
783 "monthSpent": report.month.cost,
784 "monthCap": serde_json::Value::Null,
785 "fiveHourSpent": report.five_hour.cost,
786 "fiveHourCap": serde_json::Value::Null,
787 "weeklySpent": report.weekly.cost,
788 "weeklyCap": serde_json::Value::Null,
789 "model": report.models.first().map(|m| m.id.clone()),
790 })
791 .to_string()
792}
793
794pub fn render_json(report: &Report) -> String {
796 let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
797 let model = |m: &ModelUsage| {
798 serde_json::json!({
799 "id": m.id,
800 "provider": m.provider,
801 "limit": m.limit,
802 "month": totals(&m.month),
803 "fiveHour": totals(&m.five_hour),
804 "weekly": totals(&m.weekly),
805 })
806 };
807 let meter = |m: &crate::ocuse::api::Meter| {
811 serde_json::json!({
812 "status": m.status,
813 "percent": m.percent,
814 "resetsAt": m.reset_ms(),
815 })
816 };
817 serde_json::to_string_pretty(&serde_json::json!({
818 "source": "opencode.db",
819 "clients": &report.clients,
823 "generatedAt": report.now_ms,
824 "periodStart": report.since_ms,
825 "periodStartAt": report.since_ms,
830 "periodEndAt": report.period_end_ms,
831 "periodEnd": report
834 .period_end_ms
835 .map(|ms| date_of(ms.div_euclid(86_400_000))),
836 "periodInferred": report.month_inferred,
837 "summary": totals(&report.month),
841 "totals": {
842 "month": totals(&report.month),
843 "fiveHour": totals(&report.five_hour),
844 "weekly": totals(&report.weekly),
845 },
846 "account": report.account.as_ref().map(|a| serde_json::json!({
849 "rolling": meter(&a.rolling),
850 "weekly": meter(&a.weekly),
851 "monthly": meter(&a.monthly),
852 })),
853 "accountError": report.account_error,
858 "models": report.models.iter().map(model).collect::<Vec<_>>(),
859 }))
860 .expect("json")
861}
862
863fn shorten(path: &str) -> String {
864 match std::env::var("HOME") {
865 Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
866 _ => path.to_string(),
867 }
868}
869
870pub fn plans_text(colour: bool) -> String {
877 let ink = Ink::new(colour);
878 let catalog = crate::ocuse::zen::catalog();
879 let mut out = format!(
880 "{}OpenCode plans{} {}\n",
881 ink.bold,
882 ink.reset,
883 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
884 );
885 out.push_str(&format!(
886 "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
887 ink.dim,
888 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
889 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
890 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
891 ink.reset,
892 ));
893 for plan in catalog.go.plans.values() {
894 out.push_str(&format!(
895 "\n{}{}{} — {}/month\n",
896 ink.bold,
897 plan.label,
898 ink.reset,
899 ink.cyan_on(format!("${:.2}", plan.price_usd)),
900 ));
901 out.push_str(&format!(
903 "{}{:<28} {:>7} {:>6} {:>6} {:>8}{}\n",
904 ink.bold, "MODEL", "LIMIT", "IN", "OUT", "CACHE", ink.reset
905 ));
906 for model in &catalog.go.models {
907 let rate = model.variants.first();
908 let limit = plan
909 .allowance_by_model
910 .get(&model.id)
911 .map(|limit| cmduse_core::money(*limit))
912 .unwrap_or_else(|| "—".into());
913 out.push_str(&format!(
914 "{:<28} {} {} {} {}\n",
915 model.id,
916 ink.cyan_on(format!("{limit:>7}")),
917 ink.cyan_on(format!(
918 "{:>6}",
919 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
920 )),
921 ink.cyan_on(format!(
922 "{:>6}",
923 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
924 )),
925 ink.cyan_on(format!(
926 "{:>8}",
927 rate.map(|r| format!("${}", r.cache_read))
928 .unwrap_or_default()
929 )),
930 ));
931 }
932 }
933 out.push_str(&format!(
934 "\n{}\n",
935 ink.dim_on(format!(
936 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
937 catalog.zen.models.len()
938 ))
939 ));
940 out
941}
942
943pub fn plans_json() -> String {
947 let catalog = crate::ocuse::zen::catalog();
948 let plans: Vec<serde_json::Value> = catalog
949 .go
950 .plans
951 .iter()
952 .map(|(id, plan)| {
953 serde_json::json!({
954 "id": id,
955 "label": plan.label,
956 "priceUsd": plan.price_usd,
957 "allowanceByModel": &plan.allowance_by_model,
958 })
959 })
960 .collect();
961 serde_json::json!({
962 "extractedAt": catalog.extracted_at,
963 "windows": {
964 "fiveHour": catalog.go.window_share.five_hour,
965 "weekly": catalog.go.window_share.weekly,
966 "monthly": catalog.go.window_share.monthly,
967 },
968 "plans": plans,
969 "models": catalog.go.models.iter().map(rate_json).collect::<Vec<_>>(),
970 "zen": catalog.zen.models.iter().map(rate_json).collect::<Vec<_>>(),
971 })
972 .to_string()
973}
974
975fn rate_json(model: &zen::Model) -> serde_json::Value {
977 let rate = model.variants.first();
978 serde_json::json!({
979 "id": model.id,
980 "name": model.name,
981 "input": rate.map(|r| r.input),
982 "output": rate.map(|r| r.output),
983 "cacheRead": rate.map(|r| r.cache_read),
984 "cacheWrite": rate.map(|r| r.cache_write),
985 })
986}
987
988struct ModelRow {
991 id: String,
992 name: String,
993 input: Option<f64>,
994 output: Option<f64>,
995 cache_read: Option<f64>,
996 limit: Option<f64>,
997}
998
999fn model_rows(plan_id: &str, gated: bool) -> Vec<ModelRow> {
1002 let catalog = crate::ocuse::zen::catalog();
1003 let plan = catalog.plan(plan_id);
1004 let mut rows: Vec<ModelRow> = catalog
1005 .go
1006 .models
1007 .iter()
1008 .map(|m| {
1009 let rate = m.variants.first();
1010 ModelRow {
1011 id: m.id.clone(),
1012 name: m.name.clone(),
1013 input: rate.map(|r| r.input),
1014 output: rate.map(|r| r.output),
1015 cache_read: rate.map(|r| r.cache_read),
1016 limit: plan.and_then(|p| p.allowance_by_model.get(&m.id).copied()),
1017 }
1018 })
1019 .collect();
1020 if gated {
1021 rows.retain(|r| r.limit.is_some());
1022 }
1023 rows
1024}
1025
1026pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
1030 let ink = Ink::new(colour);
1031 let catalog = crate::ocuse::zen::catalog();
1032 let label = catalog
1033 .plan(plan_id)
1034 .map(|p| p.label.clone())
1035 .unwrap_or_else(|| plan_id.to_string());
1036 let granted = catalog
1037 .plan(plan_id)
1038 .map(|p| p.allowance_by_model.len())
1039 .unwrap_or(0);
1040 let rows = model_rows(plan_id, gated);
1041 let mut out = format!(
1042 "{}{label} models{} {}\n",
1043 ink.bold,
1044 ink.reset,
1045 ink.dim_on(format!(
1046 "(plan {plan_id} · {granted}/{} granted)",
1047 catalog.go.models.len()
1048 )),
1049 );
1050 out.push_str(&format!(
1051 "{}{:<30} {:<28} {:>7} {:>7} {:>7} {:>8} {:>7}{}\n",
1052 ink.bold, "MODEL", "ID", "IN", "OUT", "CACHE", "LIMIT", "GRANTED", ink.reset
1053 ));
1054 let rate_cell = |v: Option<f64>| match v {
1055 Some(v) => ink.cyan_on(format!("{:>7}", format!("${v}"))),
1056 None => ink.dim_on(format!("{:>7}", "—")),
1057 };
1058 for r in &rows {
1059 let limit = match r.limit {
1060 Some(l) => ink.cyan_on(format!("{:>8}", cmduse_core::money(l))),
1061 None => ink.dim_on(format!("{:>8}", "—")),
1062 };
1063 let granted = if r.limit.is_some() { "yes" } else { "no" };
1064 out.push_str(&format!(
1065 "{:<30} {:<28} {} {} {} {} {:>7}\n",
1066 r.name,
1067 r.id,
1068 rate_cell(r.input),
1069 rate_cell(r.output),
1070 rate_cell(r.cache_read),
1071 limit,
1072 granted,
1073 ));
1074 }
1075 out
1076}
1077
1078pub fn models_json(plan_id: &str, gated: bool) -> String {
1082 let mut out = String::new();
1083 for r in model_rows(plan_id, gated) {
1084 out.push_str(
1085 &serde_json::json!({
1086 "id": r.id,
1087 "name": r.name,
1088 "input": r.input,
1089 "output": r.output,
1090 "cacheRead": r.cache_read,
1091 "limit": r.limit,
1092 "granted": r.limit.is_some(),
1093 })
1094 .to_string(),
1095 );
1096 out.push('\n');
1097 }
1098 out
1099}
1100
1101pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1102 let ink = Ink::new(colour);
1103 let Some(model) = report.models.iter().find(|m| m.id == id) else {
1104 return format!("{id}: no usage in the period\n");
1105 };
1106 let limit = model
1107 .limit
1108 .map(cmduse_core::money)
1109 .unwrap_or_else(|| "—".into());
1110 let row =
1111 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
1112 let mut out = format!(
1113 "{} {}\n",
1114 ink.bold_on(model.id.clone()),
1115 ink.dim_on(format!("({})", model.provider)),
1116 );
1117 out.push_str(&row("limit", ink.cyan_on(limit)));
1118 out.push_str(&row(
1119 "month",
1120 ink.cyan_on(format!(
1121 "{} · {} requests",
1122 cmduse_core::money(model.month.cost),
1123 model.month.requests
1124 )),
1125 ));
1126 out.push_str(&row(
1127 "5-hour",
1128 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
1129 ));
1130 out.push_str(&row(
1131 "weekly",
1132 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
1133 ));
1134 if report.month.cost > 0.0 {
1137 out.push_str(&row(
1138 "share",
1139 ink.dim_on(format!(
1140 "{:.1}% of period spend",
1141 model.month.cost / report.month.cost * 100.0
1142 )),
1143 ));
1144 }
1145 out
1146}
1147
1148fn totals_json(
1153 cost: f64,
1154 requests: u64,
1155 input: u64,
1156 output: u64,
1157 cache_read: u64,
1158) -> serde_json::Value {
1159 serde_json::json!({
1160 "requests": requests,
1161 "tokensIn": input,
1162 "tokensOut": output,
1163 "cacheRead": cache_read,
1164 "costUsd": cost,
1165 })
1166}
1167
1168#[derive(Default, Clone, Copy)]
1171struct Bucket {
1172 cost: f64,
1173 requests: u64,
1174 input: u64,
1175 output: u64,
1176 cache_read: u64,
1177}
1178
1179impl Bucket {
1180 fn add(&mut self, row: &crate::ocuse::db::Row) {
1181 self.cost += row.cost_usd;
1182 self.requests += 1;
1183 self.input += row.input;
1184 self.output += row.output;
1185 self.cache_read += row.cache_read;
1186 }
1187
1188 fn json(&self) -> serde_json::Value {
1189 totals_json(
1190 self.cost,
1191 self.requests,
1192 self.input,
1193 self.output,
1194 self.cache_read,
1195 )
1196 }
1197}
1198
1199pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1207 let models: Vec<&ModelUsage> = report
1208 .models
1209 .iter()
1210 .filter(|m| id.is_none_or(|want| m.id == want))
1211 .collect();
1212 match fmt {
1213 Fmt::Json => {
1214 let map: serde_json::Map<String, serde_json::Value> = models
1215 .iter()
1216 .map(|m| {
1217 let mut body = totals_json(
1218 m.month.cost,
1219 m.month.requests,
1220 m.month.input,
1221 m.month.output,
1222 m.month.cache_read,
1223 );
1224 body["limit"] = serde_json::json!(m.limit);
1225 (m.id.clone(), body)
1226 })
1227 .collect();
1228 serde_json::json!({
1229 "scope": "local",
1230 "source": "opencode",
1231 "sinceMs": report.since_ms,
1232 "models": map,
1233 })
1234 .to_string()
1235 }
1236 Fmt::Csv => {
1237 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1238 for m in models {
1239 out.push_str(&format!(
1240 "{},{},{},{},{},{:.2}\n",
1241 crate::report_render::csv_field(&m.id),
1242 m.month.requests,
1243 m.month.input,
1244 m.month.output,
1245 m.month.cache_read,
1246 m.month.cost,
1247 ));
1248 }
1249 out
1250 }
1251 Fmt::Table => String::new(), }
1253}
1254
1255pub fn bucket_text(
1263 rows: &[crate::ocuse::db::Row],
1264 now_ms: i64,
1265 lookback_ms: i64,
1266 size_ms: i64,
1267 tz_secs: i64,
1268 colour: bool,
1269 fmt: Fmt,
1270) -> String {
1271 let shift = tz_secs * 1000;
1273 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1274 for row in rows {
1275 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1276 buckets.entry(bucket).or_default().add(row);
1277 }
1278 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1279 let daily = size_ms >= 86_400_000;
1280 let label = if daily { "day" } else { "hour" };
1281 let stamp = |bucket: i64| {
1282 if daily {
1283 date_of(bucket)
1284 } else {
1285 format!(
1286 "{} {:02}h",
1287 date_of(bucket.div_euclid(24)),
1288 bucket.rem_euclid(24)
1289 )
1290 }
1291 };
1292 let window: Vec<(i64, Bucket)> = buckets
1293 .range(start..)
1294 .map(|(bucket, b)| (*bucket, *b))
1295 .collect();
1296
1297 match fmt {
1298 Fmt::Json => {
1299 let mut keyed = serde_json::Map::new();
1300 let mut total = Bucket::default();
1301 for (bucket, b) in &window {
1302 keyed.insert(stamp(*bucket), b.json());
1303 total.add_bucket(b);
1304 }
1305 let key = if daily { "days" } else { "hours" };
1306 serde_json::json!({
1307 "scope": "local",
1308 "source": "opencode",
1309 "total": total.json(),
1310 key: keyed,
1311 })
1312 .to_string()
1313 }
1314 Fmt::Csv => {
1315 let head = if daily { "day" } else { "hour" };
1316 let mut out = format!("{head},requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1317 let mut total = Bucket::default();
1318 for (bucket, b) in &window {
1319 out.push_str(&format!(
1320 "{},{},{},{},{},{:.2}\n",
1321 crate::report_render::csv_field(&stamp(*bucket)),
1322 b.requests,
1323 b.input,
1324 b.output,
1325 b.cache_read,
1326 b.cost,
1327 ));
1328 total.add_bucket(b);
1329 }
1330 out.push_str(&format!(
1331 "total,{},{},{},{},{:.2}\n",
1332 total.requests, total.input, total.output, total.cache_read, total.cost,
1333 ));
1334 out
1335 }
1336 Fmt::Table => {
1337 let ink = Ink::new(colour);
1338 let mut out = format!(
1339 "{}{label:<12} spend requests tokens{}\n",
1340 ink.bold, ink.reset
1341 );
1342 for (bucket, b) in &window {
1343 out.push_str(&format!(
1344 "{:<12} {} {} {}\n",
1345 stamp(*bucket),
1346 ink.cyan_on(format!("{:>9}", cmduse_core::money(b.cost))),
1347 ink.cyan_on(format!("{:>12}", cmduse_core::compact(b.requests))),
1348 ink.cyan_on(format!(
1349 "{:>9}",
1350 cmduse_core::compact(b.input + b.output + b.cache_read)
1351 )),
1352 ));
1353 }
1354 if out.lines().count() == 1 {
1355 out.push_str(&format!(
1356 "{}\n",
1357 ink.dim_on("(no OpenCode usage in the window)".into())
1358 ));
1359 }
1360 out
1361 }
1362 }
1363}
1364
1365type SessionTotals<'a> = (
1368 f64,
1369 u64,
1370 u64,
1371 u64,
1372 u64,
1373 i64,
1374 std::collections::BTreeSet<&'a str>,
1375);
1376
1377pub fn session_text(
1382 rows: &[crate::ocuse::db::Row],
1383 limit: usize,
1384 colour: bool,
1385 fmt: Fmt,
1386) -> String {
1387 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1388 for row in rows {
1389 let entry = by_session
1390 .entry(row.session.as_str())
1391 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1392 entry.0 += row.cost_usd;
1393 entry.1 += 1;
1394 entry.2 += row.input;
1395 entry.3 += row.output;
1396 entry.4 += row.cache_read;
1397 entry.5 = entry.5.max(row.at_ms);
1398 entry.6.insert(row.model.as_str());
1399 }
1400 let mut sessions: Vec<_> = by_session.into_iter().collect();
1401 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1402
1403 match fmt {
1404 Fmt::Json => {
1405 let mut keyed = serde_json::Map::new();
1406 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1407 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1408 body["lastMs"] = serde_json::json!(*last);
1409 body["models"] = serde_json::json!(models.len());
1410 keyed.insert((*id).to_string(), body);
1411 }
1412 serde_json::json!({
1413 "scope": "local",
1414 "source": "opencode",
1415 "sessions": keyed,
1416 })
1417 .to_string()
1418 }
1419 Fmt::Csv => {
1420 let mut out =
1421 String::from("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1422 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1423 out.push_str(&format!(
1424 "{},{},{},{},{},{:.2}\n",
1425 crate::report_render::csv_field(id),
1426 requests,
1427 input,
1428 output,
1429 cache_read,
1430 cost,
1431 ));
1432 }
1433 out
1434 }
1435 Fmt::Table => {
1436 let ink = Ink::new(colour);
1437 let mut out = format!(
1438 "{}session last models spend req{}\n",
1439 ink.bold, ink.reset
1440 );
1441 for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1442 let short = id.rsplit('_').next().unwrap_or(*id);
1443 out.push_str(&format!(
1444 "{:<32} {:<11} {:>6} {} {:>6}\n",
1445 short,
1446 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1447 models.len(),
1448 ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
1449 requests,
1450 ));
1451 }
1452 if sessions.len() > limit {
1453 out.push_str(&format!(
1454 "{}\n",
1455 ink.dim_on(format!("… {} more", sessions.len() - limit))
1456 ));
1457 }
1458 if out.lines().count() == 1 {
1459 out.push_str(&format!(
1460 "{}\n",
1461 ink.dim_on("(no sessions in the window)".into())
1462 ));
1463 }
1464 out
1465 }
1466 }
1467}
1468
1469impl Bucket {
1470 fn add_bucket(&mut self, other: &Bucket) {
1472 self.cost += other.cost;
1473 self.requests += other.requests;
1474 self.input += other.input;
1475 self.output += other.output;
1476 self.cache_read += other.cache_read;
1477 }
1478}
1479
1480pub fn date_of(day: i64) -> String {
1483 cmduse_core::dates::civil_from_days(day)
1484}
1485
1486#[cfg(test)]
1487mod tests {
1488 use super::*;
1489 use crate::ocuse::db::Row;
1490
1491 fn report() -> Report {
1492 let now = 1_800_000_000_000;
1493 let rows = vec![Row {
1494 at_ms: now - 60_000,
1495 provider: "opencode-go".into(),
1496 model: "glm-5.3-flash".into(),
1497 cost_usd: 30.0,
1498 ..Default::default()
1499 }];
1500 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None)
1501 }
1502
1503 #[test]
1504 fn text_shows_the_model_against_its_limit() {
1505 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1506 assert!(text.contains("glm-5.3-flash"), "{text}");
1507 assert!(text.contains("$60"), "the model's monthly limit: {text}");
1508 assert!(
1509 text.contains("50.0%"),
1510 "the model table keeps its own USE: {text}"
1511 );
1512 }
1513
1514 #[test]
1517 fn the_header_names_a_client_beside_opencode() {
1518 let now = 1_800_000_000_000;
1519 let rows = vec![
1520 Row {
1521 client: "opencode",
1522 at_ms: now - 60_000,
1523 provider: "opencode-go".into(),
1524 model: "glm-5.3-flash".into(),
1525 cost_usd: 1.0,
1526 ..Default::default()
1527 },
1528 Row {
1529 client: "pi",
1530 at_ms: now - 60_000,
1531 provider: "opencode-go".into(),
1532 model: "glm-5.3-flash".into(),
1533 cost_usd: 1.0,
1534 ..Default::default()
1535 },
1536 ];
1537 let report =
1538 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
1539 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1540 assert!(
1541 text.contains("/tmp/opencode.db + pi"),
1542 "the header names the extra client: {text}"
1543 );
1544 }
1545
1546 fn cell_ends(line: &str) -> Vec<usize> {
1548 let mut ends = Vec::new();
1549 let mut cursor = 0;
1550 for cell in line.split_whitespace() {
1551 let start = line[cursor..].find(cell).expect("cell in line") + cursor;
1552 cursor = start + cell.len();
1553 ends.push(cursor);
1554 }
1555 ends
1556 }
1557
1558 #[test]
1561 fn the_model_table_header_aligns_with_its_rows() {
1562 let now = 1_800_000_000_000;
1563 let rows = vec![Row {
1566 at_ms: now - 60_000,
1567 provider: "opencode-go".into(),
1568 model: "glm-5.3-flash".into(),
1569 cost_usd: 5.0,
1570 ..Default::default()
1571 }];
1572 let report =
1573 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
1574 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1575 let mut lines = text.lines().skip_while(|line| !line.starts_with("MODEL "));
1576 let header = lines.next().expect("the table header");
1577 let row = lines
1578 .find(|line| line.starts_with("glm-5.3-flash"))
1579 .expect("the model row");
1580 let header_ends = cell_ends(header);
1581 assert_eq!(
1582 header_ends[1..],
1583 cell_ends(row)[1..header_ends.len()],
1584 "header columns must end where their cells do:\n{header}\n{row}"
1585 );
1586 }
1587
1588 #[test]
1590 fn the_models_table_header_aligns_with_its_rows() {
1591 let text = models_text(zen::DEFAULT_PLAN, false, false);
1592 let mut lines = text.lines().skip_while(|line| !line.starts_with("MODEL "));
1593 let header = lines.next().expect("the table header");
1594 let row = lines
1597 .find(|line| line.starts_with("DeepSeek V4 Flash "))
1598 .expect("the model row");
1599 let header_ends = cell_ends(header);
1600 let row_ends = cell_ends(row);
1601 assert_eq!(
1605 &header_ends[header_ends.len() - 5..],
1606 &row_ends[row_ends.len() - 5..],
1607 "header columns must end where their cells do:\n{header}\n{row}"
1608 );
1609 }
1610
1611 fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
1612 crate::ocuse::api::Meter {
1613 status: status.into(),
1614 percent,
1615 resets_at: Some(resets_at.into()),
1616 }
1617 }
1618
1619 fn report_with_account() -> Report {
1626 let mut report = report();
1627 report.account = Some(crate::ocuse::api::Usage {
1628 rolling: meter("ok", 5.0, "2027-01-15T12:31:00.000Z"),
1629 weekly: meter("ok", 59.0, "2027-01-18T00:00:00.000Z"),
1630 monthly: meter("ok", 29.0, "2027-02-15T08:00:00.000Z"),
1631 });
1632 report.period_end_ms = Some(1_802_678_400_000); report.since_ms = crate::ocuse::window::prev_month(1_802_678_400_000);
1634 report
1635 }
1636
1637 #[test]
1638 fn the_window_rows_come_from_the_account_when_it_answered() {
1639 let text = render_text(
1640 &report_with_account(),
1641 "/tmp/opencode.db",
1642 10,
1643 false,
1644 DEFAULT_BAR_WIDTH,
1645 );
1646 assert!(text.contains("· 5% of limit"), "{text}");
1649 assert!(text.contains("· 59% of limit"), "{text}");
1650 assert!(text.contains("· 29% of limit"), "{text}");
1651 assert!(text.contains("account meters"), "{text}");
1652 assert!(
1653 text.contains("resets 4h 31m"),
1654 "the 5h block's own end: {text}"
1655 );
1656 assert!(text.contains(".00 · 5% of limit"), "{text}");
1662 assert!(text.contains(".00 · 29% of limit"), "{text}");
1663 assert!(text.contains("of limit"), "{text}");
1664 assert!(text.contains("elapsed"), "{text}");
1669 assert_eq!(text.matches("by now").count(), 1, "{text}");
1670 assert!(text.contains("the same cuts its percentages use"), "{text}");
1673 }
1674
1675 #[test]
1682 fn the_pair_is_the_ceiling_the_share_implies() {
1683 assert_eq!(implied_cap(30.0, 5.0), Some(600.0));
1685 assert_eq!(implied_cap(30.0, 59.0), Some(50.847457627118644));
1686 assert_eq!(implied_cap(20.45, 0.0), None);
1688 assert_eq!(implied_cap(20.45, 0.04), None);
1689 assert_eq!(implied_cap(20.45, 0.1), Some(20450.0));
1690 assert_eq!(implied_cap(0.0, 1.0), Some(0.0));
1691 }
1692
1693 #[test]
1698 fn the_gauge_carries_the_share_and_the_body_does_not_repeat_it() {
1699 let colour = render_text(
1700 &report_with_account(),
1701 "/tmp/opencode.db",
1702 10,
1703 true,
1704 DEFAULT_BAR_WIDTH,
1705 );
1706 let five = colour
1707 .lines()
1708 .find(|line| line.contains("5-hour"))
1709 .expect("a 5-hour row");
1710 assert!(!five.contains("of limit"), "no repeated share: {five}");
1711 assert!(
1712 five.contains(" 5%"),
1713 "the endpoint's whole figure: {five}"
1714 );
1715 let plain = render_text(
1716 &report_with_account(),
1717 "/tmp/opencode.db",
1718 10,
1719 false,
1720 DEFAULT_BAR_WIDTH,
1721 );
1722 assert!(plain.contains("· 5% of limit"), "{plain}");
1723 }
1724
1725 #[test]
1727 fn a_reachable_cap_gets_the_pace_line() {
1728 let mut report = report_with_account();
1729 if let Some(account) = &mut report.account {
1730 account.rolling = meter("ok", 90.0, "2027-01-15T12:00:00.000Z");
1733 }
1734 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1735 assert!(text.contains("on pace to hit cap in"), "{text}");
1736 }
1737
1738 #[test]
1739 fn without_an_account_the_rows_say_they_are_local() {
1740 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1741 assert!(text.contains("estimated locally"), "{text}");
1742 assert!(text.contains("no pool"), "no aggregate ceiling: {text}");
1743 assert!(!text.contains("of limit"), "{text}");
1745 assert!(!text.contains("resets"), "{text}");
1746 assert!(!text.contains("elapsed"), "{text}");
1747 assert!(!text.contains("account meters"), "{text}");
1749 assert!(
1750 !text.contains("the same cuts its percentages use"),
1751 "{text}"
1752 );
1753 assert!(text.contains("local, not the account's windows"), "{text}");
1756 }
1757
1758 #[test]
1759 fn an_over_limit_meter_says_so_before_its_countdown() {
1760 let mut report = report_with_account();
1761 if let Some(account) = &mut report.account {
1762 account.rolling = meter("rate_limited", 100.0, "2027-01-15T12:31:00.000Z");
1763 }
1764 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1765 assert!(text.contains("LIMIT EXCEEDED"), "{text}");
1766 }
1767
1768 #[test]
1769 fn json_carries_the_account_meters_and_null_without_them() {
1770 let parsed: serde_json::Value =
1771 serde_json::from_str(&render_json(&report_with_account())).unwrap();
1772 assert_eq!(parsed["account"]["rolling"]["percent"], 5.0);
1773 assert_eq!(parsed["account"]["weekly"]["percent"], 59.0);
1774 assert_eq!(parsed["account"]["monthly"]["status"], "ok");
1775 let monthly = cmduse_core::dates::parse_iso_utc("2027-02-15T08:00:00.000Z").unwrap();
1778 assert_eq!(parsed["account"]["monthly"]["resetsAt"], monthly as i64);
1779
1780 let parsed: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1783 assert_eq!(parsed["account"], serde_json::Value::Null);
1784 }
1785
1786 #[test]
1787 fn statusline_prefers_the_accounts_share_over_the_local_estimate() {
1788 let tpl = "{five_hour_pct} {weekly_pct} {month_pct} {five_hour}";
1789 let account = statusline_template(
1790 &report_with_account(),
1791 "go",
1792 tpl,
1793 false,
1794 false,
1795 DEFAULT_BAR_WIDTH,
1796 );
1797 assert!(account.starts_with("5.0% 59.0% 29.0%"), "{account}");
1798 assert!(account.contains("$30.00"), "{account}");
1800
1801 let local = statusline_template(&report(), "go", tpl, false, false, DEFAULT_BAR_WIDTH);
1804 assert!(!local.starts_with("5.0%"), "{local}");
1805 }
1806
1807 #[test]
1808 fn json_and_statusline_carry_the_totals() {
1809 let json = render_json(&report());
1810 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1811 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
1812 assert_eq!(parsed["summary"], parsed["totals"]["month"]);
1816 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
1817 let line = statusline_template(&report(), "go", "{plan} {models}", false, false, 20);
1818 assert_eq!(line, "go glm-5.3-flash");
1819 }
1820
1821 #[test]
1822 fn json_carries_the_period_bounds() {
1823 let mut r = report();
1826 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1827 let parsed: serde_json::Value = serde_json::from_str(&render_json(&r)).unwrap();
1828 assert_eq!(parsed["periodStart"], r.since_ms);
1829 assert_eq!(parsed["periodStartAt"], r.since_ms);
1830 assert_eq!(parsed["periodEndAt"], r.period_end_ms.unwrap());
1831
1832 let mut all = report();
1835 all.period_end_ms = None;
1836 let parsed: serde_json::Value = serde_json::from_str(&render_json(&all)).unwrap();
1837 assert_eq!(parsed["periodEndAt"], serde_json::Value::Null);
1838 assert_eq!(parsed["periodStart"], all.since_ms);
1839 assert!(parsed.get("periodInferred").is_some());
1840 }
1841
1842 #[test]
1848 fn every_renderer_respects_the_colour_flag() {
1849 let mut report = report();
1850 report.period_end_ms = Some(crate::ocuse::window::next_month(report.since_ms));
1853 let rows = vec![Row {
1854 at_ms: report.now_ms - 60_000,
1855 session: "ses_fixture".into(),
1856 provider: "opencode-go".into(),
1857 model: "glm-5.3-flash".into(),
1858 cost_usd: 30.0,
1859 ..Default::default()
1860 }];
1861 let now = report.now_ms;
1862
1863 let cases: Vec<(&str, String, String)> = vec![
1864 (
1865 "render_text",
1866 render_text(&report, "/tmp/oc.db", 10, false, DEFAULT_BAR_WIDTH),
1867 render_text(&report, "/tmp/oc.db", 10, true, DEFAULT_BAR_WIDTH),
1868 ),
1869 (
1870 "statusline_template",
1871 statusline_template(
1872 &report,
1873 "go",
1874 "{period} {month} {month_pct}",
1875 false,
1876 false,
1877 20,
1878 ),
1879 statusline_template(
1880 &report,
1881 "go",
1882 "{period} {month} {month_pct}",
1883 true,
1884 false,
1885 20,
1886 ),
1887 ),
1888 (
1889 "compact_line",
1890 compact_line(&report, &[], false),
1891 compact_line(&report, &[], true),
1892 ),
1893 ("plans_text", plans_text(false), plans_text(true)),
1894 (
1895 "model_text",
1896 model_text(&report, "glm-5.3-flash", false),
1897 model_text(&report, "glm-5.3-flash", true),
1898 ),
1899 (
1900 "bucket_text/day",
1901 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Table),
1902 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Table),
1903 ),
1904 (
1905 "bucket_text/hour",
1906 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Table),
1907 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Table),
1908 ),
1909 (
1910 "session_text",
1911 session_text(&rows, 20, false, Fmt::Table),
1912 session_text(&rows, 20, true, Fmt::Table),
1913 ),
1914 (
1915 "models_text",
1916 models_text("go", false, false),
1917 models_text("go", false, true),
1918 ),
1919 ];
1920 for (name, plain, coloured) in cases {
1921 assert!(
1922 !plain.contains('\x1b'),
1923 "{name} leaked escapes when off: {plain}"
1924 );
1925 assert!(
1926 coloured.contains('\x1b'),
1927 "{name} rendered nothing coloured when on: {coloured}"
1928 );
1929 }
1930
1931 let flagged: Vec<(&str, String, String)> = vec![
1934 (
1935 "bucket_text/day csv",
1936 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv),
1937 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Csv),
1938 ),
1939 (
1940 "bucket_text/hour json",
1941 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Json),
1942 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Json),
1943 ),
1944 (
1945 "session_text csv",
1946 session_text(&rows, 20, false, Fmt::Csv),
1947 session_text(&rows, 20, true, Fmt::Csv),
1948 ),
1949 (
1950 "session_text json",
1951 session_text(&rows, 20, false, Fmt::Json),
1952 session_text(&rows, 20, true, Fmt::Json),
1953 ),
1954 ];
1955 for (name, off, on) in flagged {
1956 assert!(
1957 !off.contains('\x1b'),
1958 "{name} leaked escapes with colour off: {off}"
1959 );
1960 assert!(
1961 !on.contains('\x1b'),
1962 "{name} must stay uncoloured with colour on: {on}"
1963 );
1964 }
1965 for (name, out) in [
1966 ("model_report json", model_report(&report, None, Fmt::Json)),
1967 ("model_report csv", model_report(&report, None, Fmt::Csv)),
1968 ("plans_json", plans_json()),
1969 ("statusline_json", statusline_json(&report, "go")),
1970 ("models_json", models_json("go", false)),
1971 ] {
1972 assert!(
1973 !out.contains('\x1b'),
1974 "{name} must never be coloured: {out}"
1975 );
1976 }
1977 }
1978
1979 #[test]
1980 fn session_rows_cap_the_table_and_never_the_machine_formats() {
1981 let now = 1_800_000_000_000;
1985 let rows: Vec<Row> = (0..25)
1986 .map(|i| Row {
1987 at_ms: now - i as i64 * 60_000,
1988 provider: "opencode-go".into(),
1989 model: "glm-5.3-flash".into(),
1990 session: format!("ses_{i:02}"),
1991 cost_usd: 0.1,
1992 ..Default::default()
1993 })
1994 .collect();
1995 let table = session_text(&rows, 2, false, Fmt::Table);
1996 assert!(table.contains("… 23 more"), "{table}");
1997 assert_eq!(
1998 table.lines().count(),
1999 4,
2000 "header + 2 rows + the tail: {table}"
2001 );
2002 let json: serde_json::Value =
2003 serde_json::from_str(&session_text(&rows, 2, false, Fmt::Json)).unwrap();
2004 assert_eq!(json["sessions"].as_object().unwrap().len(), 25);
2005 let csv = session_text(&rows, 2, false, Fmt::Csv);
2006 assert_eq!(csv.lines().count(), 26, "header + every session: {csv}");
2007 }
2008
2009 #[test]
2010 fn bucket_reports_carry_the_documented_columns() {
2011 let now = 1_800_000_000_000;
2012 let rows = vec![Row {
2013 at_ms: now - 60_000,
2014 provider: "opencode-go".into(),
2015 model: "glm-5.3-flash".into(),
2016 cost_usd: 1.5,
2017 input: 10,
2018 output: 20,
2019 cache_read: 30,
2020 cache_write: 40,
2021 ..Default::default()
2022 }];
2023 let csv = bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv);
2024 assert!(
2025 csv.starts_with("day,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
2026 "{csv}"
2027 );
2028 assert!(!csv.contains("cache_write"), "{csv}");
2030 assert!(csv.contains(",10,20,30,1.50\n"), "{csv}");
2031 assert!(csv.trim_end().ends_with("total,1,10,20,30,1.50"), "{csv}");
2032
2033 let json: serde_json::Value = serde_json::from_str(&bucket_text(
2034 &rows,
2035 now,
2036 7 * 86_400_000,
2037 86_400_000,
2038 0,
2039 false,
2040 Fmt::Json,
2041 ))
2042 .unwrap();
2043 assert_eq!(json["total"]["requests"], 1);
2044 assert_eq!(json["total"]["cacheRead"], 30);
2045 assert_eq!(json["days"].as_object().unwrap().len(), 1);
2046 }
2047
2048 #[test]
2049 fn session_report_json_and_csv_agree_with_the_store() {
2050 let now = 1_800_000_000_000;
2051 let rows = vec![
2052 Row {
2053 at_ms: now - 60_000,
2054 session: "ses_first".into(),
2055 model: "glm-5.3-flash".into(),
2056 cost_usd: 2.0,
2057 input: 5,
2058 output: 6,
2059 cache_read: 7,
2060 ..Default::default()
2061 },
2062 Row {
2063 at_ms: now - 30_000,
2064 session: "ses_second".into(),
2065 model: "kimi-k3".into(),
2066 cost_usd: 1.0,
2067 ..Default::default()
2068 },
2069 ];
2070 let json: serde_json::Value =
2071 serde_json::from_str(&session_text(&rows, 20, false, Fmt::Json)).unwrap();
2072 assert_eq!(json["sessions"]["ses_first"]["tokensIn"], 5);
2073 assert_eq!(json["sessions"]["ses_first"]["models"], 1);
2074 assert_eq!(json["sessions"]["ses_second"]["costUsd"], 1.0);
2075 let csv = session_text(&rows, 20, false, Fmt::Csv);
2076 assert!(
2077 csv.starts_with("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
2078 "{csv}"
2079 );
2080 assert!(csv.contains("ses_second,1,0,0,0,1.00\n"), "{csv}");
2081 }
2082
2083 #[test]
2084 fn model_report_projects_the_report_models() {
2085 let json: serde_json::Value =
2086 serde_json::from_str(&model_report(&report(), None, Fmt::Json)).unwrap();
2087 assert_eq!(json["source"], "opencode");
2088 assert_eq!(json["models"]["glm-5.3-flash"]["costUsd"], 30.0);
2089 assert_eq!(json["models"]["glm-5.3-flash"]["limit"], 60.0);
2090 let csv = model_report(&report(), None, Fmt::Csv);
2091 assert!(csv.contains("glm-5.3-flash,1,0,0,0,30.00\n"), "{csv}");
2092 assert_eq!(
2094 model_report(&report(), Some("nope"), Fmt::Csv),
2095 "model,requests,tokens_in,tokens_out,cache_read,cost_usd\n"
2096 );
2097 }
2098
2099 #[test]
2100 fn catalogue_json_carries_both_plans_and_windows() {
2101 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
2102 assert_eq!(json["windows"]["fiveHour"], 0.2);
2103 assert_eq!(json["windows"]["monthly"], 1.0);
2104 assert_eq!(json["plans"][0]["id"], "go");
2105 assert_eq!(json["plans"][0]["allowanceByModel"]["glm-5.3-flash"], 60.0);
2106 assert_eq!(json["plans"][1]["priceUsd"], 40.0);
2107 assert!(!json["models"].as_array().unwrap().is_empty());
2108 assert!(!json["zen"].as_array().unwrap().is_empty());
2109 }
2110
2111 #[test]
2112 fn models_lists_the_plan_and_marks_granted() {
2113 let all = models_text("go", false, false);
2115 assert!(all.contains("glm-5.3-flash"), "{all}");
2116 assert!(all.contains("GRANTED"), "{all}");
2117 let gated = models_text("go", true, false);
2119 for line in gated.lines().skip(2) {
2120 assert!(
2121 line.trim_end().ends_with("yes"),
2122 "ungranted row kept: {line}"
2123 );
2124 }
2125 let every: Vec<serde_json::Value> = models_json("go", false)
2127 .lines()
2128 .map(|l| serde_json::from_str(l).unwrap())
2129 .collect();
2130 assert!(every.iter().any(|m| m["granted"] == false));
2131 assert!(every
2132 .iter()
2133 .any(|m| m["id"] == "glm-5.3-flash" && m["limit"] == 60.0));
2134 let only: Vec<serde_json::Value> = models_json("go", true)
2135 .lines()
2136 .map(|l| serde_json::from_str(l).unwrap())
2137 .collect();
2138 assert!(!only.is_empty());
2139 assert!(only.iter().all(|m| m["granted"] == true));
2140 }
2141
2142 #[test]
2143 fn statusline_json_mirrors_the_template_numbers() {
2144 let json: serde_json::Value =
2145 serde_json::from_str(&statusline_json(&report(), "go")).unwrap();
2146 assert_eq!(json["plan"], "go");
2147 assert_eq!(json["period"], "calendar month");
2148 assert_eq!(json["monthSpent"], 30.0);
2149 assert_eq!(json["monthCap"], serde_json::Value::Null);
2152 assert_eq!(json["fiveHourCap"], serde_json::Value::Null);
2153 assert_eq!(json["weeklyCap"], serde_json::Value::Null);
2154 assert_eq!(json["model"], "glm-5.3-flash");
2155 }
2156
2157 #[test]
2158 fn plain_frames_carry_no_escapes() {
2159 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2160 assert!(
2161 !text.contains('\x1b'),
2162 "piped output must stay plain: {text}"
2163 );
2164 assert!(text.contains("$30.00"), "{text}");
2167 }
2168
2169 #[test]
2173 fn monthly_row_carries_the_real_period_metrics() {
2174 let mut r = report();
2175 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
2176 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2177
2178 let monthly = text
2179 .lines()
2180 .find(|line| line.trim_start().starts_with("Monthly"))
2181 .expect("the monthly row");
2182 assert!(monthly.contains("resets"), "{monthly}");
2183 assert!(
2184 monthly.contains("window") && monthly.contains("% elapsed"),
2185 "the real period gives an elapsed share: {monthly}"
2186 );
2187
2188 for label in ["5-hour", "Weekly"] {
2189 let row = text
2190 .lines()
2191 .find(|line| line.trim_start().starts_with(label))
2192 .unwrap_or_else(|| panic!("the {label} row"));
2193 assert!(!row.contains("elapsed"), "{label} has no boundary: {row}");
2194 assert!(!row.contains("resets"), "{label} has no boundary: {row}");
2195 }
2196 }
2197
2198 #[test]
2201 fn period_line_splits_tokens_and_shows_the_end_date() {
2202 let mut r = report();
2203 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
2204 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2205 let header = text
2206 .lines()
2207 .find(|line| line.starts_with("period:"))
2208 .expect("the period line");
2209 assert!(
2210 header.contains(" in / "),
2211 "token split like cmduse: {header}"
2212 );
2213 assert!(header.contains(" out tokens"), "{header}");
2214 assert!(
2215 header.contains(&date_of(r.period_end_ms.unwrap() / 86_400_000)),
2216 "the resolved end date: {header}"
2217 );
2218
2219 let no_end = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2221 let header = no_end
2222 .lines()
2223 .find(|line| line.starts_with("period:"))
2224 .expect("the period line");
2225 assert!(!header.contains("ends"), "{header}");
2226 }
2227
2228 #[test]
2229 fn an_over_cap_window_is_flagged_in_both_modes() {
2230 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2232 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
2233 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
2234 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
2235 assert!(
2236 coloured.contains(crate::render::RED),
2237 "the flag and the gauge are red: {coloured}"
2238 );
2239 }
2240
2241 #[test]
2242 fn a_model_over_any_of_its_own_windows_is_flagged() {
2243 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2246 let row = plain
2249 .lines()
2250 .skip_while(|line| !line.starts_with("MODEL "))
2251 .find(|line| line.starts_with("glm-5.3-flash"))
2252 .expect("the model row");
2253 assert!(row.contains("LIMIT EXCEEDED"), "{row}");
2254 assert!(!row.trim_end().ends_with('%'), "no trailing share: {row}");
2257 assert!(row.trim_end().ends_with(char::is_numeric), "{row}");
2258 }
2259
2260 #[test]
2264 fn period_line_carries_the_account_monthly_share() {
2265 let with = render_text(
2266 &report_with_account(),
2267 "/tmp/opencode.db",
2268 10,
2269 false,
2270 DEFAULT_BAR_WIDTH,
2271 );
2272 let header = with
2273 .lines()
2274 .find(|line| line.starts_with("period:"))
2275 .expect("the period line");
2276 assert!(header.contains("% of limit"), "{header}");
2277 assert!(
2278 header.contains("29%"),
2279 "the fixture's monthly meter: {header}"
2280 );
2281
2282 let without = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2283 let header = without
2284 .lines()
2285 .find(|line| line.starts_with("period:"))
2286 .expect("the period line");
2287 assert!(
2288 !header.contains("of limit"),
2289 "no account, no share: {header}"
2290 );
2291 }
2292
2293 #[test]
2296 fn top_models_state_their_share_of_money_and_tokens() {
2297 let now = 1_800_000_000_000;
2301 let rows = vec![Row {
2302 at_ms: now - 60_000,
2303 provider: "opencode-go".into(),
2304 model: "glm-5.3-flash".into(),
2305 input: 1_200_000,
2306 output: 400_000,
2307 ..Default::default()
2308 }];
2309 let report =
2310 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
2311 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2312 let block = text
2313 .lines()
2314 .skip_while(|line| !line.starts_with("TOP 3 MODELS"))
2315 .take_while(|line| !line.starts_with("MODEL "))
2316 .collect::<Vec<_>>()
2317 .join("\n");
2318 assert!(block.contains("glm-5.3-flash"), "{block}");
2319 assert!(
2320 block.contains("MONEY") && block.contains("TOKENS"),
2321 "{block}"
2322 );
2323 assert_eq!(block.matches("100.0%").count(), 2, "{block}");
2325 assert!(!text.contains("of period spend"), "{text}");
2327 }
2328
2329 #[test]
2330 fn a_model_within_its_limits_is_not_flagged() {
2331 let now = 1_800_000_000_000;
2332 let rows = vec![Row {
2334 at_ms: now - 60_000,
2335 provider: "opencode-go".into(),
2336 model: "glm-5.3-flash".into(),
2337 cost_usd: 5.0,
2338 ..Default::default()
2339 }];
2340 let report =
2341 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
2342 for colour in [false, true] {
2343 let text = render_text(&report, "/tmp/opencode.db", 10, colour, DEFAULT_BAR_WIDTH);
2344 assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
2345 }
2346 }
2347
2348 #[test]
2349 fn the_model_detail_spells_out_the_share() {
2350 let detail = model_text(&report(), "glm-5.3-flash", false);
2351 assert!(detail.contains("100.0% of period spend"), "{detail}");
2352 let empty = crate::ocuse::window::build(
2354 &[],
2355 &zen::catalog(),
2356 zen::DEFAULT_PLAN,
2357 1_800_000_000_000,
2358 None,
2359 None,
2360 );
2361 assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
2362 }
2363
2364 #[test]
2365 fn colour_frames_get_the_palette_and_a_gauge() {
2366 let text = render_text(
2371 &report_with_account(),
2372 "/tmp/opencode.db",
2373 10,
2374 true,
2375 DEFAULT_BAR_WIDTH,
2376 );
2377 assert!(text.contains(crate::render::BOLD), "{text}");
2378 assert!(text.contains(crate::render::CYAN), "{text}");
2379 assert!(
2380 text.contains(crate::render::GREEN),
2381 "the 50% USE cell is green: {text}"
2382 );
2383 assert!(
2384 text.contains('━') && text.contains('╱'),
2385 "the account share's gauge bar: {text}"
2386 );
2387 assert!(text.contains("resets in"), "{text}");
2388 }
2389
2390 #[test]
2391 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
2392 let frame = watch_frame(
2393 &report(),
2394 "/tmp/opencode.db",
2395 10,
2396 true,
2397 &[0.0, 1.5, 0.0],
2398 10,
2399 DEFAULT_BAR_WIDTH,
2400 );
2401 assert!(
2402 !frame.ends_with('\n'),
2403 "the caller's status line owns the last row"
2404 );
2405 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
2406 assert!(
2407 frame.contains('█'),
2408 "a nonzero delta shows as a tall bar: {frame}"
2409 );
2410 }
2411
2412 #[test]
2413 fn idle_history_omits_the_sparkline() {
2414 let all_idle = watch_frame(
2415 &report(),
2416 "/tmp/opencode.db",
2417 10,
2418 true,
2419 &[0.0, 0.0],
2420 10,
2421 DEFAULT_BAR_WIDTH,
2422 );
2423 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
2424 let first_frame = watch_frame(
2425 &report(),
2426 "/tmp/opencode.db",
2427 10,
2428 true,
2429 &[],
2430 10,
2431 DEFAULT_BAR_WIDTH,
2432 );
2433 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
2434 }
2435
2436 #[test]
2440 fn statusline_resolves_every_placeholder() {
2441 let r = report();
2442 let line = |tpl: &str| statusline_template(&r, "go", tpl, false, false, 20);
2443
2444 assert_eq!(line("{plan}"), "go");
2446 assert_eq!(line("{period}"), "calendar month");
2447 assert_eq!(line("{month}"), "$30.00");
2449 assert_eq!(line("{five_hour}"), "$30.00");
2450 assert_eq!(line("{weekly}"), "$30.00");
2451 assert_eq!(line("{models}"), "glm-5.3-flash");
2453 assert_eq!(line("{month_pct}"), "—");
2457 assert_eq!(line("{month_bar}"), "");
2458 assert_eq!(line("{five_hour_pct}"), "—");
2459 assert_eq!(line("{weekly_pct}"), "—");
2460
2461 let default = line("{period} {month} · 5h {five_hour} · wk {weekly}");
2463 assert_eq!(default, "calendar month $30.00 · 5h $30.00 · wk $30.00");
2464
2465 assert_eq!(line("{plan} {bogus} | tail"), "go | tail");
2467 assert_eq!(line("{plan} {oops"), "go {oops");
2469 assert!(!line("{plan} {month} {month_pct} {month_bar}").contains('\x1b'));
2471 }
2472
2473 #[test]
2476 fn statusline_without_caps_degrades_gracefully() {
2477 let empty = crate::ocuse::window::build(
2478 &[],
2479 &zen::catalog(),
2480 zen::DEFAULT_PLAN,
2481 1_800_000_000_000,
2482 None,
2483 None,
2484 );
2485 let line = statusline_template(&empty, "go", "{month_pct} {models}", false, false, 20);
2486 assert_eq!(line, "— —");
2487 }
2488
2489 #[test]
2491 fn compact_line_is_one_line_with_inline_sparkline() {
2492 let plain = compact_line(&report(), &[], true);
2493 assert_eq!(plain.lines().count(), 1, "{plain}");
2494 assert!(!plain.ends_with('\n'), "{plain}");
2495 let bursty = compact_line(&report(), &[0.0, 1.5], true);
2496 assert!(bursty.contains('█'), "nonzero delta shows: {bursty}");
2497 assert!(!compact_line(&report(), &[], false).contains('\x1b'));
2499 assert!(compact_line(&report(), &[], true).contains(crate::render::BOLD));
2500 }
2501}