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