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