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 if let Some(why) = &report.account_error {
403 out.push_str(&format!("{} usage API: {why}{}\n", ink.dim, ink.reset));
404 }
405 out.push_str(&window_line(
406 &ink,
407 &RowSpec {
408 label: "5-hour",
409 used: &report.five_hour,
410 reset: None,
411 share_pct: None,
412 status: None,
413 },
414 report.now_ms,
415 ));
416 out.push_str(&window_line(
417 &ink,
418 &RowSpec {
419 label: "Weekly",
420 used: &report.weekly,
421 reset: None,
422 share_pct: None,
423 status: None,
424 },
425 report.now_ms,
426 ));
427 out.push_str(&window_line(
428 &ink,
429 &RowSpec {
430 label: "Monthly",
431 used: &report.month,
432 reset: report.period_end_ms.zip(monthly_dur),
433 share_pct: None,
434 status: None,
435 },
436 report.now_ms,
437 ));
438 }
439 None => {}
440 }
441
442 out.push_str(&format!(
443 "\n{}MODEL LIMIT SPENT USE 5H WK REQ{}\n",
444 ink.bold, ink.reset
445 ));
446 let total_cost = report.month.cost;
447 for model in report.models.iter().take(rows) {
448 out.push_str(&model_row(&ink, model, total_cost));
449 }
450 if report.models.len() > rows {
451 out.push_str(&format!(
452 "{}\n",
453 ink.dim_on(format!("… {} more", report.models.len() - rows))
454 ));
455 }
456 out.push_str(&format!(
457 "\n{}\n",
458 ink.dim_on(
459 "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
460 .into()
461 )
462 ));
463 if report.account.is_some() {
467 out.push_str(&format!(
468 "{}\n",
469 ink.dim_on(
470 "5H/WK columns: this model's own spend over the last 5h / 7 days (local, not the account's windows)."
471 .into()
472 )
473 ));
474 }
475 out
476}
477
478pub fn watch_frame(
483 report: &Report,
484 db_path: &str,
485 rows: usize,
486 colour: bool,
487 history: &[f64],
488 interval_secs: u64,
489 bar_width: usize,
490) -> String {
491 let ink = Ink::new(colour);
492 let mut out = render_text(report, db_path, rows, colour, bar_width);
493 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
494 out.push_str(&format!(
495 "{}spend bursts ({}s samples){} {}\n",
496 ink.dim,
497 interval_secs,
498 ink.reset,
499 crate::render::sparkline(history),
500 ));
501 }
502 out.trim_end_matches('\n').to_string()
503}
504
505pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
510 let ink = Ink::new(colour);
511 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
512 format!(
513 " {}{}{}",
514 ink.dim,
515 crate::render::sparkline(history),
516 ink.reset
517 )
518 } else {
519 String::new()
520 };
521 let window_share = match &report.account {
526 Some(account) => format!(
527 " · 5h {} · wk {}",
528 ink.cyan_on(pct(account.rolling.percent, 100.0)),
529 ink.cyan_on(pct(account.weekly.percent, 100.0)),
530 ),
531 None => format!(" · 5h {}", ink.cyan_on(money(report.five_hour.cost))),
532 };
533 format!(
534 "{}oc{} {} month{window_share}{spark}",
535 ink.bold,
536 ink.reset,
537 ink.cyan_on(money(report.month.cost)),
538 )
539}
540
541pub fn statusline_template(
555 report: &Report,
556 plan_id: &str,
557 tpl: &str,
558 colours: bool,
559 ascii: bool,
560 bar_width: usize,
561) -> String {
562 let ink = Ink::new(colours);
563 let cash = |v: f64| ink.cyan_on(money(v));
564 let account = report.account.as_ref();
571 let share_of = |meter: Option<&crate::ocuse::api::Meter>| match meter {
572 Some(meter) => pct(meter.percent, 100.0),
573 None => "—".into(),
574 };
575 let share_bar = |meter: Option<&crate::ocuse::api::Meter>| match meter {
576 Some(meter) => crate::report_render::bar(meter.percent, bar_width, ascii, colours),
577 None => String::new(),
578 };
579 let top_model = || {
580 report
581 .models
582 .first()
583 .map(|m| m.id.clone())
584 .unwrap_or_else(|| "—".into())
585 };
586 crate::report_render::render_template(tpl, |key| match key {
587 "plan" => plan_id.to_string(),
588 "period" => report.period_label.clone(),
589 "month" => cash(report.month.cost),
590 "month_pct" => share_of(account.map(|a| &a.monthly)),
591 "month_bar" => share_bar(account.map(|a| &a.monthly)),
592 "five_hour" | "5h" => cash(report.five_hour.cost),
593 "five_hour_pct" | "5h_pct" => share_of(account.map(|a| &a.rolling)),
594 "five_hour_bar" | "5h_bar" => share_bar(account.map(|a| &a.rolling)),
595 "weekly" | "wk" => cash(report.weekly.cost),
596 "weekly_pct" | "wk_pct" => share_of(account.map(|a| &a.weekly)),
597 "weekly_bar" | "wk_bar" => share_bar(account.map(|a| &a.weekly)),
598 "models" => top_model(),
599 _ => String::new(),
600 })
601}
602
603pub fn statusline_json(report: &Report, plan_id: &str) -> String {
609 serde_json::json!({
610 "plan": plan_id,
611 "period": report.period_label,
612 "monthSpent": report.month.cost,
613 "monthCap": serde_json::Value::Null,
614 "fiveHourSpent": report.five_hour.cost,
615 "fiveHourCap": serde_json::Value::Null,
616 "weeklySpent": report.weekly.cost,
617 "weeklyCap": serde_json::Value::Null,
618 "model": report.models.first().map(|m| m.id.clone()),
619 })
620 .to_string()
621}
622
623pub fn render_json(report: &Report) -> String {
625 let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
626 let model = |m: &ModelUsage| {
627 serde_json::json!({
628 "id": m.id,
629 "provider": m.provider,
630 "limit": m.limit,
631 "month": totals(&m.month),
632 "fiveHour": totals(&m.five_hour),
633 "weekly": totals(&m.weekly),
634 })
635 };
636 let meter = |m: &crate::ocuse::api::Meter| {
640 serde_json::json!({
641 "status": m.status,
642 "percent": m.percent,
643 "resetsAt": m.reset_ms(),
644 })
645 };
646 serde_json::to_string_pretty(&serde_json::json!({
647 "source": "opencode.db",
648 "generatedAt": report.now_ms,
649 "periodStart": report.since_ms,
650 "periodStartAt": report.since_ms,
655 "periodEndAt": report.period_end_ms,
656 "periodInferred": report.month_inferred,
657 "totals": {
658 "month": totals(&report.month),
659 "fiveHour": totals(&report.five_hour),
660 "weekly": totals(&report.weekly),
661 },
662 "account": report.account.as_ref().map(|a| serde_json::json!({
665 "rolling": meter(&a.rolling),
666 "weekly": meter(&a.weekly),
667 "monthly": meter(&a.monthly),
668 })),
669 "accountError": report.account_error,
674 "models": report.models.iter().map(model).collect::<Vec<_>>(),
675 }))
676 .expect("json")
677}
678
679fn shorten(path: &str) -> String {
680 match std::env::var("HOME") {
681 Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
682 _ => path.to_string(),
683 }
684}
685
686pub fn plans_text(colour: bool) -> String {
693 let ink = Ink::new(colour);
694 let catalog = crate::ocuse::zen::catalog();
695 let mut out = format!(
696 "{}OpenCode plans{} {}\n",
697 ink.bold,
698 ink.reset,
699 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
700 );
701 out.push_str(&format!(
702 "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
703 ink.dim,
704 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
705 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
706 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
707 ink.reset,
708 ));
709 for plan in catalog.go.plans.values() {
710 out.push_str(&format!(
711 "\n{}{}{} — {}/month\n",
712 ink.bold,
713 plan.label,
714 ink.reset,
715 ink.cyan_on(format!("${:.2}", plan.price_usd)),
716 ));
717 out.push_str(&format!(
718 "{}MODEL LIMIT IN OUT CACHE{}\n",
719 ink.bold, ink.reset
720 ));
721 for model in &catalog.go.models {
722 let rate = model.variants.first();
723 let limit = plan
724 .allowance_by_model
725 .get(&model.id)
726 .map(|limit| cmduse_core::money(*limit))
727 .unwrap_or_else(|| "—".into());
728 out.push_str(&format!(
729 "{:<28} {} {} {} {}\n",
730 model.id,
731 ink.cyan_on(format!("{limit:>7}")),
732 ink.cyan_on(format!(
733 "{:>6}",
734 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
735 )),
736 ink.cyan_on(format!(
737 "{:>6}",
738 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
739 )),
740 ink.cyan_on(format!(
741 "{:>8}",
742 rate.map(|r| format!("${}", r.cache_read))
743 .unwrap_or_default()
744 )),
745 ));
746 }
747 }
748 out.push_str(&format!(
749 "\n{}\n",
750 ink.dim_on(format!(
751 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
752 catalog.zen.models.len()
753 ))
754 ));
755 out
756}
757
758pub fn plans_json() -> String {
762 let catalog = crate::ocuse::zen::catalog();
763 let plans: Vec<serde_json::Value> = catalog
764 .go
765 .plans
766 .iter()
767 .map(|(id, plan)| {
768 serde_json::json!({
769 "id": id,
770 "label": plan.label,
771 "priceUsd": plan.price_usd,
772 "allowanceByModel": &plan.allowance_by_model,
773 })
774 })
775 .collect();
776 serde_json::json!({
777 "extractedAt": catalog.extracted_at,
778 "windows": {
779 "fiveHour": catalog.go.window_share.five_hour,
780 "weekly": catalog.go.window_share.weekly,
781 "monthly": catalog.go.window_share.monthly,
782 },
783 "plans": plans,
784 "models": catalog.go.models.iter().map(rate_json).collect::<Vec<_>>(),
785 "zen": catalog.zen.models.iter().map(rate_json).collect::<Vec<_>>(),
786 })
787 .to_string()
788}
789
790fn rate_json(model: &zen::Model) -> serde_json::Value {
792 let rate = model.variants.first();
793 serde_json::json!({
794 "id": model.id,
795 "name": model.name,
796 "input": rate.map(|r| r.input),
797 "output": rate.map(|r| r.output),
798 "cacheRead": rate.map(|r| r.cache_read),
799 "cacheWrite": rate.map(|r| r.cache_write),
800 })
801}
802
803struct ModelRow {
806 id: String,
807 name: String,
808 input: Option<f64>,
809 output: Option<f64>,
810 cache_read: Option<f64>,
811 limit: Option<f64>,
812}
813
814fn model_rows(plan_id: &str, gated: bool) -> Vec<ModelRow> {
817 let catalog = crate::ocuse::zen::catalog();
818 let plan = catalog.plan(plan_id);
819 let mut rows: Vec<ModelRow> = catalog
820 .go
821 .models
822 .iter()
823 .map(|m| {
824 let rate = m.variants.first();
825 ModelRow {
826 id: m.id.clone(),
827 name: m.name.clone(),
828 input: rate.map(|r| r.input),
829 output: rate.map(|r| r.output),
830 cache_read: rate.map(|r| r.cache_read),
831 limit: plan.and_then(|p| p.allowance_by_model.get(&m.id).copied()),
832 }
833 })
834 .collect();
835 if gated {
836 rows.retain(|r| r.limit.is_some());
837 }
838 rows
839}
840
841pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
845 let ink = Ink::new(colour);
846 let catalog = crate::ocuse::zen::catalog();
847 let label = catalog
848 .plan(plan_id)
849 .map(|p| p.label.clone())
850 .unwrap_or_else(|| plan_id.to_string());
851 let granted = catalog
852 .plan(plan_id)
853 .map(|p| p.allowance_by_model.len())
854 .unwrap_or(0);
855 let rows = model_rows(plan_id, gated);
856 let mut out = format!(
857 "{}{label} models{} {}\n",
858 ink.bold,
859 ink.reset,
860 ink.dim_on(format!(
861 "(plan {plan_id} · {granted}/{} granted)",
862 catalog.go.models.len()
863 )),
864 );
865 out.push_str(&format!(
866 "{}{:<30} {:<28} {:>7} {:>7} {:>7} {:>8} {:>7}{}\n",
867 ink.bold, "MODEL", "ID", "IN", "OUT", "CACHE", "LIMIT", "GRANTED", ink.reset
868 ));
869 let rate_cell = |v: Option<f64>| match v {
870 Some(v) => ink.cyan_on(format!("{:>7}", format!("${v}"))),
871 None => ink.dim_on(format!("{:>7}", "—")),
872 };
873 for r in &rows {
874 let limit = match r.limit {
875 Some(l) => ink.cyan_on(format!("{:>8}", cmduse_core::money(l))),
876 None => ink.dim_on(format!("{:>8}", "—")),
877 };
878 let granted = if r.limit.is_some() { "yes" } else { "no" };
879 out.push_str(&format!(
880 "{:<30} {:<28} {} {} {} {} {:>7}\n",
881 r.name,
882 r.id,
883 rate_cell(r.input),
884 rate_cell(r.output),
885 rate_cell(r.cache_read),
886 limit,
887 granted,
888 ));
889 }
890 out
891}
892
893pub fn models_json(plan_id: &str, gated: bool) -> String {
897 let mut out = String::new();
898 for r in model_rows(plan_id, gated) {
899 out.push_str(
900 &serde_json::json!({
901 "id": r.id,
902 "name": r.name,
903 "input": r.input,
904 "output": r.output,
905 "cacheRead": r.cache_read,
906 "limit": r.limit,
907 "granted": r.limit.is_some(),
908 })
909 .to_string(),
910 );
911 out.push('\n');
912 }
913 out
914}
915
916pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
917 let ink = Ink::new(colour);
918 let Some(model) = report.models.iter().find(|m| m.id == id) else {
919 return format!("{id}: no usage in the period\n");
920 };
921 let limit = model
922 .limit
923 .map(cmduse_core::money)
924 .unwrap_or_else(|| "—".into());
925 let row =
926 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
927 let mut out = format!(
928 "{} {}\n",
929 ink.bold_on(model.id.clone()),
930 ink.dim_on(format!("({})", model.provider)),
931 );
932 out.push_str(&row("limit", ink.cyan_on(limit)));
933 out.push_str(&row(
934 "month",
935 ink.cyan_on(format!(
936 "{} · {} requests",
937 cmduse_core::money(model.month.cost),
938 model.month.requests
939 )),
940 ));
941 out.push_str(&row(
942 "5-hour",
943 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
944 ));
945 out.push_str(&row(
946 "weekly",
947 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
948 ));
949 if report.month.cost > 0.0 {
952 out.push_str(&row(
953 "share",
954 ink.dim_on(format!(
955 "{:.1}% of period spend",
956 model.month.cost / report.month.cost * 100.0
957 )),
958 ));
959 }
960 out
961}
962
963fn totals_json(
968 cost: f64,
969 requests: u64,
970 input: u64,
971 output: u64,
972 cache_read: u64,
973) -> serde_json::Value {
974 serde_json::json!({
975 "requests": requests,
976 "tokensIn": input,
977 "tokensOut": output,
978 "cacheRead": cache_read,
979 "costUsd": cost,
980 })
981}
982
983#[derive(Default, Clone, Copy)]
986struct Bucket {
987 cost: f64,
988 requests: u64,
989 input: u64,
990 output: u64,
991 cache_read: u64,
992}
993
994impl Bucket {
995 fn add(&mut self, row: &crate::ocuse::db::Row) {
996 self.cost += row.cost_usd;
997 self.requests += 1;
998 self.input += row.input;
999 self.output += row.output;
1000 self.cache_read += row.cache_read;
1001 }
1002
1003 fn json(&self) -> serde_json::Value {
1004 totals_json(
1005 self.cost,
1006 self.requests,
1007 self.input,
1008 self.output,
1009 self.cache_read,
1010 )
1011 }
1012}
1013
1014pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
1022 let models: Vec<&ModelUsage> = report
1023 .models
1024 .iter()
1025 .filter(|m| id.is_none_or(|want| m.id == want))
1026 .collect();
1027 match fmt {
1028 Fmt::Json => {
1029 let map: serde_json::Map<String, serde_json::Value> = models
1030 .iter()
1031 .map(|m| {
1032 let mut body = totals_json(
1033 m.month.cost,
1034 m.month.requests,
1035 m.month.input,
1036 m.month.output,
1037 m.month.cache_read,
1038 );
1039 body["limit"] = serde_json::json!(m.limit);
1040 (m.id.clone(), body)
1041 })
1042 .collect();
1043 serde_json::json!({
1044 "scope": "local",
1045 "source": "opencode",
1046 "sinceMs": report.since_ms,
1047 "models": map,
1048 })
1049 .to_string()
1050 }
1051 Fmt::Csv => {
1052 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1053 for m in models {
1054 out.push_str(&format!(
1055 "{},{},{},{},{},{:.2}\n",
1056 crate::report_render::csv_field(&m.id),
1057 m.month.requests,
1058 m.month.input,
1059 m.month.output,
1060 m.month.cache_read,
1061 m.month.cost,
1062 ));
1063 }
1064 out
1065 }
1066 Fmt::Table => String::new(), }
1068}
1069
1070pub fn bucket_text(
1078 rows: &[crate::ocuse::db::Row],
1079 now_ms: i64,
1080 lookback_ms: i64,
1081 size_ms: i64,
1082 tz_secs: i64,
1083 colour: bool,
1084 fmt: Fmt,
1085) -> String {
1086 let shift = tz_secs * 1000;
1088 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
1089 for row in rows {
1090 let bucket = (row.at_ms + shift).div_euclid(size_ms);
1091 buckets.entry(bucket).or_default().add(row);
1092 }
1093 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
1094 let daily = size_ms >= 86_400_000;
1095 let label = if daily { "day" } else { "hour" };
1096 let stamp = |bucket: i64| {
1097 if daily {
1098 date_of(bucket)
1099 } else {
1100 format!(
1101 "{} {:02}h",
1102 date_of(bucket.div_euclid(24)),
1103 bucket.rem_euclid(24)
1104 )
1105 }
1106 };
1107 let window: Vec<(i64, Bucket)> = buckets
1108 .range(start..)
1109 .map(|(bucket, b)| (*bucket, *b))
1110 .collect();
1111
1112 match fmt {
1113 Fmt::Json => {
1114 let mut keyed = serde_json::Map::new();
1115 let mut total = Bucket::default();
1116 for (bucket, b) in &window {
1117 keyed.insert(stamp(*bucket), b.json());
1118 total.add_bucket(b);
1119 }
1120 let key = if daily { "days" } else { "hours" };
1121 serde_json::json!({
1122 "scope": "local",
1123 "source": "opencode",
1124 "total": total.json(),
1125 key: keyed,
1126 })
1127 .to_string()
1128 }
1129 Fmt::Csv => {
1130 let head = if daily { "day" } else { "hour" };
1131 let mut out = format!("{head},requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1132 let mut total = Bucket::default();
1133 for (bucket, b) in &window {
1134 out.push_str(&format!(
1135 "{},{},{},{},{},{:.2}\n",
1136 crate::report_render::csv_field(&stamp(*bucket)),
1137 b.requests,
1138 b.input,
1139 b.output,
1140 b.cache_read,
1141 b.cost,
1142 ));
1143 total.add_bucket(b);
1144 }
1145 out.push_str(&format!(
1146 "total,{},{},{},{},{:.2}\n",
1147 total.requests, total.input, total.output, total.cache_read, total.cost,
1148 ));
1149 out
1150 }
1151 Fmt::Table => {
1152 let ink = Ink::new(colour);
1153 let mut out = format!(
1154 "{}{label:<12} spend requests tokens{}\n",
1155 ink.bold, ink.reset
1156 );
1157 for (bucket, b) in &window {
1158 out.push_str(&format!(
1159 "{:<12} {} {} {}\n",
1160 stamp(*bucket),
1161 ink.cyan_on(format!("{:>9}", cmduse_core::money(b.cost))),
1162 ink.cyan_on(format!("{:>12}", cmduse_core::compact(b.requests))),
1163 ink.cyan_on(format!(
1164 "{:>9}",
1165 cmduse_core::compact(b.input + b.output + b.cache_read)
1166 )),
1167 ));
1168 }
1169 if out.lines().count() == 1 {
1170 out.push_str(&format!(
1171 "{}\n",
1172 ink.dim_on("(no OpenCode usage in the window)".into())
1173 ));
1174 }
1175 out
1176 }
1177 }
1178}
1179
1180type SessionTotals<'a> = (
1183 f64,
1184 u64,
1185 u64,
1186 u64,
1187 u64,
1188 i64,
1189 std::collections::BTreeSet<&'a str>,
1190);
1191
1192pub fn session_text(
1197 rows: &[crate::ocuse::db::Row],
1198 limit: usize,
1199 colour: bool,
1200 fmt: Fmt,
1201) -> String {
1202 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1203 for row in rows {
1204 let entry = by_session
1205 .entry(row.session.as_str())
1206 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1207 entry.0 += row.cost_usd;
1208 entry.1 += 1;
1209 entry.2 += row.input;
1210 entry.3 += row.output;
1211 entry.4 += row.cache_read;
1212 entry.5 = entry.5.max(row.at_ms);
1213 entry.6.insert(row.model.as_str());
1214 }
1215 let mut sessions: Vec<_> = by_session.into_iter().collect();
1216 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1217 sessions.truncate(limit);
1218
1219 match fmt {
1220 Fmt::Json => {
1221 let mut keyed = serde_json::Map::new();
1222 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1223 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1224 body["lastMs"] = serde_json::json!(*last);
1225 body["models"] = serde_json::json!(models.len());
1226 keyed.insert((*id).to_string(), body);
1227 }
1228 serde_json::json!({
1229 "scope": "local",
1230 "source": "opencode",
1231 "sessions": keyed,
1232 })
1233 .to_string()
1234 }
1235 Fmt::Csv => {
1236 let mut out =
1237 String::from("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1238 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1239 out.push_str(&format!(
1240 "{},{},{},{},{},{:.2}\n",
1241 crate::report_render::csv_field(id),
1242 requests,
1243 input,
1244 output,
1245 cache_read,
1246 cost,
1247 ));
1248 }
1249 out
1250 }
1251 Fmt::Table => {
1252 let ink = Ink::new(colour);
1253 let mut out = format!(
1254 "{}session last models spend req{}\n",
1255 ink.bold, ink.reset
1256 );
1257 for (id, (cost, requests, _, _, _, last, models)) in sessions {
1258 let short = id.rsplit('_').next().unwrap_or(id);
1259 out.push_str(&format!(
1260 "{:<32} {:<11} {:>6} {} {:>6}\n",
1261 short,
1262 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1263 models.len(),
1264 ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
1265 requests,
1266 ));
1267 }
1268 if out.lines().count() == 1 {
1269 out.push_str(&format!(
1270 "{}\n",
1271 ink.dim_on("(no sessions in the window)".into())
1272 ));
1273 }
1274 out
1275 }
1276 }
1277}
1278
1279impl Bucket {
1280 fn add_bucket(&mut self, other: &Bucket) {
1282 self.cost += other.cost;
1283 self.requests += other.requests;
1284 self.input += other.input;
1285 self.output += other.output;
1286 self.cache_read += other.cache_read;
1287 }
1288}
1289
1290pub fn date_of(day: i64) -> String {
1293 let z = day + 719_468;
1294 let era = z.div_euclid(146_097);
1295 let doe = z.rem_euclid(146_097);
1296 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1297 let y = yoe + era * 400;
1298 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1299 let mp = (5 * doy + 2) / 153;
1300 let d = doy - (153 * mp + 2) / 5 + 1;
1301 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1302 let year = if m <= 2 { y + 1 } else { y };
1303 format!("{year:04}-{m:02}-{d:02}")
1304}
1305
1306#[cfg(test)]
1307mod tests {
1308 use super::*;
1309 use crate::ocuse::db::Row;
1310
1311 fn report() -> Report {
1312 let now = 1_800_000_000_000;
1313 let rows = vec![Row {
1314 at_ms: now - 60_000,
1315 provider: "opencode-go".into(),
1316 model: "glm-5.3-flash".into(),
1317 cost_usd: 30.0,
1318 ..Default::default()
1319 }];
1320 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None)
1321 }
1322
1323 #[test]
1324 fn text_shows_the_model_against_its_limit() {
1325 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1326 assert!(text.contains("glm-5.3-flash"), "{text}");
1327 assert!(text.contains("$60"), "the model's monthly limit: {text}");
1328 assert!(
1329 text.contains("50.0%"),
1330 "the model table keeps its own USE: {text}"
1331 );
1332 }
1333
1334 fn meter(status: &str, percent: f64, resets_at: &str) -> crate::ocuse::api::Meter {
1335 crate::ocuse::api::Meter {
1336 status: status.into(),
1337 percent,
1338 resets_at: Some(resets_at.into()),
1339 }
1340 }
1341
1342 fn report_with_account() -> Report {
1349 let mut report = report();
1350 report.account = Some(crate::ocuse::api::Usage {
1351 rolling: meter("ok", 5.0, "2027-01-15T12:31:00.000Z"),
1352 weekly: meter("ok", 59.0, "2027-01-18T00:00:00.000Z"),
1353 monthly: meter("ok", 29.0, "2027-02-15T08:00:00.000Z"),
1354 });
1355 report.period_end_ms = Some(1_802_678_400_000); report.since_ms = crate::ocuse::window::prev_month(1_802_678_400_000);
1357 report
1358 }
1359
1360 #[test]
1361 fn the_window_rows_come_from_the_account_when_it_answered() {
1362 let text = render_text(
1363 &report_with_account(),
1364 "/tmp/opencode.db",
1365 10,
1366 false,
1367 DEFAULT_BAR_WIDTH,
1368 );
1369 assert!(text.contains("5.0%"), "{text}");
1371 assert!(text.contains("59.0%"), "{text}");
1372 assert!(text.contains("29.0%"), "{text}");
1373 assert!(text.contains("account meters"), "{text}");
1374 assert!(
1375 text.contains("resets 4h 31m"),
1376 "the 5h block's own end: {text}"
1377 );
1378 assert!(text.contains("$30.00 (5.0% of limit)"), "{text}");
1383 assert!(text.contains("$30.00 (29.0% of limit)"), "{text}");
1384 assert!(text.contains("of limit"), "{text}");
1385 assert!(!text.contains("$30.00 /"), "no money pair: {text}");
1386 assert!(text.contains("elapsed"), "{text}");
1390 assert!(!text.contains("by now"), "no pool, no pro-rata: {text}");
1391 assert!(text.contains("not the account's windows"), "{text}");
1393 }
1394
1395 #[test]
1396 fn without_an_account_the_rows_say_they_are_local() {
1397 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1398 assert!(text.contains("estimated locally"), "{text}");
1399 assert!(text.contains("no pool"), "no aggregate ceiling: {text}");
1400 assert!(!text.contains("of limit"), "{text}");
1402 assert!(!text.contains("resets"), "{text}");
1403 assert!(!text.contains("elapsed"), "{text}");
1404 assert!(!text.contains("account meters"), "{text}");
1406 assert!(!text.contains("not the account's windows"), "{text}");
1407 }
1408
1409 #[test]
1410 fn an_over_limit_meter_says_so_before_its_countdown() {
1411 let mut report = report_with_account();
1412 if let Some(account) = &mut report.account {
1413 account.rolling = meter("rate_limited", 100.0, "2027-01-15T12:31:00.000Z");
1414 }
1415 let text = render_text(&report, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1416 assert!(text.contains("LIMIT EXCEEDED"), "{text}");
1417 }
1418
1419 #[test]
1420 fn json_carries_the_account_meters_and_null_without_them() {
1421 let parsed: serde_json::Value =
1422 serde_json::from_str(&render_json(&report_with_account())).unwrap();
1423 assert_eq!(parsed["account"]["rolling"]["percent"], 5.0);
1424 assert_eq!(parsed["account"]["weekly"]["percent"], 59.0);
1425 assert_eq!(parsed["account"]["monthly"]["status"], "ok");
1426 let monthly = cmduse_core::dates::parse_iso_utc("2027-02-15T08:00:00.000Z").unwrap();
1429 assert_eq!(parsed["account"]["monthly"]["resetsAt"], monthly as i64);
1430
1431 let parsed: serde_json::Value = serde_json::from_str(&render_json(&report())).unwrap();
1434 assert_eq!(parsed["account"], serde_json::Value::Null);
1435 }
1436
1437 #[test]
1438 fn statusline_prefers_the_accounts_share_over_the_local_estimate() {
1439 let tpl = "{five_hour_pct} {weekly_pct} {month_pct} {five_hour}";
1440 let account = statusline_template(
1441 &report_with_account(),
1442 "go",
1443 tpl,
1444 false,
1445 false,
1446 DEFAULT_BAR_WIDTH,
1447 );
1448 assert!(account.starts_with("5.0% 59.0% 29.0%"), "{account}");
1449 assert!(account.contains("$30.00"), "{account}");
1451
1452 let local = statusline_template(&report(), "go", tpl, false, false, DEFAULT_BAR_WIDTH);
1455 assert!(!local.starts_with("5.0%"), "{local}");
1456 }
1457
1458 #[test]
1459 fn json_and_statusline_carry_the_totals() {
1460 let json = render_json(&report());
1461 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1462 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
1463 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
1464 let line = statusline_template(&report(), "go", "{plan} {models}", false, false, 20);
1465 assert_eq!(line, "go glm-5.3-flash");
1466 }
1467
1468 #[test]
1469 fn json_carries_the_period_bounds() {
1470 let mut r = report();
1473 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1474 let parsed: serde_json::Value = serde_json::from_str(&render_json(&r)).unwrap();
1475 assert_eq!(parsed["periodStart"], r.since_ms);
1476 assert_eq!(parsed["periodStartAt"], r.since_ms);
1477 assert_eq!(parsed["periodEndAt"], r.period_end_ms.unwrap());
1478
1479 let mut all = report();
1482 all.period_end_ms = None;
1483 let parsed: serde_json::Value = serde_json::from_str(&render_json(&all)).unwrap();
1484 assert_eq!(parsed["periodEndAt"], serde_json::Value::Null);
1485 assert_eq!(parsed["periodStart"], all.since_ms);
1486 assert!(parsed.get("periodInferred").is_some());
1487 }
1488
1489 #[test]
1495 fn every_renderer_respects_the_colour_flag() {
1496 let mut report = report();
1497 report.period_end_ms = Some(crate::ocuse::window::next_month(report.since_ms));
1500 let rows = vec![Row {
1501 at_ms: report.now_ms - 60_000,
1502 session: "ses_fixture".into(),
1503 provider: "opencode-go".into(),
1504 model: "glm-5.3-flash".into(),
1505 cost_usd: 30.0,
1506 ..Default::default()
1507 }];
1508 let now = report.now_ms;
1509
1510 let cases: Vec<(&str, String, String)> = vec![
1511 (
1512 "render_text",
1513 render_text(&report, "/tmp/oc.db", 10, false, DEFAULT_BAR_WIDTH),
1514 render_text(&report, "/tmp/oc.db", 10, true, DEFAULT_BAR_WIDTH),
1515 ),
1516 (
1517 "statusline_template",
1518 statusline_template(
1519 &report,
1520 "go",
1521 "{period} {month} {month_pct}",
1522 false,
1523 false,
1524 20,
1525 ),
1526 statusline_template(
1527 &report,
1528 "go",
1529 "{period} {month} {month_pct}",
1530 true,
1531 false,
1532 20,
1533 ),
1534 ),
1535 (
1536 "compact_line",
1537 compact_line(&report, &[], false),
1538 compact_line(&report, &[], true),
1539 ),
1540 ("plans_text", plans_text(false), plans_text(true)),
1541 (
1542 "model_text",
1543 model_text(&report, "glm-5.3-flash", false),
1544 model_text(&report, "glm-5.3-flash", true),
1545 ),
1546 (
1547 "bucket_text/day",
1548 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Table),
1549 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Table),
1550 ),
1551 (
1552 "bucket_text/hour",
1553 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Table),
1554 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Table),
1555 ),
1556 (
1557 "session_text",
1558 session_text(&rows, 20, false, Fmt::Table),
1559 session_text(&rows, 20, true, Fmt::Table),
1560 ),
1561 (
1562 "models_text",
1563 models_text("go", false, false),
1564 models_text("go", false, true),
1565 ),
1566 ];
1567
1568 for (name, plain, coloured) in cases {
1569 assert!(
1570 !plain.contains('\x1b'),
1571 "{name} leaked escapes when off: {plain}"
1572 );
1573 assert!(
1574 coloured.contains('\x1b'),
1575 "{name} rendered nothing coloured when on: {coloured}"
1576 );
1577 }
1578
1579 let flagged: Vec<(&str, String, String)> = vec![
1582 (
1583 "bucket_text/day csv",
1584 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv),
1585 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Csv),
1586 ),
1587 (
1588 "bucket_text/hour json",
1589 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Json),
1590 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Json),
1591 ),
1592 (
1593 "session_text csv",
1594 session_text(&rows, 20, false, Fmt::Csv),
1595 session_text(&rows, 20, true, Fmt::Csv),
1596 ),
1597 (
1598 "session_text json",
1599 session_text(&rows, 20, false, Fmt::Json),
1600 session_text(&rows, 20, true, Fmt::Json),
1601 ),
1602 ];
1603 for (name, off, on) in flagged {
1604 assert!(
1605 !off.contains('\x1b'),
1606 "{name} leaked escapes with colour off: {off}"
1607 );
1608 assert!(
1609 !on.contains('\x1b'),
1610 "{name} must stay uncoloured with colour on: {on}"
1611 );
1612 }
1613 for (name, out) in [
1614 ("model_report json", model_report(&report, None, Fmt::Json)),
1615 ("model_report csv", model_report(&report, None, Fmt::Csv)),
1616 ("plans_json", plans_json()),
1617 ("statusline_json", statusline_json(&report, "go")),
1618 ("models_json", models_json("go", false)),
1619 ] {
1620 assert!(
1621 !out.contains('\x1b'),
1622 "{name} must never be coloured: {out}"
1623 );
1624 }
1625 }
1626
1627 #[test]
1628 fn bucket_reports_carry_the_documented_columns() {
1629 let now = 1_800_000_000_000;
1630 let rows = vec![Row {
1631 at_ms: now - 60_000,
1632 provider: "opencode-go".into(),
1633 model: "glm-5.3-flash".into(),
1634 cost_usd: 1.5,
1635 input: 10,
1636 output: 20,
1637 cache_read: 30,
1638 cache_write: 40,
1639 ..Default::default()
1640 }];
1641 let csv = bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv);
1642 assert!(
1643 csv.starts_with("day,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1644 "{csv}"
1645 );
1646 assert!(!csv.contains("cache_write"), "{csv}");
1648 assert!(csv.contains(",10,20,30,1.50\n"), "{csv}");
1649 assert!(csv.trim_end().ends_with("total,1,10,20,30,1.50"), "{csv}");
1650
1651 let json: serde_json::Value = serde_json::from_str(&bucket_text(
1652 &rows,
1653 now,
1654 7 * 86_400_000,
1655 86_400_000,
1656 0,
1657 false,
1658 Fmt::Json,
1659 ))
1660 .unwrap();
1661 assert_eq!(json["total"]["requests"], 1);
1662 assert_eq!(json["total"]["cacheRead"], 30);
1663 assert_eq!(json["days"].as_object().unwrap().len(), 1);
1664 }
1665
1666 #[test]
1667 fn session_report_json_and_csv_agree_with_the_store() {
1668 let now = 1_800_000_000_000;
1669 let rows = vec![
1670 Row {
1671 at_ms: now - 60_000,
1672 session: "ses_first".into(),
1673 model: "glm-5.3-flash".into(),
1674 cost_usd: 2.0,
1675 input: 5,
1676 output: 6,
1677 cache_read: 7,
1678 ..Default::default()
1679 },
1680 Row {
1681 at_ms: now - 30_000,
1682 session: "ses_second".into(),
1683 model: "kimi-k3".into(),
1684 cost_usd: 1.0,
1685 ..Default::default()
1686 },
1687 ];
1688 let json: serde_json::Value =
1689 serde_json::from_str(&session_text(&rows, 20, false, Fmt::Json)).unwrap();
1690 assert_eq!(json["sessions"]["ses_first"]["tokensIn"], 5);
1691 assert_eq!(json["sessions"]["ses_first"]["models"], 1);
1692 assert_eq!(json["sessions"]["ses_second"]["costUsd"], 1.0);
1693 let csv = session_text(&rows, 20, false, Fmt::Csv);
1694 assert!(
1695 csv.starts_with("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1696 "{csv}"
1697 );
1698 assert!(csv.contains("ses_second,1,0,0,0,1.00\n"), "{csv}");
1699 }
1700
1701 #[test]
1702 fn model_report_projects_the_report_models() {
1703 let json: serde_json::Value =
1704 serde_json::from_str(&model_report(&report(), None, Fmt::Json)).unwrap();
1705 assert_eq!(json["source"], "opencode");
1706 assert_eq!(json["models"]["glm-5.3-flash"]["costUsd"], 30.0);
1707 assert_eq!(json["models"]["glm-5.3-flash"]["limit"], 60.0);
1708 let csv = model_report(&report(), None, Fmt::Csv);
1709 assert!(csv.contains("glm-5.3-flash,1,0,0,0,30.00\n"), "{csv}");
1710 assert_eq!(
1712 model_report(&report(), Some("nope"), Fmt::Csv),
1713 "model,requests,tokens_in,tokens_out,cache_read,cost_usd\n"
1714 );
1715 }
1716
1717 #[test]
1718 fn catalogue_json_carries_both_plans_and_windows() {
1719 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1720 assert_eq!(json["windows"]["fiveHour"], 0.2);
1721 assert_eq!(json["windows"]["monthly"], 1.0);
1722 assert_eq!(json["plans"][0]["id"], "go");
1723 assert_eq!(json["plans"][0]["allowanceByModel"]["glm-5.3-flash"], 60.0);
1724 assert_eq!(json["plans"][1]["priceUsd"], 40.0);
1725 assert!(!json["models"].as_array().unwrap().is_empty());
1726 assert!(!json["zen"].as_array().unwrap().is_empty());
1727 }
1728
1729 #[test]
1730 fn models_lists_the_plan_and_marks_granted() {
1731 let all = models_text("go", false, false);
1733 assert!(all.contains("glm-5.3-flash"), "{all}");
1734 assert!(all.contains("GRANTED"), "{all}");
1735 let gated = models_text("go", true, false);
1737 for line in gated.lines().skip(2) {
1738 assert!(
1739 line.trim_end().ends_with("yes"),
1740 "ungranted row kept: {line}"
1741 );
1742 }
1743 let every: Vec<serde_json::Value> = models_json("go", false)
1745 .lines()
1746 .map(|l| serde_json::from_str(l).unwrap())
1747 .collect();
1748 assert!(every.iter().any(|m| m["granted"] == false));
1749 assert!(every
1750 .iter()
1751 .any(|m| m["id"] == "glm-5.3-flash" && m["limit"] == 60.0));
1752 let only: Vec<serde_json::Value> = models_json("go", true)
1753 .lines()
1754 .map(|l| serde_json::from_str(l).unwrap())
1755 .collect();
1756 assert!(!only.is_empty());
1757 assert!(only.iter().all(|m| m["granted"] == true));
1758 }
1759
1760 #[test]
1761 fn statusline_json_mirrors_the_template_numbers() {
1762 let json: serde_json::Value =
1763 serde_json::from_str(&statusline_json(&report(), "go")).unwrap();
1764 assert_eq!(json["plan"], "go");
1765 assert_eq!(json["period"], "calendar month");
1766 assert_eq!(json["monthSpent"], 30.0);
1767 assert_eq!(json["monthCap"], serde_json::Value::Null);
1770 assert_eq!(json["fiveHourCap"], serde_json::Value::Null);
1771 assert_eq!(json["weeklyCap"], serde_json::Value::Null);
1772 assert_eq!(json["model"], "glm-5.3-flash");
1773 }
1774
1775 #[test]
1776 fn plain_frames_carry_no_escapes() {
1777 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1778 assert!(
1779 !text.contains('\x1b'),
1780 "piped output must stay plain: {text}"
1781 );
1782 assert!(text.contains("$30.00"), "{text}");
1785 }
1786
1787 #[test]
1791 fn monthly_row_carries_the_real_period_metrics() {
1792 let mut r = report();
1793 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1794 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1795
1796 let monthly = text
1797 .lines()
1798 .find(|line| line.trim_start().starts_with("Monthly"))
1799 .expect("the monthly row");
1800 assert!(monthly.contains("resets"), "{monthly}");
1801 assert!(
1802 monthly.contains("window") && monthly.contains("% elapsed"),
1803 "the real period gives an elapsed share: {monthly}"
1804 );
1805
1806 for label in ["5-hour", "Weekly"] {
1807 let row = text
1808 .lines()
1809 .find(|line| line.trim_start().starts_with(label))
1810 .unwrap_or_else(|| panic!("the {label} row"));
1811 assert!(!row.contains("elapsed"), "{label} has no boundary: {row}");
1812 assert!(!row.contains("resets"), "{label} has no boundary: {row}");
1813 }
1814 }
1815
1816 #[test]
1819 fn period_line_splits_tokens_and_shows_the_end_date() {
1820 let mut r = report();
1821 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1822 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1823 let header = text
1824 .lines()
1825 .find(|line| line.starts_with("period:"))
1826 .expect("the period line");
1827 assert!(
1828 header.contains(" in / "),
1829 "token split like cmduse: {header}"
1830 );
1831 assert!(header.contains(" out tokens"), "{header}");
1832 assert!(
1833 header.contains(&date_of(r.period_end_ms.unwrap() / 86_400_000)),
1834 "the resolved end date: {header}"
1835 );
1836
1837 let no_end = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1839 let header = no_end
1840 .lines()
1841 .find(|line| line.starts_with("period:"))
1842 .expect("the period line");
1843 assert!(!header.contains("ends"), "{header}");
1844 }
1845
1846 #[test]
1847 fn an_over_cap_window_is_flagged_in_both_modes() {
1848 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1850 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
1851 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1852 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
1853 assert!(
1854 coloured.contains(crate::render::RED),
1855 "the flag and the gauge are red: {coloured}"
1856 );
1857 }
1858
1859 #[test]
1860 fn a_model_over_any_of_its_own_windows_is_flagged() {
1861 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1864 let row = plain
1865 .lines()
1866 .find(|line| line.starts_with("glm-5.3-flash"))
1867 .expect("the model row");
1868 assert!(row.contains("LIMIT EXCEEDED"), "{row}");
1869 assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
1871 }
1872
1873 #[test]
1874 fn a_model_within_its_limits_is_not_flagged() {
1875 let now = 1_800_000_000_000;
1876 let rows = vec![Row {
1878 at_ms: now - 60_000,
1879 provider: "opencode-go".into(),
1880 model: "glm-5.3-flash".into(),
1881 cost_usd: 5.0,
1882 ..Default::default()
1883 }];
1884 let report =
1885 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None);
1886 for colour in [false, true] {
1887 let text = render_text(&report, "/tmp/opencode.db", 10, colour, DEFAULT_BAR_WIDTH);
1888 assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
1889 }
1890 }
1891
1892 #[test]
1893 fn the_model_detail_spells_out_the_share() {
1894 let detail = model_text(&report(), "glm-5.3-flash", false);
1895 assert!(detail.contains("100.0% of period spend"), "{detail}");
1896 let empty = crate::ocuse::window::build(
1898 &[],
1899 &zen::catalog(),
1900 zen::DEFAULT_PLAN,
1901 1_800_000_000_000,
1902 None,
1903 );
1904 assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
1905 }
1906
1907 #[test]
1908 fn colour_frames_get_the_palette_and_a_gauge() {
1909 let text = render_text(
1914 &report_with_account(),
1915 "/tmp/opencode.db",
1916 10,
1917 true,
1918 DEFAULT_BAR_WIDTH,
1919 );
1920 assert!(text.contains(crate::render::BOLD), "{text}");
1921 assert!(text.contains(crate::render::CYAN), "{text}");
1922 assert!(
1923 text.contains(crate::render::GREEN),
1924 "the 50% USE cell is green: {text}"
1925 );
1926 assert!(
1927 text.contains('━') && text.contains('╱'),
1928 "the account share's gauge bar: {text}"
1929 );
1930 assert!(text.contains("resets in"), "{text}");
1931 }
1932
1933 #[test]
1934 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
1935 let frame = watch_frame(
1936 &report(),
1937 "/tmp/opencode.db",
1938 10,
1939 true,
1940 &[0.0, 1.5, 0.0],
1941 10,
1942 DEFAULT_BAR_WIDTH,
1943 );
1944 assert!(
1945 !frame.ends_with('\n'),
1946 "the caller's status line owns the last row"
1947 );
1948 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
1949 assert!(
1950 frame.contains('█'),
1951 "a nonzero delta shows as a tall bar: {frame}"
1952 );
1953 }
1954
1955 #[test]
1956 fn idle_history_omits_the_sparkline() {
1957 let all_idle = watch_frame(
1958 &report(),
1959 "/tmp/opencode.db",
1960 10,
1961 true,
1962 &[0.0, 0.0],
1963 10,
1964 DEFAULT_BAR_WIDTH,
1965 );
1966 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
1967 let first_frame = watch_frame(
1968 &report(),
1969 "/tmp/opencode.db",
1970 10,
1971 true,
1972 &[],
1973 10,
1974 DEFAULT_BAR_WIDTH,
1975 );
1976 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
1977 }
1978
1979 #[test]
1983 fn statusline_resolves_every_placeholder() {
1984 let r = report();
1985 let line = |tpl: &str| statusline_template(&r, "go", tpl, false, false, 20);
1986
1987 assert_eq!(line("{plan}"), "go");
1989 assert_eq!(line("{period}"), "calendar month");
1990 assert_eq!(line("{month}"), "$30.00");
1992 assert_eq!(line("{five_hour}"), "$30.00");
1993 assert_eq!(line("{weekly}"), "$30.00");
1994 assert_eq!(line("{models}"), "glm-5.3-flash");
1996 assert_eq!(line("{month_pct}"), "—");
2000 assert_eq!(line("{month_bar}"), "");
2001 assert_eq!(line("{five_hour_pct}"), "—");
2002 assert_eq!(line("{weekly_pct}"), "—");
2003
2004 let default = line("{period} {month} · 5h {five_hour} · wk {weekly}");
2006 assert_eq!(default, "calendar month $30.00 · 5h $30.00 · wk $30.00");
2007
2008 assert_eq!(line("{plan} {bogus} | tail"), "go | tail");
2010 assert_eq!(line("{plan} {oops"), "go {oops");
2012 assert!(!line("{plan} {month} {month_pct} {month_bar}").contains('\x1b'));
2014 }
2015
2016 #[test]
2019 fn statusline_without_caps_degrades_gracefully() {
2020 let empty = crate::ocuse::window::build(
2021 &[],
2022 &zen::catalog(),
2023 zen::DEFAULT_PLAN,
2024 1_800_000_000_000,
2025 None,
2026 );
2027 let line = statusline_template(&empty, "go", "{month_pct} {models}", false, false, 20);
2028 assert_eq!(line, "— —");
2029 }
2030
2031 #[test]
2033 fn compact_line_is_one_line_with_inline_sparkline() {
2034 let plain = compact_line(&report(), &[], true);
2035 assert_eq!(plain.lines().count(), 1, "{plain}");
2036 assert!(!plain.ends_with('\n'), "{plain}");
2037 let bursty = compact_line(&report(), &[0.0, 1.5], true);
2038 assert!(bursty.contains('█'), "nonzero delta shows: {bursty}");
2039 assert!(!compact_line(&report(), &[], false).contains('\x1b'));
2041 assert!(compact_line(&report(), &[], true).contains(crate::render::BOLD));
2042 }
2043}