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