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 model_row(ink: &Ink, model: &ModelUsage, total_cost: f64) -> String {
290 let limit = match model.limit {
291 Some(limit) => money(limit),
292 None => ink.dim_on("—".into()),
293 };
294 let use_fraction = model.month_use();
295 let use_text = use_fraction
296 .map(|fraction| pct(fraction * 100.0, 100.0))
297 .unwrap_or_else(|| "—".into());
298 let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
300 let use_cell = match use_fraction {
301 Some(fraction) => {
302 let tone = ink.for_pct(fraction * 100.0);
303 if tone.is_empty() {
304 format!("{use_text:>7}")
305 } else {
306 format!("{tone}{use_text:>7}{}", ink.reset)
307 }
308 }
309 None => ink.dim_on(format!("{use_text:>7}")),
310 };
311 let flag = if exceeded(model) {
314 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
315 } else {
316 String::new()
317 };
318 let share = if total_cost > 0.0 {
319 ink.dim_on(format!(" {:>5.1}%", model.month.cost / total_cost * 100.0))
320 } else {
321 String::new()
322 };
323 format!(
324 "{:<28}{flag} {:>7} {spent} {use_cell} {} {} {:>7}{share}\n",
325 model.id,
326 limit,
327 window_cell(ink, model, model.five_hour.cost, 0.20),
329 window_cell(ink, model, model.weekly.cost, 0.50),
330 compact(model.month.requests),
331 )
332}
333
334fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
337 let text = format!("{:>8}", money(cost));
338 match model.limit.filter(|limit| *limit > 0.0) {
339 Some(limit) => {
340 let tone = ink.for_pct(cost / (limit * share) * 100.0);
341 if tone.is_empty() {
342 text
343 } else {
344 format!("{tone}{text}{}", ink.reset)
345 }
346 }
347 None => ink.dim_on(text),
348 }
349}
350
351pub fn render_text(
353 report: &Report,
354 db_path: &str,
355 rows: usize,
356 colour: bool,
357 bar_width: usize,
358) -> String {
359 let ink = Ink::new(colour).width(bar_width);
360 let c = |text: String| ink.cyan_on(text);
361 let mut out = String::new();
362 out.push_str(&format!(
363 "{}OpenCode usage{} — go + zen, from {}{}{}\n",
364 ink.bold,
365 ink.reset,
366 ink.dim,
367 shorten(db_path),
368 ink.reset,
369 ));
370 let end = report
374 .period_end_ms
375 .map(|ms| format!(" · ends {}", date_of(ms / 86_400_000)))
376 .unwrap_or_default();
377 out.push_str(&format!(
378 "period: {}{end} · {} requests · {} · {} in / {} out tokens\n",
379 report.period_label,
380 c(compact(report.month.requests)),
381 c(money(report.month.cost)),
382 c(compact(report.month.input)),
383 c(compact(report.month.output)),
384 ));
385
386 match &report.account {
391 Some(account) => {
392 out.push_str(&format!(
393 "\n{}GO{} {}\n",
394 ink.bold,
395 ink.reset,
396 ink.dim_on(
397 "(account meters — what opencode.ai/console reports; the $ is this store's spend priced on the documented peak/off-peak schedule)"
398 .into()
399 ),
400 ));
401 let monthly_dur = report
407 .period_end_ms
408 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
409 let rows = [
410 (
411 "5-hour",
412 &account.rolling,
413 &report.five_hour,
414 Some(crate::ocuse::window::FIVE_HOUR_SECS as u64),
415 ),
416 (
417 "Weekly",
418 &account.weekly,
419 &report.weekly,
420 Some(crate::ocuse::window::WEEKLY_SECS as u64),
421 ),
422 ("Monthly", &account.monthly, &report.month, monthly_dur),
423 ];
424 for (label, meter, used, dur_secs) in rows {
425 out.push_str(&window_line(
426 &ink,
427 &RowSpec {
428 label,
429 used,
430 reset: meter_reset(meter, dur_secs),
431 share_pct: Some(meter.percent),
432 status: Some(meter.status.as_str()),
433 pro_rata: label == "Monthly",
436 },
437 report.now_ms,
438 ));
439 }
440 }
441 None if report.month.requests > 0 => {
446 let monthly_dur = report
451 .period_end_ms
452 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
453 out.push_str(&format!(
454 "\n{}GO{} {}\n",
455 ink.bold,
456 ink.reset,
457 ink.dim_on(
458 "(estimated locally: limits are per model, so no pool: spend alone)".into()
459 ),
460 ));
461 if let Some(why) = &report.account_error {
465 out.push_str(&format!("{} usage API: {why}{}\n", ink.dim, ink.reset));
466 }
467 out.push_str(&window_line(
468 &ink,
469 &RowSpec {
470 label: "5-hour",
471 used: &report.five_hour,
472 reset: None,
473 share_pct: None,
474 status: None,
475 pro_rata: false,
476 },
477 report.now_ms,
478 ));
479 out.push_str(&window_line(
480 &ink,
481 &RowSpec {
482 label: "Weekly",
483 used: &report.weekly,
484 reset: None,
485 share_pct: None,
486 status: None,
487 pro_rata: false,
488 },
489 report.now_ms,
490 ));
491 out.push_str(&window_line(
492 &ink,
493 &RowSpec {
494 label: "Monthly",
495 used: &report.month,
496 reset: report.period_end_ms.zip(monthly_dur),
497 share_pct: None,
498 status: None,
499 pro_rata: false,
502 },
503 report.now_ms,
504 ));
505 }
506 None => {}
507 }
508
509 out.push_str(&format!(
510 "\n{}MODEL LIMIT SPENT USE 5H WK REQ{}\n",
511 ink.bold, ink.reset
512 ));
513 let total_cost = report.month.cost;
514 for model in report.models.iter().take(rows) {
515 out.push_str(&model_row(&ink, model, total_cost));
516 }
517 if report.models.len() > rows {
518 out.push_str(&format!(
519 "{}\n",
520 ink.dim_on(format!("… {} more", report.models.len() - rows))
521 ));
522 }
523 out.push_str(&format!(
524 "\n{}\n",
525 ink.dim_on(
526 "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
527 .into()
528 )
529 ));
530 if report.account.is_some() {
536 out.push_str(&format!(
537 "{}\n",
538 ink.dim_on(
539 "5H/WK columns: this model's own spend inside the account's windows (the same cuts its percentages use)."
540 .into()
541 )
542 ));
543 } else {
544 out.push_str(&format!(
545 "{}\n",
546 ink.dim_on(
547 "5H/WK columns: this model's own spend over the last 5h / 7 days (local, not the account's windows)."
548 .into()
549 )
550 ));
551 }
552 out
553}
554
555pub fn watch_frame(
560 report: &Report,
561 db_path: &str,
562 rows: usize,
563 colour: bool,
564 history: &[f64],
565 interval_secs: u64,
566 bar_width: usize,
567) -> String {
568 let ink = Ink::new(colour);
569 let mut out = render_text(report, db_path, rows, colour, bar_width);
570 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
571 out.push_str(&format!(
572 "{}spend bursts ({}s samples){} {}\n",
573 ink.dim,
574 interval_secs,
575 ink.reset,
576 crate::render::sparkline(history),
577 ));
578 }
579 out.trim_end_matches('\n').to_string()
580}
581
582pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
587 let ink = Ink::new(colour);
588 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
589 format!(
590 " {}{}{}",
591 ink.dim,
592 crate::render::sparkline(history),
593 ink.reset
594 )
595 } else {
596 String::new()
597 };
598 let window_share = match &report.account {
603 Some(account) => format!(
604 " · 5h {} · wk {}",
605 ink.cyan_on(pct(account.rolling.percent, 100.0)),
606 ink.cyan_on(pct(account.weekly.percent, 100.0)),
607 ),
608 None => format!(" · 5h {}", ink.cyan_on(money(report.five_hour.cost))),
609 };
610 format!(
611 "{}oc{} {} month{window_share}{spark}",
612 ink.bold,
613 ink.reset,
614 ink.cyan_on(money(report.month.cost)),
615 )
616}
617
618pub fn statusline_template(
632 report: &Report,
633 plan_id: &str,
634 tpl: &str,
635 colours: bool,
636 ascii: bool,
637 bar_width: usize,
638) -> String {
639 let ink = Ink::new(colours);
640 let cash = |v: f64| ink.cyan_on(money(v));
641 let account = report.account.as_ref();
648 let share_of = |meter: Option<&crate::ocuse::api::Meter>| match meter {
649 Some(meter) => pct(meter.percent, 100.0),
650 None => "—".into(),
651 };
652 let share_bar = |meter: Option<&crate::ocuse::api::Meter>| match meter {
653 Some(meter) => crate::report_render::bar(meter.percent, bar_width, ascii, colours),
654 None => String::new(),
655 };
656 let top_model = || {
657 report
658 .models
659 .first()
660 .map(|m| m.id.clone())
661 .unwrap_or_else(|| "—".into())
662 };
663 crate::report_render::render_template(tpl, |key| match key {
664 "plan" => plan_id.to_string(),
665 "period" => report.period_label.clone(),
666 "month" => cash(report.month.cost),
667 "month_pct" => share_of(account.map(|a| &a.monthly)),
668 "month_bar" => share_bar(account.map(|a| &a.monthly)),
669 "five_hour" | "5h" => cash(report.five_hour.cost),
670 "five_hour_pct" | "5h_pct" => share_of(account.map(|a| &a.rolling)),
671 "five_hour_bar" | "5h_bar" => share_bar(account.map(|a| &a.rolling)),
672 "weekly" | "wk" => cash(report.weekly.cost),
673 "weekly_pct" | "wk_pct" => share_of(account.map(|a| &a.weekly)),
674 "weekly_bar" | "wk_bar" => share_bar(account.map(|a| &a.weekly)),
675 "models" => top_model(),
676 _ => String::new(),
677 })
678}
679
680pub fn statusline_json(report: &Report, plan_id: &str) -> String {
686 serde_json::json!({
687 "plan": plan_id,
688 "period": report.period_label,
689 "monthSpent": report.month.cost,
690 "monthCap": serde_json::Value::Null,
691 "fiveHourSpent": report.five_hour.cost,
692 "fiveHourCap": serde_json::Value::Null,
693 "weeklySpent": report.weekly.cost,
694 "weeklyCap": serde_json::Value::Null,
695 "model": report.models.first().map(|m| m.id.clone()),
696 })
697 .to_string()
698}
699
700pub fn render_json(report: &Report) -> String {
702 let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
703 let model = |m: &ModelUsage| {
704 serde_json::json!({
705 "id": m.id,
706 "provider": m.provider,
707 "limit": m.limit,
708 "month": totals(&m.month),
709 "fiveHour": totals(&m.five_hour),
710 "weekly": totals(&m.weekly),
711 })
712 };
713 let meter = |m: &crate::ocuse::api::Meter| {
717 serde_json::json!({
718 "status": m.status,
719 "percent": m.percent,
720 "resetsAt": m.reset_ms(),
721 })
722 };
723 serde_json::to_string_pretty(&serde_json::json!({
724 "source": "opencode.db",
725 "generatedAt": report.now_ms,
726 "periodStart": report.since_ms,
727 "periodStartAt": report.since_ms,
732 "periodEndAt": report.period_end_ms,
733 "periodEnd": report
736 .period_end_ms
737 .map(|ms| date_of(ms.div_euclid(86_400_000))),
738 "periodInferred": report.month_inferred,
739 "summary": totals(&report.month),
743 "totals": {
744 "month": totals(&report.month),
745 "fiveHour": totals(&report.five_hour),
746 "weekly": totals(&report.weekly),
747 },
748 "account": report.account.as_ref().map(|a| serde_json::json!({
751 "rolling": meter(&a.rolling),
752 "weekly": meter(&a.weekly),
753 "monthly": meter(&a.monthly),
754 })),
755 "accountError": report.account_error,
760 "models": report.models.iter().map(model).collect::<Vec<_>>(),
761 }))
762 .expect("json")
763}
764
765fn shorten(path: &str) -> String {
766 match std::env::var("HOME") {
767 Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
768 _ => path.to_string(),
769 }
770}
771
772pub fn plans_text(colour: bool) -> String {
779 let ink = Ink::new(colour);
780 let catalog = crate::ocuse::zen::catalog();
781 let mut out = format!(
782 "{}OpenCode plans{} {}\n",
783 ink.bold,
784 ink.reset,
785 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
786 );
787 out.push_str(&format!(
788 "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
789 ink.dim,
790 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
791 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
792 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
793 ink.reset,
794 ));
795 for plan in catalog.go.plans.values() {
796 out.push_str(&format!(
797 "\n{}{}{} — {}/month\n",
798 ink.bold,
799 plan.label,
800 ink.reset,
801 ink.cyan_on(format!("${:.2}", plan.price_usd)),
802 ));
803 out.push_str(&format!(
804 "{}MODEL LIMIT IN OUT CACHE{}\n",
805 ink.bold, ink.reset
806 ));
807 for model in &catalog.go.models {
808 let rate = model.variants.first();
809 let limit = plan
810 .allowance_by_model
811 .get(&model.id)
812 .map(|limit| cmduse_core::money(*limit))
813 .unwrap_or_else(|| "—".into());
814 out.push_str(&format!(
815 "{:<28} {} {} {} {}\n",
816 model.id,
817 ink.cyan_on(format!("{limit:>7}")),
818 ink.cyan_on(format!(
819 "{:>6}",
820 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
821 )),
822 ink.cyan_on(format!(
823 "{:>6}",
824 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
825 )),
826 ink.cyan_on(format!(
827 "{:>8}",
828 rate.map(|r| format!("${}", r.cache_read))
829 .unwrap_or_default()
830 )),
831 ));
832 }
833 }
834 out.push_str(&format!(
835 "\n{}\n",
836 ink.dim_on(format!(
837 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
838 catalog.zen.models.len()
839 ))
840 ));
841 out
842}
843
844pub fn plans_json() -> String {
848 let catalog = crate::ocuse::zen::catalog();
849 let plans: Vec<serde_json::Value> = catalog
850 .go
851 .plans
852 .iter()
853 .map(|(id, plan)| {
854 serde_json::json!({
855 "id": id,
856 "label": plan.label,
857 "priceUsd": plan.price_usd,
858 "allowanceByModel": &plan.allowance_by_model,
859 })
860 })
861 .collect();
862 serde_json::json!({
863 "extractedAt": catalog.extracted_at,
864 "windows": {
865 "fiveHour": catalog.go.window_share.five_hour,
866 "weekly": catalog.go.window_share.weekly,
867 "monthly": catalog.go.window_share.monthly,
868 },
869 "plans": plans,
870 "models": catalog.go.models.iter().map(rate_json).collect::<Vec<_>>(),
871 "zen": catalog.zen.models.iter().map(rate_json).collect::<Vec<_>>(),
872 })
873 .to_string()
874}
875
876fn rate_json(model: &zen::Model) -> serde_json::Value {
878 let rate = model.variants.first();
879 serde_json::json!({
880 "id": model.id,
881 "name": model.name,
882 "input": rate.map(|r| r.input),
883 "output": rate.map(|r| r.output),
884 "cacheRead": rate.map(|r| r.cache_read),
885 "cacheWrite": rate.map(|r| r.cache_write),
886 })
887}
888
889struct ModelRow {
892 id: String,
893 name: String,
894 input: Option<f64>,
895 output: Option<f64>,
896 cache_read: Option<f64>,
897 limit: Option<f64>,
898}
899
900fn model_rows(plan_id: &str, gated: bool) -> Vec<ModelRow> {
903 let catalog = crate::ocuse::zen::catalog();
904 let plan = catalog.plan(plan_id);
905 let mut rows: Vec<ModelRow> = catalog
906 .go
907 .models
908 .iter()
909 .map(|m| {
910 let rate = m.variants.first();
911 ModelRow {
912 id: m.id.clone(),
913 name: m.name.clone(),
914 input: rate.map(|r| r.input),
915 output: rate.map(|r| r.output),
916 cache_read: rate.map(|r| r.cache_read),
917 limit: plan.and_then(|p| p.allowance_by_model.get(&m.id).copied()),
918 }
919 })
920 .collect();
921 if gated {
922 rows.retain(|r| r.limit.is_some());
923 }
924 rows
925}
926
927pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
931 let ink = Ink::new(colour);
932 let catalog = crate::ocuse::zen::catalog();
933 let label = catalog
934 .plan(plan_id)
935 .map(|p| p.label.clone())
936 .unwrap_or_else(|| plan_id.to_string());
937 let granted = catalog
938 .plan(plan_id)
939 .map(|p| p.allowance_by_model.len())
940 .unwrap_or(0);
941 let rows = model_rows(plan_id, gated);
942 let mut out = format!(
943 "{}{label} models{} {}\n",
944 ink.bold,
945 ink.reset,
946 ink.dim_on(format!(
947 "(plan {plan_id} · {granted}/{} granted)",
948 catalog.go.models.len()
949 )),
950 );
951 out.push_str(&format!(
952 "{}{:<30} {:<28} {:>7} {:>7} {:>7} {:>8} {:>7}{}\n",
953 ink.bold, "MODEL", "ID", "IN", "OUT", "CACHE", "LIMIT", "GRANTED", ink.reset
954 ));
955 let rate_cell = |v: Option<f64>| match v {
956 Some(v) => ink.cyan_on(format!("{:>7}", format!("${v}"))),
957 None => ink.dim_on(format!("{:>7}", "—")),
958 };
959 for r in &rows {
960 let limit = match r.limit {
961 Some(l) => ink.cyan_on(format!("{:>8}", cmduse_core::money(l))),
962 None => ink.dim_on(format!("{:>8}", "—")),
963 };
964 let granted = if r.limit.is_some() { "yes" } else { "no" };
965 out.push_str(&format!(
966 "{:<30} {:<28} {} {} {} {} {:>7}\n",
967 r.name,
968 r.id,
969 rate_cell(r.input),
970 rate_cell(r.output),
971 rate_cell(r.cache_read),
972 limit,
973 granted,
974 ));
975 }
976 out
977}
978
979pub fn models_json(plan_id: &str, gated: bool) -> String {
983 let mut out = String::new();
984 for r in model_rows(plan_id, gated) {
985 out.push_str(
986 &serde_json::json!({
987 "id": r.id,
988 "name": r.name,
989 "input": r.input,
990 "output": r.output,
991 "cacheRead": r.cache_read,
992 "limit": r.limit,
993 "granted": r.limit.is_some(),
994 })
995 .to_string(),
996 );
997 out.push('\n');
998 }
999 out
1000}
1001
1002pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
1003 let ink = Ink::new(colour);
1004 let Some(model) = report.models.iter().find(|m| m.id == id) else {
1005 return format!("{id}: no usage in the period\n");
1006 };
1007 let limit = model
1008 .limit
1009 .map(cmduse_core::money)
1010 .unwrap_or_else(|| "—".into());
1011 let row =
1012 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
1013 let mut out = format!(
1014 "{} {}\n",
1015 ink.bold_on(model.id.clone()),
1016 ink.dim_on(format!("({})", model.provider)),
1017 );
1018 out.push_str(&row("limit", ink.cyan_on(limit)));
1019 out.push_str(&row(
1020 "month",
1021 ink.cyan_on(format!(
1022 "{} · {} requests",
1023 cmduse_core::money(model.month.cost),
1024 model.month.requests
1025 )),
1026 ));
1027 out.push_str(&row(
1028 "5-hour",
1029 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
1030 ));
1031 out.push_str(&row(
1032 "weekly",
1033 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
1034 ));
1035 if report.month.cost > 0.0 {
1038 out.push_str(&row(
1039 "share",
1040 ink.dim_on(format!(
1041 "{:.1}% of period spend",
1042 model.month.cost / report.month.cost * 100.0
1043 )),
1044 ));
1045 }
1046 out
1047}
1048
1049fn totals_json(
1054 cost: f64,
1055 requests: u64,
1056 input: u64,
1057 output: u64,
1058 cache_read: u64,
1059) -> serde_json::Value {
1060 serde_json::json!({
1061 "requests": requests,
1062 "tokensIn": input,
1063 "tokensOut": output,
1064 "cacheRead": cache_read,
1065 "costUsd": cost,
1066 })
1067}
1068
1069#[derive(Default, Clone, Copy)]
1072struct Bucket {
1073 cost: f64,
1074 requests: u64,
1075 input: u64,
1076 output: u64,
1077 cache_read: u64,
1078}
1079
1080impl Bucket {
1081 fn add(&mut self, row: &crate::ocuse::db::Row) {
1082 self.cost += row.cost_usd;
1083 self.requests += 1;
1084 self.input += row.input;
1085 self.output += row.output;
1086 self.cache_read += row.cache_read;
1087 }
1088
1089 fn json(&self) -> serde_json::Value {
1090 totals_json(
1091 self.cost,
1092 self.requests,
1093 self.input,
1094 self.output,
1095 self.cache_read,
1096 )
1097 }
1098}
1099
1100pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1108 let models: Vec<&ModelUsage> = report
1109 .models
1110 .iter()
1111 .filter(|m| id.is_none_or(|want| m.id == want))
1112 .collect();
1113 match fmt {
1114 Fmt::Json => {
1115 let map: serde_json::Map<String, serde_json::Value> = models
1116 .iter()
1117 .map(|m| {
1118 let mut body = totals_json(
1119 m.month.cost,
1120 m.month.requests,
1121 m.month.input,
1122 m.month.output,
1123 m.month.cache_read,
1124 );
1125 body["limit"] = serde_json::json!(m.limit);
1126 (m.id.clone(), body)
1127 })
1128 .collect();
1129 serde_json::json!({
1130 "scope": "local",
1131 "source": "opencode",
1132 "sinceMs": report.since_ms,
1133 "models": map,
1134 })
1135 .to_string()
1136 }
1137 Fmt::Csv => {
1138 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1139 for m in models {
1140 out.push_str(&format!(
1141 "{},{},{},{},{},{:.2}\n",
1142 crate::report_render::csv_field(&m.id),
1143 m.month.requests,
1144 m.month.input,
1145 m.month.output,
1146 m.month.cache_read,
1147 m.month.cost,
1148 ));
1149 }
1150 out
1151 }
1152 Fmt::Table => String::new(), }
1154}
1155
1156pub fn bucket_text(
1164 rows: &[crate::ocuse::db::Row],
1165 now_ms: i64,
1166 lookback_ms: i64,
1167 size_ms: i64,
1168 tz_secs: i64,
1169 colour: bool,
1170 fmt: Fmt,
1171) -> String {
1172 let shift = tz_secs * 1000;
1174 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1175 for row in rows {
1176 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1177 buckets.entry(bucket).or_default().add(row);
1178 }
1179 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1180 let daily = size_ms >= 86_400_000;
1181 let label = if daily { "day" } else { "hour" };
1182 let stamp = |bucket: i64| {
1183 if daily {
1184 date_of(bucket)
1185 } else {
1186 format!(
1187 "{} {:02}h",
1188 date_of(bucket.div_euclid(24)),
1189 bucket.rem_euclid(24)
1190 )
1191 }
1192 };
1193 let window: Vec<(i64, Bucket)> = buckets
1194 .range(start..)
1195 .map(|(bucket, b)| (*bucket, *b))
1196 .collect();
1197
1198 match fmt {
1199 Fmt::Json => {
1200 let mut keyed = serde_json::Map::new();
1201 let mut total = Bucket::default();
1202 for (bucket, b) in &window {
1203 keyed.insert(stamp(*bucket), b.json());
1204 total.add_bucket(b);
1205 }
1206 let key = if daily { "days" } else { "hours" };
1207 serde_json::json!({
1208 "scope": "local",
1209 "source": "opencode",
1210 "total": total.json(),
1211 key: keyed,
1212 })
1213 .to_string()
1214 }
1215 Fmt::Csv => {
1216 let head = if daily { "day" } else { "hour" };
1217 let mut out = format!("{head},requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1218 let mut total = Bucket::default();
1219 for (bucket, b) in &window {
1220 out.push_str(&format!(
1221 "{},{},{},{},{},{:.2}\n",
1222 crate::report_render::csv_field(&stamp(*bucket)),
1223 b.requests,
1224 b.input,
1225 b.output,
1226 b.cache_read,
1227 b.cost,
1228 ));
1229 total.add_bucket(b);
1230 }
1231 out.push_str(&format!(
1232 "total,{},{},{},{},{:.2}\n",
1233 total.requests, total.input, total.output, total.cache_read, total.cost,
1234 ));
1235 out
1236 }
1237 Fmt::Table => {
1238 let ink = Ink::new(colour);
1239 let mut out = format!(
1240 "{}{label:<12} spend requests tokens{}\n",
1241 ink.bold, ink.reset
1242 );
1243 for (bucket, b) in &window {
1244 out.push_str(&format!(
1245 "{:<12} {} {} {}\n",
1246 stamp(*bucket),
1247 ink.cyan_on(format!("{:>9}", cmduse_core::money(b.cost))),
1248 ink.cyan_on(format!("{:>12}", cmduse_core::compact(b.requests))),
1249 ink.cyan_on(format!(
1250 "{:>9}",
1251 cmduse_core::compact(b.input + b.output + b.cache_read)
1252 )),
1253 ));
1254 }
1255 if out.lines().count() == 1 {
1256 out.push_str(&format!(
1257 "{}\n",
1258 ink.dim_on("(no OpenCode usage in the window)".into())
1259 ));
1260 }
1261 out
1262 }
1263 }
1264}
1265
1266type SessionTotals<'a> = (
1269 f64,
1270 u64,
1271 u64,
1272 u64,
1273 u64,
1274 i64,
1275 std::collections::BTreeSet<&'a str>,
1276);
1277
1278pub fn session_text(
1283 rows: &[crate::ocuse::db::Row],
1284 limit: usize,
1285 colour: bool,
1286 fmt: Fmt,
1287) -> String {
1288 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1289 for row in rows {
1290 let entry = by_session
1291 .entry(row.session.as_str())
1292 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1293 entry.0 += row.cost_usd;
1294 entry.1 += 1;
1295 entry.2 += row.input;
1296 entry.3 += row.output;
1297 entry.4 += row.cache_read;
1298 entry.5 = entry.5.max(row.at_ms);
1299 entry.6.insert(row.model.as_str());
1300 }
1301 let mut sessions: Vec<_> = by_session.into_iter().collect();
1302 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1303
1304 match fmt {
1305 Fmt::Json => {
1306 let mut keyed = serde_json::Map::new();
1307 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1308 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1309 body["lastMs"] = serde_json::json!(*last);
1310 body["models"] = serde_json::json!(models.len());
1311 keyed.insert((*id).to_string(), body);
1312 }
1313 serde_json::json!({
1314 "scope": "local",
1315 "source": "opencode",
1316 "sessions": keyed,
1317 })
1318 .to_string()
1319 }
1320 Fmt::Csv => {
1321 let mut out =
1322 String::from("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1323 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1324 out.push_str(&format!(
1325 "{},{},{},{},{},{:.2}\n",
1326 crate::report_render::csv_field(id),
1327 requests,
1328 input,
1329 output,
1330 cache_read,
1331 cost,
1332 ));
1333 }
1334 out
1335 }
1336 Fmt::Table => {
1337 let ink = Ink::new(colour);
1338 let mut out = format!(
1339 "{}session last models spend req{}\n",
1340 ink.bold, ink.reset
1341 );
1342 for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1343 let short = id.rsplit('_').next().unwrap_or(*id);
1344 out.push_str(&format!(
1345 "{:<32} {:<11} {:>6} {} {:>6}\n",
1346 short,
1347 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1348 models.len(),
1349 ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
1350 requests,
1351 ));
1352 }
1353 if sessions.len() > limit {
1354 out.push_str(&format!(
1355 "{}\n",
1356 ink.dim_on(format!("… {} more", sessions.len() - limit))
1357 ));
1358 }
1359 if out.lines().count() == 1 {
1360 out.push_str(&format!(
1361 "{}\n",
1362 ink.dim_on("(no sessions in the window)".into())
1363 ));
1364 }
1365 out
1366 }
1367 }
1368}
1369
1370impl Bucket {
1371 fn add_bucket(&mut self, other: &Bucket) {
1373 self.cost += other.cost;
1374 self.requests += other.requests;
1375 self.input += other.input;
1376 self.output += other.output;
1377 self.cache_read += other.cache_read;
1378 }
1379}
1380
1381pub fn date_of(day: i64) -> String {
1384 cmduse_core::dates::civil_from_days(day)
1385}
1386
1387#[cfg(test)]
1388mod tests {
1389 use super::*;
1390 use crate::ocuse::db::Row;
1391
1392 fn report() -> Report {
1393 let now = 1_800_000_000_000;
1394 let rows = vec![Row {
1395 at_ms: now - 60_000,
1396 provider: "opencode-go".into(),
1397 model: "glm-5.3-flash".into(),
1398 cost_usd: 30.0,
1399 ..Default::default()
1400 }];
1401 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None)
1402 }
1403
1404 #[test]
1405 fn text_shows_the_model_against_its_limit() {
1406 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1407 assert!(text.contains("glm-5.3-flash"), "{text}");
1408 assert!(text.contains("$60"), "the model's monthly limit: {text}");
1409 assert!(
1410 text.contains("50.0%"),
1411 "the model table keeps its own USE: {text}"
1412 );
1413 }
1414
1415 fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
1416 crate::ocuse::api::Meter {
1417 status: status.into(),
1418 percent,
1419 resets_at: Some(resets_at.into()),
1420 }
1421 }
1422
1423 fn report_with_account() -> Report {
1430 let mut report = report();
1431 report.account = Some(crate::ocuse::api::Usage {
1432 rolling: meter("ok", 5.0, "2027-01-15T12:31:00.000Z"),
1433 weekly: meter("ok", 59.0, "2027-01-18T00:00:00.000Z"),
1434 monthly: meter("ok", 29.0, "2027-02-15T08:00:00.000Z"),
1435 });
1436 report.period_end_ms = Some(1_802_678_400_000); report.since_ms = crate::ocuse::window::prev_month(1_802_678_400_000);
1438 report
1439 }
1440
1441 #[test]
1442 fn the_window_rows_come_from_the_account_when_it_answered() {
1443 let text = render_text(
1444 &report_with_account(),
1445 "/tmp/opencode.db",
1446 10,
1447 false,
1448 DEFAULT_BAR_WIDTH,
1449 );
1450 assert!(text.contains("· 5% of limit"), "{text}");
1453 assert!(text.contains("· 59% of limit"), "{text}");
1454 assert!(text.contains("· 29% of limit"), "{text}");
1455 assert!(text.contains("account meters"), "{text}");
1456 assert!(
1457 text.contains("resets 4h 31m"),
1458 "the 5h block's own end: {text}"
1459 );
1460 assert!(text.contains(".00 · 5% of limit"), "{text}");
1466 assert!(text.contains(".00 · 29% of limit"), "{text}");
1467 assert!(text.contains("of limit"), "{text}");
1468 assert!(text.contains("elapsed"), "{text}");
1473 assert_eq!(text.matches("by now").count(), 1, "{text}");
1474 assert!(text.contains("the same cuts its percentages use"), "{text}");
1477 }
1478
1479 #[test]
1486 fn the_pair_is_the_ceiling_the_share_implies() {
1487 assert_eq!(implied_cap(30.0, 5.0), Some(600.0));
1489 assert_eq!(implied_cap(30.0, 59.0), Some(50.847457627118644));
1490 assert_eq!(implied_cap(20.45, 0.0), None);
1492 assert_eq!(implied_cap(20.45, 0.04), None);
1493 assert_eq!(implied_cap(20.45, 0.1), Some(20450.0));
1494 assert_eq!(implied_cap(0.0, 1.0), Some(0.0));
1495 }
1496
1497 #[test]
1502 fn the_gauge_carries_the_share_and_the_body_does_not_repeat_it() {
1503 let colour = render_text(
1504 &report_with_account(),
1505 "/tmp/opencode.db",
1506 10,
1507 true,
1508 DEFAULT_BAR_WIDTH,
1509 );
1510 let five = colour
1511 .lines()
1512 .find(|line| line.contains("5-hour"))
1513 .expect("a 5-hour row");
1514 assert!(!five.contains("of limit"), "no repeated share: {five}");
1515 assert!(
1516 five.contains(" 5%"),
1517 "the endpoint's whole figure: {five}"
1518 );
1519 let plain = render_text(
1520 &report_with_account(),
1521 "/tmp/opencode.db",
1522 10,
1523 false,
1524 DEFAULT_BAR_WIDTH,
1525 );
1526 assert!(plain.contains("· 5% of limit"), "{plain}");
1527 }
1528
1529 #[test]
1531 fn a_reachable_cap_gets_the_pace_line() {
1532 let mut report = report_with_account();
1533 if let Some(account) = &mut report.account {
1534 account.rolling = meter("ok", 90.0, "2027-01-15T12:00:00.000Z");
1537 }
1538 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1539 assert!(text.contains("on pace to hit cap in"), "{text}");
1540 }
1541
1542 #[test]
1543 fn without_an_account_the_rows_say_they_are_local() {
1544 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1545 assert!(text.contains("estimated locally"), "{text}");
1546 assert!(text.contains("no pool"), "no aggregate ceiling: {text}");
1547 assert!(!text.contains("of limit"), "{text}");
1549 assert!(!text.contains("resets"), "{text}");
1550 assert!(!text.contains("elapsed"), "{text}");
1551 assert!(!text.contains("account meters"), "{text}");
1553 assert!(
1554 !text.contains("the same cuts its percentages use"),
1555 "{text}"
1556 );
1557 assert!(text.contains("local, not the account's windows"), "{text}");
1560 }
1561
1562 #[test]
1563 fn an_over_limit_meter_says_so_before_its_countdown() {
1564 let mut report = report_with_account();
1565 if let Some(account) = &mut report.account {
1566 account.rolling = meter("rate_limited", 100.0, "2027-01-15T12:31:00.000Z");
1567 }
1568 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1569 assert!(text.contains("LIMIT EXCEEDED"), "{text}");
1570 }
1571
1572 #[test]
1573 fn json_carries_the_account_meters_and_null_without_them() {
1574 let parsed: serde_json::Value =
1575 serde_json::from_str(&render_json(&report_with_account())).unwrap();
1576 assert_eq!(parsed["account"]["rolling"]["percent"], 5.0);
1577 assert_eq!(parsed["account"]["weekly"]["percent"], 59.0);
1578 assert_eq!(parsed["account"]["monthly"]["status"], "ok");
1579 let monthly = cmduse_core::dates::parse_iso_utc("2027-02-15T08:00:00.000Z").unwrap();
1582 assert_eq!(parsed["account"]["monthly"]["resetsAt"], monthly as i64);
1583
1584 let parsed: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1587 assert_eq!(parsed["account"], serde_json::Value::Null);
1588 }
1589
1590 #[test]
1591 fn statusline_prefers_the_accounts_share_over_the_local_estimate() {
1592 let tpl = "{five_hour_pct} {weekly_pct} {month_pct} {five_hour}";
1593 let account = statusline_template(
1594 &report_with_account(),
1595 "go",
1596 tpl,
1597 false,
1598 false,
1599 DEFAULT_BAR_WIDTH,
1600 );
1601 assert!(account.starts_with("5.0% 59.0% 29.0%"), "{account}");
1602 assert!(account.contains("$30.00"), "{account}");
1604
1605 let local = statusline_template(&report(), "go", tpl, false, false, DEFAULT_BAR_WIDTH);
1608 assert!(!local.starts_with("5.0%"), "{local}");
1609 }
1610
1611 #[test]
1612 fn json_and_statusline_carry_the_totals() {
1613 let json = render_json(&report());
1614 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1615 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
1616 assert_eq!(parsed["summary"], parsed["totals"]["month"]);
1620 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
1621 let line = statusline_template(&report(), "go", "{plan} {models}", false, false, 20);
1622 assert_eq!(line, "go glm-5.3-flash");
1623 }
1624
1625 #[test]
1626 fn json_carries_the_period_bounds() {
1627 let mut r = report();
1630 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1631 let parsed: serde_json::Value = serde_json::from_str(&render_json(&r)).unwrap();
1632 assert_eq!(parsed["periodStart"], r.since_ms);
1633 assert_eq!(parsed["periodStartAt"], r.since_ms);
1634 assert_eq!(parsed["periodEndAt"], r.period_end_ms.unwrap());
1635
1636 let mut all = report();
1639 all.period_end_ms = None;
1640 let parsed: serde_json::Value = serde_json::from_str(&render_json(&all)).unwrap();
1641 assert_eq!(parsed["periodEndAt"], serde_json::Value::Null);
1642 assert_eq!(parsed["periodStart"], all.since_ms);
1643 assert!(parsed.get("periodInferred").is_some());
1644 }
1645
1646 #[test]
1652 fn every_renderer_respects_the_colour_flag() {
1653 let mut report = report();
1654 report.period_end_ms = Some(crate::ocuse::window::next_month(report.since_ms));
1657 let rows = vec![Row {
1658 at_ms: report.now_ms - 60_000,
1659 session: "ses_fixture".into(),
1660 provider: "opencode-go".into(),
1661 model: "glm-5.3-flash".into(),
1662 cost_usd: 30.0,
1663 ..Default::default()
1664 }];
1665 let now = report.now_ms;
1666
1667 let cases: Vec<(&str, String, String)> = vec![
1668 (
1669 "render_text",
1670 render_text(&report, "/tmp/oc.db", 10, false, DEFAULT_BAR_WIDTH),
1671 render_text(&report, "/tmp/oc.db", 10, true, DEFAULT_BAR_WIDTH),
1672 ),
1673 (
1674 "statusline_template",
1675 statusline_template(
1676 &report,
1677 "go",
1678 "{period} {month} {month_pct}",
1679 false,
1680 false,
1681 20,
1682 ),
1683 statusline_template(
1684 &report,
1685 "go",
1686 "{period} {month} {month_pct}",
1687 true,
1688 false,
1689 20,
1690 ),
1691 ),
1692 (
1693 "compact_line",
1694 compact_line(&report, &[], false),
1695 compact_line(&report, &[], true),
1696 ),
1697 ("plans_text", plans_text(false), plans_text(true)),
1698 (
1699 "model_text",
1700 model_text(&report, "glm-5.3-flash", false),
1701 model_text(&report, "glm-5.3-flash", true),
1702 ),
1703 (
1704 "bucket_text/day",
1705 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Table),
1706 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Table),
1707 ),
1708 (
1709 "bucket_text/hour",
1710 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Table),
1711 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Table),
1712 ),
1713 (
1714 "session_text",
1715 session_text(&rows, 20, false, Fmt::Table),
1716 session_text(&rows, 20, true, Fmt::Table),
1717 ),
1718 (
1719 "models_text",
1720 models_text("go", false, false),
1721 models_text("go", false, true),
1722 ),
1723 ];
1724 for (name, plain, coloured) in cases {
1725 assert!(
1726 !plain.contains('\x1b'),
1727 "{name} leaked escapes when off: {plain}"
1728 );
1729 assert!(
1730 coloured.contains('\x1b'),
1731 "{name} rendered nothing coloured when on: {coloured}"
1732 );
1733 }
1734
1735 let flagged: Vec<(&str, String, String)> = vec![
1738 (
1739 "bucket_text/day csv",
1740 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv),
1741 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Csv),
1742 ),
1743 (
1744 "bucket_text/hour json",
1745 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Json),
1746 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Json),
1747 ),
1748 (
1749 "session_text csv",
1750 session_text(&rows, 20, false, Fmt::Csv),
1751 session_text(&rows, 20, true, Fmt::Csv),
1752 ),
1753 (
1754 "session_text json",
1755 session_text(&rows, 20, false, Fmt::Json),
1756 session_text(&rows, 20, true, Fmt::Json),
1757 ),
1758 ];
1759 for (name, off, on) in flagged {
1760 assert!(
1761 !off.contains('\x1b'),
1762 "{name} leaked escapes with colour off: {off}"
1763 );
1764 assert!(
1765 !on.contains('\x1b'),
1766 "{name} must stay uncoloured with colour on: {on}"
1767 );
1768 }
1769 for (name, out) in [
1770 ("model_report json", model_report(&report, None, Fmt::Json)),
1771 ("model_report csv", model_report(&report, None, Fmt::Csv)),
1772 ("plans_json", plans_json()),
1773 ("statusline_json", statusline_json(&report, "go")),
1774 ("models_json", models_json("go", false)),
1775 ] {
1776 assert!(
1777 !out.contains('\x1b'),
1778 "{name} must never be coloured: {out}"
1779 );
1780 }
1781 }
1782
1783 #[test]
1784 fn session_rows_cap_the_table_and_never_the_machine_formats() {
1785 let now = 1_800_000_000_000;
1789 let rows: Vec<Row> = (0..25)
1790 .map(|i| Row {
1791 at_ms: now - i as i64 * 60_000,
1792 provider: "opencode-go".into(),
1793 model: "glm-5.3-flash".into(),
1794 session: format!("ses_{i:02}"),
1795 cost_usd: 0.1,
1796 ..Default::default()
1797 })
1798 .collect();
1799 let table = session_text(&rows, 2, false, Fmt::Table);
1800 assert!(table.contains("… 23 more"), "{table}");
1801 assert_eq!(
1802 table.lines().count(),
1803 4,
1804 "header + 2 rows + the tail: {table}"
1805 );
1806 let json: serde_json::Value =
1807 serde_json::from_str(&session_text(&rows, 2, false, Fmt::Json)).unwrap();
1808 assert_eq!(json["sessions"].as_object().unwrap().len(), 25);
1809 let csv = session_text(&rows, 2, false, Fmt::Csv);
1810 assert_eq!(csv.lines().count(), 26, "header + every session: {csv}");
1811 }
1812
1813 #[test]
1814 fn bucket_reports_carry_the_documented_columns() {
1815 let now = 1_800_000_000_000;
1816 let rows = vec![Row {
1817 at_ms: now - 60_000,
1818 provider: "opencode-go".into(),
1819 model: "glm-5.3-flash".into(),
1820 cost_usd: 1.5,
1821 input: 10,
1822 output: 20,
1823 cache_read: 30,
1824 cache_write: 40,
1825 ..Default::default()
1826 }];
1827 let csv = bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv);
1828 assert!(
1829 csv.starts_with("day,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1830 "{csv}"
1831 );
1832 assert!(!csv.contains("cache_write"), "{csv}");
1834 assert!(csv.contains(",10,20,30,1.50\n"), "{csv}");
1835 assert!(csv.trim_end().ends_with("total,1,10,20,30,1.50"), "{csv}");
1836
1837 let json: serde_json::Value = serde_json::from_str(&bucket_text(
1838 &rows,
1839 now,
1840 7 * 86_400_000,
1841 86_400_000,
1842 0,
1843 false,
1844 Fmt::Json,
1845 ))
1846 .unwrap();
1847 assert_eq!(json["total"]["requests"], 1);
1848 assert_eq!(json["total"]["cacheRead"], 30);
1849 assert_eq!(json["days"].as_object().unwrap().len(), 1);
1850 }
1851
1852 #[test]
1853 fn session_report_json_and_csv_agree_with_the_store() {
1854 let now = 1_800_000_000_000;
1855 let rows = vec![
1856 Row {
1857 at_ms: now - 60_000,
1858 session: "ses_first".into(),
1859 model: "glm-5.3-flash".into(),
1860 cost_usd: 2.0,
1861 input: 5,
1862 output: 6,
1863 cache_read: 7,
1864 ..Default::default()
1865 },
1866 Row {
1867 at_ms: now - 30_000,
1868 session: "ses_second".into(),
1869 model: "kimi-k3".into(),
1870 cost_usd: 1.0,
1871 ..Default::default()
1872 },
1873 ];
1874 let json: serde_json::Value =
1875 serde_json::from_str(&session_text(&rows, 20, false, Fmt::Json)).unwrap();
1876 assert_eq!(json["sessions"]["ses_first"]["tokensIn"], 5);
1877 assert_eq!(json["sessions"]["ses_first"]["models"], 1);
1878 assert_eq!(json["sessions"]["ses_second"]["costUsd"], 1.0);
1879 let csv = session_text(&rows, 20, false, Fmt::Csv);
1880 assert!(
1881 csv.starts_with("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1882 "{csv}"
1883 );
1884 assert!(csv.contains("ses_second,1,0,0,0,1.00\n"), "{csv}");
1885 }
1886
1887 #[test]
1888 fn model_report_projects_the_report_models() {
1889 let json: serde_json::Value =
1890 serde_json::from_str(&model_report(&report(), None, Fmt::Json)).unwrap();
1891 assert_eq!(json["source"], "opencode");
1892 assert_eq!(json["models"]["glm-5.3-flash"]["costUsd"], 30.0);
1893 assert_eq!(json["models"]["glm-5.3-flash"]["limit"], 60.0);
1894 let csv = model_report(&report(), None, Fmt::Csv);
1895 assert!(csv.contains("glm-5.3-flash,1,0,0,0,30.00\n"), "{csv}");
1896 assert_eq!(
1898 model_report(&report(), Some("nope"), Fmt::Csv),
1899 "model,requests,tokens_in,tokens_out,cache_read,cost_usd\n"
1900 );
1901 }
1902
1903 #[test]
1904 fn catalogue_json_carries_both_plans_and_windows() {
1905 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1906 assert_eq!(json["windows"]["fiveHour"], 0.2);
1907 assert_eq!(json["windows"]["monthly"], 1.0);
1908 assert_eq!(json["plans"][0]["id"], "go");
1909 assert_eq!(json["plans"][0]["allowanceByModel"]["glm-5.3-flash"], 60.0);
1910 assert_eq!(json["plans"][1]["priceUsd"], 40.0);
1911 assert!(!json["models"].as_array().unwrap().is_empty());
1912 assert!(!json["zen"].as_array().unwrap().is_empty());
1913 }
1914
1915 #[test]
1916 fn models_lists_the_plan_and_marks_granted() {
1917 let all = models_text("go", false, false);
1919 assert!(all.contains("glm-5.3-flash"), "{all}");
1920 assert!(all.contains("GRANTED"), "{all}");
1921 let gated = models_text("go", true, false);
1923 for line in gated.lines().skip(2) {
1924 assert!(
1925 line.trim_end().ends_with("yes"),
1926 "ungranted row kept: {line}"
1927 );
1928 }
1929 let every: Vec<serde_json::Value> = models_json("go", false)
1931 .lines()
1932 .map(|l| serde_json::from_str(l).unwrap())
1933 .collect();
1934 assert!(every.iter().any(|m| m["granted"] == false));
1935 assert!(every
1936 .iter()
1937 .any(|m| m["id"] == "glm-5.3-flash" && m["limit"] == 60.0));
1938 let only: Vec<serde_json::Value> = models_json("go", true)
1939 .lines()
1940 .map(|l| serde_json::from_str(l).unwrap())
1941 .collect();
1942 assert!(!only.is_empty());
1943 assert!(only.iter().all(|m| m["granted"] == true));
1944 }
1945
1946 #[test]
1947 fn statusline_json_mirrors_the_template_numbers() {
1948 let json: serde_json::Value =
1949 serde_json::from_str(&statusline_json(&report(), "go")).unwrap();
1950 assert_eq!(json["plan"], "go");
1951 assert_eq!(json["period"], "calendar month");
1952 assert_eq!(json["monthSpent"], 30.0);
1953 assert_eq!(json["monthCap"], serde_json::Value::Null);
1956 assert_eq!(json["fiveHourCap"], serde_json::Value::Null);
1957 assert_eq!(json["weeklyCap"], serde_json::Value::Null);
1958 assert_eq!(json["model"], "glm-5.3-flash");
1959 }
1960
1961 #[test]
1962 fn plain_frames_carry_no_escapes() {
1963 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1964 assert!(
1965 !text.contains('\x1b'),
1966 "piped output must stay plain: {text}"
1967 );
1968 assert!(text.contains("$30.00"), "{text}");
1971 }
1972
1973 #[test]
1977 fn monthly_row_carries_the_real_period_metrics() {
1978 let mut r = report();
1979 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1980 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1981
1982 let monthly = text
1983 .lines()
1984 .find(|line| line.trim_start().starts_with("Monthly"))
1985 .expect("the monthly row");
1986 assert!(monthly.contains("resets"), "{monthly}");
1987 assert!(
1988 monthly.contains("window") && monthly.contains("% elapsed"),
1989 "the real period gives an elapsed share: {monthly}"
1990 );
1991
1992 for label in ["5-hour", "Weekly"] {
1993 let row = text
1994 .lines()
1995 .find(|line| line.trim_start().starts_with(label))
1996 .unwrap_or_else(|| panic!("the {label} row"));
1997 assert!(!row.contains("elapsed"), "{label} has no boundary: {row}");
1998 assert!(!row.contains("resets"), "{label} has no boundary: {row}");
1999 }
2000 }
2001
2002 #[test]
2005 fn period_line_splits_tokens_and_shows_the_end_date() {
2006 let mut r = report();
2007 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
2008 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2009 let header = text
2010 .lines()
2011 .find(|line| line.starts_with("period:"))
2012 .expect("the period line");
2013 assert!(
2014 header.contains(" in / "),
2015 "token split like cmduse: {header}"
2016 );
2017 assert!(header.contains(" out tokens"), "{header}");
2018 assert!(
2019 header.contains(&date_of(r.period_end_ms.unwrap() / 86_400_000)),
2020 "the resolved end date: {header}"
2021 );
2022
2023 let no_end = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2025 let header = no_end
2026 .lines()
2027 .find(|line| line.starts_with("period:"))
2028 .expect("the period line");
2029 assert!(!header.contains("ends"), "{header}");
2030 }
2031
2032 #[test]
2033 fn an_over_cap_window_is_flagged_in_both_modes() {
2034 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2036 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
2037 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
2038 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
2039 assert!(
2040 coloured.contains(crate::render::RED),
2041 "the flag and the gauge are red: {coloured}"
2042 );
2043 }
2044
2045 #[test]
2046 fn a_model_over_any_of_its_own_windows_is_flagged() {
2047 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2050 let row = plain
2051 .lines()
2052 .find(|line| line.starts_with("glm-5.3-flash"))
2053 .expect("the model row");
2054 assert!(row.contains("LIMIT EXCEEDED"), "{row}");
2055 assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
2057 }
2058
2059 #[test]
2060 fn a_model_within_its_limits_is_not_flagged() {
2061 let now = 1_800_000_000_000;
2062 let rows = vec![Row {
2064 at_ms: now - 60_000,
2065 provider: "opencode-go".into(),
2066 model: "glm-5.3-flash".into(),
2067 cost_usd: 5.0,
2068 ..Default::default()
2069 }];
2070 let report =
2071 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
2072 for colour in [false, true] {
2073 let text = render_text(&report, "/tmp/opencode.db", 10, colour, DEFAULT_BAR_WIDTH);
2074 assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
2075 }
2076 }
2077
2078 #[test]
2079 fn the_model_detail_spells_out_the_share() {
2080 let detail = model_text(&report(), "glm-5.3-flash", false);
2081 assert!(detail.contains("100.0% of period spend"), "{detail}");
2082 let empty = crate::ocuse::window::build(
2084 &[],
2085 &zen::catalog(),
2086 zen::DEFAULT_PLAN,
2087 1_800_000_000_000,
2088 None,
2089 None,
2090 );
2091 assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
2092 }
2093
2094 #[test]
2095 fn colour_frames_get_the_palette_and_a_gauge() {
2096 let text = render_text(
2101 &report_with_account(),
2102 "/tmp/opencode.db",
2103 10,
2104 true,
2105 DEFAULT_BAR_WIDTH,
2106 );
2107 assert!(text.contains(crate::render::BOLD), "{text}");
2108 assert!(text.contains(crate::render::CYAN), "{text}");
2109 assert!(
2110 text.contains(crate::render::GREEN),
2111 "the 50% USE cell is green: {text}"
2112 );
2113 assert!(
2114 text.contains('━') && text.contains('╱'),
2115 "the account share's gauge bar: {text}"
2116 );
2117 assert!(text.contains("resets in"), "{text}");
2118 }
2119
2120 #[test]
2121 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
2122 let frame = watch_frame(
2123 &report(),
2124 "/tmp/opencode.db",
2125 10,
2126 true,
2127 &[0.0, 1.5, 0.0],
2128 10,
2129 DEFAULT_BAR_WIDTH,
2130 );
2131 assert!(
2132 !frame.ends_with('\n'),
2133 "the caller's status line owns the last row"
2134 );
2135 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
2136 assert!(
2137 frame.contains('█'),
2138 "a nonzero delta shows as a tall bar: {frame}"
2139 );
2140 }
2141
2142 #[test]
2143 fn idle_history_omits_the_sparkline() {
2144 let all_idle = watch_frame(
2145 &report(),
2146 "/tmp/opencode.db",
2147 10,
2148 true,
2149 &[0.0, 0.0],
2150 10,
2151 DEFAULT_BAR_WIDTH,
2152 );
2153 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
2154 let first_frame = watch_frame(
2155 &report(),
2156 "/tmp/opencode.db",
2157 10,
2158 true,
2159 &[],
2160 10,
2161 DEFAULT_BAR_WIDTH,
2162 );
2163 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
2164 }
2165
2166 #[test]
2170 fn statusline_resolves_every_placeholder() {
2171 let r = report();
2172 let line = |tpl: &str| statusline_template(&r, "go", tpl, false, false, 20);
2173
2174 assert_eq!(line("{plan}"), "go");
2176 assert_eq!(line("{period}"), "calendar month");
2177 assert_eq!(line("{month}"), "$30.00");
2179 assert_eq!(line("{five_hour}"), "$30.00");
2180 assert_eq!(line("{weekly}"), "$30.00");
2181 assert_eq!(line("{models}"), "glm-5.3-flash");
2183 assert_eq!(line("{month_pct}"), "—");
2187 assert_eq!(line("{month_bar}"), "");
2188 assert_eq!(line("{five_hour_pct}"), "—");
2189 assert_eq!(line("{weekly_pct}"), "—");
2190
2191 let default = line("{period} {month} · 5h {five_hour} · wk {weekly}");
2193 assert_eq!(default, "calendar month $30.00 · 5h $30.00 · wk $30.00");
2194
2195 assert_eq!(line("{plan} {bogus} | tail"), "go | tail");
2197 assert_eq!(line("{plan} {oops"), "go {oops");
2199 assert!(!line("{plan} {month} {month_pct} {month_bar}").contains('\x1b'));
2201 }
2202
2203 #[test]
2206 fn statusline_without_caps_degrades_gracefully() {
2207 let empty = crate::ocuse::window::build(
2208 &[],
2209 &zen::catalog(),
2210 zen::DEFAULT_PLAN,
2211 1_800_000_000_000,
2212 None,
2213 None,
2214 );
2215 let line = statusline_template(&empty, "go", "{month_pct} {models}", false, false, 20);
2216 assert_eq!(line, "— —");
2217 }
2218
2219 #[test]
2221 fn compact_line_is_one_line_with_inline_sparkline() {
2222 let plain = compact_line(&report(), &[], true);
2223 assert_eq!(plain.lines().count(), 1, "{plain}");
2224 assert!(!plain.ends_with('\n'), "{plain}");
2225 let bursty = compact_line(&report(), &[0.0, 1.5], true);
2226 assert!(bursty.contains('█'), "nonzero delta shows: {bursty}");
2227 assert!(!compact_line(&report(), &[], false).contains('\x1b'));
2229 assert!(compact_line(&report(), &[], true).contains(crate::render::BOLD));
2230 }
2231}