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