1use std::collections::HashMap;
8
9use chrono::{DateTime, Utc};
10
11use crate::anthropic::fetch::FetchOutcome;
12use crate::countdown;
13use crate::format::{placeholders, substitute, updated_at_hm};
14use crate::pacing;
15use crate::pango::{self, color_span, escape, severity_for};
16use crate::theme::Theme;
17use crate::tooltip::{self, Line};
18use crate::usage::{AnthropicSnapshot, ExtraUsage, anthropic_severity};
19use crate::vendor::VendorId;
20use crate::waybar::{Class, WaybarOutput};
21
22pub const DEFAULT_FORMAT: &str = "{session_pct}% · {session_reset}";
24pub const WEEKLY_ONLY_FORMAT: &str = "{weekly_pct}% · {weekly_reset}";
27
28pub fn default_format(snap: &AnthropicSnapshot) -> &'static str {
32 if snap.session.is_some() {
33 DEFAULT_FORMAT
34 } else {
35 WEEKLY_ONLY_FORMAT
36 }
37}
38
39pub struct RenderInput<'a> {
42 pub outcome: &'a FetchOutcome,
43 pub theme: &'a Theme,
44 pub format: &'a str,
45 pub tooltip_format: Option<&'a str>,
46 pub icon: Option<&'a str>,
47 pub pace_tolerance: u32,
48 pub format_pace_color: bool,
49 pub tooltip_pace_pts: bool,
50 pub now: DateTime<Utc>,
51 pub claude_sessions: Option<&'a crate::context::activity::SessionActivity>,
54}
55
56pub fn render_anthropic(input: &RenderInput) -> WaybarOutput {
58 let snap = &input.outcome.snapshot;
59 let class = Class::from(anthropic_severity(snap));
60 let bar_text = render_bar_text(input, class);
61 let tooltip = if let Some(fmt) = input.tooltip_format {
62 let values = build_placeholders(input);
64 substitute(fmt, &values)
65 } else {
66 render_default_tooltip(input)
67 };
68
69 WaybarOutput {
70 text: bar_text,
71 tooltip,
72 class,
73 }
74}
75
76fn render_bar_text(input: &RenderInput, class: Class) -> String {
79 let values = build_placeholders(input);
80 let mut text = substitute(input.format, &values);
81
82 if input.outcome.stale {
84 text.push_str(" ⏸");
85 }
86
87 let wrapper_color = if input.format_pace_color && input.format.contains("_pace") {
90 input.theme.fg.clone()
91 } else {
92 bar_color_for(class, input.theme).to_string()
93 };
94 let icon_prefix = match input.icon {
95 Some(ic) if !ic.is_empty() => format!("{ic} "),
96 _ => String::new(),
97 };
98 color_span(&wrapper_color, &format!("{icon_prefix}{text}"))
99}
100
101fn bar_color_for(class: Class, theme: &Theme) -> &str {
102 match class {
103 Class::Low => &theme.green,
104 Class::Mid => &theme.yellow,
105 Class::High => &theme.orange,
106 Class::Critical => &theme.red,
107 }
108}
109
110fn build_placeholders(input: &RenderInput) -> HashMap<&'static str, String> {
116 let snap = &input.outcome.snapshot;
117 let theme = input.theme;
118
119 let session_window = snap.session.as_ref();
120 let session = session_window.map(|w| {
121 pacing::calc(
122 w.utilization_pct,
123 w.resets_at,
124 input.now,
125 w.window_duration,
126 input.pace_tolerance,
127 )
128 });
129 let weekly = pacing::calc(
130 snap.weekly.utilization_pct,
131 snap.weekly.resets_at,
132 input.now,
133 snap.weekly.window_duration,
134 input.pace_tolerance,
135 );
136 let sonnet_window = snap.sonnet.as_ref();
137 let sonnet = sonnet_window.map(|w| {
138 pacing::calc(
139 w.utilization_pct,
140 w.resets_at,
141 input.now,
142 w.window_duration,
143 input.pace_tolerance,
144 )
145 });
146
147 let session_color =
148 session_window.map(|w| pango::severity_color(severity_for(w.utilization_pct), theme));
149 let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
150 let sonnet_color =
151 sonnet_window.map(|w| pango::severity_color(severity_for(w.utilization_pct), theme));
152 let extra_color = snap
153 .extra
154 .as_ref()
155 .map(|e| pango::severity_color(severity_for(e.percent()), theme));
156
157 let session_bar = if let (Some(w), Some(c)) = (session_window, session_color) {
158 pango::progress_bar(w.utilization_pct, c, theme, None)
159 } else {
160 String::new()
161 };
162 let weekly_bar = pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None);
163 let sonnet_bar = if let (Some(w), Some(c)) = (sonnet_window, sonnet_color) {
164 pango::progress_bar(w.utilization_pct, c, theme, None)
165 } else {
166 String::new()
167 };
168 let extra_bar = if let (Some(e), Some(c)) = (snap.extra.as_ref(), extra_color) {
169 pango::progress_bar(e.percent(), c, theme, None)
170 } else {
171 String::new()
172 };
173
174 let scoped0 = snap.scoped.first();
181 let scoped0_pacing = scoped0.map(|s| {
182 pacing::calc(
183 s.window.utilization_pct,
184 s.window.resets_at,
185 input.now,
186 s.window.window_duration,
187 input.pace_tolerance,
188 )
189 });
190 let scoped0_bar = if let Some(s) = scoped0 {
191 let c = pango::severity_color(severity_for(s.window.utilization_pct), theme);
192 pango::progress_bar(s.window.utilization_pct, c, theme, None)
193 } else {
194 String::new()
195 };
196
197 let mut v = placeholders(vec![
198 ("icon", "".to_string()),
199 ("vendor_short", VendorId::Anthropic.short_name().to_string()),
200 ("plan", snap.plan.clone()),
201 (
204 "session_pct",
205 session_window
206 .map(|w| w.utilization_pct.to_string())
207 .unwrap_or_default(),
208 ),
209 (
210 "session_reset",
211 session_window
212 .map(|w| countdown::format(w.resets_at, input.now))
213 .unwrap_or_default(),
214 ),
215 (
216 "session_elapsed",
217 session
218 .as_ref()
219 .map(|s| s.elapsed_pct.to_string())
220 .unwrap_or_default(),
221 ),
222 ("session_bar", session_bar.clone()),
223 ("weekly_pct", snap.weekly.utilization_pct.to_string()),
224 (
225 "weekly_reset",
226 countdown::format(snap.weekly.resets_at, input.now),
227 ),
228 ("weekly_elapsed", weekly.elapsed_pct.to_string()),
229 ("weekly_bar", weekly_bar.clone()),
230 (
231 "sonnet_pct",
232 sonnet_window
233 .map(|w| w.utilization_pct.to_string())
234 .unwrap_or_else(|| "0".into()),
235 ),
236 (
237 "sonnet_reset",
238 sonnet_window
239 .map(|w| countdown::format(w.resets_at, input.now))
240 .unwrap_or_else(|| "—".into()),
241 ),
242 (
243 "sonnet_elapsed",
244 sonnet
245 .as_ref()
246 .map(|s| s.elapsed_pct.to_string())
247 .unwrap_or_else(|| "0".into()),
248 ),
249 ("sonnet_bar", sonnet_bar.clone()),
250 (
252 "scoped_model",
253 scoped0.map(|s| s.label.clone()).unwrap_or_default(),
254 ),
255 (
256 "scoped_pct",
257 scoped0
258 .map(|s| s.window.utilization_pct.to_string())
259 .unwrap_or_else(|| "0".into()),
260 ),
261 (
262 "scoped_reset",
263 scoped0
264 .map(|s| countdown::format(s.window.resets_at, input.now))
265 .unwrap_or_else(|| "—".into()),
266 ),
267 (
268 "scoped_elapsed",
269 scoped0_pacing
270 .as_ref()
271 .map(|p| p.elapsed_pct.to_string())
272 .unwrap_or_else(|| "0".into()),
273 ),
274 ("scoped_bar", scoped0_bar.clone()),
275 (
276 "extra_spent",
277 snap.extra
278 .as_ref()
279 .map(ExtraUsage::fmt_spent)
280 .unwrap_or_default(),
281 ),
282 (
283 "extra_limit",
284 snap.extra
288 .as_ref()
289 .map(|e| e.fmt_limit().unwrap_or_else(|| "—".into()))
290 .unwrap_or_default(),
291 ),
292 (
293 "extra_pct",
294 snap.extra
295 .as_ref()
296 .map(|e| e.percent().to_string())
297 .unwrap_or_else(|| "0".into()),
298 ),
299 ("extra_bar", extra_bar),
300 ("resets_available", snap.reset_credits.available.to_string()),
301 ("resets", crate::format::reset_credits(&snap.reset_credits)),
302 ]);
303
304 insert_pace(
305 &mut v,
306 "session",
307 session.as_ref(),
308 input.format_pace_color,
309 theme,
310 );
311 insert_pace(
312 &mut v,
313 "weekly",
314 Some(&weekly),
315 input.format_pace_color,
316 theme,
317 );
318 if let Some(sp) = sonnet.as_ref() {
319 insert_pace(&mut v, "sonnet", Some(sp), input.format_pace_color, theme);
320 } else {
321 insert_pace(
324 &mut v,
325 "sonnet",
326 Some(&pacing::Pacing::neutral()),
327 input.format_pace_color,
328 theme,
329 );
330 }
331 for key in ["plan", "scoped_model"] {
336 if let Some(value) = v.get_mut(key) {
337 *value = escape(value);
338 }
339 }
340 v
341}
342
343fn insert_pace(
347 map: &mut HashMap<&'static str, String>,
348 prefix: &'static str,
349 p: Option<&pacing::Pacing>,
350 pace_color: bool,
351 theme: &Theme,
352) {
353 let neutral = pacing::Pacing::neutral();
354 let present = p.is_some();
355 let p = p.unwrap_or(&neutral);
356 let pace_glyph = p.ratio_pace.glyph();
357 let indicator_glyph = p.point_pace.glyph();
358 let delta = p.delta.to_string();
359 let abs_delta = p.delta.unsigned_abs().to_string();
360 let pct = &p.ratio_label;
361 let pts = &p.point_label;
362
363 let wrap = |s: &str| -> String {
364 if !present {
365 String::new()
366 } else if pace_color {
367 let sev = pacing::pace_severity(p.delta);
368 let color = pango::severity_color(sev, theme);
369 color_span(color, s)
370 } else {
371 s.to_string()
372 }
373 };
374
375 let keys: [(&'static str, String); 6] = match prefix {
376 "session" => [
377 ("session_pace", wrap(pace_glyph)),
378 ("session_pace_indicator", wrap(indicator_glyph)),
379 ("session_pace_pct", wrap(pct)),
380 ("session_pace_pts", wrap(pts)),
381 ("session_pace_delta", wrap(&delta)),
382 ("session_pace_abs_delta", wrap(&abs_delta)),
383 ],
384 "weekly" => [
385 ("weekly_pace", wrap(pace_glyph)),
386 ("weekly_pace_indicator", wrap(indicator_glyph)),
387 ("weekly_pace_pct", wrap(pct)),
388 ("weekly_pace_pts", wrap(pts)),
389 ("weekly_pace_delta", wrap(&delta)),
390 ("weekly_pace_abs_delta", wrap(&abs_delta)),
391 ],
392 "sonnet" => [
393 ("sonnet_pace", wrap(pace_glyph)),
394 ("sonnet_pace_indicator", wrap(indicator_glyph)),
395 ("sonnet_pace_pct", wrap(pct)),
396 ("sonnet_pace_pts", wrap(pts)),
397 ("sonnet_pace_delta", wrap(&delta)),
398 ("sonnet_pace_abs_delta", wrap(&abs_delta)),
399 ],
400 _ => return,
401 };
402 for (k, v) in keys {
403 map.insert(k, v);
404 }
405}
406
407fn render_default_tooltip(input: &RenderInput) -> String {
409 let snap = &input.outcome.snapshot;
410 let theme = input.theme;
411 let blue = &theme.blue;
412 let dim = &theme.dim;
413 let fg = &theme.fg;
414
415 let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
416
417 let weekly_pacing = pacing::calc(
418 snap.weekly.utilization_pct,
419 snap.weekly.resets_at,
420 input.now,
421 snap.weekly.window_duration,
422 input.pace_tolerance,
423 );
424
425 let weekly_bar = if input.tooltip_pace_pts {
426 pango::progress_bar(
427 snap.weekly.utilization_pct,
428 weekly_color,
429 theme,
430 Some(weekly_pacing.elapsed_pct),
431 )
432 } else {
433 pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None)
434 };
435
436 let weekly_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &weekly_pacing);
437
438 let mut lines: Vec<Line> = Vec::new();
439 let _ = pango::severity_color; lines.push(Line::Center(format!(
441 "<span font_weight='bold' foreground='{blue}'>Claude {plan}</span>",
442 plan = escape(&snap.plan)
443 )));
444 lines.push(Line::Sep);
445 lines.push(Line::Body("".into()));
446
447 if let Some(session) = snap.session.as_ref() {
450 let session_color = pango::severity_color(severity_for(session.utilization_pct), theme);
451 let session_pacing = pacing::calc(
452 session.utilization_pct,
453 session.resets_at,
454 input.now,
455 session.window_duration,
456 input.pace_tolerance,
457 );
458 let session_bar = if input.tooltip_pace_pts {
459 pango::progress_bar(
460 session.utilization_pct,
461 session_color,
462 theme,
463 Some(session_pacing.elapsed_pct),
464 )
465 } else {
466 pango::progress_bar(session.utilization_pct, session_color, theme, None)
467 };
468 let session_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &session_pacing);
469
470 lines.push(Line::Body(format!(
471 " <span foreground='{fg}'> Session</span>"
472 )));
473 lines.push(Line::Body(format!(
474 " {bar} <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
475 bar = session_bar,
476 color = session_color,
477 pct = session.utilization_pct,
478 glyph = session_pace_glyph
479 )));
480 lines.push(Line::Body(format!(
481 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
482 cd = escape(&countdown::format(session.resets_at, input.now))
483 )));
484 lines.push(Line::Body("".into()));
485 }
486
487 lines.push(Line::Body(format!(
488 " <span foreground='{fg}'> Weekly</span>"
489 )));
490 lines.push(Line::Body(format!(
491 " {bar} <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
492 bar = weekly_bar,
493 color = weekly_color,
494 pct = snap.weekly.utilization_pct,
495 glyph = weekly_pace_glyph
496 )));
497 lines.push(Line::Body(format!(
498 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
499 cd = escape(&countdown::format(snap.weekly.resets_at, input.now))
500 )));
501
502 if let Some(sw) = snap.sonnet.as_ref() {
503 let sonnet_color = pango::severity_color(severity_for(sw.utilization_pct), theme);
504 let sonnet_pacing = pacing::calc(
505 sw.utilization_pct,
506 sw.resets_at,
507 input.now,
508 sw.window_duration,
509 input.pace_tolerance,
510 );
511 let sonnet_bar = if input.tooltip_pace_pts {
512 pango::progress_bar(
513 sw.utilization_pct,
514 sonnet_color,
515 theme,
516 Some(sonnet_pacing.elapsed_pct),
517 )
518 } else {
519 pango::progress_bar(sw.utilization_pct, sonnet_color, theme, None)
520 };
521 lines.push(Line::Body("".into()));
522 lines.push(Line::Body(format!(
523 " <span foreground='{fg}'> Sonnet only</span>"
524 )));
525 lines.push(Line::Body(format!(
526 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>",
527 bar = sonnet_bar,
528 color = sonnet_color,
529 pct = sw.utilization_pct
530 )));
531 lines.push(Line::Body(format!(
532 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
533 cd = escape(&countdown::format(sw.resets_at, input.now))
534 )));
535 }
536
537 for sw in &snap.scoped {
538 let scoped_color = pango::severity_color(severity_for(sw.window.utilization_pct), theme);
539 let scoped_pacing = pacing::calc(
540 sw.window.utilization_pct,
541 sw.window.resets_at,
542 input.now,
543 sw.window.window_duration,
544 input.pace_tolerance,
545 );
546 let scoped_bar = if input.tooltip_pace_pts {
547 pango::progress_bar(
548 sw.window.utilization_pct,
549 scoped_color,
550 theme,
551 Some(scoped_pacing.elapsed_pct),
552 )
553 } else {
554 pango::progress_bar(sw.window.utilization_pct, scoped_color, theme, None)
555 };
556 lines.push(Line::Body("".into()));
557 lines.push(Line::Body(format!(
558 " <span foreground='{fg}'> {label} weekly</span>",
559 label = escape(&sw.label)
560 )));
561 lines.push(Line::Body(format!(
562 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>",
563 bar = scoped_bar,
564 color = scoped_color,
565 pct = sw.window.utilization_pct
566 )));
567 lines.push(Line::Body(format!(
568 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
569 cd = escape(&countdown::format(sw.window.resets_at, input.now))
570 )));
571 }
572
573 if let Some(extra) = snap.extra.as_ref() {
574 let extra_color = pango::severity_color(severity_for(extra.percent()), theme);
575 let extra_bar = pango::progress_bar(extra.percent(), extra_color, theme, None);
576 lines.push(Line::Body("".into()));
577 lines.push(Line::Sep);
578 lines.push(Line::Body(format!(
579 " <span foreground='{fg}'> Extra usage</span>"
580 )));
581 lines.push(Line::Body(format!(
582 " {bar} <span font_weight='bold' foreground='{color}'>{spent}</span>",
583 bar = extra_bar,
584 color = extra_color,
585 spent = escape(&extra.fmt_spent())
586 )));
587 let lim = match extra.fmt_limit() {
588 Some(l) => l,
589 None => "none reported".into(),
594 };
595 lines.push(Line::Body(format!(
596 " <span foreground='{dim}'> Limit: {lim}</span>",
597 lim = escape(&lim)
598 )));
599 if let Some(note) = extra.status_note() {
600 lines.push(Line::Body(format!(
601 " <span foreground='{dim}'> Status: {note}</span>",
602 note = escape(note)
603 )));
604 }
605 }
606
607 if let Some(cloud) = snap.cloud_credits.as_ref() {
608 let mut detail = format!(
614 "{} left of {}",
615 crate::format::usd(cloud.remaining.0 as f64 / 100.0),
616 crate::format::usd(cloud.limit.0 as f64 / 100.0)
617 );
618 if let Some(when) = cloud.expires_at {
619 detail.push_str(" · expires ");
620 detail.push_str(&crate::format::local_date_hm(when));
621 }
622 if cloud.locked {
623 detail.push_str(" · locked");
624 }
625 lines.push(Line::Body("".into()));
626 lines.push(Line::Sep);
627 lines.push(Line::Body(format!(
628 " <span foreground='{fg}'> Cloud session credits</span>"
629 )));
630 lines.push(Line::Body(format!(
631 " <span foreground='{dim}'> {detail}</span>",
632 detail = escape(&detail)
633 )));
634 }
635
636 if let Some(usage) = snap.usage_credits.as_ref() {
637 lines.push(Line::Body("".into()));
638 lines.push(Line::Sep);
639 lines.push(Line::Body(format!(
640 " <span foreground='{fg}'> Usage credits</span>"
641 )));
642 lines.push(Line::Body(format!(
645 " <span foreground='{dim}'> balance: {balance}</span>",
646 balance = escape(&crate::usage::fmt_minor(
647 usage.balance.0,
648 usage.decimal_places,
649 usage.currency.as_deref(),
650 ))
651 )));
652 if let Some(auto_reload) = usage.auto_reload {
655 lines.push(Line::Body(format!(
656 " <span foreground='{dim}'> auto-reload: {state}</span>",
657 state = if auto_reload { "on" } else { "off" }
658 )));
659 }
660 }
661
662 if snap.reset_credits.available > 0 {
663 lines.push(Line::Body("".into()));
664 lines.push(Line::Sep);
665 lines.push(Line::Body(format!(
666 " <span foreground='{fg}'> Reset credits</span>"
667 )));
668 for line in crate::format::reset_credit_lines(&snap.reset_credits, input.now) {
669 lines.push(Line::Body(format!(
670 " <span foreground='{dim}'> {}</span>",
671 escape(&line)
672 )));
673 }
674 }
675
676 if let Some((code, msg)) = input.outcome.last_error.as_ref()
677 && *code != 0
678 {
679 let (icon, color) = if *code >= 500 {
680 ("", theme.red.as_str())
681 } else {
682 ("", theme.orange.as_str())
683 };
684 lines.push(Line::Body("".into()));
685 lines.push(Line::Sep);
686 lines.push(Line::Body(format!(
687 " <span foreground='{color}'> {icon} HTTP {code}</span>"
688 )));
689 for wrapped in wrap_words(&escape(msg), 35) {
690 lines.push(Line::Body(format!(
691 " <span foreground='{dim}'>{wrapped}</span>"
692 )));
693 }
694 }
695
696 if let Some(text) = input.claude_sessions.and_then(|s| s.summary()) {
697 lines.push(Line::Body("".into()));
698 lines.push(Line::Body(format!(
699 " <span foreground='{dim}'> {text}</span>"
700 )));
701 }
702
703 let updated = updated_at_hm(input.now, input.outcome.cache_age);
704 lines.push(Line::Body("".into()));
705 lines.push(Line::Sep);
706 lines.push(Line::Body(format!(
707 " <span foreground='{dim}'> Updated {updated}</span>"
708 )));
709
710 tooltip::render_bordered(&lines, theme)
711}
712
713fn pick_pace_glyph(point_mode: bool, p: &pacing::Pacing) -> &'static str {
714 if point_mode {
715 p.point_pace.glyph()
716 } else {
717 p.ratio_pace.glyph()
718 }
719}
720
721fn wrap_words(s: &str, width: usize) -> Vec<String> {
724 let mut out = Vec::new();
725 let mut buf = String::new();
726 for word in s.split_whitespace() {
727 if buf.is_empty() {
728 buf = word.into();
729 } else if buf.len() + 1 + word.len() <= width {
730 buf.push(' ');
731 buf.push_str(word);
732 } else {
733 out.push(std::mem::take(&mut buf));
734 buf = word.into();
735 }
736 }
737 if !buf.is_empty() {
738 out.push(buf);
739 }
740 out
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746 use crate::anthropic::fetch::FetchOutcome;
747 use crate::usage::{
748 AnthropicSnapshot, Cents, CloudCredits, ExtraUsage, UsageCredits, UsageWindow,
749 };
750 use chrono::TimeZone;
751
752 fn now() -> DateTime<Utc> {
753 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
754 }
755
756 fn sample_outcome() -> FetchOutcome {
757 let session = UsageWindow {
758 utilization_pct: 62,
759 resets_at: Some(now() + chrono::Duration::minutes(90)),
760 window_duration: chrono::Duration::hours(5),
761 };
762 let weekly = UsageWindow {
763 utilization_pct: 27,
764 resets_at: Some(now() + chrono::Duration::days(4) + chrono::Duration::hours(1)),
765 window_duration: chrono::Duration::days(7),
766 };
767 let sonnet = UsageWindow {
768 utilization_pct: 4,
769 resets_at: Some(now() + chrono::Duration::hours(2) + chrono::Duration::minutes(24)),
770 window_duration: chrono::Duration::days(7),
771 };
772 let snap = AnthropicSnapshot {
773 plan: "Max 5x".into(),
774 session: Some(session),
775 weekly,
776 sonnet: Some(sonnet),
777 scoped: vec![],
778 extra: Some(ExtraUsage {
779 limit: Some(Cents(5000)),
780 spent: Cents(250),
781 currency: None,
782 decimal_places: Some(2),
783 status: crate::usage::ExtraUsageStatus::Active,
784 }),
785 reset_credits: Default::default(),
786 cloud_credits: None,
787 usage_credits: None,
788 };
789 FetchOutcome {
790 snapshot: snap,
791 stale: false,
792 last_error: None,
793 cache_age: Some(std::time::Duration::from_secs(30)),
794 }
795 }
796
797 fn input<'a>(outcome: &'a FetchOutcome, theme: &'a Theme) -> RenderInput<'a> {
798 RenderInput {
799 claude_sessions: None,
800 outcome,
801 theme,
802 format: DEFAULT_FORMAT,
803 tooltip_format: None,
804 icon: None,
805 pace_tolerance: 5,
806 format_pace_color: false,
807 tooltip_pace_pts: false,
808 now: now(),
809 }
810 }
811
812 #[test]
813 fn uncapped_extra_usage_renders_spend_with_dash_limit() {
814 let mut oc = sample_outcome();
819 if let Some(e) = oc.snapshot.extra.as_mut() {
820 e.limit = None;
821 e.spent = Cents(14157);
822 }
823 let theme = Theme::default();
824 let mut inp = input(&oc, &theme);
825 inp.format = "{extra_spent}|{extra_limit}|{extra_pct}";
826 let out = render_anthropic(&inp);
827 assert!(out.text.contains("$141.57|—|0"), "got: {}", out.text);
828
829 let inp2 = input(&oc, &theme);
832 let out2 = render_anthropic(&inp2);
833 assert!(out2.tooltip.contains("$141.57"));
834 assert!(out2.tooltip.contains("none reported"));
835 assert!(!out2.tooltip.contains("Limit: $0.00"));
836 }
837
838 #[test]
839 fn extra_usage_placeholders_and_tooltip_use_the_blocks_currency() {
840 let mut oc = sample_outcome();
844 if let Some(e) = oc.snapshot.extra.as_mut() {
845 e.limit = None;
846 e.spent = Cents(14157);
847 e.currency = Some("BRL".into());
848 }
849 let theme = Theme::default();
850 let mut inp = input(&oc, &theme);
851 inp.format = "{extra_spent}";
852 let out = render_anthropic(&inp);
853 assert!(out.text.contains("R$141.57"), "got: {}", out.text);
854 assert!(!out.text.contains("$141.57|"), "got: {}", out.text);
855
856 let inp2 = input(&oc, &theme);
857 let out2 = render_anthropic(&inp2);
858 assert!(
859 out2.tooltip.contains("R$141.57"),
860 "tooltip must carry the block's currency"
861 );
862 }
863
864 #[test]
865 fn provider_disabled_extra_usage_explains_why_it_stopped() {
866 let mut oc = sample_outcome();
867 oc.snapshot.extra.as_mut().unwrap().status = crate::usage::ExtraUsageStatus::OutOfCredits;
868 let theme = Theme::default();
869 let out = render_anthropic(&input(&oc, &theme));
870 assert!(
871 out.tooltip.contains("Status: out of credits"),
872 "{}",
873 out.tooltip
874 );
875 assert!(out.tooltip.contains("$2.50"), "{}", out.tooltip);
876 }
877
878 #[test]
879 fn default_format_renders_pct_and_reset() {
880 let oc = sample_outcome();
881 let theme = Theme::default();
882 let out = render_anthropic(&input(&oc, &theme));
883 assert!(out.text.contains("62%"));
886 assert!(out.text.contains("1h 30m"));
887 assert_eq!(out.class, Class::Mid); }
889
890 #[test]
891 fn stale_appends_pause_indicator() {
892 let mut oc = sample_outcome();
893 oc.stale = true;
894 let theme = Theme::default();
895 let out = render_anthropic(&input(&oc, &theme));
896 assert!(out.text.contains("⏸"));
897 }
898
899 #[test]
900 fn icon_prepends() {
901 let oc = sample_outcome();
902 let theme = Theme::default();
903 let mut inp = input(&oc, &theme);
904 inp.icon = Some("");
905 let out = render_anthropic(&inp);
906 assert!(out.text.contains(" "));
907 }
908
909 #[test]
910 fn custom_tooltip_format_uses_placeholders() {
911 let oc = sample_outcome();
912 let theme = Theme::default();
913 let mut inp = input(&oc, &theme);
914 inp.tooltip_format = Some("S:{session_pct} W:{weekly_pct}");
915 let out = render_anthropic(&inp);
916 assert_eq!(out.tooltip, "S:62 W:27");
917 }
918
919 #[test]
920 fn scoped_placeholders_expose_model_scoped_window() {
921 let mut oc = sample_outcome();
924 oc.snapshot.scoped = vec![crate::usage::ScopedWindow {
925 label: "Fable".into(),
926 window: UsageWindow {
927 utilization_pct: 84,
928 resets_at: Some(now() + chrono::Duration::days(5)),
929 window_duration: chrono::Duration::days(7),
930 },
931 }];
932 let theme = Theme::default();
933 let mut inp = input(&oc, &theme);
934 inp.tooltip_format = Some(
935 "M:{scoped_model} P:{scoped_pct} R:{scoped_reset} E:{scoped_elapsed} B:{scoped_bar}",
936 );
937 let out = render_anthropic(&inp);
938 assert!(out.tooltip.starts_with("M:Fable P:84 R:"));
939 for placeholder in [
940 "{scoped_model}",
941 "{scoped_pct}",
942 "{scoped_reset}",
943 "{scoped_elapsed}",
944 "{scoped_bar}",
945 ] {
946 assert!(!out.tooltip.contains(placeholder));
947 }
948 }
949
950 #[test]
951 fn scoped_placeholders_are_neutral_when_absent() {
952 let oc = sample_outcome(); let theme = Theme::default();
954 let mut inp = input(&oc, &theme);
955 inp.tooltip_format = Some("[{scoped_model}] {scoped_pct} {scoped_reset}");
956 let out = render_anthropic(&inp);
957 assert_eq!(out.tooltip, "[] 0 —");
958 }
959
960 #[test]
961 fn plan_and_scoped_model_are_pango_escaped_in_custom_formats() {
962 let mut oc = sample_outcome();
966 oc.snapshot.plan = "Max <5x> & Team".into();
967 oc.snapshot.scoped = vec![crate::usage::ScopedWindow {
968 label: "Fable & <Opus>".into(),
969 window: UsageWindow {
970 utilization_pct: 84,
971 resets_at: Some(now() + chrono::Duration::days(5)),
972 window_duration: chrono::Duration::days(7),
973 },
974 }];
975 let theme = Theme::default();
976 let mut inp = input(&oc, &theme);
977 inp.format = "{plan} · {scoped_model}";
978 inp.tooltip_format = Some("{plan} · {scoped_model}");
979 let out = render_anthropic(&inp);
980 let escaped = "Max <5x> & Team · Fable & <Opus>";
981 assert!(out.text.contains(escaped), "{}", out.text);
982 assert!(!out.text.contains("<5x>"), "{}", out.text);
983 assert_eq!(out.tooltip, escaped);
984 }
985
986 #[test]
987 fn tooltip_lists_cloud_session_and_usage_credits() {
988 let mut oc = sample_outcome();
989 oc.snapshot.cloud_credits = Some(CloudCredits {
990 limit: Cents(25000),
991 remaining: Cents(25000),
992 expires_at: Some(now() + chrono::Duration::days(10)),
993 locked: false,
994 });
995 oc.snapshot.usage_credits = Some(UsageCredits {
996 balance: Cents(1500),
997 decimal_places: 2,
998 currency: Some("USD".into()),
999 auto_reload: Some(true),
1000 });
1001 let theme = Theme::default();
1002 let out = render_anthropic(&input(&oc, &theme));
1003 let t = &out.tooltip;
1004 assert!(t.contains("Cloud session credits"), "{}", t);
1005 assert!(
1006 t.contains("$250.00 left of $250.00 · expires "),
1007 "cloud line: {}",
1008 t
1009 );
1010 assert!(t.contains("Usage credits"), "{}", t);
1011 assert!(t.contains("balance: $15.00"), "{}", t);
1012 assert!(t.contains("auto-reload: on"), "{}", t);
1013 let extra_at = t.find("Extra usage").unwrap();
1016 let cloud_at = t.find("Cloud session credits").unwrap();
1017 let usage_at = t.find("Usage credits").unwrap();
1018 let updated_at = t.find("Updated").unwrap();
1019 assert!(
1020 extra_at < cloud_at && cloud_at < usage_at && usage_at < updated_at,
1021 "{}",
1022 t
1023 );
1024
1025 let quiet = sample_outcome();
1028 let out = render_anthropic(&input(&quiet, &theme));
1029 assert!(
1030 !out.tooltip.contains("Cloud session credits"),
1031 "{}",
1032 out.tooltip
1033 );
1034 assert!(!out.tooltip.contains("Usage credits"), "{}", out.tooltip);
1035 assert!(!out.tooltip.contains("balance: $0.00"), "{}", out.tooltip);
1036 }
1037
1038 #[test]
1039 fn tooltip_lock_line_escapes_a_hostile_locked_reason() {
1040 let raw = r#"{
1044 "five_hour": {"utilization": 12},
1045 "seven_day": {"utilization": 34},
1046 "iguana_necktie": {"resets_at": "2026-11-05T07:59:00Z",
1047 "limit_dollars": 250, "remaining_dollars": 250,
1048 "locked_reason": "<b>org_migrating</b>"}
1049 }"#;
1050 let resp: crate::anthropic::types::UsageResponse = serde_json::from_str(raw).unwrap();
1051 let mut oc = sample_outcome();
1052 oc.snapshot = resp.into_snapshot("Max 5x".into());
1053 let theme = Theme::default();
1054 let out = render_anthropic(&input(&oc, &theme));
1055 assert!(
1056 out.tooltip.contains("Cloud session credits"),
1057 "{}",
1058 out.tooltip
1059 );
1060 assert!(out.tooltip.contains("locked"), "{}", out.tooltip);
1061 assert!(!out.tooltip.contains("<b>"), "{}", out.tooltip);
1062 assert!(!out.tooltip.contains("org_migrating"), "{}", out.tooltip);
1063 }
1064
1065 #[test]
1066 fn default_tooltip_contains_all_sections() {
1067 let oc = sample_outcome();
1068 let theme = Theme::default();
1069 let out = render_anthropic(&input(&oc, &theme));
1070 assert!(out.tooltip.contains("Claude Max 5x"));
1071 assert!(out.tooltip.contains("Session"));
1072 assert!(out.tooltip.contains("Weekly"));
1073 assert!(out.tooltip.contains("Sonnet only"));
1074 assert!(out.tooltip.contains("Extra usage"));
1075 assert!(out.tooltip.contains("Updated"));
1076 assert!(out.tooltip.contains("62%"));
1077 assert!(out.tooltip.contains("27%"));
1078 assert!(out.tooltip.contains("$2.50"));
1079 assert!(out.tooltip.contains("$50.00"));
1080 }
1081
1082 #[test]
1083 fn tooltip_omits_sonnet_and_extra_when_absent() {
1084 let mut oc = sample_outcome();
1085 oc.snapshot.sonnet = None;
1086 oc.snapshot.extra = None;
1087 let theme = Theme::default();
1088 let out = render_anthropic(&input(&oc, &theme));
1089 assert!(!out.tooltip.contains("Sonnet only"));
1090 assert!(!out.tooltip.contains("Extra usage"));
1091 assert!(out.tooltip.contains("Session"));
1093 assert!(out.tooltip.contains("Weekly"));
1094 }
1095
1096 #[test]
1097 fn tooltip_reports_banked_resets_and_stays_silent_without_them() {
1098 let theme = Theme::default();
1099 let mut oc = sample_outcome();
1100
1101 let out = render_anthropic(&input(&oc, &theme));
1103 assert!(!out.tooltip.contains("Reset credits"), "{}", out.tooltip);
1104
1105 oc.snapshot.reset_credits = crate::usage::ResetCredits {
1106 available: 1,
1107 credits: vec![crate::usage::ResetCredit {
1108 title: Some("Opus 5.5 launch reset".into()),
1109 expires_at: Some(now() + chrono::Duration::days(28)),
1110 }],
1111 };
1112 let out = render_anthropic(&input(&oc, &theme));
1113 assert!(out.tooltip.contains("Reset credits"), "{}", out.tooltip);
1114 assert!(
1115 out.tooltip.contains("Opus 5.5 launch reset"),
1116 "{}",
1117 out.tooltip
1118 );
1119 assert!(out.tooltip.contains("expires"), "{}", out.tooltip);
1124 assert!(out.tooltip.contains("28d"), "{}", out.tooltip);
1125
1126 let values = build_placeholders(&input(&oc, &theme));
1127 assert_eq!(values["resets_available"], "1");
1128 assert_eq!(values["resets"], "1 reset available");
1129 }
1130
1131 #[test]
1132 fn tooltip_includes_http_error_when_last_error_present() {
1133 let mut oc = sample_outcome();
1134 oc.last_error = Some((429, "rate limited".into()));
1135 let theme = Theme::default();
1136 let out = render_anthropic(&input(&oc, &theme));
1137 assert!(out.tooltip.contains("HTTP 429"));
1138 assert!(out.tooltip.contains("rate limited"));
1139 }
1140
1141 #[test]
1142 fn tooltip_omits_http_zero() {
1143 let mut oc = sample_outcome();
1146 oc.last_error = Some((0, "n/a".into()));
1147 let theme = Theme::default();
1148 let out = render_anthropic(&input(&oc, &theme));
1149 assert!(!out.tooltip.contains("HTTP 0"));
1150 }
1151
1152 #[test]
1153 fn worst_window_promotes_class_to_critical() {
1154 let mut oc = sample_outcome();
1155 oc.snapshot.weekly.utilization_pct = 95;
1156 let theme = Theme::default();
1157 let out = render_anthropic(&input(&oc, &theme));
1158 assert_eq!(out.class, Class::Critical);
1159 }
1160
1161 #[test]
1162 fn pace_color_mode_uses_neutral_wrapper() {
1163 let oc = sample_outcome();
1164 let theme = Theme::default();
1165 let mut inp = input(&oc, &theme);
1166 inp.format = "{session_pct}% {session_pace}";
1167 inp.format_pace_color = true;
1168 let out = render_anthropic(&inp);
1169 assert!(out.text.contains(&theme.fg));
1171 }
1172
1173 fn without_session(mut oc: FetchOutcome) -> FetchOutcome {
1175 oc.snapshot.plan = "Ultra".into();
1176 oc.snapshot.session = None;
1177 oc
1178 }
1179
1180 #[test]
1181 fn without_a_session_window_the_default_bar_shows_the_weekly_window() {
1182 let oc = without_session(sample_outcome());
1183 let theme = Theme::default();
1184 let mut inp = input(&oc, &theme);
1185 inp.format = default_format(&oc.snapshot);
1186 let out = render_anthropic(&inp);
1187 assert!(out.text.contains(">27% · 4d 1h<"), "{}", out.text);
1188 assert_eq!(default_format(&sample_outcome().snapshot), DEFAULT_FORMAT);
1190 }
1191
1192 #[test]
1193 fn without_a_session_window_the_tooltip_has_no_session_block() {
1194 let oc = without_session(sample_outcome());
1195 let theme = Theme::default();
1196 let out = render_anthropic(&input(&oc, &theme));
1197 assert!(out.tooltip.contains("Claude Ultra"), "{}", out.tooltip);
1198 assert!(!out.tooltip.contains("Session"), "{}", out.tooltip);
1199 assert!(!out.tooltip.contains("Resets in 1h 30m"), "{}", out.tooltip);
1200 assert!(out.tooltip.contains(" Weekly"), "{}", out.tooltip);
1201 assert!(out.tooltip.contains("Resets in 4d 1h"), "{}", out.tooltip);
1202 }
1203
1204 #[test]
1208 fn without_a_session_window_its_placeholders_are_empty() {
1209 let oc = without_session(sample_outcome());
1210 let theme = Theme::default();
1211 let values = build_placeholders(&input(&oc, &theme));
1212 for key in [
1213 "session_pct",
1214 "session_reset",
1215 "session_elapsed",
1216 "session_bar",
1217 "session_pace",
1218 "session_pace_indicator",
1219 "session_pace_pct",
1220 "session_pace_pts",
1221 "session_pace_delta",
1222 "session_pace_abs_delta",
1223 ] {
1224 assert_eq!(values[key], "", "{key}");
1225 }
1226 assert_eq!(values["weekly_pct"], "27");
1227 assert_eq!(values["plan"], "Ultra");
1228
1229 let mut inp = input(&oc, &theme);
1230 inp.format = "[{session_pct}{session_pace}] {weekly_pct}%";
1231 inp.format_pace_color = true;
1232 let out = render_anthropic(&inp);
1233 assert!(out.text.contains(">[] 27%<"), "{}", out.text);
1234 }
1235
1236 #[test]
1237 fn wrap_words_breaks_on_width_boundary() {
1238 let lines = wrap_words("aaa bbb ccc ddd eee fff", 8);
1239 assert_eq!(lines, vec!["aaa bbb", "ccc ddd", "eee fff"]);
1241 }
1242
1243 #[test]
1246 fn tooltip_names_working_and_waiting_sessions_only_when_there_are_some() {
1247 use crate::context::activity::SessionActivity;
1248 let oc = sample_outcome();
1249 let theme = Theme::default();
1250
1251 let active = SessionActivity {
1252 working: 2,
1253 waiting: 1,
1254 };
1255 let mut inp = input(&oc, &theme);
1256 inp.claude_sessions = Some(&active);
1257 let out = render_anthropic(&inp);
1258 assert!(
1259 out.tooltip.contains("2 working · 1 waiting"),
1260 "{}",
1261 out.tooltip
1262 );
1263
1264 let idle = SessionActivity::default();
1265 let mut inp = input(&oc, &theme);
1266 inp.claude_sessions = Some(&idle);
1267 let quiet = render_anthropic(&inp);
1268 assert!(!quiet.tooltip.contains("working"), "{}", quiet.tooltip);
1269 assert!(!quiet.tooltip.contains("waiting"), "{}", quiet.tooltip);
1270 }
1271}