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(&self, used: f64, cap: f64) -> String {
98 if self.colour {
99 crate::render::bar(used, cap, self.bar_width)
100 } else {
101 String::new()
102 }
103 }
104}
105
106#[allow(clippy::too_many_arguments)]
107struct RowSpec<'a> {
118 label: &'a str,
119 used: &'a Totals,
121 reset: Option<(i64, u64)>,
125 share_pct: Option<f64>,
129 status: Option<&'a str>,
132 pro_rata: bool,
137}
138
139fn window_line(ink: &Ink, spec: &RowSpec<'_>, now_ms: i64) -> String {
140 let reset = spec
141 .reset
142 .map(|(at, _)| duration(((at - now_ms) / 1000).max(0) as u64))
143 .unwrap_or_default();
144 let reset_text = if reset.is_empty() {
145 String::new()
146 } else if ink.colour {
147 format!(" · resets in {reset}")
148 } else {
149 format!(" · resets {reset}")
150 };
151 let now_secs = (now_ms / 1000).max(0) as u64;
156 let reset_at = spec.reset.map(|(at, _)| at as f64);
157 let dur_secs = spec.reset.map(|(_, dur)| dur);
158 let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
159 let share = spec.share_pct.map(|p| p.max(0.0));
160 let cap = share.and_then(|p| implied_cap(spec.used.cost, p));
165 let pace = cap
166 .and_then(|cap| {
167 dur_secs.and_then(|d| {
168 cmduse_core::pace_eta(reset_at, d, spec.used.cost, cap, now_secs)
169 .map(|secs| duration(secs as u64))
170 })
171 })
172 .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
173 .unwrap_or_default();
174 let thru = elapsed
175 .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
176 .unwrap_or_default();
177 let by_now = match (spec.pro_rata, elapsed, cap) {
178 (true, Some(p), Some(cap)) => format!(
179 " · {}{} by now{}",
180 ink.dim,
181 money(cap * p / 100.0),
182 ink.reset
183 ),
184 _ => String::new(),
185 };
186 let label = spec.label;
187 let flag = if share.is_some_and(|p| p >= 100.0) {
192 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
193 } else if spec.status.is_some_and(|s| !s.eq_ignore_ascii_case("ok")) {
194 format!(
195 " {}{}{}{}",
196 ink.red,
197 spec.status.unwrap_or_default(),
198 ink.reset,
199 ""
200 )
201 } else {
202 String::new()
203 };
204 let pair = cap
205 .map(|cap| format!(" / {}", money(cap)))
206 .unwrap_or_default();
207 let paren = match share {
208 Some(p) => format!(" ({} of limit)", pct(p, 100.0)),
209 None => String::new(),
210 };
211 let body = format!(
214 "{}{pair}{paren}{reset_text}{flag}{pace}{thru}{by_now}",
215 money(spec.used.cost)
216 );
217 if ink.colour {
218 let bar = match share {
222 Some(p) => format!("{} ", ink.bar(p, 100.0)),
223 None => String::new(),
224 };
225 format!(
226 " {}{label:<8}{} {}{}{body}{}\n",
227 ink.bold, ink.reset, bar, ink.dim, ink.reset,
228 )
229 } else {
230 format!(" {label:<8} {body}\n")
233 }
234}
235
236fn implied_cap(spend: f64, share_pct: f64) -> Option<f64> {
243 (share_pct >= 0.05).then(|| spend * 100.0 / share_pct)
244}
245
246fn meter_reset(meter: &crate::ocuse::api::Meter, dur_secs: Option<u64>) -> Option<(i64, u64)> {
250 match (meter.reset_ms(), dur_secs) {
251 (Some(at), Some(dur)) if dur > 0 => Some((at, dur)),
252 _ => None,
253 }
254}
255
256fn exceeded(model: &ModelUsage) -> bool {
260 crate::ocuse::window::over_window(model).is_some()
261}
262
263fn model_row(ink: &Ink, model: &ModelUsage, total_cost: f64) -> String {
264 let limit = match model.limit {
265 Some(limit) => money(limit),
266 None => ink.dim_on("—".into()),
267 };
268 let use_fraction = model.month_use();
269 let use_text = use_fraction
270 .map(|fraction| pct(fraction * 100.0, 100.0))
271 .unwrap_or_else(|| "—".into());
272 let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
274 let use_cell = match use_fraction {
275 Some(fraction) => {
276 let tone = ink.for_pct(fraction * 100.0);
277 if tone.is_empty() {
278 format!("{use_text:>7}")
279 } else {
280 format!("{tone}{use_text:>7}{}", ink.reset)
281 }
282 }
283 None => ink.dim_on(format!("{use_text:>7}")),
284 };
285 let flag = if exceeded(model) {
288 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
289 } else {
290 String::new()
291 };
292 let share = if total_cost > 0.0 {
293 ink.dim_on(format!(" {:>5.1}%", model.month.cost / total_cost * 100.0))
294 } else {
295 String::new()
296 };
297 format!(
298 "{:<28}{flag} {:>7} {spent} {use_cell} {} {} {:>7}{share}\n",
299 model.id,
300 limit,
301 window_cell(ink, model, model.five_hour.cost, 0.20),
303 window_cell(ink, model, model.weekly.cost, 0.50),
304 compact(model.month.requests),
305 )
306}
307
308fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
311 let text = format!("{:>8}", money(cost));
312 match model.limit.filter(|limit| *limit > 0.0) {
313 Some(limit) => {
314 let tone = ink.for_pct(cost / (limit * share) * 100.0);
315 if tone.is_empty() {
316 text
317 } else {
318 format!("{tone}{text}{}", ink.reset)
319 }
320 }
321 None => ink.dim_on(text),
322 }
323}
324
325pub fn render_text(
327 report: &Report,
328 db_path: &str,
329 rows: usize,
330 colour: bool,
331 bar_width: usize,
332) -> String {
333 let ink = Ink::new(colour).width(bar_width);
334 let c = |text: String| ink.cyan_on(text);
335 let mut out = String::new();
336 out.push_str(&format!(
337 "{}OpenCode usage{} — go + zen, from {}{}{}\n",
338 ink.bold,
339 ink.reset,
340 ink.dim,
341 shorten(db_path),
342 ink.reset,
343 ));
344 let end = report
348 .period_end_ms
349 .map(|ms| format!(" · ends {}", date_of(ms / 86_400_000)))
350 .unwrap_or_default();
351 out.push_str(&format!(
352 "period: {}{end} · {} requests · {} · {} in / {} out tokens\n",
353 report.period_label,
354 c(compact(report.month.requests)),
355 c(money(report.month.cost)),
356 c(compact(report.month.input)),
357 c(compact(report.month.output)),
358 ));
359
360 match &report.account {
365 Some(account) => {
366 out.push_str(&format!(
367 "\n{}GO{} {}\n",
368 ink.bold,
369 ink.reset,
370 ink.dim_on(
371 "(account meters — what opencode.ai/console reports; the pair is this store's spend against the ceiling its share implies)"
372 .into()
373 ),
374 ));
375 let monthly_dur = report
381 .period_end_ms
382 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
383 let rows = [
384 (
385 "5-hour",
386 &account.rolling,
387 &report.five_hour,
388 Some(crate::ocuse::window::FIVE_HOUR_SECS as u64),
389 ),
390 (
391 "Weekly",
392 &account.weekly,
393 &report.weekly,
394 Some(crate::ocuse::window::WEEKLY_SECS as u64),
395 ),
396 ("Monthly", &account.monthly, &report.month, monthly_dur),
397 ];
398 for (label, meter, used, dur_secs) in rows {
399 out.push_str(&window_line(
400 &ink,
401 &RowSpec {
402 label,
403 used,
404 reset: meter_reset(meter, dur_secs),
405 share_pct: Some(meter.percent),
406 status: Some(meter.status.as_str()),
407 pro_rata: label == "Monthly",
410 },
411 report.now_ms,
412 ));
413 }
414 }
415 None if report.month.requests > 0 => {
420 let monthly_dur = report
425 .period_end_ms
426 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
427 out.push_str(&format!(
428 "\n{}GO{} {}\n",
429 ink.bold,
430 ink.reset,
431 ink.dim_on(
432 "(estimated locally: limits are per model, so no pool: spend alone)".into()
433 ),
434 ));
435 if let Some(why) = &report.account_error {
439 out.push_str(&format!("{} usage API: {why}{}\n", ink.dim, ink.reset));
440 }
441 out.push_str(&window_line(
442 &ink,
443 &RowSpec {
444 label: "5-hour",
445 used: &report.five_hour,
446 reset: None,
447 share_pct: None,
448 status: None,
449 pro_rata: false,
450 },
451 report.now_ms,
452 ));
453 out.push_str(&window_line(
454 &ink,
455 &RowSpec {
456 label: "Weekly",
457 used: &report.weekly,
458 reset: None,
459 share_pct: None,
460 status: None,
461 pro_rata: false,
462 },
463 report.now_ms,
464 ));
465 out.push_str(&window_line(
466 &ink,
467 &RowSpec {
468 label: "Monthly",
469 used: &report.month,
470 reset: report.period_end_ms.zip(monthly_dur),
471 share_pct: None,
472 status: None,
473 pro_rata: false,
476 },
477 report.now_ms,
478 ));
479 }
480 None => {}
481 }
482
483 out.push_str(&format!(
484 "\n{}MODEL LIMIT SPENT USE 5H WK REQ{}\n",
485 ink.bold, ink.reset
486 ));
487 let total_cost = report.month.cost;
488 for model in report.models.iter().take(rows) {
489 out.push_str(&model_row(&ink, model, total_cost));
490 }
491 if report.models.len() > rows {
492 out.push_str(&format!(
493 "{}\n",
494 ink.dim_on(format!("… {} more", report.models.len() - rows))
495 ));
496 }
497 out.push_str(&format!(
498 "\n{}\n",
499 ink.dim_on(
500 "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
501 .into()
502 )
503 ));
504 if report.account.is_some() {
510 out.push_str(&format!(
511 "{}\n",
512 ink.dim_on(
513 "5H/WK columns: this model's own spend inside the account's windows (the same cuts its percentages use)."
514 .into()
515 )
516 ));
517 } else {
518 out.push_str(&format!(
519 "{}\n",
520 ink.dim_on(
521 "5H/WK columns: this model's own spend over the last 5h / 7 days (local, not the account's windows)."
522 .into()
523 )
524 ));
525 }
526 out
527}
528
529pub fn watch_frame(
534 report: &Report,
535 db_path: &str,
536 rows: usize,
537 colour: bool,
538 history: &[f64],
539 interval_secs: u64,
540 bar_width: usize,
541) -> String {
542 let ink = Ink::new(colour);
543 let mut out = render_text(report, db_path, rows, colour, bar_width);
544 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
545 out.push_str(&format!(
546 "{}spend bursts ({}s samples){} {}\n",
547 ink.dim,
548 interval_secs,
549 ink.reset,
550 crate::render::sparkline(history),
551 ));
552 }
553 out.trim_end_matches('\n').to_string()
554}
555
556pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
561 let ink = Ink::new(colour);
562 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
563 format!(
564 " {}{}{}",
565 ink.dim,
566 crate::render::sparkline(history),
567 ink.reset
568 )
569 } else {
570 String::new()
571 };
572 let window_share = match &report.account {
577 Some(account) => format!(
578 " · 5h {} · wk {}",
579 ink.cyan_on(pct(account.rolling.percent, 100.0)),
580 ink.cyan_on(pct(account.weekly.percent, 100.0)),
581 ),
582 None => format!(" · 5h {}", ink.cyan_on(money(report.five_hour.cost))),
583 };
584 format!(
585 "{}oc{} {} month{window_share}{spark}",
586 ink.bold,
587 ink.reset,
588 ink.cyan_on(money(report.month.cost)),
589 )
590}
591
592pub fn statusline_template(
606 report: &Report,
607 plan_id: &str,
608 tpl: &str,
609 colours: bool,
610 ascii: bool,
611 bar_width: usize,
612) -> String {
613 let ink = Ink::new(colours);
614 let cash = |v: f64| ink.cyan_on(money(v));
615 let account = report.account.as_ref();
622 let share_of = |meter: Option<&crate::ocuse::api::Meter>| match meter {
623 Some(meter) => pct(meter.percent, 100.0),
624 None => "—".into(),
625 };
626 let share_bar = |meter: Option<&crate::ocuse::api::Meter>| match meter {
627 Some(meter) => crate::report_render::bar(meter.percent, bar_width, ascii, colours),
628 None => String::new(),
629 };
630 let top_model = || {
631 report
632 .models
633 .first()
634 .map(|m| m.id.clone())
635 .unwrap_or_else(|| "—".into())
636 };
637 crate::report_render::render_template(tpl, |key| match key {
638 "plan" => plan_id.to_string(),
639 "period" => report.period_label.clone(),
640 "month" => cash(report.month.cost),
641 "month_pct" => share_of(account.map(|a| &a.monthly)),
642 "month_bar" => share_bar(account.map(|a| &a.monthly)),
643 "five_hour" | "5h" => cash(report.five_hour.cost),
644 "five_hour_pct" | "5h_pct" => share_of(account.map(|a| &a.rolling)),
645 "five_hour_bar" | "5h_bar" => share_bar(account.map(|a| &a.rolling)),
646 "weekly" | "wk" => cash(report.weekly.cost),
647 "weekly_pct" | "wk_pct" => share_of(account.map(|a| &a.weekly)),
648 "weekly_bar" | "wk_bar" => share_bar(account.map(|a| &a.weekly)),
649 "models" => top_model(),
650 _ => String::new(),
651 })
652}
653
654pub fn statusline_json(report: &Report, plan_id: &str) -> String {
660 serde_json::json!({
661 "plan": plan_id,
662 "period": report.period_label,
663 "monthSpent": report.month.cost,
664 "monthCap": serde_json::Value::Null,
665 "fiveHourSpent": report.five_hour.cost,
666 "fiveHourCap": serde_json::Value::Null,
667 "weeklySpent": report.weekly.cost,
668 "weeklyCap": serde_json::Value::Null,
669 "model": report.models.first().map(|m| m.id.clone()),
670 })
671 .to_string()
672}
673
674pub fn render_json(report: &Report) -> String {
676 let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
677 let model = |m: &ModelUsage| {
678 serde_json::json!({
679 "id": m.id,
680 "provider": m.provider,
681 "limit": m.limit,
682 "month": totals(&m.month),
683 "fiveHour": totals(&m.five_hour),
684 "weekly": totals(&m.weekly),
685 })
686 };
687 let meter = |m: &crate::ocuse::api::Meter| {
691 serde_json::json!({
692 "status": m.status,
693 "percent": m.percent,
694 "resetsAt": m.reset_ms(),
695 })
696 };
697 serde_json::to_string_pretty(&serde_json::json!({
698 "source": "opencode.db",
699 "generatedAt": report.now_ms,
700 "periodStart": report.since_ms,
701 "periodStartAt": report.since_ms,
706 "periodEndAt": report.period_end_ms,
707 "periodInferred": report.month_inferred,
708 "totals": {
709 "month": totals(&report.month),
710 "fiveHour": totals(&report.five_hour),
711 "weekly": totals(&report.weekly),
712 },
713 "account": report.account.as_ref().map(|a| serde_json::json!({
716 "rolling": meter(&a.rolling),
717 "weekly": meter(&a.weekly),
718 "monthly": meter(&a.monthly),
719 })),
720 "accountError": report.account_error,
725 "models": report.models.iter().map(model).collect::<Vec<_>>(),
726 }))
727 .expect("json")
728}
729
730fn shorten(path: &str) -> String {
731 match std::env::var("HOME") {
732 Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
733 _ => path.to_string(),
734 }
735}
736
737pub fn plans_text(colour: bool) -> String {
744 let ink = Ink::new(colour);
745 let catalog = crate::ocuse::zen::catalog();
746 let mut out = format!(
747 "{}OpenCode plans{} {}\n",
748 ink.bold,
749 ink.reset,
750 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
751 );
752 out.push_str(&format!(
753 "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
754 ink.dim,
755 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
756 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
757 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
758 ink.reset,
759 ));
760 for plan in catalog.go.plans.values() {
761 out.push_str(&format!(
762 "\n{}{}{} — {}/month\n",
763 ink.bold,
764 plan.label,
765 ink.reset,
766 ink.cyan_on(format!("${:.2}", plan.price_usd)),
767 ));
768 out.push_str(&format!(
769 "{}MODEL LIMIT IN OUT CACHE{}\n",
770 ink.bold, ink.reset
771 ));
772 for model in &catalog.go.models {
773 let rate = model.variants.first();
774 let limit = plan
775 .allowance_by_model
776 .get(&model.id)
777 .map(|limit| cmduse_core::money(*limit))
778 .unwrap_or_else(|| "—".into());
779 out.push_str(&format!(
780 "{:<28} {} {} {} {}\n",
781 model.id,
782 ink.cyan_on(format!("{limit:>7}")),
783 ink.cyan_on(format!(
784 "{:>6}",
785 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
786 )),
787 ink.cyan_on(format!(
788 "{:>6}",
789 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
790 )),
791 ink.cyan_on(format!(
792 "{:>8}",
793 rate.map(|r| format!("${}", r.cache_read))
794 .unwrap_or_default()
795 )),
796 ));
797 }
798 }
799 out.push_str(&format!(
800 "\n{}\n",
801 ink.dim_on(format!(
802 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
803 catalog.zen.models.len()
804 ))
805 ));
806 out
807}
808
809pub fn plans_json() -> String {
813 let catalog = crate::ocuse::zen::catalog();
814 let plans: Vec<serde_json::Value> = catalog
815 .go
816 .plans
817 .iter()
818 .map(|(id, plan)| {
819 serde_json::json!({
820 "id": id,
821 "label": plan.label,
822 "priceUsd": plan.price_usd,
823 "allowanceByModel": &plan.allowance_by_model,
824 })
825 })
826 .collect();
827 serde_json::json!({
828 "extractedAt": catalog.extracted_at,
829 "windows": {
830 "fiveHour": catalog.go.window_share.five_hour,
831 "weekly": catalog.go.window_share.weekly,
832 "monthly": catalog.go.window_share.monthly,
833 },
834 "plans": plans,
835 "models": catalog.go.models.iter().map(rate_json).collect::<Vec<_>>(),
836 "zen": catalog.zen.models.iter().map(rate_json).collect::<Vec<_>>(),
837 })
838 .to_string()
839}
840
841fn rate_json(model: &zen::Model) -> serde_json::Value {
843 let rate = model.variants.first();
844 serde_json::json!({
845 "id": model.id,
846 "name": model.name,
847 "input": rate.map(|r| r.input),
848 "output": rate.map(|r| r.output),
849 "cacheRead": rate.map(|r| r.cache_read),
850 "cacheWrite": rate.map(|r| r.cache_write),
851 })
852}
853
854struct ModelRow {
857 id: String,
858 name: String,
859 input: Option<f64>,
860 output: Option<f64>,
861 cache_read: Option<f64>,
862 limit: Option<f64>,
863}
864
865fn model_rows(plan_id: &str, gated: bool) -> Vec<ModelRow> {
868 let catalog = crate::ocuse::zen::catalog();
869 let plan = catalog.plan(plan_id);
870 let mut rows: Vec<ModelRow> = catalog
871 .go
872 .models
873 .iter()
874 .map(|m| {
875 let rate = m.variants.first();
876 ModelRow {
877 id: m.id.clone(),
878 name: m.name.clone(),
879 input: rate.map(|r| r.input),
880 output: rate.map(|r| r.output),
881 cache_read: rate.map(|r| r.cache_read),
882 limit: plan.and_then(|p| p.allowance_by_model.get(&m.id).copied()),
883 }
884 })
885 .collect();
886 if gated {
887 rows.retain(|r| r.limit.is_some());
888 }
889 rows
890}
891
892pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
896 let ink = Ink::new(colour);
897 let catalog = crate::ocuse::zen::catalog();
898 let label = catalog
899 .plan(plan_id)
900 .map(|p| p.label.clone())
901 .unwrap_or_else(|| plan_id.to_string());
902 let granted = catalog
903 .plan(plan_id)
904 .map(|p| p.allowance_by_model.len())
905 .unwrap_or(0);
906 let rows = model_rows(plan_id, gated);
907 let mut out = format!(
908 "{}{label} models{} {}\n",
909 ink.bold,
910 ink.reset,
911 ink.dim_on(format!(
912 "(plan {plan_id} · {granted}/{} granted)",
913 catalog.go.models.len()
914 )),
915 );
916 out.push_str(&format!(
917 "{}{:<30} {:<28} {:>7} {:>7} {:>7} {:>8} {:>7}{}\n",
918 ink.bold, "MODEL", "ID", "IN", "OUT", "CACHE", "LIMIT", "GRANTED", ink.reset
919 ));
920 let rate_cell = |v: Option<f64>| match v {
921 Some(v) => ink.cyan_on(format!("{:>7}", format!("${v}"))),
922 None => ink.dim_on(format!("{:>7}", "—")),
923 };
924 for r in &rows {
925 let limit = match r.limit {
926 Some(l) => ink.cyan_on(format!("{:>8}", cmduse_core::money(l))),
927 None => ink.dim_on(format!("{:>8}", "—")),
928 };
929 let granted = if r.limit.is_some() { "yes" } else { "no" };
930 out.push_str(&format!(
931 "{:<30} {:<28} {} {} {} {} {:>7}\n",
932 r.name,
933 r.id,
934 rate_cell(r.input),
935 rate_cell(r.output),
936 rate_cell(r.cache_read),
937 limit,
938 granted,
939 ));
940 }
941 out
942}
943
944pub fn models_json(plan_id: &str, gated: bool) -> String {
948 let mut out = String::new();
949 for r in model_rows(plan_id, gated) {
950 out.push_str(
951 &serde_json::json!({
952 "id": r.id,
953 "name": r.name,
954 "input": r.input,
955 "output": r.output,
956 "cacheRead": r.cache_read,
957 "limit": r.limit,
958 "granted": r.limit.is_some(),
959 })
960 .to_string(),
961 );
962 out.push('\n');
963 }
964 out
965}
966
967pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
968 let ink = Ink::new(colour);
969 let Some(model) = report.models.iter().find(|m| m.id == id) else {
970 return format!("{id}: no usage in the period\n");
971 };
972 let limit = model
973 .limit
974 .map(cmduse_core::money)
975 .unwrap_or_else(|| "—".into());
976 let row =
977 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
978 let mut out = format!(
979 "{} {}\n",
980 ink.bold_on(model.id.clone()),
981 ink.dim_on(format!("({})", model.provider)),
982 );
983 out.push_str(&row("limit", ink.cyan_on(limit)));
984 out.push_str(&row(
985 "month",
986 ink.cyan_on(format!(
987 "{} · {} requests",
988 cmduse_core::money(model.month.cost),
989 model.month.requests
990 )),
991 ));
992 out.push_str(&row(
993 "5-hour",
994 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
995 ));
996 out.push_str(&row(
997 "weekly",
998 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
999 ));
1000 if report.month.cost > 0.0 {
1003 out.push_str(&row(
1004 "share",
1005 ink.dim_on(format!(
1006 "{:.1}% of period spend",
1007 model.month.cost / report.month.cost * 100.0
1008 )),
1009 ));
1010 }
1011 out
1012}
1013
1014fn totals_json(
1019 cost: f64,
1020 requests: u64,
1021 input: u64,
1022 output: u64,
1023 cache_read: u64,
1024) -> serde_json::Value {
1025 serde_json::json!({
1026 "requests": requests,
1027 "tokensIn": input,
1028 "tokensOut": output,
1029 "cacheRead": cache_read,
1030 "costUsd": cost,
1031 })
1032}
1033
1034#[derive(Default, Clone, Copy)]
1037struct Bucket {
1038 cost: f64,
1039 requests: u64,
1040 input: u64,
1041 output: u64,
1042 cache_read: u64,
1043}
1044
1045impl Bucket {
1046 fn add(&mut self, row: &crate::ocuse::db::Row) {
1047 self.cost += row.cost_usd;
1048 self.requests += 1;
1049 self.input += row.input;
1050 self.output += row.output;
1051 self.cache_read += row.cache_read;
1052 }
1053
1054 fn json(&self) -> serde_json::Value {
1055 totals_json(
1056 self.cost,
1057 self.requests,
1058 self.input,
1059 self.output,
1060 self.cache_read,
1061 )
1062 }
1063}
1064
1065pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1073 let models: Vec<&ModelUsage> = report
1074 .models
1075 .iter()
1076 .filter(|m| id.is_none_or(|want| m.id == want))
1077 .collect();
1078 match fmt {
1079 Fmt::Json => {
1080 let map: serde_json::Map<String, serde_json::Value> = models
1081 .iter()
1082 .map(|m| {
1083 let mut body = totals_json(
1084 m.month.cost,
1085 m.month.requests,
1086 m.month.input,
1087 m.month.output,
1088 m.month.cache_read,
1089 );
1090 body["limit"] = serde_json::json!(m.limit);
1091 (m.id.clone(), body)
1092 })
1093 .collect();
1094 serde_json::json!({
1095 "scope": "local",
1096 "source": "opencode",
1097 "sinceMs": report.since_ms,
1098 "models": map,
1099 })
1100 .to_string()
1101 }
1102 Fmt::Csv => {
1103 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1104 for m in models {
1105 out.push_str(&format!(
1106 "{},{},{},{},{},{:.2}\n",
1107 crate::report_render::csv_field(&m.id),
1108 m.month.requests,
1109 m.month.input,
1110 m.month.output,
1111 m.month.cache_read,
1112 m.month.cost,
1113 ));
1114 }
1115 out
1116 }
1117 Fmt::Table => String::new(), }
1119}
1120
1121pub fn bucket_text(
1129 rows: &[crate::ocuse::db::Row],
1130 now_ms: i64,
1131 lookback_ms: i64,
1132 size_ms: i64,
1133 tz_secs: i64,
1134 colour: bool,
1135 fmt: Fmt,
1136) -> String {
1137 let shift = tz_secs * 1000;
1139 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1140 for row in rows {
1141 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1142 buckets.entry(bucket).or_default().add(row);
1143 }
1144 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1145 let daily = size_ms >= 86_400_000;
1146 let label = if daily { "day" } else { "hour" };
1147 let stamp = |bucket: i64| {
1148 if daily {
1149 date_of(bucket)
1150 } else {
1151 format!(
1152 "{} {:02}h",
1153 date_of(bucket.div_euclid(24)),
1154 bucket.rem_euclid(24)
1155 )
1156 }
1157 };
1158 let window: Vec<(i64, Bucket)> = buckets
1159 .range(start..)
1160 .map(|(bucket, b)| (*bucket, *b))
1161 .collect();
1162
1163 match fmt {
1164 Fmt::Json => {
1165 let mut keyed = serde_json::Map::new();
1166 let mut total = Bucket::default();
1167 for (bucket, b) in &window {
1168 keyed.insert(stamp(*bucket), b.json());
1169 total.add_bucket(b);
1170 }
1171 let key = if daily { "days" } else { "hours" };
1172 serde_json::json!({
1173 "scope": "local",
1174 "source": "opencode",
1175 "total": total.json(),
1176 key: keyed,
1177 })
1178 .to_string()
1179 }
1180 Fmt::Csv => {
1181 let head = if daily { "day" } else { "hour" };
1182 let mut out = format!("{head},requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1183 let mut total = Bucket::default();
1184 for (bucket, b) in &window {
1185 out.push_str(&format!(
1186 "{},{},{},{},{},{:.2}\n",
1187 crate::report_render::csv_field(&stamp(*bucket)),
1188 b.requests,
1189 b.input,
1190 b.output,
1191 b.cache_read,
1192 b.cost,
1193 ));
1194 total.add_bucket(b);
1195 }
1196 out.push_str(&format!(
1197 "total,{},{},{},{},{:.2}\n",
1198 total.requests, total.input, total.output, total.cache_read, total.cost,
1199 ));
1200 out
1201 }
1202 Fmt::Table => {
1203 let ink = Ink::new(colour);
1204 let mut out = format!(
1205 "{}{label:<12} spend requests tokens{}\n",
1206 ink.bold, ink.reset
1207 );
1208 for (bucket, b) in &window {
1209 out.push_str(&format!(
1210 "{:<12} {} {} {}\n",
1211 stamp(*bucket),
1212 ink.cyan_on(format!("{:>9}", cmduse_core::money(b.cost))),
1213 ink.cyan_on(format!("{:>12}", cmduse_core::compact(b.requests))),
1214 ink.cyan_on(format!(
1215 "{:>9}",
1216 cmduse_core::compact(b.input + b.output + b.cache_read)
1217 )),
1218 ));
1219 }
1220 if out.lines().count() == 1 {
1221 out.push_str(&format!(
1222 "{}\n",
1223 ink.dim_on("(no OpenCode usage in the window)".into())
1224 ));
1225 }
1226 out
1227 }
1228 }
1229}
1230
1231type SessionTotals<'a> = (
1234 f64,
1235 u64,
1236 u64,
1237 u64,
1238 u64,
1239 i64,
1240 std::collections::BTreeSet<&'a str>,
1241);
1242
1243pub fn session_text(
1248 rows: &[crate::ocuse::db::Row],
1249 limit: usize,
1250 colour: bool,
1251 fmt: Fmt,
1252) -> String {
1253 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1254 for row in rows {
1255 let entry = by_session
1256 .entry(row.session.as_str())
1257 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1258 entry.0 += row.cost_usd;
1259 entry.1 += 1;
1260 entry.2 += row.input;
1261 entry.3 += row.output;
1262 entry.4 += row.cache_read;
1263 entry.5 = entry.5.max(row.at_ms);
1264 entry.6.insert(row.model.as_str());
1265 }
1266 let mut sessions: Vec<_> = by_session.into_iter().collect();
1267 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1268 sessions.truncate(limit);
1269
1270 match fmt {
1271 Fmt::Json => {
1272 let mut keyed = serde_json::Map::new();
1273 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1274 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1275 body["lastMs"] = serde_json::json!(*last);
1276 body["models"] = serde_json::json!(models.len());
1277 keyed.insert((*id).to_string(), body);
1278 }
1279 serde_json::json!({
1280 "scope": "local",
1281 "source": "opencode",
1282 "sessions": keyed,
1283 })
1284 .to_string()
1285 }
1286 Fmt::Csv => {
1287 let mut out =
1288 String::from("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1289 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1290 out.push_str(&format!(
1291 "{},{},{},{},{},{:.2}\n",
1292 crate::report_render::csv_field(id),
1293 requests,
1294 input,
1295 output,
1296 cache_read,
1297 cost,
1298 ));
1299 }
1300 out
1301 }
1302 Fmt::Table => {
1303 let ink = Ink::new(colour);
1304 let mut out = format!(
1305 "{}session last models spend req{}\n",
1306 ink.bold, ink.reset
1307 );
1308 for (id, (cost, requests, _, _, _, last, models)) in sessions {
1309 let short = id.rsplit('_').next().unwrap_or(id);
1310 out.push_str(&format!(
1311 "{:<32} {:<11} {:>6} {} {:>6}\n",
1312 short,
1313 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1314 models.len(),
1315 ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
1316 requests,
1317 ));
1318 }
1319 if out.lines().count() == 1 {
1320 out.push_str(&format!(
1321 "{}\n",
1322 ink.dim_on("(no sessions in the window)".into())
1323 ));
1324 }
1325 out
1326 }
1327 }
1328}
1329
1330impl Bucket {
1331 fn add_bucket(&mut self, other: &Bucket) {
1333 self.cost += other.cost;
1334 self.requests += other.requests;
1335 self.input += other.input;
1336 self.output += other.output;
1337 self.cache_read += other.cache_read;
1338 }
1339}
1340
1341pub fn date_of(day: i64) -> String {
1344 let z = day + 719_468;
1345 let era = z.div_euclid(146_097);
1346 let doe = z.rem_euclid(146_097);
1347 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1348 let y = yoe + era * 400;
1349 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1350 let mp = (5 * doy + 2) / 153;
1351 let d = doy - (153 * mp + 2) / 5 + 1;
1352 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1353 let year = if m <= 2 { y + 1 } else { y };
1354 format!("{year:04}-{m:02}-{d:02}")
1355}
1356
1357#[cfg(test)]
1358mod tests {
1359 use super::*;
1360 use crate::ocuse::db::Row;
1361
1362 fn report() -> Report {
1363 let now = 1_800_000_000_000;
1364 let rows = vec![Row {
1365 at_ms: now - 60_000,
1366 provider: "opencode-go".into(),
1367 model: "glm-5.3-flash".into(),
1368 cost_usd: 30.0,
1369 ..Default::default()
1370 }];
1371 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None)
1372 }
1373
1374 #[test]
1375 fn text_shows_the_model_against_its_limit() {
1376 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1377 assert!(text.contains("glm-5.3-flash"), "{text}");
1378 assert!(text.contains("$60"), "the model's monthly limit: {text}");
1379 assert!(
1380 text.contains("50.0%"),
1381 "the model table keeps its own USE: {text}"
1382 );
1383 }
1384
1385 fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
1386 crate::ocuse::api::Meter {
1387 status: status.into(),
1388 percent,
1389 resets_at: Some(resets_at.into()),
1390 }
1391 }
1392
1393 fn report_with_account() -> Report {
1400 let mut report = report();
1401 report.account = Some(crate::ocuse::api::Usage {
1402 rolling: meter("ok", 5.0, "2027-01-15T12:31:00.000Z"),
1403 weekly: meter("ok", 59.0, "2027-01-18T00:00:00.000Z"),
1404 monthly: meter("ok", 29.0, "2027-02-15T08:00:00.000Z"),
1405 });
1406 report.period_end_ms = Some(1_802_678_400_000); report.since_ms = crate::ocuse::window::prev_month(1_802_678_400_000);
1408 report
1409 }
1410
1411 #[test]
1412 fn the_window_rows_come_from_the_account_when_it_answered() {
1413 let text = render_text(
1414 &report_with_account(),
1415 "/tmp/opencode.db",
1416 10,
1417 false,
1418 DEFAULT_BAR_WIDTH,
1419 );
1420 assert!(text.contains("5.0%"), "{text}");
1422 assert!(text.contains("59.0%"), "{text}");
1423 assert!(text.contains("29.0%"), "{text}");
1424 assert!(text.contains("account meters"), "{text}");
1425 assert!(
1426 text.contains("resets 4h 31m"),
1427 "the 5h block's own end: {text}"
1428 );
1429 assert!(text.contains("$30.00 / $600.00 (5.0% of limit)"), "{text}");
1434 assert!(text.contains("$30.00 / $103.45 (29.0% of limit)"), "{text}");
1435 assert!(text.contains("of limit"), "{text}");
1436 assert!(text.contains("elapsed"), "{text}");
1441 assert_eq!(text.matches("by now").count(), 1, "{text}");
1442 assert!(text.contains("the same cuts its percentages use"), "{text}");
1445 }
1446
1447 #[test]
1454 fn the_pair_is_the_ceiling_the_share_implies() {
1455 assert_eq!(implied_cap(30.0, 5.0), Some(600.0));
1457 assert_eq!(implied_cap(30.0, 59.0), Some(50.847457627118644));
1458 assert_eq!(implied_cap(20.45, 0.0), None);
1460 assert_eq!(implied_cap(20.45, 0.04), None);
1461 assert_eq!(implied_cap(20.45, 0.1), Some(20450.0));
1462 assert_eq!(implied_cap(0.0, 1.0), Some(0.0));
1463 }
1464
1465 #[test]
1467 fn a_reachable_cap_gets_the_pace_line() {
1468 let mut report = report_with_account();
1469 if let Some(account) = &mut report.account {
1470 account.rolling = meter("ok", 90.0, "2027-01-15T12:00:00.000Z");
1473 }
1474 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1475 assert!(text.contains("on pace to hit cap in"), "{text}");
1476 }
1477
1478 #[test]
1479 fn without_an_account_the_rows_say_they_are_local() {
1480 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1481 assert!(text.contains("estimated locally"), "{text}");
1482 assert!(text.contains("no pool"), "no aggregate ceiling: {text}");
1483 assert!(!text.contains("of limit"), "{text}");
1485 assert!(!text.contains("resets"), "{text}");
1486 assert!(!text.contains("elapsed"), "{text}");
1487 assert!(!text.contains("account meters"), "{text}");
1489 assert!(
1490 !text.contains("the same cuts its percentages use"),
1491 "{text}"
1492 );
1493 assert!(text.contains("local, not the account's windows"), "{text}");
1496 }
1497
1498 #[test]
1499 fn an_over_limit_meter_says_so_before_its_countdown() {
1500 let mut report = report_with_account();
1501 if let Some(account) = &mut report.account {
1502 account.rolling = meter("rate_limited", 100.0, "2027-01-15T12:31:00.000Z");
1503 }
1504 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1505 assert!(text.contains("LIMIT EXCEEDED"), "{text}");
1506 }
1507
1508 #[test]
1509 fn json_carries_the_account_meters_and_null_without_them() {
1510 let parsed: serde_json::Value =
1511 serde_json::from_str(&render_json(&report_with_account())).unwrap();
1512 assert_eq!(parsed["account"]["rolling"]["percent"], 5.0);
1513 assert_eq!(parsed["account"]["weekly"]["percent"], 59.0);
1514 assert_eq!(parsed["account"]["monthly"]["status"], "ok");
1515 let monthly = cmduse_core::dates::parse_iso_utc("2027-02-15T08:00:00.000Z").unwrap();
1518 assert_eq!(parsed["account"]["monthly"]["resetsAt"], monthly as i64);
1519
1520 let parsed: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1523 assert_eq!(parsed["account"], serde_json::Value::Null);
1524 }
1525
1526 #[test]
1527 fn statusline_prefers_the_accounts_share_over_the_local_estimate() {
1528 let tpl = "{five_hour_pct} {weekly_pct} {month_pct} {five_hour}";
1529 let account = statusline_template(
1530 &report_with_account(),
1531 "go",
1532 tpl,
1533 false,
1534 false,
1535 DEFAULT_BAR_WIDTH,
1536 );
1537 assert!(account.starts_with("5.0% 59.0% 29.0%"), "{account}");
1538 assert!(account.contains("$30.00"), "{account}");
1540
1541 let local = statusline_template(&report(), "go", tpl, false, false, DEFAULT_BAR_WIDTH);
1544 assert!(!local.starts_with("5.0%"), "{local}");
1545 }
1546
1547 #[test]
1548 fn json_and_statusline_carry_the_totals() {
1549 let json = render_json(&report());
1550 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1551 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
1552 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
1553 let line = statusline_template(&report(), "go", "{plan} {models}", false, false, 20);
1554 assert_eq!(line, "go glm-5.3-flash");
1555 }
1556
1557 #[test]
1558 fn json_carries_the_period_bounds() {
1559 let mut r = report();
1562 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1563 let parsed: serde_json::Value = serde_json::from_str(&render_json(&r)).unwrap();
1564 assert_eq!(parsed["periodStart"], r.since_ms);
1565 assert_eq!(parsed["periodStartAt"], r.since_ms);
1566 assert_eq!(parsed["periodEndAt"], r.period_end_ms.unwrap());
1567
1568 let mut all = report();
1571 all.period_end_ms = None;
1572 let parsed: serde_json::Value = serde_json::from_str(&render_json(&all)).unwrap();
1573 assert_eq!(parsed["periodEndAt"], serde_json::Value::Null);
1574 assert_eq!(parsed["periodStart"], all.since_ms);
1575 assert!(parsed.get("periodInferred").is_some());
1576 }
1577
1578 #[test]
1584 fn every_renderer_respects_the_colour_flag() {
1585 let mut report = report();
1586 report.period_end_ms = Some(crate::ocuse::window::next_month(report.since_ms));
1589 let rows = vec![Row {
1590 at_ms: report.now_ms - 60_000,
1591 session: "ses_fixture".into(),
1592 provider: "opencode-go".into(),
1593 model: "glm-5.3-flash".into(),
1594 cost_usd: 30.0,
1595 ..Default::default()
1596 }];
1597 let now = report.now_ms;
1598
1599 let cases: Vec<(&str, String, String)> = vec![
1600 (
1601 "render_text",
1602 render_text(&report, "/tmp/oc.db", 10, false, DEFAULT_BAR_WIDTH),
1603 render_text(&report, "/tmp/oc.db", 10, true, DEFAULT_BAR_WIDTH),
1604 ),
1605 (
1606 "statusline_template",
1607 statusline_template(
1608 &report,
1609 "go",
1610 "{period} {month} {month_pct}",
1611 false,
1612 false,
1613 20,
1614 ),
1615 statusline_template(
1616 &report,
1617 "go",
1618 "{period} {month} {month_pct}",
1619 true,
1620 false,
1621 20,
1622 ),
1623 ),
1624 (
1625 "compact_line",
1626 compact_line(&report, &[], false),
1627 compact_line(&report, &[], true),
1628 ),
1629 ("plans_text", plans_text(false), plans_text(true)),
1630 (
1631 "model_text",
1632 model_text(&report, "glm-5.3-flash", false),
1633 model_text(&report, "glm-5.3-flash", true),
1634 ),
1635 (
1636 "bucket_text/day",
1637 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Table),
1638 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Table),
1639 ),
1640 (
1641 "bucket_text/hour",
1642 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Table),
1643 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Table),
1644 ),
1645 (
1646 "session_text",
1647 session_text(&rows, 20, false, Fmt::Table),
1648 session_text(&rows, 20, true, Fmt::Table),
1649 ),
1650 (
1651 "models_text",
1652 models_text("go", false, false),
1653 models_text("go", false, true),
1654 ),
1655 ];
1656
1657 for (name, plain, coloured) in cases {
1658 assert!(
1659 !plain.contains('\x1b'),
1660 "{name} leaked escapes when off: {plain}"
1661 );
1662 assert!(
1663 coloured.contains('\x1b'),
1664 "{name} rendered nothing coloured when on: {coloured}"
1665 );
1666 }
1667
1668 let flagged: Vec<(&str, String, String)> = vec![
1671 (
1672 "bucket_text/day csv",
1673 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv),
1674 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Csv),
1675 ),
1676 (
1677 "bucket_text/hour json",
1678 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Json),
1679 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Json),
1680 ),
1681 (
1682 "session_text csv",
1683 session_text(&rows, 20, false, Fmt::Csv),
1684 session_text(&rows, 20, true, Fmt::Csv),
1685 ),
1686 (
1687 "session_text json",
1688 session_text(&rows, 20, false, Fmt::Json),
1689 session_text(&rows, 20, true, Fmt::Json),
1690 ),
1691 ];
1692 for (name, off, on) in flagged {
1693 assert!(
1694 !off.contains('\x1b'),
1695 "{name} leaked escapes with colour off: {off}"
1696 );
1697 assert!(
1698 !on.contains('\x1b'),
1699 "{name} must stay uncoloured with colour on: {on}"
1700 );
1701 }
1702 for (name, out) in [
1703 ("model_report json", model_report(&report, None, Fmt::Json)),
1704 ("model_report csv", model_report(&report, None, Fmt::Csv)),
1705 ("plans_json", plans_json()),
1706 ("statusline_json", statusline_json(&report, "go")),
1707 ("models_json", models_json("go", false)),
1708 ] {
1709 assert!(
1710 !out.contains('\x1b'),
1711 "{name} must never be coloured: {out}"
1712 );
1713 }
1714 }
1715
1716 #[test]
1717 fn bucket_reports_carry_the_documented_columns() {
1718 let now = 1_800_000_000_000;
1719 let rows = vec![Row {
1720 at_ms: now - 60_000,
1721 provider: "opencode-go".into(),
1722 model: "glm-5.3-flash".into(),
1723 cost_usd: 1.5,
1724 input: 10,
1725 output: 20,
1726 cache_read: 30,
1727 cache_write: 40,
1728 ..Default::default()
1729 }];
1730 let csv = bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv);
1731 assert!(
1732 csv.starts_with("day,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1733 "{csv}"
1734 );
1735 assert!(!csv.contains("cache_write"), "{csv}");
1737 assert!(csv.contains(",10,20,30,1.50\n"), "{csv}");
1738 assert!(csv.trim_end().ends_with("total,1,10,20,30,1.50"), "{csv}");
1739
1740 let json: serde_json::Value = serde_json::from_str(&bucket_text(
1741 &rows,
1742 now,
1743 7 * 86_400_000,
1744 86_400_000,
1745 0,
1746 false,
1747 Fmt::Json,
1748 ))
1749 .unwrap();
1750 assert_eq!(json["total"]["requests"], 1);
1751 assert_eq!(json["total"]["cacheRead"], 30);
1752 assert_eq!(json["days"].as_object().unwrap().len(), 1);
1753 }
1754
1755 #[test]
1756 fn session_report_json_and_csv_agree_with_the_store() {
1757 let now = 1_800_000_000_000;
1758 let rows = vec![
1759 Row {
1760 at_ms: now - 60_000,
1761 session: "ses_first".into(),
1762 model: "glm-5.3-flash".into(),
1763 cost_usd: 2.0,
1764 input: 5,
1765 output: 6,
1766 cache_read: 7,
1767 ..Default::default()
1768 },
1769 Row {
1770 at_ms: now - 30_000,
1771 session: "ses_second".into(),
1772 model: "kimi-k3".into(),
1773 cost_usd: 1.0,
1774 ..Default::default()
1775 },
1776 ];
1777 let json: serde_json::Value =
1778 serde_json::from_str(&session_text(&rows, 20, false, Fmt::Json)).unwrap();
1779 assert_eq!(json["sessions"]["ses_first"]["tokensIn"], 5);
1780 assert_eq!(json["sessions"]["ses_first"]["models"], 1);
1781 assert_eq!(json["sessions"]["ses_second"]["costUsd"], 1.0);
1782 let csv = session_text(&rows, 20, false, Fmt::Csv);
1783 assert!(
1784 csv.starts_with("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1785 "{csv}"
1786 );
1787 assert!(csv.contains("ses_second,1,0,0,0,1.00\n"), "{csv}");
1788 }
1789
1790 #[test]
1791 fn model_report_projects_the_report_models() {
1792 let json: serde_json::Value =
1793 serde_json::from_str(&model_report(&report(), None, Fmt::Json)).unwrap();
1794 assert_eq!(json["source"], "opencode");
1795 assert_eq!(json["models"]["glm-5.3-flash"]["costUsd"], 30.0);
1796 assert_eq!(json["models"]["glm-5.3-flash"]["limit"], 60.0);
1797 let csv = model_report(&report(), None, Fmt::Csv);
1798 assert!(csv.contains("glm-5.3-flash,1,0,0,0,30.00\n"), "{csv}");
1799 assert_eq!(
1801 model_report(&report(), Some("nope"), Fmt::Csv),
1802 "model,requests,tokens_in,tokens_out,cache_read,cost_usd\n"
1803 );
1804 }
1805
1806 #[test]
1807 fn catalogue_json_carries_both_plans_and_windows() {
1808 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1809 assert_eq!(json["windows"]["fiveHour"], 0.2);
1810 assert_eq!(json["windows"]["monthly"], 1.0);
1811 assert_eq!(json["plans"][0]["id"], "go");
1812 assert_eq!(json["plans"][0]["allowanceByModel"]["glm-5.3-flash"], 60.0);
1813 assert_eq!(json["plans"][1]["priceUsd"], 40.0);
1814 assert!(!json["models"].as_array().unwrap().is_empty());
1815 assert!(!json["zen"].as_array().unwrap().is_empty());
1816 }
1817
1818 #[test]
1819 fn models_lists_the_plan_and_marks_granted() {
1820 let all = models_text("go", false, false);
1822 assert!(all.contains("glm-5.3-flash"), "{all}");
1823 assert!(all.contains("GRANTED"), "{all}");
1824 let gated = models_text("go", true, false);
1826 for line in gated.lines().skip(2) {
1827 assert!(
1828 line.trim_end().ends_with("yes"),
1829 "ungranted row kept: {line}"
1830 );
1831 }
1832 let every: Vec<serde_json::Value> = models_json("go", false)
1834 .lines()
1835 .map(|l| serde_json::from_str(l).unwrap())
1836 .collect();
1837 assert!(every.iter().any(|m| m["granted"] == false));
1838 assert!(every
1839 .iter()
1840 .any(|m| m["id"] == "glm-5.3-flash" && m["limit"] == 60.0));
1841 let only: Vec<serde_json::Value> = models_json("go", true)
1842 .lines()
1843 .map(|l| serde_json::from_str(l).unwrap())
1844 .collect();
1845 assert!(!only.is_empty());
1846 assert!(only.iter().all(|m| m["granted"] == true));
1847 }
1848
1849 #[test]
1850 fn statusline_json_mirrors_the_template_numbers() {
1851 let json: serde_json::Value =
1852 serde_json::from_str(&statusline_json(&report(), "go")).unwrap();
1853 assert_eq!(json["plan"], "go");
1854 assert_eq!(json["period"], "calendar month");
1855 assert_eq!(json["monthSpent"], 30.0);
1856 assert_eq!(json["monthCap"], serde_json::Value::Null);
1859 assert_eq!(json["fiveHourCap"], serde_json::Value::Null);
1860 assert_eq!(json["weeklyCap"], serde_json::Value::Null);
1861 assert_eq!(json["model"], "glm-5.3-flash");
1862 }
1863
1864 #[test]
1865 fn plain_frames_carry_no_escapes() {
1866 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1867 assert!(
1868 !text.contains('\x1b'),
1869 "piped output must stay plain: {text}"
1870 );
1871 assert!(text.contains("$30.00"), "{text}");
1874 }
1875
1876 #[test]
1880 fn monthly_row_carries_the_real_period_metrics() {
1881 let mut r = report();
1882 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1883 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1884
1885 let monthly = text
1886 .lines()
1887 .find(|line| line.trim_start().starts_with("Monthly"))
1888 .expect("the monthly row");
1889 assert!(monthly.contains("resets"), "{monthly}");
1890 assert!(
1891 monthly.contains("window") && monthly.contains("% elapsed"),
1892 "the real period gives an elapsed share: {monthly}"
1893 );
1894
1895 for label in ["5-hour", "Weekly"] {
1896 let row = text
1897 .lines()
1898 .find(|line| line.trim_start().starts_with(label))
1899 .unwrap_or_else(|| panic!("the {label} row"));
1900 assert!(!row.contains("elapsed"), "{label} has no boundary: {row}");
1901 assert!(!row.contains("resets"), "{label} has no boundary: {row}");
1902 }
1903 }
1904
1905 #[test]
1908 fn period_line_splits_tokens_and_shows_the_end_date() {
1909 let mut r = report();
1910 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1911 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1912 let header = text
1913 .lines()
1914 .find(|line| line.starts_with("period:"))
1915 .expect("the period line");
1916 assert!(
1917 header.contains(" in / "),
1918 "token split like cmduse: {header}"
1919 );
1920 assert!(header.contains(" out tokens"), "{header}");
1921 assert!(
1922 header.contains(&date_of(r.period_end_ms.unwrap() / 86_400_000)),
1923 "the resolved end date: {header}"
1924 );
1925
1926 let no_end = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1928 let header = no_end
1929 .lines()
1930 .find(|line| line.starts_with("period:"))
1931 .expect("the period line");
1932 assert!(!header.contains("ends"), "{header}");
1933 }
1934
1935 #[test]
1936 fn an_over_cap_window_is_flagged_in_both_modes() {
1937 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1939 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
1940 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1941 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
1942 assert!(
1943 coloured.contains(crate::render::RED),
1944 "the flag and the gauge are red: {coloured}"
1945 );
1946 }
1947
1948 #[test]
1949 fn a_model_over_any_of_its_own_windows_is_flagged() {
1950 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1953 let row = plain
1954 .lines()
1955 .find(|line| line.starts_with("glm-5.3-flash"))
1956 .expect("the model row");
1957 assert!(row.contains("LIMIT EXCEEDED"), "{row}");
1958 assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
1960 }
1961
1962 #[test]
1963 fn a_model_within_its_limits_is_not_flagged() {
1964 let now = 1_800_000_000_000;
1965 let rows = vec![Row {
1967 at_ms: now - 60_000,
1968 provider: "opencode-go".into(),
1969 model: "glm-5.3-flash".into(),
1970 cost_usd: 5.0,
1971 ..Default::default()
1972 }];
1973 let report =
1974 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None, None);
1975 for colour in [false, true] {
1976 let text = render_text(&report, "/tmp/opencode.db", 10, colour, DEFAULT_BAR_WIDTH);
1977 assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
1978 }
1979 }
1980
1981 #[test]
1982 fn the_model_detail_spells_out_the_share() {
1983 let detail = model_text(&report(), "glm-5.3-flash", false);
1984 assert!(detail.contains("100.0% of period spend"), "{detail}");
1985 let empty = crate::ocuse::window::build(
1987 &[],
1988 &zen::catalog(),
1989 zen::DEFAULT_PLAN,
1990 1_800_000_000_000,
1991 None,
1992 None,
1993 );
1994 assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
1995 }
1996
1997 #[test]
1998 fn colour_frames_get_the_palette_and_a_gauge() {
1999 let text = render_text(
2004 &report_with_account(),
2005 "/tmp/opencode.db",
2006 10,
2007 true,
2008 DEFAULT_BAR_WIDTH,
2009 );
2010 assert!(text.contains(crate::render::BOLD), "{text}");
2011 assert!(text.contains(crate::render::CYAN), "{text}");
2012 assert!(
2013 text.contains(crate::render::GREEN),
2014 "the 50% USE cell is green: {text}"
2015 );
2016 assert!(
2017 text.contains('━') && text.contains('╱'),
2018 "the account share's gauge bar: {text}"
2019 );
2020 assert!(text.contains("resets in"), "{text}");
2021 }
2022
2023 #[test]
2024 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
2025 let frame = watch_frame(
2026 &report(),
2027 "/tmp/opencode.db",
2028 10,
2029 true,
2030 &[0.0, 1.5, 0.0],
2031 10,
2032 DEFAULT_BAR_WIDTH,
2033 );
2034 assert!(
2035 !frame.ends_with('\n'),
2036 "the caller's status line owns the last row"
2037 );
2038 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
2039 assert!(
2040 frame.contains('█'),
2041 "a nonzero delta shows as a tall bar: {frame}"
2042 );
2043 }
2044
2045 #[test]
2046 fn idle_history_omits_the_sparkline() {
2047 let all_idle = watch_frame(
2048 &report(),
2049 "/tmp/opencode.db",
2050 10,
2051 true,
2052 &[0.0, 0.0],
2053 10,
2054 DEFAULT_BAR_WIDTH,
2055 );
2056 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
2057 let first_frame = watch_frame(
2058 &report(),
2059 "/tmp/opencode.db",
2060 10,
2061 true,
2062 &[],
2063 10,
2064 DEFAULT_BAR_WIDTH,
2065 );
2066 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
2067 }
2068
2069 #[test]
2073 fn statusline_resolves_every_placeholder() {
2074 let r = report();
2075 let line = |tpl: &str| statusline_template(&r, "go", tpl, false, false, 20);
2076
2077 assert_eq!(line("{plan}"), "go");
2079 assert_eq!(line("{period}"), "calendar month");
2080 assert_eq!(line("{month}"), "$30.00");
2082 assert_eq!(line("{five_hour}"), "$30.00");
2083 assert_eq!(line("{weekly}"), "$30.00");
2084 assert_eq!(line("{models}"), "glm-5.3-flash");
2086 assert_eq!(line("{month_pct}"), "—");
2090 assert_eq!(line("{month_bar}"), "");
2091 assert_eq!(line("{five_hour_pct}"), "—");
2092 assert_eq!(line("{weekly_pct}"), "—");
2093
2094 let default = line("{period} {month} · 5h {five_hour} · wk {weekly}");
2096 assert_eq!(default, "calendar month $30.00 · 5h $30.00 · wk $30.00");
2097
2098 assert_eq!(line("{plan} {bogus} | tail"), "go | tail");
2100 assert_eq!(line("{plan} {oops"), "go {oops");
2102 assert!(!line("{plan} {month} {month_pct} {month_bar}").contains('\x1b'));
2104 }
2105
2106 #[test]
2109 fn statusline_without_caps_degrades_gracefully() {
2110 let empty = crate::ocuse::window::build(
2111 &[],
2112 &zen::catalog(),
2113 zen::DEFAULT_PLAN,
2114 1_800_000_000_000,
2115 None,
2116 None,
2117 );
2118 let line = statusline_template(&empty, "go", "{month_pct} {models}", false, false, 20);
2119 assert_eq!(line, "— —");
2120 }
2121
2122 #[test]
2124 fn compact_line_is_one_line_with_inline_sparkline() {
2125 let plain = compact_line(&report(), &[], true);
2126 assert_eq!(plain.lines().count(), 1, "{plain}");
2127 assert!(!plain.ends_with('\n'), "{plain}");
2128 let bursty = compact_line(&report(), &[0.0, 1.5], true);
2129 assert!(bursty.contains('█'), "nonzero delta shows: {bursty}");
2130 assert!(!compact_line(&report(), &[], false).contains('\x1b'));
2132 assert!(compact_line(&report(), &[], true).contains(crate::render::BOLD));
2133 }
2134}