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)]
107fn window_line(
108 ink: &Ink,
109 label: &str,
110 used: &Totals,
111 cap: Option<f64>,
112 reset_ms: Option<i64>,
113 dur_secs: Option<u64>,
117 pro_rata: bool,
121 now_ms: i64,
122) -> String {
123 let reset = reset_ms
124 .map(|at| duration(((at - now_ms) / 1000).max(0) as u64))
125 .unwrap_or_default();
126 let reset_text = if reset.is_empty() {
127 String::new()
128 } else if ink.colour {
129 format!(" · resets in {reset}")
130 } else {
131 format!(" · resets {reset}")
132 };
133 let now_secs = (now_ms / 1000).max(0) as u64;
138 let reset_at = reset_ms.map(|ms| ms as f64);
139 let elapsed = dur_secs.and_then(|d| cmduse_core::elapsed_pct(reset_at, d, Some(now_secs)));
140 let pace = dur_secs
141 .and_then(|d| {
142 cmduse_core::pace_eta(reset_at, d, used.cost, cap.unwrap_or(0.0), now_secs)
143 .map(|secs| duration(secs as u64))
144 })
145 .map(|eta| format!(" · {}on pace to hit cap in {eta}{}", ink.yellow, ink.reset))
146 .unwrap_or_default();
147 let thru = elapsed
148 .map(|p| format!(" · {}window {p:.1}% elapsed{}", ink.dim, ink.reset))
149 .unwrap_or_default();
150 let by_now = match (pro_rata, elapsed) {
151 (true, Some(p)) => format!(
152 " · {}{} by now{}",
153 ink.dim,
154 money(cap.unwrap_or(0.0) * p / 100.0),
155 ink.reset
156 ),
157 _ => String::new(),
158 };
159 match cap {
160 Some(cap) if cap > 0.0 => {
161 let flag = if used.cost > cap {
163 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
164 } else {
165 String::new()
166 };
167 if ink.colour {
168 format!(
169 " {}{label:<8}{} {} {}{} / {}{}{}{}{}{}{}\n",
170 ink.bold,
171 ink.reset,
172 ink.bar(used.cost, cap),
173 ink.dim,
174 money(used.cost),
175 money(cap),
176 reset_text,
177 flag,
178 pace,
179 thru,
180 by_now,
181 ink.reset,
182 )
183 } else {
184 format!(
187 " {label:<8} {} / {} ({}){reset_text}{flag}{pace}{thru}{by_now}\n",
188 money(used.cost),
189 money(cap),
190 pct(used.cost, cap),
191 )
192 }
193 }
194 _ => format!(
197 " {label:<8} {}\n",
198 ink.dim_on(format!("{}{}", money(used.cost), reset_text))
199 ),
200 }
201}
202
203fn aggregate_cap(report: &Report, share: f64) -> Option<f64> {
206 let total: f64 = report
207 .models
208 .iter()
209 .filter(|m| m.month.cost > 0.0)
210 .filter_map(|m| m.limit)
211 .map(|limit| limit * share)
212 .sum();
213 (total > 0.0).then_some(total)
214}
215
216fn exceeded(model: &ModelUsage) -> bool {
220 crate::ocuse::window::over_window(model).is_some()
221}
222
223fn model_row(ink: &Ink, model: &ModelUsage, total_cost: f64) -> String {
224 let limit = match model.limit {
225 Some(limit) => money(limit),
226 None => ink.dim_on("—".into()),
227 };
228 let use_fraction = model.month_use();
229 let use_text = use_fraction
230 .map(|fraction| pct(fraction * 100.0, 100.0))
231 .unwrap_or_else(|| "—".into());
232 let spent = ink.cyan_on(format!("{:>10}", money(model.month.cost)));
234 let use_cell = match use_fraction {
235 Some(fraction) => {
236 let tone = ink.for_pct(fraction * 100.0);
237 if tone.is_empty() {
238 format!("{use_text:>7}")
239 } else {
240 format!("{tone}{use_text:>7}{}", ink.reset)
241 }
242 }
243 None => ink.dim_on(format!("{use_text:>7}")),
244 };
245 let flag = if exceeded(model) {
248 format!(" {}{}LIMIT EXCEEDED{}", ink.red, ink.bold, ink.reset)
249 } else {
250 String::new()
251 };
252 let share = if total_cost > 0.0 {
253 ink.dim_on(format!(" {:>5.1}%", model.month.cost / total_cost * 100.0))
254 } else {
255 String::new()
256 };
257 format!(
258 "{:<28}{flag} {:>7} {spent} {use_cell} {} {} {:>7}{share}\n",
259 model.id,
260 limit,
261 window_cell(ink, model, model.five_hour.cost, 0.20),
263 window_cell(ink, model, model.weekly.cost, 0.50),
264 compact(model.month.requests),
265 )
266}
267
268fn window_cell(ink: &Ink, model: &ModelUsage, cost: f64, share: f64) -> String {
271 let text = format!("{:>8}", money(cost));
272 match model.limit.filter(|limit| *limit > 0.0) {
273 Some(limit) => {
274 let tone = ink.for_pct(cost / (limit * share) * 100.0);
275 if tone.is_empty() {
276 text
277 } else {
278 format!("{tone}{text}{}", ink.reset)
279 }
280 }
281 None => ink.dim_on(text),
282 }
283}
284
285pub fn render_text(
287 report: &Report,
288 db_path: &str,
289 rows: usize,
290 colour: bool,
291 bar_width: usize,
292) -> String {
293 let ink = Ink::new(colour).width(bar_width);
294 let c = |text: String| ink.cyan_on(text);
295 let shares = &zen::catalog().go.window_share;
296 let mut out = String::new();
297 out.push_str(&format!(
298 "{}OpenCode usage{} — go + zen, from {}{}{}\n",
299 ink.bold,
300 ink.reset,
301 ink.dim,
302 shorten(db_path),
303 ink.reset,
304 ));
305 let end = report
309 .period_end_ms
310 .map(|ms| format!(" · ends {}", date_of(ms / 86_400_000)))
311 .unwrap_or_default();
312 out.push_str(&format!(
313 "period: {}{end} · {} requests · {} · {} in / {} out tokens\n",
314 report.period_label,
315 c(compact(report.month.requests)),
316 c(money(report.month.cost)),
317 c(compact(report.month.input)),
318 c(compact(report.month.output)),
319 ));
320
321 if report.month.requests > 0 {
322 let five_reset = report.now_ms + crate::ocuse::window::FIVE_HOUR_SECS * 1000;
323 let week_reset = report.now_ms + crate::ocuse::window::WEEKLY_SECS * 1000;
324 let monthly_dur = report
328 .period_end_ms
329 .map(|end| ((end - report.since_ms) / 1000).max(0) as u64);
330 out.push_str(&format!(
331 "\n{}GO{} {}\n",
332 ink.bold,
333 ink.reset,
334 ink.dim_on("(limits are per model: 5h = 20%, weekly = 50%, monthly = 100%)".into()),
335 ));
336 out.push_str(&window_line(
337 &ink,
338 "5-hour",
339 &report.five_hour,
340 aggregate_cap(report, shares.five_hour),
341 Some(five_reset),
342 None,
343 false,
344 report.now_ms,
345 ));
346 out.push_str(&window_line(
347 &ink,
348 "Weekly",
349 &report.weekly,
350 aggregate_cap(report, shares.weekly),
351 Some(week_reset),
352 None,
353 false,
354 report.now_ms,
355 ));
356 out.push_str(&window_line(
357 &ink,
358 "Monthly",
359 &report.month,
360 aggregate_cap(report, shares.monthly),
361 report.period_end_ms,
362 monthly_dur,
363 true,
364 report.now_ms,
365 ));
366 }
367
368 out.push_str(&format!(
369 "\n{}MODEL LIMIT SPENT USE 5H WK REQ{}\n",
370 ink.bold, ink.reset
371 ));
372 let total_cost = report.month.cost;
373 for model in report.models.iter().take(rows) {
374 out.push_str(&model_row(&ink, model, total_cost));
375 }
376 if report.models.len() > rows {
377 out.push_str(&format!(
378 "{}\n",
379 ink.dim_on(format!("… {} more", report.models.len() - rows))
380 ));
381 }
382 out.push_str(&format!(
383 "\n{}\n",
384 ink.dim_on(
385 "ZEN is pay-as-you-go: the same rows carry its spend, but the docs give no limit."
386 .into()
387 )
388 ));
389 out
390}
391
392pub fn watch_frame(
397 report: &Report,
398 db_path: &str,
399 rows: usize,
400 colour: bool,
401 history: &[f64],
402 interval_secs: u64,
403 bar_width: usize,
404) -> String {
405 let ink = Ink::new(colour);
406 let mut out = render_text(report, db_path, rows, colour, bar_width);
407 if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
408 out.push_str(&format!(
409 "{}spend bursts ({}s samples){} {}\n",
410 ink.dim,
411 interval_secs,
412 ink.reset,
413 crate::render::sparkline(history),
414 ));
415 }
416 out.trim_end_matches('\n').to_string()
417}
418
419pub fn compact_line(report: &Report, history: &[f64], colour: bool) -> String {
424 let ink = Ink::new(colour);
425 let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
426 format!(
427 " {}{}{}",
428 ink.dim,
429 crate::render::sparkline(history),
430 ink.reset
431 )
432 } else {
433 String::new()
434 };
435 format!(
436 "{}oc{} {} month · 5h {}{spark}",
437 ink.bold,
438 ink.reset,
439 ink.cyan_on(money(report.month.cost)),
440 ink.cyan_on(money(report.five_hour.cost)),
441 )
442}
443
444pub fn statusline_template(
458 report: &Report,
459 plan_id: &str,
460 tpl: &str,
461 colours: bool,
462 ascii: bool,
463 bar_width: usize,
464) -> String {
465 let ink = Ink::new(colours);
466 let shares = &zen::catalog().go.window_share;
467 let month_cap = aggregate_cap(report, shares.monthly);
468 let five_cap = aggregate_cap(report, shares.five_hour);
469 let week_cap = aggregate_cap(report, shares.weekly);
470 let cash = |v: f64| ink.cyan_on(money(v));
471 let pct_of = |used: f64, cap: Option<f64>| match cap {
472 Some(cap) => pct(used, cap),
473 None => "—".into(),
474 };
475 let bar_of = |used: f64, cap: Option<f64>| match cap.filter(|c| *c > 0.0) {
476 Some(cap) => crate::report_render::bar(used / cap * 100.0, bar_width, ascii, colours),
477 None => String::new(),
478 };
479 let top_model = || {
480 report
481 .models
482 .first()
483 .map(|m| m.id.clone())
484 .unwrap_or_else(|| "—".into())
485 };
486 crate::report_render::render_template(tpl, |key| match key {
487 "plan" => plan_id.to_string(),
488 "period" => report.period_label.clone(),
489 "month" => cash(report.month.cost),
490 "month_pct" => pct_of(report.month.cost, month_cap),
491 "month_bar" => bar_of(report.month.cost, month_cap),
492 "five_hour" | "5h" => cash(report.five_hour.cost),
493 "five_hour_pct" | "5h_pct" => pct_of(report.five_hour.cost, five_cap),
494 "five_hour_bar" | "5h_bar" => bar_of(report.five_hour.cost, five_cap),
495 "weekly" | "wk" => cash(report.weekly.cost),
496 "weekly_pct" | "wk_pct" => pct_of(report.weekly.cost, week_cap),
497 "weekly_bar" | "wk_bar" => bar_of(report.weekly.cost, week_cap),
498 "models" => top_model(),
499 _ => String::new(),
500 })
501}
502
503pub fn statusline_json(report: &Report, plan_id: &str) -> String {
508 let shares = &zen::catalog().go.window_share;
509 serde_json::json!({
510 "plan": plan_id,
511 "period": report.period_label,
512 "monthSpent": report.month.cost,
513 "monthCap": aggregate_cap(report, shares.monthly),
514 "fiveHourSpent": report.five_hour.cost,
515 "fiveHourCap": aggregate_cap(report, shares.five_hour),
516 "weeklySpent": report.weekly.cost,
517 "weeklyCap": aggregate_cap(report, shares.weekly),
518 "model": report.models.first().map(|m| m.id.clone()),
519 })
520 .to_string()
521}
522
523pub fn render_json(report: &Report) -> String {
525 let totals = |t: &Totals| serde_json::json!({ "cost": t.cost, "requests": t.requests, "tokens": t.tokens() });
526 let model = |m: &ModelUsage| {
527 serde_json::json!({
528 "id": m.id,
529 "provider": m.provider,
530 "limit": m.limit,
531 "month": totals(&m.month),
532 "fiveHour": totals(&m.five_hour),
533 "weekly": totals(&m.weekly),
534 })
535 };
536 serde_json::to_string_pretty(&serde_json::json!({
537 "source": "opencode.db",
538 "generatedAt": report.now_ms,
539 "periodStart": report.since_ms,
540 "periodStartAt": report.since_ms,
545 "periodEndAt": report.period_end_ms,
546 "periodInferred": report.month_inferred,
547 "totals": {
548 "month": totals(&report.month),
549 "fiveHour": totals(&report.five_hour),
550 "weekly": totals(&report.weekly),
551 },
552 "models": report.models.iter().map(model).collect::<Vec<_>>(),
553 }))
554 .expect("json")
555}
556
557fn shorten(path: &str) -> String {
558 match std::env::var("HOME") {
559 Ok(home) if !home.is_empty() => path.replacen(&home, "~", 1),
560 _ => path.to_string(),
561 }
562}
563
564pub fn plans_text(colour: bool) -> String {
571 let ink = Ink::new(colour);
572 let catalog = crate::ocuse::zen::catalog();
573 let mut out = format!(
574 "{}OpenCode plans{} {}\n",
575 ink.bold,
576 ink.reset,
577 ink.dim_on(format!("(docs, extracted {})", catalog.extracted_at)),
578 );
579 out.push_str(&format!(
580 "{}windows (both plans): 5h {} · weekly {} · monthly {}{}\n",
581 ink.dim,
582 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.five_hour * 100.0)),
583 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.weekly * 100.0)),
584 ink.cyan_on(format!("{:.1}%", catalog.go.window_share.monthly * 100.0)),
585 ink.reset,
586 ));
587 for plan in catalog.go.plans.values() {
588 out.push_str(&format!(
589 "\n{}{}{} — {}/month\n",
590 ink.bold,
591 plan.label,
592 ink.reset,
593 ink.cyan_on(format!("${:.2}", plan.price_usd)),
594 ));
595 out.push_str(&format!(
596 "{}MODEL LIMIT IN OUT CACHE{}\n",
597 ink.bold, ink.reset
598 ));
599 for model in &catalog.go.models {
600 let rate = model.variants.first();
601 let limit = plan
602 .allowance_by_model
603 .get(&model.id)
604 .map(|limit| cmduse_core::money(*limit))
605 .unwrap_or_else(|| "—".into());
606 out.push_str(&format!(
607 "{:<28} {} {} {} {}\n",
608 model.id,
609 ink.cyan_on(format!("{limit:>7}")),
610 ink.cyan_on(format!(
611 "{:>6}",
612 rate.map(|r| format!("${}", r.input)).unwrap_or_default()
613 )),
614 ink.cyan_on(format!(
615 "{:>6}",
616 rate.map(|r| format!("${}", r.output)).unwrap_or_default()
617 )),
618 ink.cyan_on(format!(
619 "{:>8}",
620 rate.map(|r| format!("${}", r.cache_read))
621 .unwrap_or_default()
622 )),
623 ));
624 }
625 }
626 out.push_str(&format!(
627 "\n{}\n",
628 ink.dim_on(format!(
629 "ZEN — pay-as-you-go, {} models priced per 1M tokens",
630 catalog.zen.models.len()
631 ))
632 ));
633 out
634}
635
636pub fn plans_json() -> String {
640 let catalog = crate::ocuse::zen::catalog();
641 let plans: Vec<serde_json::Value> = catalog
642 .go
643 .plans
644 .iter()
645 .map(|(id, plan)| {
646 serde_json::json!({
647 "id": id,
648 "label": plan.label,
649 "priceUsd": plan.price_usd,
650 "allowanceByModel": &plan.allowance_by_model,
651 })
652 })
653 .collect();
654 serde_json::json!({
655 "extractedAt": catalog.extracted_at,
656 "windows": {
657 "fiveHour": catalog.go.window_share.five_hour,
658 "weekly": catalog.go.window_share.weekly,
659 "monthly": catalog.go.window_share.monthly,
660 },
661 "plans": plans,
662 "models": catalog.go.models.iter().map(rate_json).collect::<Vec<_>>(),
663 "zen": catalog.zen.models.iter().map(rate_json).collect::<Vec<_>>(),
664 })
665 .to_string()
666}
667
668fn rate_json(model: &zen::Model) -> serde_json::Value {
670 let rate = model.variants.first();
671 serde_json::json!({
672 "id": model.id,
673 "name": model.name,
674 "input": rate.map(|r| r.input),
675 "output": rate.map(|r| r.output),
676 "cacheRead": rate.map(|r| r.cache_read),
677 "cacheWrite": rate.map(|r| r.cache_write),
678 })
679}
680
681struct ModelRow {
684 id: String,
685 name: String,
686 input: Option<f64>,
687 output: Option<f64>,
688 cache_read: Option<f64>,
689 limit: Option<f64>,
690}
691
692fn model_rows(plan_id: &str, gated: bool) -> Vec<ModelRow> {
695 let catalog = crate::ocuse::zen::catalog();
696 let plan = catalog.plan(plan_id);
697 let mut rows: Vec<ModelRow> = catalog
698 .go
699 .models
700 .iter()
701 .map(|m| {
702 let rate = m.variants.first();
703 ModelRow {
704 id: m.id.clone(),
705 name: m.name.clone(),
706 input: rate.map(|r| r.input),
707 output: rate.map(|r| r.output),
708 cache_read: rate.map(|r| r.cache_read),
709 limit: plan.and_then(|p| p.allowance_by_model.get(&m.id).copied()),
710 }
711 })
712 .collect();
713 if gated {
714 rows.retain(|r| r.limit.is_some());
715 }
716 rows
717}
718
719pub fn models_text(plan_id: &str, gated: bool, colour: bool) -> String {
723 let ink = Ink::new(colour);
724 let catalog = crate::ocuse::zen::catalog();
725 let label = catalog
726 .plan(plan_id)
727 .map(|p| p.label.clone())
728 .unwrap_or_else(|| plan_id.to_string());
729 let granted = catalog
730 .plan(plan_id)
731 .map(|p| p.allowance_by_model.len())
732 .unwrap_or(0);
733 let rows = model_rows(plan_id, gated);
734 let mut out = format!(
735 "{}{label} models{} {}\n",
736 ink.bold,
737 ink.reset,
738 ink.dim_on(format!(
739 "(plan {plan_id} · {granted}/{} granted)",
740 catalog.go.models.len()
741 )),
742 );
743 out.push_str(&format!(
744 "{}{:<30} {:<28} {:>7} {:>7} {:>7} {:>8} {:>7}{}\n",
745 ink.bold, "MODEL", "ID", "IN", "OUT", "CACHE", "LIMIT", "GRANTED", ink.reset
746 ));
747 let rate_cell = |v: Option<f64>| match v {
748 Some(v) => ink.cyan_on(format!("{:>7}", format!("${v}"))),
749 None => ink.dim_on(format!("{:>7}", "—")),
750 };
751 for r in &rows {
752 let limit = match r.limit {
753 Some(l) => ink.cyan_on(format!("{:>8}", cmduse_core::money(l))),
754 None => ink.dim_on(format!("{:>8}", "—")),
755 };
756 let granted = if r.limit.is_some() { "yes" } else { "no" };
757 out.push_str(&format!(
758 "{:<30} {:<28} {} {} {} {} {:>7}\n",
759 r.name,
760 r.id,
761 rate_cell(r.input),
762 rate_cell(r.output),
763 rate_cell(r.cache_read),
764 limit,
765 granted,
766 ));
767 }
768 out
769}
770
771pub fn models_json(plan_id: &str, gated: bool) -> String {
775 let mut out = String::new();
776 for r in model_rows(plan_id, gated) {
777 out.push_str(
778 &serde_json::json!({
779 "id": r.id,
780 "name": r.name,
781 "input": r.input,
782 "output": r.output,
783 "cacheRead": r.cache_read,
784 "limit": r.limit,
785 "granted": r.limit.is_some(),
786 })
787 .to_string(),
788 );
789 out.push('\n');
790 }
791 out
792}
793
794pub fn model_text(report: &Report, id: &str, colour: bool) -> String {
795 let ink = Ink::new(colour);
796 let Some(model) = report.models.iter().find(|m| m.id == id) else {
797 return format!("{id}: no usage in the period\n");
798 };
799 let limit = model
800 .limit
801 .map(cmduse_core::money)
802 .unwrap_or_else(|| "—".into());
803 let row =
804 |label: &str, value: String| format!(" {}{value}\n", ink.dim_on(format!("{label:<10}")));
805 let mut out = format!(
806 "{} {}\n",
807 ink.bold_on(model.id.clone()),
808 ink.dim_on(format!("({})", model.provider)),
809 );
810 out.push_str(&row("limit", ink.cyan_on(limit)));
811 out.push_str(&row(
812 "month",
813 ink.cyan_on(format!(
814 "{} · {} requests",
815 cmduse_core::money(model.month.cost),
816 model.month.requests
817 )),
818 ));
819 out.push_str(&row(
820 "5-hour",
821 ink.cyan_on(cmduse_core::money(model.five_hour.cost)),
822 ));
823 out.push_str(&row(
824 "weekly",
825 ink.cyan_on(cmduse_core::money(model.weekly.cost)),
826 ));
827 if report.month.cost > 0.0 {
830 out.push_str(&row(
831 "share",
832 ink.dim_on(format!(
833 "{:.1}% of period spend",
834 model.month.cost / report.month.cost * 100.0
835 )),
836 ));
837 }
838 out
839}
840
841fn totals_json(
846 cost: f64,
847 requests: u64,
848 input: u64,
849 output: u64,
850 cache_read: u64,
851) -> serde_json::Value {
852 serde_json::json!({
853 "requests": requests,
854 "tokensIn": input,
855 "tokensOut": output,
856 "cacheRead": cache_read,
857 "costUsd": cost,
858 })
859}
860
861#[derive(Default, Clone, Copy)]
864struct Bucket {
865 cost: f64,
866 requests: u64,
867 input: u64,
868 output: u64,
869 cache_read: u64,
870}
871
872impl Bucket {
873 fn add(&mut self, row: &crate::ocuse::db::Row) {
874 self.cost += row.cost_usd;
875 self.requests += 1;
876 self.input += row.input;
877 self.output += row.output;
878 self.cache_read += row.cache_read;
879 }
880
881 fn json(&self) -> serde_json::Value {
882 totals_json(
883 self.cost,
884 self.requests,
885 self.input,
886 self.output,
887 self.cache_read,
888 )
889 }
890}
891
892pub fn model_report(report: &Report, id: Option<&str>, fmt: Fmt) -> String {
900 let models: Vec<&ModelUsage> = report
901 .models
902 .iter()
903 .filter(|m| id.is_none_or(|want| m.id == want))
904 .collect();
905 match fmt {
906 Fmt::Json => {
907 let map: serde_json::Map<String, serde_json::Value> = models
908 .iter()
909 .map(|m| {
910 let mut body = totals_json(
911 m.month.cost,
912 m.month.requests,
913 m.month.input,
914 m.month.output,
915 m.month.cache_read,
916 );
917 body["limit"] = serde_json::json!(m.limit);
918 (m.id.clone(), body)
919 })
920 .collect();
921 serde_json::json!({
922 "scope": "local",
923 "source": "opencode",
924 "sinceMs": report.since_ms,
925 "models": map,
926 })
927 .to_string()
928 }
929 Fmt::Csv => {
930 let mut out = String::from("model,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
931 for m in models {
932 out.push_str(&format!(
933 "{},{},{},{},{},{:.2}\n",
934 crate::report_render::csv_field(&m.id),
935 m.month.requests,
936 m.month.input,
937 m.month.output,
938 m.month.cache_read,
939 m.month.cost,
940 ));
941 }
942 out
943 }
944 Fmt::Table => String::new(), }
946}
947
948pub fn bucket_text(
956 rows: &[crate::ocuse::db::Row],
957 now_ms: i64,
958 lookback_ms: i64,
959 size_ms: i64,
960 tz_secs: i64,
961 colour: bool,
962 fmt: Fmt,
963) -> String {
964 let shift = tz_secs * 1000;
966 let mut buckets: std::collections::BTreeMap<i64, Bucket> = Default::default();
967 for row in rows {
968 let bucket = (row.at_ms + shift).div_euclid(size_ms);
969 buckets.entry(bucket).or_default().add(row);
970 }
971 let start = (now_ms + shift - lookback_ms).div_euclid(size_ms);
972 let daily = size_ms >= 86_400_000;
973 let label = if daily { "day" } else { "hour" };
974 let stamp = |bucket: i64| {
975 if daily {
976 date_of(bucket)
977 } else {
978 format!(
979 "{} {:02}h",
980 date_of(bucket.div_euclid(24)),
981 bucket.rem_euclid(24)
982 )
983 }
984 };
985 let window: Vec<(i64, Bucket)> = buckets
986 .range(start..)
987 .map(|(bucket, b)| (*bucket, *b))
988 .collect();
989
990 match fmt {
991 Fmt::Json => {
992 let mut keyed = serde_json::Map::new();
993 let mut total = Bucket::default();
994 for (bucket, b) in &window {
995 keyed.insert(stamp(*bucket), b.json());
996 total.add_bucket(b);
997 }
998 let key = if daily { "days" } else { "hours" };
999 serde_json::json!({
1000 "scope": "local",
1001 "source": "opencode",
1002 "total": total.json(),
1003 key: keyed,
1004 })
1005 .to_string()
1006 }
1007 Fmt::Csv => {
1008 let head = if daily { "day" } else { "hour" };
1009 let mut out = format!("{head},requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1010 let mut total = Bucket::default();
1011 for (bucket, b) in &window {
1012 out.push_str(&format!(
1013 "{},{},{},{},{},{:.2}\n",
1014 crate::report_render::csv_field(&stamp(*bucket)),
1015 b.requests,
1016 b.input,
1017 b.output,
1018 b.cache_read,
1019 b.cost,
1020 ));
1021 total.add_bucket(b);
1022 }
1023 out.push_str(&format!(
1024 "total,{},{},{},{},{:.2}\n",
1025 total.requests, total.input, total.output, total.cache_read, total.cost,
1026 ));
1027 out
1028 }
1029 Fmt::Table => {
1030 let ink = Ink::new(colour);
1031 let mut out = format!(
1032 "{}{label:<12} spend requests tokens{}\n",
1033 ink.bold, ink.reset
1034 );
1035 for (bucket, b) in &window {
1036 out.push_str(&format!(
1037 "{:<12} {} {} {}\n",
1038 stamp(*bucket),
1039 ink.cyan_on(format!("{:>9}", cmduse_core::money(b.cost))),
1040 ink.cyan_on(format!("{:>12}", cmduse_core::compact(b.requests))),
1041 ink.cyan_on(format!(
1042 "{:>9}",
1043 cmduse_core::compact(b.input + b.output + b.cache_read)
1044 )),
1045 ));
1046 }
1047 if out.lines().count() == 1 {
1048 out.push_str(&format!(
1049 "{}\n",
1050 ink.dim_on("(no OpenCode usage in the window)".into())
1051 ));
1052 }
1053 out
1054 }
1055 }
1056}
1057
1058type SessionTotals<'a> = (
1061 f64,
1062 u64,
1063 u64,
1064 u64,
1065 u64,
1066 i64,
1067 std::collections::BTreeSet<&'a str>,
1068);
1069
1070pub fn session_text(
1075 rows: &[crate::ocuse::db::Row],
1076 limit: usize,
1077 colour: bool,
1078 fmt: Fmt,
1079) -> String {
1080 let mut by_session: std::collections::BTreeMap<&str, SessionTotals> = Default::default();
1081 for row in rows {
1082 let entry = by_session
1083 .entry(row.session.as_str())
1084 .or_insert_with(|| (0.0, 0, 0, 0, 0, 0, Default::default()));
1085 entry.0 += row.cost_usd;
1086 entry.1 += 1;
1087 entry.2 += row.input;
1088 entry.3 += row.output;
1089 entry.4 += row.cache_read;
1090 entry.5 = entry.5.max(row.at_ms);
1091 entry.6.insert(row.model.as_str());
1092 }
1093 let mut sessions: Vec<_> = by_session.into_iter().collect();
1094 sessions.sort_by_key(|(_, (_, _, _, _, _, last, _))| std::cmp::Reverse(*last));
1095 sessions.truncate(limit);
1096
1097 match fmt {
1098 Fmt::Json => {
1099 let mut keyed = serde_json::Map::new();
1100 for (id, (cost, requests, input, output, cache_read, last, models)) in &sessions {
1101 let mut body = totals_json(*cost, *requests, *input, *output, *cache_read);
1102 body["lastMs"] = serde_json::json!(*last);
1103 body["models"] = serde_json::json!(models.len());
1104 keyed.insert((*id).to_string(), body);
1105 }
1106 serde_json::json!({
1107 "scope": "local",
1108 "source": "opencode",
1109 "sessions": keyed,
1110 })
1111 .to_string()
1112 }
1113 Fmt::Csv => {
1114 let mut out =
1115 String::from("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n");
1116 for (id, (cost, requests, input, output, cache_read, _, _)) in &sessions {
1117 out.push_str(&format!(
1118 "{},{},{},{},{},{:.2}\n",
1119 crate::report_render::csv_field(id),
1120 requests,
1121 input,
1122 output,
1123 cache_read,
1124 cost,
1125 ));
1126 }
1127 out
1128 }
1129 Fmt::Table => {
1130 let ink = Ink::new(colour);
1131 let mut out = format!(
1132 "{}session last models spend req{}\n",
1133 ink.bold, ink.reset
1134 );
1135 for (id, (cost, requests, _, _, _, last, models)) in sessions {
1136 let short = id.rsplit('_').next().unwrap_or(id);
1137 out.push_str(&format!(
1138 "{:<32} {:<11} {:>6} {} {:>6}\n",
1139 short,
1140 ink.dim_on(format!("{:<11}", date_of(last.div_euclid(86_400_000)))),
1141 models.len(),
1142 ink.cyan_on(format!("{:>9}", cmduse_core::money(cost))),
1143 requests,
1144 ));
1145 }
1146 if out.lines().count() == 1 {
1147 out.push_str(&format!(
1148 "{}\n",
1149 ink.dim_on("(no sessions in the window)".into())
1150 ));
1151 }
1152 out
1153 }
1154 }
1155}
1156
1157impl Bucket {
1158 fn add_bucket(&mut self, other: &Bucket) {
1160 self.cost += other.cost;
1161 self.requests += other.requests;
1162 self.input += other.input;
1163 self.output += other.output;
1164 self.cache_read += other.cache_read;
1165 }
1166}
1167
1168pub fn date_of(day: i64) -> String {
1171 let z = day + 719_468;
1172 let era = z.div_euclid(146_097);
1173 let doe = z.rem_euclid(146_097);
1174 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1175 let y = yoe + era * 400;
1176 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1177 let mp = (5 * doy + 2) / 153;
1178 let d = doy - (153 * mp + 2) / 5 + 1;
1179 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1180 let year = if m <= 2 { y + 1 } else { y };
1181 format!("{year:04}-{m:02}-{d:02}")
1182}
1183
1184#[cfg(test)]
1185mod tests {
1186 use super::*;
1187 use crate::ocuse::db::Row;
1188
1189 fn report() -> Report {
1190 let now = 1_800_000_000_000;
1191 let rows = vec![Row {
1192 at_ms: now - 60_000,
1193 provider: "opencode-go".into(),
1194 model: "glm-5.3-flash".into(),
1195 cost_usd: 30.0,
1196 ..Default::default()
1197 }];
1198 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None)
1199 }
1200
1201 #[test]
1202 fn text_shows_the_model_against_its_limit() {
1203 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1204 assert!(text.contains("glm-5.3-flash"), "{text}");
1205 assert!(text.contains("$60"), "the model's monthly limit: {text}");
1206 assert!(text.contains("50%"), "30 of 60 is half: {text}");
1207 }
1208
1209 #[test]
1210 fn json_and_statusline_carry_the_totals() {
1211 let json = render_json(&report());
1212 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1213 assert_eq!(parsed["totals"]["month"]["cost"], 30.0);
1214 assert_eq!(parsed["models"][0]["id"], "glm-5.3-flash");
1215 let line = statusline_template(&report(), "go", "{plan} {models}", false, false, 20);
1216 assert_eq!(line, "go glm-5.3-flash");
1217 }
1218
1219 #[test]
1220 fn json_carries_the_period_bounds() {
1221 let mut r = report();
1224 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1225 let parsed: serde_json::Value = serde_json::from_str(&render_json(&r)).unwrap();
1226 assert_eq!(parsed["periodStart"], r.since_ms);
1227 assert_eq!(parsed["periodStartAt"], r.since_ms);
1228 assert_eq!(parsed["periodEndAt"], r.period_end_ms.unwrap());
1229
1230 let mut all = report();
1233 all.period_end_ms = None;
1234 let parsed: serde_json::Value = serde_json::from_str(&render_json(&all)).unwrap();
1235 assert_eq!(parsed["periodEndAt"], serde_json::Value::Null);
1236 assert_eq!(parsed["periodStart"], all.since_ms);
1237 assert!(parsed.get("periodInferred").is_some());
1238 }
1239
1240 #[test]
1246 fn every_renderer_respects_the_colour_flag() {
1247 let mut report = report();
1248 report.period_end_ms = Some(crate::ocuse::window::next_month(report.since_ms));
1251 let rows = vec![Row {
1252 at_ms: report.now_ms - 60_000,
1253 session: "ses_fixture".into(),
1254 provider: "opencode-go".into(),
1255 model: "glm-5.3-flash".into(),
1256 cost_usd: 30.0,
1257 ..Default::default()
1258 }];
1259 let now = report.now_ms;
1260
1261 let cases: Vec<(&str, String, String)> = vec![
1262 (
1263 "render_text",
1264 render_text(&report, "/tmp/oc.db", 10, false, DEFAULT_BAR_WIDTH),
1265 render_text(&report, "/tmp/oc.db", 10, true, DEFAULT_BAR_WIDTH),
1266 ),
1267 (
1268 "statusline_template",
1269 statusline_template(
1270 &report,
1271 "go",
1272 "{period} {month} {month_pct}",
1273 false,
1274 false,
1275 20,
1276 ),
1277 statusline_template(
1278 &report,
1279 "go",
1280 "{period} {month} {month_pct}",
1281 true,
1282 false,
1283 20,
1284 ),
1285 ),
1286 (
1287 "compact_line",
1288 compact_line(&report, &[], false),
1289 compact_line(&report, &[], true),
1290 ),
1291 ("plans_text", plans_text(false), plans_text(true)),
1292 (
1293 "model_text",
1294 model_text(&report, "glm-5.3-flash", false),
1295 model_text(&report, "glm-5.3-flash", true),
1296 ),
1297 (
1298 "bucket_text/day",
1299 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Table),
1300 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Table),
1301 ),
1302 (
1303 "bucket_text/hour",
1304 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Table),
1305 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Table),
1306 ),
1307 (
1308 "session_text",
1309 session_text(&rows, 20, false, Fmt::Table),
1310 session_text(&rows, 20, true, Fmt::Table),
1311 ),
1312 (
1313 "models_text",
1314 models_text("go", false, false),
1315 models_text("go", false, true),
1316 ),
1317 ];
1318
1319 for (name, plain, coloured) in cases {
1320 assert!(
1321 !plain.contains('\x1b'),
1322 "{name} leaked escapes when off: {plain}"
1323 );
1324 assert!(
1325 coloured.contains('\x1b'),
1326 "{name} rendered nothing coloured when on: {coloured}"
1327 );
1328 }
1329
1330 let flagged: Vec<(&str, String, String)> = vec![
1333 (
1334 "bucket_text/day csv",
1335 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv),
1336 bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, true, Fmt::Csv),
1337 ),
1338 (
1339 "bucket_text/hour json",
1340 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, false, Fmt::Json),
1341 bucket_text(&rows, now, 3_600_000, 3_600_000, 0, true, Fmt::Json),
1342 ),
1343 (
1344 "session_text csv",
1345 session_text(&rows, 20, false, Fmt::Csv),
1346 session_text(&rows, 20, true, Fmt::Csv),
1347 ),
1348 (
1349 "session_text json",
1350 session_text(&rows, 20, false, Fmt::Json),
1351 session_text(&rows, 20, true, Fmt::Json),
1352 ),
1353 ];
1354 for (name, off, on) in flagged {
1355 assert!(
1356 !off.contains('\x1b'),
1357 "{name} leaked escapes with colour off: {off}"
1358 );
1359 assert!(
1360 !on.contains('\x1b'),
1361 "{name} must stay uncoloured with colour on: {on}"
1362 );
1363 }
1364 for (name, out) in [
1365 ("model_report json", model_report(&report, None, Fmt::Json)),
1366 ("model_report csv", model_report(&report, None, Fmt::Csv)),
1367 ("plans_json", plans_json()),
1368 ("statusline_json", statusline_json(&report, "go")),
1369 ("models_json", models_json("go", false)),
1370 ] {
1371 assert!(
1372 !out.contains('\x1b'),
1373 "{name} must never be coloured: {out}"
1374 );
1375 }
1376 }
1377
1378 #[test]
1379 fn bucket_reports_carry_the_documented_columns() {
1380 let now = 1_800_000_000_000;
1381 let rows = vec![Row {
1382 at_ms: now - 60_000,
1383 provider: "opencode-go".into(),
1384 model: "glm-5.3-flash".into(),
1385 cost_usd: 1.5,
1386 input: 10,
1387 output: 20,
1388 cache_read: 30,
1389 cache_write: 40,
1390 ..Default::default()
1391 }];
1392 let csv = bucket_text(&rows, now, 7 * 86_400_000, 86_400_000, 0, false, Fmt::Csv);
1393 assert!(
1394 csv.starts_with("day,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1395 "{csv}"
1396 );
1397 assert!(!csv.contains("cache_write"), "{csv}");
1399 assert!(csv.contains(",10,20,30,1.50\n"), "{csv}");
1400 assert!(csv.trim_end().ends_with("total,1,10,20,30,1.50"), "{csv}");
1401
1402 let json: serde_json::Value = serde_json::from_str(&bucket_text(
1403 &rows,
1404 now,
1405 7 * 86_400_000,
1406 86_400_000,
1407 0,
1408 false,
1409 Fmt::Json,
1410 ))
1411 .unwrap();
1412 assert_eq!(json["total"]["requests"], 1);
1413 assert_eq!(json["total"]["cacheRead"], 30);
1414 assert_eq!(json["days"].as_object().unwrap().len(), 1);
1415 }
1416
1417 #[test]
1418 fn session_report_json_and_csv_agree_with_the_store() {
1419 let now = 1_800_000_000_000;
1420 let rows = vec![
1421 Row {
1422 at_ms: now - 60_000,
1423 session: "ses_first".into(),
1424 model: "glm-5.3-flash".into(),
1425 cost_usd: 2.0,
1426 input: 5,
1427 output: 6,
1428 cache_read: 7,
1429 ..Default::default()
1430 },
1431 Row {
1432 at_ms: now - 30_000,
1433 session: "ses_second".into(),
1434 model: "kimi-k3".into(),
1435 cost_usd: 1.0,
1436 ..Default::default()
1437 },
1438 ];
1439 let json: serde_json::Value =
1440 serde_json::from_str(&session_text(&rows, 20, false, Fmt::Json)).unwrap();
1441 assert_eq!(json["sessions"]["ses_first"]["tokensIn"], 5);
1442 assert_eq!(json["sessions"]["ses_first"]["models"], 1);
1443 assert_eq!(json["sessions"]["ses_second"]["costUsd"], 1.0);
1444 let csv = session_text(&rows, 20, false, Fmt::Csv);
1445 assert!(
1446 csv.starts_with("session,requests,tokens_in,tokens_out,cache_read,cost_usd\n"),
1447 "{csv}"
1448 );
1449 assert!(csv.contains("ses_second,1,0,0,0,1.00\n"), "{csv}");
1450 }
1451
1452 #[test]
1453 fn model_report_projects_the_report_models() {
1454 let json: serde_json::Value =
1455 serde_json::from_str(&model_report(&report(), None, Fmt::Json)).unwrap();
1456 assert_eq!(json["source"], "opencode");
1457 assert_eq!(json["models"]["glm-5.3-flash"]["costUsd"], 30.0);
1458 assert_eq!(json["models"]["glm-5.3-flash"]["limit"], 60.0);
1459 let csv = model_report(&report(), None, Fmt::Csv);
1460 assert!(csv.contains("glm-5.3-flash,1,0,0,0,30.00\n"), "{csv}");
1461 assert_eq!(
1463 model_report(&report(), Some("nope"), Fmt::Csv),
1464 "model,requests,tokens_in,tokens_out,cache_read,cost_usd\n"
1465 );
1466 }
1467
1468 #[test]
1469 fn catalogue_json_carries_both_plans_and_windows() {
1470 let json: serde_json::Value = serde_json::from_str(&plans_json()).unwrap();
1471 assert_eq!(json["windows"]["fiveHour"], 0.2);
1472 assert_eq!(json["windows"]["monthly"], 1.0);
1473 assert_eq!(json["plans"][0]["id"], "go");
1474 assert_eq!(json["plans"][0]["allowanceByModel"]["glm-5.3-flash"], 60.0);
1475 assert_eq!(json["plans"][1]["priceUsd"], 40.0);
1476 assert!(!json["models"].as_array().unwrap().is_empty());
1477 assert!(!json["zen"].as_array().unwrap().is_empty());
1478 }
1479
1480 #[test]
1481 fn models_lists_the_plan_and_marks_granted() {
1482 let all = models_text("go", false, false);
1484 assert!(all.contains("glm-5.3-flash"), "{all}");
1485 assert!(all.contains("GRANTED"), "{all}");
1486 let gated = models_text("go", true, false);
1488 for line in gated.lines().skip(2) {
1489 assert!(
1490 line.trim_end().ends_with("yes"),
1491 "ungranted row kept: {line}"
1492 );
1493 }
1494 let every: Vec<serde_json::Value> = models_json("go", false)
1496 .lines()
1497 .map(|l| serde_json::from_str(l).unwrap())
1498 .collect();
1499 assert!(every.iter().any(|m| m["granted"] == false));
1500 assert!(every
1501 .iter()
1502 .any(|m| m["id"] == "glm-5.3-flash" && m["limit"] == 60.0));
1503 let only: Vec<serde_json::Value> = models_json("go", true)
1504 .lines()
1505 .map(|l| serde_json::from_str(l).unwrap())
1506 .collect();
1507 assert!(!only.is_empty());
1508 assert!(only.iter().all(|m| m["granted"] == true));
1509 }
1510
1511 #[test]
1512 fn statusline_json_mirrors_the_template_numbers() {
1513 let json: serde_json::Value =
1514 serde_json::from_str(&statusline_json(&report(), "go")).unwrap();
1515 assert_eq!(json["plan"], "go");
1516 assert_eq!(json["period"], "calendar month");
1517 assert_eq!(json["monthSpent"], 30.0);
1518 assert_eq!(json["monthCap"], 60.0);
1519 assert_eq!(json["model"], "glm-5.3-flash");
1520 }
1521
1522 #[test]
1523 fn plain_frames_carry_no_escapes() {
1524 let text = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1525 assert!(
1526 !text.contains('\x1b'),
1527 "piped output must stay plain: {text}"
1528 );
1529 assert!(text.contains("50%"), "{text}");
1531 }
1532
1533 #[test]
1537 fn monthly_row_carries_the_real_period_metrics() {
1538 let mut r = report();
1539 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1540 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1541
1542 let monthly = text
1543 .lines()
1544 .find(|line| line.trim_start().starts_with("Monthly"))
1545 .expect("the monthly row");
1546 assert!(monthly.contains("resets"), "{monthly}");
1547 assert!(
1548 monthly.contains("window") && monthly.contains("% elapsed"),
1549 "the real period gives an elapsed share: {monthly}"
1550 );
1551 assert!(
1552 monthly.contains("by now"),
1553 "monthly is the pro-rata budget: {monthly}"
1554 );
1555
1556 for label in ["5-hour", "Weekly"] {
1557 let row = text
1558 .lines()
1559 .find(|line| line.trim_start().starts_with(label))
1560 .unwrap_or_else(|| panic!("the {label} row"));
1561 assert!(!row.contains("elapsed"), "{label} has no boundary: {row}");
1562 assert!(!row.contains("by now"), "{label} is a throttle: {row}");
1563 }
1564 }
1565
1566 #[test]
1569 fn period_line_splits_tokens_and_shows_the_end_date() {
1570 let mut r = report();
1571 r.period_end_ms = Some(crate::ocuse::window::next_month(r.since_ms));
1572 let text = render_text(&r, "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1573 let header = text
1574 .lines()
1575 .find(|line| line.starts_with("period:"))
1576 .expect("the period line");
1577 assert!(
1578 header.contains(" in / "),
1579 "token split like cmduse: {header}"
1580 );
1581 assert!(header.contains(" out tokens"), "{header}");
1582 assert!(
1583 header.contains(&date_of(r.period_end_ms.unwrap() / 86_400_000)),
1584 "the resolved end date: {header}"
1585 );
1586
1587 let no_end = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1589 let header = no_end
1590 .lines()
1591 .find(|line| line.starts_with("period:"))
1592 .expect("the period line");
1593 assert!(!header.contains("ends"), "{header}");
1594 }
1595
1596 #[test]
1597 fn an_over_cap_window_is_flagged_in_both_modes() {
1598 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1600 assert!(plain.contains("LIMIT EXCEEDED"), "{plain}");
1601 let coloured = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1602 assert!(coloured.contains("LIMIT EXCEEDED"), "{coloured}");
1603 assert!(
1604 coloured.contains(crate::render::RED),
1605 "the flag and the gauge are red: {coloured}"
1606 );
1607 }
1608
1609 #[test]
1610 fn a_model_over_any_of_its_own_windows_is_flagged() {
1611 let plain = render_text(&report(), "/tmp/opencode.db", 10, false, DEFAULT_BAR_WIDTH);
1614 let row = plain
1615 .lines()
1616 .find(|line| line.starts_with("glm-5.3-flash"))
1617 .expect("the model row");
1618 assert!(row.contains("LIMIT EXCEEDED"), "{row}");
1619 assert!(row.trim_end().ends_with('%'), "share ends the row: {row}");
1621 }
1622
1623 #[test]
1624 fn a_model_within_its_limits_is_not_flagged() {
1625 let now = 1_800_000_000_000;
1626 let rows = vec![Row {
1628 at_ms: now - 60_000,
1629 provider: "opencode-go".into(),
1630 model: "glm-5.3-flash".into(),
1631 cost_usd: 5.0,
1632 ..Default::default()
1633 }];
1634 let report =
1635 crate::ocuse::window::build(&rows, &zen::catalog(), zen::DEFAULT_PLAN, now, None);
1636 for colour in [false, true] {
1637 let text = render_text(&report, "/tmp/opencode.db", 10, colour, DEFAULT_BAR_WIDTH);
1638 assert!(!text.contains("LIMIT EXCEEDED"), "colour={colour}: {text}");
1639 }
1640 }
1641
1642 #[test]
1643 fn the_model_detail_spells_out_the_share() {
1644 let detail = model_text(&report(), "glm-5.3-flash", false);
1645 assert!(detail.contains("100.0% of period spend"), "{detail}");
1646 let empty = crate::ocuse::window::build(
1648 &[],
1649 &zen::catalog(),
1650 zen::DEFAULT_PLAN,
1651 1_800_000_000_000,
1652 None,
1653 );
1654 assert!(!model_text(&empty, "glm-5.3-flash", false).contains("share"));
1655 }
1656
1657 #[test]
1658 fn colour_frames_get_the_palette_and_a_gauge() {
1659 let text = render_text(&report(), "/tmp/opencode.db", 10, true, DEFAULT_BAR_WIDTH);
1660 assert!(text.contains(crate::render::BOLD), "{text}");
1661 assert!(text.contains(crate::render::CYAN), "{text}");
1662 assert!(
1663 text.contains(crate::render::GREEN),
1664 "the 50% monthly gauge and USE cell are green: {text}"
1665 );
1666 assert!(
1667 text.contains(crate::render::RED),
1668 "the over-cap 5-hour gauge (30 of 12) is red: {text}"
1669 );
1670 assert!(
1671 text.contains('━') && text.contains('╱'),
1672 "gauge bar: {text}"
1673 );
1674 assert!(text.contains("resets in"), "{text}");
1675 }
1676
1677 #[test]
1678 fn watch_frame_ends_on_the_status_line_and_shows_bursts() {
1679 let frame = watch_frame(
1680 &report(),
1681 "/tmp/opencode.db",
1682 10,
1683 true,
1684 &[0.0, 1.5, 0.0],
1685 10,
1686 DEFAULT_BAR_WIDTH,
1687 );
1688 assert!(
1689 !frame.ends_with('\n'),
1690 "the caller's status line owns the last row"
1691 );
1692 assert!(frame.contains("spend bursts (10s samples)"), "{frame}");
1693 assert!(
1694 frame.contains('█'),
1695 "a nonzero delta shows as a tall bar: {frame}"
1696 );
1697 }
1698
1699 #[test]
1700 fn idle_history_omits_the_sparkline() {
1701 let all_idle = watch_frame(
1702 &report(),
1703 "/tmp/opencode.db",
1704 10,
1705 true,
1706 &[0.0, 0.0],
1707 10,
1708 DEFAULT_BAR_WIDTH,
1709 );
1710 assert!(!all_idle.contains("spend bursts"), "{all_idle}");
1711 let first_frame = watch_frame(
1712 &report(),
1713 "/tmp/opencode.db",
1714 10,
1715 true,
1716 &[],
1717 10,
1718 DEFAULT_BAR_WIDTH,
1719 );
1720 assert!(!first_frame.contains("spend bursts"), "{first_frame}");
1721 }
1722
1723 #[test]
1727 fn statusline_resolves_every_placeholder() {
1728 let r = report();
1729 let line = |tpl: &str| statusline_template(&r, "go", tpl, false, false, 20);
1730
1731 assert_eq!(line("{plan}"), "go");
1733 assert_eq!(line("{period}"), "calendar month");
1734 assert_eq!(line("{month}"), "$30.00");
1736 assert_eq!(line("{five_hour}"), "$30.00");
1737 assert_eq!(line("{weekly}"), "$30.00");
1738 assert_eq!(line("{models}"), "glm-5.3-flash");
1740 assert_eq!(line("{month_pct}"), "50.0%");
1742 let bar = line("{month_bar}");
1744 assert!(bar.contains("50.0%") && bar.contains('━'), "{bar}");
1745
1746 let default = line("{period} {month} · 5h {five_hour} · wk {weekly}");
1748 assert_eq!(default, "calendar month $30.00 · 5h $30.00 · wk $30.00");
1749
1750 assert_eq!(line("{plan} {bogus} | tail"), "go | tail");
1752 assert_eq!(line("{plan} {oops"), "go {oops");
1754 assert!(!line("{plan} {month} {month_pct} {month_bar}").contains('\x1b'));
1756 }
1757
1758 #[test]
1761 fn statusline_without_caps_degrades_gracefully() {
1762 let empty = crate::ocuse::window::build(
1763 &[],
1764 &zen::catalog(),
1765 zen::DEFAULT_PLAN,
1766 1_800_000_000_000,
1767 None,
1768 );
1769 let line = statusline_template(&empty, "go", "{month_pct} {models}", false, false, 20);
1770 assert_eq!(line, "— —");
1771 }
1772
1773 #[test]
1775 fn compact_line_is_one_line_with_inline_sparkline() {
1776 let plain = compact_line(&report(), &[], true);
1777 assert_eq!(plain.lines().count(), 1, "{plain}");
1778 assert!(!plain.ends_with('\n'), "{plain}");
1779 let bursty = compact_line(&report(), &[0.0, 1.5], true);
1780 assert!(bursty.contains('█'), "nonzero delta shows: {bursty}");
1781 assert!(!compact_line(&report(), &[], false).contains('\x1b'));
1783 assert!(compact_line(&report(), &[], true).contains(crate::render::BOLD));
1784 }
1785}