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 "periodInferred": report.month_inferred,
734 "totals": {
735 "month": totals(&report.month),
736 "fiveHour": totals(&report.five_hour),
737 "weekly": totals(&report.weekly),
738 },
739 "account": report.account.as_ref().map(|a| serde_json::json!({
742 "rolling": meter(&a.rolling),
743 "weekly": meter(&a.weekly),
744 "monthly": meter(&a.monthly),
745 })),
746 "accountError": report.account_error,
751 "models": report.models.iter().map(model).collect::<Vec<_>>(),
752 }))
753 .expect("json")
754}
755
756fn shorten(path: &str) -> String {
757 match std::env::var("HOME") {
758 Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
759 _ => path.to_string(),
760 }
761}
762
763pub fn plans_text(colour: bool) -> String {
770 let ink = Ink::new(colour);
771 let catalog = crate::ocuse::zen::catalog();
772 let mut out = format!(
773 "{}OpenCode plans{} {}\n",
774 ink.bold,
775 ink.reset,
776 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
777 );
778 out.push_str(&format!(
779 "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
780 ink.dim,
781 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
782 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
783 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
784 ink.reset,
785 ));
786 for plan in catalog.go.plans.values() {
787 out.push_str(&format!(
788 "\n{}{}{} — {}/month\n",
789 ink.bold,
790 plan.label,
791 ink.reset,
792 ink.cyan_on(format!("${:.2}", plan.price_usd)),
793 ));
794 out.push_str(&format!(
795 "{}MODEL LIMIT IN OUT CACHE{}\n",
796 ink.bold, ink.reset
797 ));
798 for model in &catalog.go.models {
799 let rate = model.variants.first();
800 let limit = plan
801 .allowance_by_model
802 .get(&model.id)
803 .map(|limit| cmduse_core::money(*limit))
804 .unwrap_or_else(|| "—".into());
805 out.push_str(&format!(
806 "{:<28} {} {} {} {}\n",
807 model.id,
808 ink.cyan_on(format!("{limit:>7}")),
809 ink.cyan_on(format!(
810 "{:>6}",
811 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
812 )),
813 ink.cyan_on(format!(
814 "{:>6}",
815 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
816 )),
817 ink.cyan_on(format!(
818 "{:>8}",
819 rate.map(|r| format!("${}", r.cache_read))
820 .unwrap_or_default()
821 )),
822 ));
823 }
824 }
825 out.push_str(&format!(
826 "\n{}\n",
827 ink.dim_on(format!(
828 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
829 catalog.zen.models.len()
830 ))
831 ));
832 out
833}
834
835pub fn plans_json() -> String {
839 let catalog = crate::ocuse::zen::catalog();
840 let plans: Vec<serde_json::Value> = catalog
841 .go
842 .plans
843 .iter()
844 .map(|(id, plan)| {
845 serde_json::json!({
846 "id": id,
847 "label": plan.label,
848 "priceUsd": plan.price_usd,
849 "allowanceByModel": &plan.allowance_by_model,
850 })
851 })
852 .collect();
853 serde_json::json!({
854 "extractedAt": catalog.extracted_at,
855 "windows": {
856 "fiveHour": catalog.go.window_share.five_hour,
857 "weekly": catalog.go.window_share.weekly,
858 "monthly": catalog.go.window_share.monthly,
859 },
860 "plans": plans,
861 "models": catalog.go.models.iter().map(rate_json).collect::<Vec<_>>(),
862 "zen": catalog.zen.models.iter().map(rate_json).collect::<Vec<_>>(),
863 })
864 .to_string()
865}
866
867fn rate_json(model: &zen::Model) -> serde_json::Value {
869 let rate = model.variants.first();
870 serde_json::json!({
871 "id": model.id,
872 "name": model.name,
873 "input": rate.map(|r| r.input),
874 "output": rate.map(|r| r.output),
875 "cacheRead": rate.map(|r| r.cache_read),
876 "cacheWrite": rate.map(|r| r.cache_write),
877 })
878}
879
880struct ModelRow {
883 id: String,
884 name: String,
885 input: Option<f64>,
886 output: Option<f64>,
887 cache_read: Option<f64>,
888 limit: Option<f64>,
889}
890
891fn model_rows(plan_id: &str, gated: bool) -> Vec<ModelRow> {
894 let catalog = crate::ocuse::zen::catalog();
895 let plan = catalog.plan(plan_id);
896 let mut rows: Vec<ModelRow> = catalog
897 .go
898 .models
899 .iter()
900 .map(|m| {
901 let rate = m.variants.first();
902 ModelRow {
903 id: m.id.clone(),
904 name: m.name.clone(),
905 input: rate.map(|r| r.input),
906 output: rate.map(|r| r.output),
907 cache_read: rate.map(|r| r.cache_read),
908 limit: plan.and_then(|p| p.allowance_by_model.get(&m.id).copied()),
909 }
910 })
911 .collect();
912 if gated {
913 rows.retain(|r| r.limit.is_some());
914 }
915 rows
916}
917
918pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
922 let ink = Ink::new(colour);
923 let catalog = crate::ocuse::zen::catalog();
924 let label = catalog
925 .plan(plan_id)
926 .map(|p| p.label.clone())
927 .unwrap_or_else(|| plan_id.to_string());
928 let granted = catalog
929 .plan(plan_id)
930 .map(|p| p.allowance_by_model.len())
931 .unwrap_or(0);
932 let rows = model_rows(plan_id, gated);
933 let mut out = format!(
934 "{}{label} models{} {}\n",
935 ink.bold,
936 ink.reset,
937 ink.dim_on(format!(
938 "(plan {plan_id} · {granted}/{} granted)",
939 catalog.go.models.len()
940 )),
941 );
942 out.push_str(&format!(
943 "{}{:<30} {:<28} {:>7} {:>7} {:>7} {:>8} {:>7}{}\n",
944 ink.bold, "MODEL", "ID", "IN", "OUT", "CACHE", "LIMIT", "GRANTED", ink.reset
945 ));
946 let rate_cell = |v: Option<f64>| match v {
947 Some(v) => ink.cyan_on(format!("{:>7}", format!("${v}"))),
948 None => ink.dim_on(format!("{:>7}", "—")),
949 };
950 for r in &rows {
951 let limit = match r.limit {
952 Some(l) => ink.cyan_on(format!("{:>8}", cmduse_core::money(l))),
953 None => ink.dim_on(format!("{:>8}", "—")),
954 };
955 let granted = if r.limit.is_some() { "yes" } else { "no" };
956 out.push_str(&format!(
957 "{:<30} {:<28} {} {} {} {} {:>7}\n",
958 r.name,
959 r.id,
960 rate_cell(r.input),
961 rate_cell(r.output),
962 rate_cell(r.cache_read),
963 limit,
964 granted,
965 ));
966 }
967 out
968}
969
970pub fn models_json(plan_id: &str, gated: bool) -> String {
974 let mut out = String::new();
975 for r in model_rows(plan_id, gated) {
976 out.push_str(
977 &serde_json::json!({
978 "id": r.id,
979 "name": r.name,
980 "input": r.input,
981 "output": r.output,
982 "cacheRead": r.cache_read,
983 "limit": r.limit,
984 "granted": r.limit.is_some(),
985 })
986 .to_string(),
987 );
988 out.push('\n');
989 }
990 out
991}
992
993pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
994 let ink = Ink::new(colour);
995 let Some(model) = report.models.iter().find(|m| m.id == id) else {
996 return format!("{id}: no usage in the period\n");
997 };
998 let limit = model
999 .limit
1000 .map(cmduse_core::money)
1001 .unwrap_or_else(|| "—".into());
1002 let row =
1003 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
1004 let mut out = format!(
1005 "{} {}\n",
1006 ink.bold_on(model.id.clone()),
1007 ink.dim_on(format!("({})", model.provider)),
1008 );
1009 out.push_str(&row("limit", ink.cyan_on(limit)));
1010 out.push_str(&row(
1011 "month",
1012 ink.cyan_on(format!(
1013 "{} · {} requests",
1014 cmduse_core::money(model.month.cost),
1015 model.month.requests
1016 )),
1017 ));
1018 out.push_str(&row(
1019 "5-hour",
1020 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
1021 ));
1022 out.push_str(&row(
1023 "weekly",
1024 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
1025 ));
1026 if report.month.cost > 0.0 {
1029 out.push_str(&row(
1030 "share",
1031 ink.dim_on(format!(
1032 "{:.1}% of period spend",
1033 model.month.cost / report.month.cost * 100.0
1034 )),
1035 ));
1036 }
1037 out
1038}
1039
1040fn totals_json(
1045 cost: f64,
1046 requests: u64,
1047 input: u64,
1048 output: u64,
1049 cache_read: u64,
1050) -> serde_json::Value {
1051 serde_json::json!({
1052 "requests": requests,
1053 "tokensIn": input,
1054 "tokensOut": output,
1055 "cacheRead": cache_read,
1056 "costUsd": cost,
1057 })
1058}
1059
1060#[derive(Default, Clone, Copy)]
1063struct Bucket {
1064 cost: f64,
1065 requests: u64,
1066 input: u64,
1067 output: u64,
1068 cache_read: u64,
1069}
1070
1071impl Bucket {
1072 fn add(&mut self, row: &crate::ocuse::db::Row) {
1073 self.cost += row.cost_usd;
1074 self.requests += 1;
1075 self.input += row.input;
1076 self.output += row.output;
1077 self.cache_read += row.cache_read;
1078 }
1079
1080 fn json(&self) -> serde_json::Value {
1081 totals_json(
1082 self.cost,
1083 self.requests,
1084 self.input,
1085 self.output,
1086 self.cache_read,
1087 )
1088 }
1089}
1090
1091pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1099 let models: Vec<&ModelUsage> = report
1100 .models
1101 .iter()
1102 .filter(|m| id.is_none_or(|want| m.id == want))
1103 .collect();
1104 match fmt {
1105 Fmt::Json => {
1106 let map: serde_json::Map<String, serde_json::Value> = models
1107 .iter()
1108 .map(|m| {
1109 let mut body = totals_json(
1110 m.month.cost,
1111 m.month.requests,
1112 m.month.input,
1113 m.month.output,
1114 m.month.cache_read,
1115 );
1116 body["limit"] = serde_json::json!(m.limit);
1117 (m.id.clone(), body)
1118 })
1119 .collect();
1120 serde_json::json!({
1121 "scope": "local",
1122 "source": "opencode",
1123 "sinceMs": report.since_ms,
1124 "models": map,
1125 })
1126 .to_string()
1127 }
1128 Fmt::Csv => {
1129 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1130 for m in models {
1131 out.push_str(&format!(
1132 "{},{},{},{},{},{:.2}\n",
1133 crate::report_render::csv_field(&m.id),
1134 m.month.requests,
1135 m.month.input,
1136 m.month.output,
1137 m.month.cache_read,
1138 m.month.cost,
1139 ));
1140 }
1141 out
1142 }
1143 Fmt::Table => String::new(), }
1145}
1146
1147pub fn bucket_text(
1155 rows: &[crate::ocuse::db::Row],
1156 now_ms: i64,
1157 lookback_ms: i64,
1158 size_ms: i64,
1159 tz_secs: i64,
1160 colour: bool,
1161 fmt: Fmt,
1162) -> String {
1163 let shift = tz_secs * 1000;
1165 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1166 for row in rows {
1167 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1168 buckets.entry(bucket).or_default().add(row);
1169 }
1170 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1171 let daily = size_ms >= 86_400_000;
1172 let label = if daily { "day" } else { "hour" };
1173 let stamp = |bucket: i64| {
1174 if daily {
1175 date_of(bucket)
1176 } else {
1177 format!(
1178 "{} {:02}h",
1179 date_of(bucket.div_euclid(24)),
1180 bucket.rem_euclid(24)
1181 )
1182 }
1183 };
1184 let window: Vec<(i64, Bucket)> = buckets
1185 .range(start..)
1186 .map(|(bucket, b)| (*bucket, *b))
1187 .collect();
1188
1189 match fmt {
1190 Fmt::Json => {
1191 let mut keyed = serde_json::Map::new();
1192 let mut total = Bucket::default();
1193 for (bucket, b) in &window {
1194 keyed.insert(stamp(*bucket), b.json());
1195 total.add_bucket(b);
1196 }
1197 let key = if daily { "days" } else { "hours" };
1198 serde_json::json!({
1199 "scope": "local",
1200 "source": "opencode",
1201 "total": total.json(),
1202 key: keyed,
1203 })
1204 .to_string()
1205 }
1206 Fmt::Csv => {
1207 let head = if daily { "day" } else { "hour" };
1208 let mut out = format!("{head},requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1209 let mut total = Bucket::default();
1210 for (bucket, b) in &window {
1211 out.push_str(&format!(
1212 "{},{},{},{},{},{:.2}\n",
1213 crate::report_render::csv_field(&stamp(*bucket)),
1214 b.requests,
1215 b.input,
1216 b.output,
1217 b.cache_read,
1218 b.cost,
1219 ));
1220 total.add_bucket(b);
1221 }
1222 out.push_str(&format!(
1223 "total,{},{},{},{},{:.2}\n",
1224 total.requests, total.input, total.output, total.cache_read, total.cost,
1225 ));
1226 out
1227 }
1228 Fmt::Table => {
1229 let ink = Ink::new(colour);
1230 let mut out = format!(
1231 "{}{label:<12} spend requests tokens{}\n",
1232 ink.bold, ink.reset
1233 );
1234 for (bucket, b) in &window {
1235 out.push_str(&format!(
1236 "{:<12} {} {} {}\n",
1237 stamp(*bucket),
1238 ink.cyan_on(format!("{:>9}", cmduse_core::money(b.cost))),
1239 ink.cyan_on(format!("{:>12}", cmduse_core::compact(b.requests))),
1240 ink.cyan_on(format!(
1241 "{:>9}",
1242 cmduse_core::compact(b.input + b.output + b.cache_read)
1243 )),
1244 ));
1245 }
1246 if out.lines().count() == 1 {
1247 out.push_str(&format!(
1248 "{}\n",
1249 ink.dim_on("(no OpenCode usage in the window)".into())
1250 ));
1251 }
1252 out
1253 }
1254 }
1255}
1256
1257type SessionTotals<'a> = (
1260 f64,
1261 u64,
1262 u64,
1263 u64,
1264 u64,
1265 i64,
1266 std::collections::BTreeSet<&'a str>,
1267);
1268
1269pub fn session_text(
1274 rows: &[crate::ocuse::db::Row],
1275 limit: usize,
1276 colour: bool,
1277 fmt: Fmt,
1278) -> String {
1279 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1280 for row in rows {
1281 let entry = by_session
1282 .entry(row.session.as_str())
1283 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1284 entry.0 += row.cost_usd;
1285 entry.1 += 1;
1286 entry.2 += row.input;
1287 entry.3 += row.output;
1288 entry.4 += row.cache_read;
1289 entry.5 = entry.5.max(row.at_ms);
1290 entry.6.insert(row.model.as_str());
1291 }
1292 let mut sessions: Vec<_> = by_session.into_iter().collect();
1293 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1294
1295 match fmt {
1296 Fmt::Json => {
1297 let mut keyed = serde_json::Map::new();
1298 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1299 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1300 body["lastMs"] = serde_json::json!(*last);
1301 body["models"] = serde_json::json!(models.len());
1302 keyed.insert((*id).to_string(), body);
1303 }
1304 serde_json::json!({
1305 "scope": "local",
1306 "source": "opencode",
1307 "sessions": keyed,
1308 })
1309 .to_string()
1310 }
1311 Fmt::Csv => {
1312 let mut out =
1313 String::from("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1314 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1315 out.push_str(&format!(
1316 "{},{},{},{},{},{:.2}\n",
1317 crate::report_render::csv_field(id),
1318 requests,
1319 input,
1320 output,
1321 cache_read,
1322 cost,
1323 ));
1324 }
1325 out
1326 }
1327 Fmt::Table => {
1328 let ink = Ink::new(colour);
1329 let mut out = format!(
1330 "{}session last models spend req{}\n",
1331 ink.bold, ink.reset
1332 );
1333 for (id, (cost, requests, _, _, _, last, models)) in sessions.iter().take(limit) {
1334 let short = id.rsplit('_').next().unwrap_or(*id);
1335 out.push_str(&format!(
1336 "{:<32} {:<11} {:>6} {} {:>6}\n",
1337 short,
1338 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1339 models.len(),
1340 ink.cyan_on(format!("{:>9}", cmduse_core::money(*cost))),
1341 requests,
1342 ));
1343 }
1344 if sessions.len() > limit {
1345 out.push_str(&format!(
1346 "{}\n",
1347 ink.dim_on(format!("… {} more", sessions.len() - limit))
1348 ));
1349 }
1350 if out.lines().count() == 1 {
1351 out.push_str(&format!(
1352 "{}\n",
1353 ink.dim_on("(no sessions in the window)".into())
1354 ));
1355 }
1356 out
1357 }
1358 }
1359}
1360
1361impl Bucket {
1362 fn add_bucket(&mut self, other: &Bucket) {
1364 self.cost += other.cost;
1365 self.requests += other.requests;
1366 self.input += other.input;
1367 self.output += other.output;
1368 self.cache_read += other.cache_read;
1369 }
1370}
1371
1372pub fn date_of(day: i64) -> String {
1375 let z = day + 719_468;
1376 let era = z.div_euclid(146_097);
1377 let doe = z.rem_euclid(146_097);
1378 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1379 let y = yoe + era * 400;
1380 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1381 let mp = (5 * doy + 2) / 153;
1382 let d = doy - (153 * mp + 2) / 5 + 1;
1383 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1384 let year = if m <= 2 { y + 1 } else { y };
1385 format!("{year:04}-{m:02}-{d:02}")
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391 use crate::ocuse::db::Row;
1392
1393 fn report() -> Report {
1394 let now = 1_800_000_000_000;
1395 let rows = vec![Row {
1396 at_ms: now - 60_000,
1397 provider: "opencode-go".into(),
1398 model: "glm-5.3-flash".into(),
1399 cost_usd: 30.0,
1400 ..Default::default()
1401 }];
1402 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None)
1403 }
1404
1405 #[test]
1406 fn text_shows_the_model_against_its_limit() {
1407 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1408 assert!(text.contains("glm-5.3-flash"), "{text}");
1409 assert!(text.contains("$60"), "the model's monthly limit: {text}");
1410 assert!(
1411 text.contains("50.0%"),
1412 "the model table keeps its own USE: {text}"
1413 );
1414 }
1415
1416 fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
1417 crate::ocuse::api::Meter {
1418 status: status.into(),
1419 percent,
1420 resets_at: Some(resets_at.into()),
1421 }
1422 }
1423
1424 fn report_with_account() -> Report {
1431 let mut report = report();
1432 report.account = Some(crate::ocuse::api::Usage {
1433 rolling: meter("ok", 5.0, "2027-01-15T12:31:00.000Z"),
1434 weekly: meter("ok", 59.0, "2027-01-18T00:00:00.000Z"),
1435 monthly: meter("ok", 29.0, "2027-02-15T08:00:00.000Z"),
1436 });
1437 report.period_end_ms = Some(1_802_678_400_000); report.since_ms = crate::ocuse::window::prev_month(1_802_678_400_000);
1439 report
1440 }
1441
1442 #[test]
1443 fn the_window_rows_come_from_the_account_when_it_answered() {
1444 let text = render_text(
1445 &report_with_account(),
1446 "/tmp/opencode.db",
1447 10,
1448 false,
1449 DEFAULT_BAR_WIDTH,
1450 );
1451 assert!(text.contains("· 5% of limit"), "{text}");
1454 assert!(text.contains("· 59% of limit"), "{text}");
1455 assert!(text.contains("· 29% of limit"), "{text}");
1456 assert!(text.contains("account meters"), "{text}");
1457 assert!(
1458 text.contains("resets 4h 31m"),
1459 "the 5h block's own end: {text}"
1460 );
1461 assert!(text.contains(".00 · 5% of limit"), "{text}");
1467 assert!(text.contains(".00 · 29% of limit"), "{text}");
1468 assert!(text.contains("of limit"), "{text}");
1469 assert!(text.contains("elapsed"), "{text}");
1474 assert_eq!(text.matches("by now").count(), 1, "{text}");
1475 assert!(text.contains("the same cuts its percentages use"), "{text}");
1478 }
1479
1480 #[test]
1487 fn the_pair_is_the_ceiling_the_share_implies() {
1488 assert_eq!(implied_cap(30.0, 5.0), Some(600.0));
1490 assert_eq!(implied_cap(30.0, 59.0), Some(50.847457627118644));
1491 assert_eq!(implied_cap(20.45, 0.0), None);
1493 assert_eq!(implied_cap(20.45, 0.04), None);
1494 assert_eq!(implied_cap(20.45, 0.1), Some(20450.0));
1495 assert_eq!(implied_cap(0.0, 1.0), Some(0.0));
1496 }
1497
1498 #[test]
1503 fn the_gauge_carries_the_share_and_the_body_does_not_repeat_it() {
1504 let colour = render_text(
1505 &report_with_account(),
1506 "/tmp/opencode.db",
1507 10,
1508 true,
1509 DEFAULT_BAR_WIDTH,
1510 );
1511 let five = colour
1512 .lines()
1513 .find(|line| line.contains("5-hour"))
1514 .expect("a 5-hour row");
1515 assert!(!five.contains("of limit"), "no repeated share: {five}");
1516 assert!(
1517 five.contains(" 5%"),
1518 "the endpoint's whole figure: {five}"
1519 );
1520 let plain = render_text(
1521 &report_with_account(),
1522 "/tmp/opencode.db",
1523 10,
1524 false,
1525 DEFAULT_BAR_WIDTH,
1526 );
1527 assert!(plain.contains("· 5% of limit"), "{plain}");
1528 }
1529
1530 #[test]
1532 fn a_reachable_cap_gets_the_pace_line() {
1533 let mut report = report_with_account();
1534 if let Some(account) = &mut report.account {
1535 account.rolling = meter("ok", 90.0, "2027-01-15T12:00:00.000Z");
1538 }
1539 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1540 assert!(text.contains("on pace to hit cap in"), "{text}");
1541 }
1542
1543 #[test]
1544 fn without_an_account_the_rows_say_they_are_local() {
1545 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1546 assert!(text.contains("estimated locally"), "{text}");
1547 assert!(text.contains("no pool"), "no aggregate ceiling: {text}");
1548 assert!(!text.contains("of limit"), "{text}");
1550 assert!(!text.contains("resets"), "{text}");
1551 assert!(!text.contains("elapsed"), "{text}");
1552 assert!(!text.contains("account meters"), "{text}");
1554 assert!(
1555 !text.contains("the same cuts its percentages use"),
1556 "{text}"
1557 );
1558 assert!(text.contains("local, not the account's windows"), "{text}");
1561 }
1562
1563 #[test]
1564 fn an_over_limit_meter_says_so_before_its_countdown() {
1565 let mut report = report_with_account();
1566 if let Some(account) = &mut report.account {
1567 account.rolling = meter("rate_limited", 100.0, "2027-01-15T12:31:00.000Z");
1568 }
1569 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1570 assert!(text.contains("LIMIT EXCEEDED"), "{text}");
1571 }
1572
1573 #[test]
1574 fn json_carries_the_account_meters_and_null_without_them() {
1575 let parsed: serde_json::Value =
1576 serde_json::from_str(&render_json(&report_with_account())).unwrap();
1577 assert_eq!(parsed["account"]["rolling"]["percent"], 5.0);
1578 assert_eq!(parsed["account"]["weekly"]["percent"], 59.0);
1579 assert_eq!(parsed["account"]["monthly"]["status"], "ok");
1580 let monthly = cmduse_core::dates::parse_iso_utc("2027-02-15T08:00:00.000Z").unwrap();
1583 assert_eq!(parsed["account"]["monthly"]["resetsAt"], monthly as i64);
1584
1585 let parsed: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1588 assert_eq!(parsed["account"], serde_json::Value::Null);
1589 }
1590
1591 #[test]
1592 fn statusline_prefers_the_accounts_share_over_the_local_estimate() {
1593 let tpl = "{five_hour_pct} {weekly_pct} {month_pct} {five_hour}";
1594 let account = statusline_template(
1595 &report_with_account(),
1596 "go",
1597 tpl,
1598 false,
1599 false,
1600 DEFAULT_BAR_WIDTH,
1601 );
1602 assert!(account.starts_with("5.0% 59.0% 29.0%"), "{account}");
1603 assert!(account.contains("$30.00"), "{account}");
1605
1606 let local = statusline_template(&report(), "go", tpl, false, false, DEFAULT_BAR_WIDTH);
1609 assert!(!local.starts_with("5.0%"), "{local}");
1610 }
1611
1612 #[test]
1613 fn json_and_statusline_carry_the_totals() {
1614 let json = render_json(&report());
1615 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1616 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
1617 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
1618 let line = statusline_template(&report(), "go", "{plan} {models}", false, false, 20);
1619 assert_eq!(line, "go glm-5.3-flash");
1620 }
1621
1622 #[test]
1623 fn json_carries_the_period_bounds() {
1624 let mut r = report();
1627 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1628 let parsed: serde_json::Value = serde_json::from_str(&render_json(&r)).unwrap();
1629 assert_eq!(parsed["periodStart"], r.since_ms);
1630 assert_eq!(parsed["periodStartAt"], r.since_ms);
1631 assert_eq!(parsed["periodEndAt"], r.period_end_ms.unwrap());
1632
1633 let mut all = report();
1636 all.period_end_ms = None;
1637 let parsed: serde_json::Value = serde_json::from_str(&render_json(&all)).unwrap();
1638 assert_eq!(parsed["periodEndAt"], serde_json::Value::Null);
1639 assert_eq!(parsed["periodStart"], all.since_ms);
1640 assert!(parsed.get("periodInferred").is_some());
1641 }
1642
1643 #[test]
1649 fn every_renderer_respects_the_colour_flag() {
1650 let mut report = report();
1651 report.period_end_ms = Some(crate::ocuse::window::next_month(report.since_ms));
1654 let rows = vec![Row {
1655 at_ms: report.now_ms - 60_000,
1656 session: "ses_fixture".into(),
1657 provider: "opencode-go".into(),
1658 model: "glm-5.3-flash".into(),
1659 cost_usd: 30.0,
1660 ..Default::default()
1661 }];
1662 let now = report.now_ms;
1663
1664 let cases: Vec<(&str, String, String)> = vec![
1665 (
1666 "render_text",
1667 render_text(&report, "/tmp/oc.db", 10, false, DEFAULT_BAR_WIDTH),
1668 render_text(&report, "/tmp/oc.db", 10, true, DEFAULT_BAR_WIDTH),
1669 ),
1670 (
1671 "statusline_template",
1672 statusline_template(
1673 &report,
1674 "go",
1675 "{period} {month} {month_pct}",
1676 false,
1677 false,
1678 20,
1679 ),
1680 statusline_template(
1681 &report,
1682 "go",
1683 "{period} {month} {month_pct}",
1684 true,
1685 false,
1686 20,
1687 ),
1688 ),
1689 (
1690 "compact_line",
1691 compact_line(&report, &[], false),
1692 compact_line(&report, &[], true),
1693 ),
1694 ("plans_text", plans_text(false), plans_text(true)),
1695 (
1696 "model_text",
1697 model_text(&report, "glm-5.3-flash", false),
1698 model_text(&report, "glm-5.3-flash", true),
1699 ),
1700 (
1701 "bucket_text/day",
1702 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Table),
1703 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Table),
1704 ),
1705 (
1706 "bucket_text/hour",
1707 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Table),
1708 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Table),
1709 ),
1710 (
1711 "session_text",
1712 session_text(&rows, 20, false, Fmt::Table),
1713 session_text(&rows, 20, true, Fmt::Table),
1714 ),
1715 (
1716 "models_text",
1717 models_text("go", false, false),
1718 models_text("go", false, true),
1719 ),
1720 ];
1721 for (name, plain, coloured) in cases {
1722 assert!(
1723 !plain.contains('\x1b'),
1724 "{name} leaked escapes when off: {plain}"
1725 );
1726 assert!(
1727 coloured.contains('\x1b'),
1728 "{name} rendered nothing coloured when on: {coloured}"
1729 );
1730 }
1731
1732 let flagged: Vec<(&str, String, String)> = vec![
1735 (
1736 "bucket_text/day csv",
1737 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv),
1738 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Csv),
1739 ),
1740 (
1741 "bucket_text/hour json",
1742 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Json),
1743 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Json),
1744 ),
1745 (
1746 "session_text csv",
1747 session_text(&rows, 20, false, Fmt::Csv),
1748 session_text(&rows, 20, true, Fmt::Csv),
1749 ),
1750 (
1751 "session_text json",
1752 session_text(&rows, 20, false, Fmt::Json),
1753 session_text(&rows, 20, true, Fmt::Json),
1754 ),
1755 ];
1756 for (name, off, on) in flagged {
1757 assert!(
1758 !off.contains('\x1b'),
1759 "{name} leaked escapes with colour off: {off}"
1760 );
1761 assert!(
1762 !on.contains('\x1b'),
1763 "{name} must stay uncoloured with colour on: {on}"
1764 );
1765 }
1766 for (name, out) in [
1767 ("model_report json", model_report(&report, None, Fmt::Json)),
1768 ("model_report csv", model_report(&report, None, Fmt::Csv)),
1769 ("plans_json", plans_json()),
1770 ("statusline_json", statusline_json(&report, "go")),
1771 ("models_json", models_json("go", false)),
1772 ] {
1773 assert!(
1774 !out.contains('\x1b'),
1775 "{name} must never be coloured: {out}"
1776 );
1777 }
1778 }
1779
1780 #[test]
1781 fn session_rows_cap_the_table_and_never_the_machine_formats() {
1782 let now = 1_800_000_000_000;
1786 let rows: Vec<Row> = (0..25)
1787 .map(|i| Row {
1788 at_ms: now - i as i64 * 60_000,
1789 provider: "opencode-go".into(),
1790 model: "glm-5.3-flash".into(),
1791 session: format!("ses_{i:02}"),
1792 cost_usd: 0.1,
1793 ..Default::default()
1794 })
1795 .collect();
1796 let table = session_text(&rows, 2, false, Fmt::Table);
1797 assert!(table.contains("… 23 more"), "{table}");
1798 assert_eq!(
1799 table.lines().count(),
1800 4,
1801 "header + 2 rows + the tail: {table}"
1802 );
1803 let json: serde_json::Value =
1804 serde_json::from_str(&session_text(&rows, 2, false, Fmt::Json)).unwrap();
1805 assert_eq!(json["sessions"].as_object().unwrap().len(), 25);
1806 let csv = session_text(&rows, 2, false, Fmt::Csv);
1807 assert_eq!(csv.lines().count(), 26, "header + every session: {csv}");
1808 }
1809
1810 #[test]
1811 fn bucket_reports_carry_the_documented_columns() {
1812 let now = 1_800_000_000_000;
1813 let rows = vec![Row {
1814 at_ms: now - 60_000,
1815 provider: "opencode-go".into(),
1816 model: "glm-5.3-flash".into(),
1817 cost_usd: 1.5,
1818 input: 10,
1819 output: 20,
1820 cache_read: 30,
1821 cache_write: 40,
1822 ..Default::default()
1823 }];
1824 let csv = bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv);
1825 assert!(
1826 csv.starts_with("day,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1827 "{csv}"
1828 );
1829 assert!(!csv.contains("cache_write"), "{csv}");
1831 assert!(csv.contains(",10,20,30,1.50\n"), "{csv}");
1832 assert!(csv.trim_end().ends_with("total,1,10,20,30,1.50"), "{csv}");
1833
1834 let json: serde_json::Value = serde_json::from_str(&bucket_text(
1835 &rows,
1836 now,
1837 7 * 86_400_000,
1838 86_400_000,
1839 0,
1840 false,
1841 Fmt::Json,
1842 ))
1843 .unwrap();
1844 assert_eq!(json["total"]["requests"], 1);
1845 assert_eq!(json["total"]["cacheRead"], 30);
1846 assert_eq!(json["days"].as_object().unwrap().len(), 1);
1847 }
1848
1849 #[test]
1850 fn session_report_json_and_csv_agree_with_the_store() {
1851 let now = 1_800_000_000_000;
1852 let rows = vec![
1853 Row {
1854 at_ms: now - 60_000,
1855 session: "ses_first".into(),
1856 model: "glm-5.3-flash".into(),
1857 cost_usd: 2.0,
1858 input: 5,
1859 output: 6,
1860 cache_read: 7,
1861 ..Default::default()
1862 },
1863 Row {
1864 at_ms: now - 30_000,
1865 session: "ses_second".into(),
1866 model: "kimi-k3".into(),
1867 cost_usd: 1.0,
1868 ..Default::default()
1869 },
1870 ];
1871 let json: serde_json::Value =
1872 serde_json::from_str(&session_text(&rows, 20, false, Fmt::Json)).unwrap();
1873 assert_eq!(json["sessions"]["ses_first"]["tokensIn"], 5);
1874 assert_eq!(json["sessions"]["ses_first"]["models"], 1);
1875 assert_eq!(json["sessions"]["ses_second"]["costUsd"], 1.0);
1876 let csv = session_text(&rows, 20, false, Fmt::Csv);
1877 assert!(
1878 csv.starts_with("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1879 "{csv}"
1880 );
1881 assert!(csv.contains("ses_second,1,0,0,0,1.00\n"), "{csv}");
1882 }
1883
1884 #[test]
1885 fn model_report_projects_the_report_models() {
1886 let json: serde_json::Value =
1887 serde_json::from_str(&model_report(&report(), None, Fmt::Json)).unwrap();
1888 assert_eq!(json["source"], "opencode");
1889 assert_eq!(json["models"]["glm-5.3-flash"]["costUsd"], 30.0);
1890 assert_eq!(json["models"]["glm-5.3-flash"]["limit"], 60.0);
1891 let csv = model_report(&report(), None, Fmt::Csv);
1892 assert!(csv.contains("glm-5.3-flash,1,0,0,0,30.00\n"), "{csv}");
1893 assert_eq!(
1895 model_report(&report(), Some("nope"), Fmt::Csv),
1896 "model,requests,tokens_in,tokens_out,cache_read,cost_usd\n"
1897 );
1898 }
1899
1900 #[test]
1901 fn catalogue_json_carries_both_plans_and_windows() {
1902 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1903 assert_eq!(json["windows"]["fiveHour"], 0.2);
1904 assert_eq!(json["windows"]["monthly"], 1.0);
1905 assert_eq!(json["plans"][0]["id"], "go");
1906 assert_eq!(json["plans"][0]["allowanceByModel"]["glm-5.3-flash"], 60.0);
1907 assert_eq!(json["plans"][1]["priceUsd"], 40.0);
1908 assert!(!json["models"].as_array().unwrap().is_empty());
1909 assert!(!json["zen"].as_array().unwrap().is_empty());
1910 }
1911
1912 #[test]
1913 fn models_lists_the_plan_and_marks_granted() {
1914 let all = models_text("go", false, false);
1916 assert!(all.contains("glm-5.3-flash"), "{all}");
1917 assert!(all.contains("GRANTED"), "{all}");
1918 let gated = models_text("go", true, false);
1920 for line in gated.lines().skip(2) {
1921 assert!(
1922 line.trim_end().ends_with("yes"),
1923 "ungranted row kept: {line}"
1924 );
1925 }
1926 let every: Vec<serde_json::Value> = models_json("go", false)
1928 .lines()
1929 .map(|l| serde_json::from_str(l).unwrap())
1930 .collect();
1931 assert!(every.iter().any(|m| m["granted"] == false));
1932 assert!(every
1933 .iter()
1934 .any(|m| m["id"] == "glm-5.3-flash" && m["limit"] == 60.0));
1935 let only: Vec<serde_json::Value> = models_json("go", true)
1936 .lines()
1937 .map(|l| serde_json::from_str(l).unwrap())
1938 .collect();
1939 assert!(!only.is_empty());
1940 assert!(only.iter().all(|m| m["granted"] == true));
1941 }
1942
1943 #[test]
1944 fn statusline_json_mirrors_the_template_numbers() {
1945 let json: serde_json::Value =
1946 serde_json::from_str(&statusline_json(&report(), "go")).unwrap();
1947 assert_eq!(json["plan"], "go");
1948 assert_eq!(json["period"], "calendar month");
1949 assert_eq!(json["monthSpent"], 30.0);
1950 assert_eq!(json["monthCap"], serde_json::Value::Null);
1953 assert_eq!(json["fiveHourCap"], serde_json::Value::Null);
1954 assert_eq!(json["weeklyCap"], serde_json::Value::Null);
1955 assert_eq!(json["model"], "glm-5.3-flash");
1956 }
1957
1958 #[test]
1959 fn plain_frames_carry_no_escapes() {
1960 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1961 assert!(
1962 !text.contains('\x1b'),
1963 "piped output must stay plain: {text}"
1964 );
1965 assert!(text.contains("$30.00"), "{text}");
1968 }
1969
1970 #[test]
1974 fn monthly_row_carries_the_real_period_metrics() {
1975 let mut r = report();
1976 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1977 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1978
1979 let monthly = text
1980 .lines()
1981 .find(|line| line.trim_start().starts_with("Monthly"))
1982 .expect("the monthly row");
1983 assert!(monthly.contains("resets"), "{monthly}");
1984 assert!(
1985 monthly.contains("window") && monthly.contains("% elapsed"),
1986 "the real period gives an elapsed share: {monthly}"
1987 );
1988
1989 for label in ["5-hour", "Weekly"] {
1990 let row = text
1991 .lines()
1992 .find(|line| line.trim_start().starts_with(label))
1993 .unwrap_or_else(|| panic!("the {label} row"));
1994 assert!(!row.contains("elapsed"), "{label} has no boundary: {row}");
1995 assert!(!row.contains("resets"), "{label} has no boundary: {row}");
1996 }
1997 }
1998
1999 #[test]
2002 fn period_line_splits_tokens_and_shows_the_end_date() {
2003 let mut r = report();
2004 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
2005 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2006 let header = text
2007 .lines()
2008 .find(|line| line.starts_with("period:"))
2009 .expect("the period line");
2010 assert!(
2011 header.contains(" in / "),
2012 "token split like cmduse: {header}"
2013 );
2014 assert!(header.contains(" out tokens"), "{header}");
2015 assert!(
2016 header.contains(&date_of(r.period_end_ms.unwrap() / 86_400_000)),
2017 "the resolved end date: {header}"
2018 );
2019
2020 let no_end = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2022 let header = no_end
2023 .lines()
2024 .find(|line| line.starts_with("period:"))
2025 .expect("the period line");
2026 assert!(!header.contains("ends"), "{header}");
2027 }
2028
2029 #[test]
2030 fn an_over_cap_window_is_flagged_in_both_modes() {
2031 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2033 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
2034 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
2035 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
2036 assert!(
2037 coloured.contains(crate::render::RED),
2038 "the flag and the gauge are red: {coloured}"
2039 );
2040 }
2041
2042 #[test]
2043 fn a_model_over_any_of_its_own_windows_is_flagged() {
2044 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
2047 let row = plain
2048 .lines()
2049 .find(|line| line.starts_with("glm-5.3-flash"))
2050 .expect("the model row");
2051 assert!(row.contains("LIMIT EXCEEDED"), "{row}");
2052 assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
2054 }
2055
2056 #[test]
2057 fn a_model_within_its_limits_is_not_flagged() {
2058 let now = 1_800_000_000_000;
2059 let rows = vec![Row {
2061 at_ms: now - 60_000,
2062 provider: "opencode-go".into(),
2063 model: "glm-5.3-flash".into(),
2064 cost_usd: 5.0,
2065 ..Default::default()
2066 }];
2067 let report =
2068 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
2069 for colour in [false, true] {
2070 let text = render_text(&report, "/tmp/opencode.db", 10, colour, DEFAULT_BAR_WIDTH);
2071 assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
2072 }
2073 }
2074
2075 #[test]
2076 fn the_model_detail_spells_out_the_share() {
2077 let detail = model_text(&report(), "glm-5.3-flash", false);
2078 assert!(detail.contains("100.0% of period spend"), "{detail}");
2079 let empty = crate::ocuse::window::build(
2081 &[],
2082 &zen::catalog(),
2083 zen::DEFAULT_PLAN,
2084 1_800_000_000_000,
2085 None,
2086 None,
2087 );
2088 assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
2089 }
2090
2091 #[test]
2092 fn colour_frames_get_the_palette_and_a_gauge() {
2093 let text = render_text(
2098 &report_with_account(),
2099 "/tmp/opencode.db",
2100 10,
2101 true,
2102 DEFAULT_BAR_WIDTH,
2103 );
2104 assert!(text.contains(crate::render::BOLD), "{text}");
2105 assert!(text.contains(crate::render::CYAN), "{text}");
2106 assert!(
2107 text.contains(crate::render::GREEN),
2108 "the 50% USE cell is green: {text}"
2109 );
2110 assert!(
2111 text.contains('━') && text.contains('╱'),
2112 "the account share's gauge bar: {text}"
2113 );
2114 assert!(text.contains("resets in"), "{text}");
2115 }
2116
2117 #[test]
2118 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
2119 let frame = watch_frame(
2120 &report(),
2121 "/tmp/opencode.db",
2122 10,
2123 true,
2124 &[0.0, 1.5, 0.0],
2125 10,
2126 DEFAULT_BAR_WIDTH,
2127 );
2128 assert!(
2129 !frame.ends_with('\n'),
2130 "the caller's status line owns the last row"
2131 );
2132 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
2133 assert!(
2134 frame.contains('█'),
2135 "a nonzero delta shows as a tall bar: {frame}"
2136 );
2137 }
2138
2139 #[test]
2140 fn idle_history_omits_the_sparkline() {
2141 let all_idle = watch_frame(
2142 &report(),
2143 "/tmp/opencode.db",
2144 10,
2145 true,
2146 &[0.0, 0.0],
2147 10,
2148 DEFAULT_BAR_WIDTH,
2149 );
2150 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
2151 let first_frame = watch_frame(
2152 &report(),
2153 "/tmp/opencode.db",
2154 10,
2155 true,
2156 &[],
2157 10,
2158 DEFAULT_BAR_WIDTH,
2159 );
2160 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
2161 }
2162
2163 #[test]
2167 fn statusline_resolves_every_placeholder() {
2168 let r = report();
2169 let line = |tpl: &str| statusline_template(&r, "go", tpl, false, false, 20);
2170
2171 assert_eq!(line("{plan}"), "go");
2173 assert_eq!(line("{period}"), "calendar month");
2174 assert_eq!(line("{month}"), "$30.00");
2176 assert_eq!(line("{five_hour}"), "$30.00");
2177 assert_eq!(line("{weekly}"), "$30.00");
2178 assert_eq!(line("{models}"), "glm-5.3-flash");
2180 assert_eq!(line("{month_pct}"), "—");
2184 assert_eq!(line("{month_bar}"), "");
2185 assert_eq!(line("{five_hour_pct}"), "—");
2186 assert_eq!(line("{weekly_pct}"), "—");
2187
2188 let default = line("{period} {month} · 5h {five_hour} · wk {weekly}");
2190 assert_eq!(default, "calendar month $30.00 · 5h $30.00 · wk $30.00");
2191
2192 assert_eq!(line("{plan} {bogus} | tail"), "go | tail");
2194 assert_eq!(line("{plan} {oops"), "go {oops");
2196 assert!(!line("{plan} {month} {month_pct} {month_bar}").contains('\x1b'));
2198 }
2199
2200 #[test]
2203 fn statusline_without_caps_degrades_gracefully() {
2204 let empty = crate::ocuse::window::build(
2205 &[],
2206 &zen::catalog(),
2207 zen::DEFAULT_PLAN,
2208 1_800_000_000_000,
2209 None,
2210 None,
2211 );
2212 let line = statusline_template(&empty, "go", "{month_pct} {models}", false, false, 20);
2213 assert_eq!(line, "— —");
2214 }
2215
2216 #[test]
2218 fn compact_line_is_one_line_with_inline_sparkline() {
2219 let plain = compact_line(&report(), &[], true);
2220 assert_eq!(plain.lines().count(), 1, "{plain}");
2221 assert!(!plain.ends_with('\n'), "{plain}");
2222 let bursty = compact_line(&report(), &[0.0, 1.5], true);
2223 assert!(bursty.contains('█'), "nonzero delta shows: {bursty}");
2224 assert!(!compact_line(&report(), &[], false).contains('\x1b'));
2226 assert!(compact_line(&report(), &[], true).contains(crate::render::BOLD));
2227 }
2228}