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