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::{ExtraUsage, anthropic_severity};
19use crate::vendor::VendorId;
20use crate::waybar::{Class, WaybarOutput};
21
22pub const DEFAULT_FORMAT: &str = "{session_pct}% · {session_reset}";
24
25pub struct RenderInput<'a> {
28 pub outcome: &'a FetchOutcome,
29 pub theme: &'a Theme,
30 pub format: &'a str,
31 pub tooltip_format: Option<&'a str>,
32 pub icon: Option<&'a str>,
33 pub pace_tolerance: u32,
34 pub format_pace_color: bool,
35 pub tooltip_pace_pts: bool,
36 pub now: DateTime<Utc>,
37 pub claude_sessions: Option<&'a crate::context::activity::SessionActivity>,
40}
41
42pub fn render_anthropic(input: &RenderInput) -> WaybarOutput {
44 let snap = &input.outcome.snapshot;
45 let class = Class::from(anthropic_severity(snap));
46 let bar_text = render_bar_text(input, class);
47 let tooltip = if let Some(fmt) = input.tooltip_format {
48 let values = build_placeholders(input);
50 substitute(fmt, &values)
51 } else {
52 render_default_tooltip(input)
53 };
54
55 WaybarOutput {
56 text: bar_text,
57 tooltip,
58 class,
59 }
60}
61
62fn render_bar_text(input: &RenderInput, class: Class) -> String {
65 let values = build_placeholders(input);
66 let mut text = substitute(input.format, &values);
67
68 if input.outcome.stale {
70 text.push_str(" ⏸");
71 }
72
73 let wrapper_color = if input.format_pace_color && input.format.contains("_pace") {
76 input.theme.fg.clone()
77 } else {
78 bar_color_for(class, input.theme).to_string()
79 };
80 let icon_prefix = match input.icon {
81 Some(ic) if !ic.is_empty() => format!("{ic} "),
82 _ => String::new(),
83 };
84 color_span(&wrapper_color, &format!("{icon_prefix}{text}"))
85}
86
87fn bar_color_for(class: Class, theme: &Theme) -> &str {
88 match class {
89 Class::Low => &theme.green,
90 Class::Mid => &theme.yellow,
91 Class::High => &theme.orange,
92 Class::Critical => &theme.red,
93 }
94}
95
96fn build_placeholders(input: &RenderInput) -> HashMap<&'static str, String> {
102 let snap = &input.outcome.snapshot;
103 let theme = input.theme;
104
105 let session = pacing::calc(
106 snap.session.utilization_pct,
107 snap.session.resets_at,
108 input.now,
109 snap.session.window_duration,
110 input.pace_tolerance,
111 );
112 let weekly = pacing::calc(
113 snap.weekly.utilization_pct,
114 snap.weekly.resets_at,
115 input.now,
116 snap.weekly.window_duration,
117 input.pace_tolerance,
118 );
119 let sonnet_window = snap.sonnet.as_ref();
120 let sonnet = sonnet_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
130 let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
131 let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
132 let sonnet_color =
133 sonnet_window.map(|w| pango::severity_color(severity_for(w.utilization_pct), theme));
134 let extra_color = snap
135 .extra
136 .as_ref()
137 .map(|e| pango::severity_color(severity_for(e.percent()), theme));
138
139 let session_bar = pango::progress_bar(snap.session.utilization_pct, session_color, theme, None);
140 let weekly_bar = pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None);
141 let sonnet_bar = if let (Some(w), Some(c)) = (sonnet_window, sonnet_color) {
142 pango::progress_bar(w.utilization_pct, c, theme, None)
143 } else {
144 String::new()
145 };
146 let extra_bar = if let (Some(e), Some(c)) = (snap.extra.as_ref(), extra_color) {
147 pango::progress_bar(e.percent(), c, theme, None)
148 } else {
149 String::new()
150 };
151
152 let scoped0 = snap.scoped.first();
159 let scoped0_pacing = scoped0.map(|s| {
160 pacing::calc(
161 s.window.utilization_pct,
162 s.window.resets_at,
163 input.now,
164 s.window.window_duration,
165 input.pace_tolerance,
166 )
167 });
168 let scoped0_bar = if let Some(s) = scoped0 {
169 let c = pango::severity_color(severity_for(s.window.utilization_pct), theme);
170 pango::progress_bar(s.window.utilization_pct, c, theme, None)
171 } else {
172 String::new()
173 };
174
175 let mut v = placeholders(vec![
176 ("icon", "".to_string()),
177 ("vendor_short", VendorId::Anthropic.short_name().to_string()),
178 ("plan", snap.plan.clone()),
179 ("session_pct", snap.session.utilization_pct.to_string()),
180 (
181 "session_reset",
182 countdown::format(snap.session.resets_at, input.now),
183 ),
184 ("session_elapsed", session.elapsed_pct.to_string()),
185 ("session_bar", session_bar.clone()),
186 ("weekly_pct", snap.weekly.utilization_pct.to_string()),
187 (
188 "weekly_reset",
189 countdown::format(snap.weekly.resets_at, input.now),
190 ),
191 ("weekly_elapsed", weekly.elapsed_pct.to_string()),
192 ("weekly_bar", weekly_bar.clone()),
193 (
194 "sonnet_pct",
195 sonnet_window
196 .map(|w| w.utilization_pct.to_string())
197 .unwrap_or_else(|| "0".into()),
198 ),
199 (
200 "sonnet_reset",
201 sonnet_window
202 .map(|w| countdown::format(w.resets_at, input.now))
203 .unwrap_or_else(|| "—".into()),
204 ),
205 (
206 "sonnet_elapsed",
207 sonnet
208 .as_ref()
209 .map(|s| s.elapsed_pct.to_string())
210 .unwrap_or_else(|| "0".into()),
211 ),
212 ("sonnet_bar", sonnet_bar.clone()),
213 (
215 "scoped_model",
216 scoped0.map(|s| s.label.clone()).unwrap_or_default(),
217 ),
218 (
219 "scoped_pct",
220 scoped0
221 .map(|s| s.window.utilization_pct.to_string())
222 .unwrap_or_else(|| "0".into()),
223 ),
224 (
225 "scoped_reset",
226 scoped0
227 .map(|s| countdown::format(s.window.resets_at, input.now))
228 .unwrap_or_else(|| "—".into()),
229 ),
230 (
231 "scoped_elapsed",
232 scoped0_pacing
233 .as_ref()
234 .map(|p| p.elapsed_pct.to_string())
235 .unwrap_or_else(|| "0".into()),
236 ),
237 ("scoped_bar", scoped0_bar.clone()),
238 (
239 "extra_spent",
240 snap.extra
241 .as_ref()
242 .map(ExtraUsage::fmt_spent)
243 .unwrap_or_default(),
244 ),
245 (
246 "extra_limit",
247 snap.extra
251 .as_ref()
252 .map(|e| e.fmt_limit().unwrap_or_else(|| "—".into()))
253 .unwrap_or_default(),
254 ),
255 (
256 "extra_pct",
257 snap.extra
258 .as_ref()
259 .map(|e| e.percent().to_string())
260 .unwrap_or_else(|| "0".into()),
261 ),
262 ("extra_bar", extra_bar),
263 ("resets_available", snap.reset_credits.available.to_string()),
264 ("resets", crate::format::reset_credits(&snap.reset_credits)),
265 ]);
266
267 insert_pace(&mut v, "session", &session, input.format_pace_color, theme);
268 insert_pace(&mut v, "weekly", &weekly, input.format_pace_color, theme);
269 if let Some(sp) = sonnet.as_ref() {
270 insert_pace(&mut v, "sonnet", sp, input.format_pace_color, theme);
271 } else {
272 insert_pace(
275 &mut v,
276 "sonnet",
277 &pacing::Pacing::neutral(),
278 input.format_pace_color,
279 theme,
280 );
281 }
282 for key in ["plan", "scoped_model"] {
287 if let Some(value) = v.get_mut(key) {
288 *value = escape(value);
289 }
290 }
291 v
292}
293
294fn insert_pace(
295 map: &mut HashMap<&'static str, String>,
296 prefix: &'static str,
297 p: &pacing::Pacing,
298 pace_color: bool,
299 theme: &Theme,
300) {
301 let pace_glyph = p.ratio_pace.glyph();
302 let indicator_glyph = p.point_pace.glyph();
303 let delta = p.delta.to_string();
304 let abs_delta = p.delta.unsigned_abs().to_string();
305 let pct = &p.ratio_label;
306 let pts = &p.point_label;
307
308 let wrap = |s: &str| -> String {
309 if pace_color {
310 let sev = pacing::pace_severity(p.delta);
311 let color = pango::severity_color(sev, theme);
312 color_span(color, s)
313 } else {
314 s.to_string()
315 }
316 };
317
318 let keys: [(&'static str, String); 6] = match prefix {
319 "session" => [
320 ("session_pace", wrap(pace_glyph)),
321 ("session_pace_indicator", wrap(indicator_glyph)),
322 ("session_pace_pct", wrap(pct)),
323 ("session_pace_pts", wrap(pts)),
324 ("session_pace_delta", wrap(&delta)),
325 ("session_pace_abs_delta", wrap(&abs_delta)),
326 ],
327 "weekly" => [
328 ("weekly_pace", wrap(pace_glyph)),
329 ("weekly_pace_indicator", wrap(indicator_glyph)),
330 ("weekly_pace_pct", wrap(pct)),
331 ("weekly_pace_pts", wrap(pts)),
332 ("weekly_pace_delta", wrap(&delta)),
333 ("weekly_pace_abs_delta", wrap(&abs_delta)),
334 ],
335 "sonnet" => [
336 ("sonnet_pace", wrap(pace_glyph)),
337 ("sonnet_pace_indicator", wrap(indicator_glyph)),
338 ("sonnet_pace_pct", wrap(pct)),
339 ("sonnet_pace_pts", wrap(pts)),
340 ("sonnet_pace_delta", wrap(&delta)),
341 ("sonnet_pace_abs_delta", wrap(&abs_delta)),
342 ],
343 _ => return,
344 };
345 for (k, v) in keys {
346 map.insert(k, v);
347 }
348}
349
350fn render_default_tooltip(input: &RenderInput) -> String {
352 let snap = &input.outcome.snapshot;
353 let theme = input.theme;
354 let blue = &theme.blue;
355 let dim = &theme.dim;
356 let fg = &theme.fg;
357
358 let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
359 let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
360
361 let session_pacing = pacing::calc(
362 snap.session.utilization_pct,
363 snap.session.resets_at,
364 input.now,
365 snap.session.window_duration,
366 input.pace_tolerance,
367 );
368 let weekly_pacing = pacing::calc(
369 snap.weekly.utilization_pct,
370 snap.weekly.resets_at,
371 input.now,
372 snap.weekly.window_duration,
373 input.pace_tolerance,
374 );
375
376 let session_bar = if input.tooltip_pace_pts {
377 pango::progress_bar(
378 snap.session.utilization_pct,
379 session_color,
380 theme,
381 Some(session_pacing.elapsed_pct),
382 )
383 } else {
384 pango::progress_bar(snap.session.utilization_pct, session_color, theme, None)
385 };
386 let weekly_bar = if input.tooltip_pace_pts {
387 pango::progress_bar(
388 snap.weekly.utilization_pct,
389 weekly_color,
390 theme,
391 Some(weekly_pacing.elapsed_pct),
392 )
393 } else {
394 pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None)
395 };
396
397 let session_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &session_pacing);
398 let weekly_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &weekly_pacing);
399
400 let mut lines: Vec<Line> = Vec::new();
401 let _ = pango::severity_color; lines.push(Line::Center(format!(
403 "<span font_weight='bold' foreground='{blue}'>Claude {plan}</span>",
404 plan = escape(&snap.plan)
405 )));
406 lines.push(Line::Sep);
407 lines.push(Line::Body("".into()));
408
409 lines.push(Line::Body(format!(
410 " <span foreground='{fg}'> Session</span>"
411 )));
412 lines.push(Line::Body(format!(
413 " {bar} <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
414 bar = session_bar,
415 color = session_color,
416 pct = snap.session.utilization_pct,
417 glyph = session_pace_glyph
418 )));
419 lines.push(Line::Body(format!(
420 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
421 cd = escape(&countdown::format(snap.session.resets_at, input.now))
422 )));
423 lines.push(Line::Body("".into()));
424
425 lines.push(Line::Body(format!(
426 " <span foreground='{fg}'> Weekly</span>"
427 )));
428 lines.push(Line::Body(format!(
429 " {bar} <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
430 bar = weekly_bar,
431 color = weekly_color,
432 pct = snap.weekly.utilization_pct,
433 glyph = weekly_pace_glyph
434 )));
435 lines.push(Line::Body(format!(
436 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
437 cd = escape(&countdown::format(snap.weekly.resets_at, input.now))
438 )));
439
440 if let Some(sw) = snap.sonnet.as_ref() {
441 let sonnet_color = pango::severity_color(severity_for(sw.utilization_pct), theme);
442 let sonnet_pacing = pacing::calc(
443 sw.utilization_pct,
444 sw.resets_at,
445 input.now,
446 sw.window_duration,
447 input.pace_tolerance,
448 );
449 let sonnet_bar = if input.tooltip_pace_pts {
450 pango::progress_bar(
451 sw.utilization_pct,
452 sonnet_color,
453 theme,
454 Some(sonnet_pacing.elapsed_pct),
455 )
456 } else {
457 pango::progress_bar(sw.utilization_pct, sonnet_color, theme, None)
458 };
459 lines.push(Line::Body("".into()));
460 lines.push(Line::Body(format!(
461 " <span foreground='{fg}'> Sonnet only</span>"
462 )));
463 lines.push(Line::Body(format!(
464 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>",
465 bar = sonnet_bar,
466 color = sonnet_color,
467 pct = sw.utilization_pct
468 )));
469 lines.push(Line::Body(format!(
470 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
471 cd = escape(&countdown::format(sw.resets_at, input.now))
472 )));
473 }
474
475 for sw in &snap.scoped {
476 let scoped_color = pango::severity_color(severity_for(sw.window.utilization_pct), theme);
477 let scoped_pacing = pacing::calc(
478 sw.window.utilization_pct,
479 sw.window.resets_at,
480 input.now,
481 sw.window.window_duration,
482 input.pace_tolerance,
483 );
484 let scoped_bar = if input.tooltip_pace_pts {
485 pango::progress_bar(
486 sw.window.utilization_pct,
487 scoped_color,
488 theme,
489 Some(scoped_pacing.elapsed_pct),
490 )
491 } else {
492 pango::progress_bar(sw.window.utilization_pct, scoped_color, theme, None)
493 };
494 lines.push(Line::Body("".into()));
495 lines.push(Line::Body(format!(
496 " <span foreground='{fg}'> {label} weekly</span>",
497 label = escape(&sw.label)
498 )));
499 lines.push(Line::Body(format!(
500 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>",
501 bar = scoped_bar,
502 color = scoped_color,
503 pct = sw.window.utilization_pct
504 )));
505 lines.push(Line::Body(format!(
506 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
507 cd = escape(&countdown::format(sw.window.resets_at, input.now))
508 )));
509 }
510
511 if let Some(extra) = snap.extra.as_ref() {
512 let extra_color = pango::severity_color(severity_for(extra.percent()), theme);
513 let extra_bar = pango::progress_bar(extra.percent(), extra_color, theme, None);
514 lines.push(Line::Body("".into()));
515 lines.push(Line::Sep);
516 lines.push(Line::Body(format!(
517 " <span foreground='{fg}'> Extra usage</span>"
518 )));
519 lines.push(Line::Body(format!(
520 " {bar} <span font_weight='bold' foreground='{color}'>{spent}</span>",
521 bar = extra_bar,
522 color = extra_color,
523 spent = escape(&extra.fmt_spent())
524 )));
525 let lim = match extra.fmt_limit() {
526 Some(l) => l,
527 None => "none reported".into(),
532 };
533 lines.push(Line::Body(format!(
534 " <span foreground='{dim}'> Limit: {lim}</span>",
535 lim = escape(&lim)
536 )));
537 }
538
539 if snap.reset_credits.available > 0 {
540 lines.push(Line::Body("".into()));
541 lines.push(Line::Sep);
542 lines.push(Line::Body(format!(
543 " <span foreground='{fg}'> Reset credits</span>"
544 )));
545 for line in crate::format::reset_credit_lines(&snap.reset_credits, input.now) {
546 lines.push(Line::Body(format!(
547 " <span foreground='{dim}'> {}</span>",
548 escape(&line)
549 )));
550 }
551 }
552
553 if let Some((code, msg)) = input.outcome.last_error.as_ref()
554 && *code != 0
555 {
556 let (icon, color) = if *code >= 500 {
557 ("", theme.red.as_str())
558 } else {
559 ("", theme.orange.as_str())
560 };
561 lines.push(Line::Body("".into()));
562 lines.push(Line::Sep);
563 lines.push(Line::Body(format!(
564 " <span foreground='{color}'> {icon} HTTP {code}</span>"
565 )));
566 for wrapped in wrap_words(&escape(msg), 35) {
567 lines.push(Line::Body(format!(
568 " <span foreground='{dim}'>{wrapped}</span>"
569 )));
570 }
571 }
572
573 if let Some(text) = input.claude_sessions.and_then(|s| s.summary()) {
574 lines.push(Line::Body("".into()));
575 lines.push(Line::Body(format!(
576 " <span foreground='{dim}'> {text}</span>"
577 )));
578 }
579
580 let updated = updated_at_hm(input.now, input.outcome.cache_age);
581 lines.push(Line::Body("".into()));
582 lines.push(Line::Sep);
583 lines.push(Line::Body(format!(
584 " <span foreground='{dim}'> Updated {updated}</span>"
585 )));
586
587 tooltip::render_bordered(&lines, theme)
588}
589
590fn pick_pace_glyph(point_mode: bool, p: &pacing::Pacing) -> &'static str {
591 if point_mode {
592 p.point_pace.glyph()
593 } else {
594 p.ratio_pace.glyph()
595 }
596}
597
598fn wrap_words(s: &str, width: usize) -> Vec<String> {
601 let mut out = Vec::new();
602 let mut buf = String::new();
603 for word in s.split_whitespace() {
604 if buf.is_empty() {
605 buf = word.into();
606 } else if buf.len() + 1 + word.len() <= width {
607 buf.push(' ');
608 buf.push_str(word);
609 } else {
610 out.push(std::mem::take(&mut buf));
611 buf = word.into();
612 }
613 }
614 if !buf.is_empty() {
615 out.push(buf);
616 }
617 out
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use crate::anthropic::fetch::FetchOutcome;
624 use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, UsageWindow};
625 use chrono::TimeZone;
626
627 fn now() -> DateTime<Utc> {
628 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
629 }
630
631 fn sample_outcome() -> FetchOutcome {
632 let session = UsageWindow {
633 utilization_pct: 62,
634 resets_at: Some(now() + chrono::Duration::minutes(90)),
635 window_duration: chrono::Duration::hours(5),
636 };
637 let weekly = UsageWindow {
638 utilization_pct: 27,
639 resets_at: Some(now() + chrono::Duration::days(4) + chrono::Duration::hours(1)),
640 window_duration: chrono::Duration::days(7),
641 };
642 let sonnet = UsageWindow {
643 utilization_pct: 4,
644 resets_at: Some(now() + chrono::Duration::hours(2) + chrono::Duration::minutes(24)),
645 window_duration: chrono::Duration::days(7),
646 };
647 let snap = AnthropicSnapshot {
648 plan: "Max 5x".into(),
649 session,
650 weekly,
651 sonnet: Some(sonnet),
652 scoped: vec![],
653 extra: Some(ExtraUsage {
654 limit: Some(Cents(5000)),
655 spent: Cents(250),
656 currency: None,
657 decimal_places: Some(2),
658 }),
659 reset_credits: Default::default(),
660 };
661 FetchOutcome {
662 snapshot: snap,
663 stale: false,
664 last_error: None,
665 cache_age: Some(std::time::Duration::from_secs(30)),
666 }
667 }
668
669 fn input<'a>(outcome: &'a FetchOutcome, theme: &'a Theme) -> RenderInput<'a> {
670 RenderInput {
671 claude_sessions: None,
672 outcome,
673 theme,
674 format: DEFAULT_FORMAT,
675 tooltip_format: None,
676 icon: None,
677 pace_tolerance: 5,
678 format_pace_color: false,
679 tooltip_pace_pts: false,
680 now: now(),
681 }
682 }
683
684 #[test]
685 fn uncapped_extra_usage_renders_spend_with_dash_limit() {
686 let mut oc = sample_outcome();
691 if let Some(e) = oc.snapshot.extra.as_mut() {
692 e.limit = None;
693 e.spent = Cents(14157);
694 }
695 let theme = Theme::default();
696 let mut inp = input(&oc, &theme);
697 inp.format = "{extra_spent}|{extra_limit}|{extra_pct}";
698 let out = render_anthropic(&inp);
699 assert!(out.text.contains("$141.57|—|0"), "got: {}", out.text);
700
701 let inp2 = input(&oc, &theme);
704 let out2 = render_anthropic(&inp2);
705 assert!(out2.tooltip.contains("$141.57"));
706 assert!(out2.tooltip.contains("none reported"));
707 assert!(!out2.tooltip.contains("Limit: $0.00"));
708 }
709
710 #[test]
711 fn extra_usage_placeholders_and_tooltip_use_the_blocks_currency() {
712 let mut oc = sample_outcome();
716 if let Some(e) = oc.snapshot.extra.as_mut() {
717 e.limit = None;
718 e.spent = Cents(14157);
719 e.currency = Some("BRL".into());
720 }
721 let theme = Theme::default();
722 let mut inp = input(&oc, &theme);
723 inp.format = "{extra_spent}";
724 let out = render_anthropic(&inp);
725 assert!(out.text.contains("R$141.57"), "got: {}", out.text);
726 assert!(!out.text.contains("$141.57|"), "got: {}", out.text);
727
728 let inp2 = input(&oc, &theme);
729 let out2 = render_anthropic(&inp2);
730 assert!(
731 out2.tooltip.contains("R$141.57"),
732 "tooltip must carry the block's currency"
733 );
734 }
735
736 #[test]
737 fn default_format_renders_pct_and_reset() {
738 let oc = sample_outcome();
739 let theme = Theme::default();
740 let out = render_anthropic(&input(&oc, &theme));
741 assert!(out.text.contains("62%"));
744 assert!(out.text.contains("1h 30m"));
745 assert_eq!(out.class, Class::Mid); }
747
748 #[test]
749 fn stale_appends_pause_indicator() {
750 let mut oc = sample_outcome();
751 oc.stale = true;
752 let theme = Theme::default();
753 let out = render_anthropic(&input(&oc, &theme));
754 assert!(out.text.contains("⏸"));
755 }
756
757 #[test]
758 fn icon_prepends() {
759 let oc = sample_outcome();
760 let theme = Theme::default();
761 let mut inp = input(&oc, &theme);
762 inp.icon = Some("");
763 let out = render_anthropic(&inp);
764 assert!(out.text.contains(" "));
765 }
766
767 #[test]
768 fn custom_tooltip_format_uses_placeholders() {
769 let oc = sample_outcome();
770 let theme = Theme::default();
771 let mut inp = input(&oc, &theme);
772 inp.tooltip_format = Some("S:{session_pct} W:{weekly_pct}");
773 let out = render_anthropic(&inp);
774 assert_eq!(out.tooltip, "S:62 W:27");
775 }
776
777 #[test]
778 fn scoped_placeholders_expose_model_scoped_window() {
779 let mut oc = sample_outcome();
782 oc.snapshot.scoped = vec![crate::usage::ScopedWindow {
783 label: "Fable".into(),
784 window: UsageWindow {
785 utilization_pct: 84,
786 resets_at: Some(now() + chrono::Duration::days(5)),
787 window_duration: chrono::Duration::days(7),
788 },
789 }];
790 let theme = Theme::default();
791 let mut inp = input(&oc, &theme);
792 inp.tooltip_format = Some(
793 "M:{scoped_model} P:{scoped_pct} R:{scoped_reset} E:{scoped_elapsed} B:{scoped_bar}",
794 );
795 let out = render_anthropic(&inp);
796 assert!(out.tooltip.starts_with("M:Fable P:84 R:"));
797 for placeholder in [
798 "{scoped_model}",
799 "{scoped_pct}",
800 "{scoped_reset}",
801 "{scoped_elapsed}",
802 "{scoped_bar}",
803 ] {
804 assert!(!out.tooltip.contains(placeholder));
805 }
806 }
807
808 #[test]
809 fn scoped_placeholders_are_neutral_when_absent() {
810 let oc = sample_outcome(); let theme = Theme::default();
812 let mut inp = input(&oc, &theme);
813 inp.tooltip_format = Some("[{scoped_model}] {scoped_pct} {scoped_reset}");
814 let out = render_anthropic(&inp);
815 assert_eq!(out.tooltip, "[] 0 —");
816 }
817
818 #[test]
819 fn plan_and_scoped_model_are_pango_escaped_in_custom_formats() {
820 let mut oc = sample_outcome();
824 oc.snapshot.plan = "Max <5x> & Team".into();
825 oc.snapshot.scoped = vec![crate::usage::ScopedWindow {
826 label: "Fable & <Opus>".into(),
827 window: UsageWindow {
828 utilization_pct: 84,
829 resets_at: Some(now() + chrono::Duration::days(5)),
830 window_duration: chrono::Duration::days(7),
831 },
832 }];
833 let theme = Theme::default();
834 let mut inp = input(&oc, &theme);
835 inp.format = "{plan} · {scoped_model}";
836 inp.tooltip_format = Some("{plan} · {scoped_model}");
837 let out = render_anthropic(&inp);
838 let escaped = "Max <5x> & Team · Fable & <Opus>";
839 assert!(out.text.contains(escaped), "{}", out.text);
840 assert!(!out.text.contains("<5x>"), "{}", out.text);
841 assert_eq!(out.tooltip, escaped);
842 }
843
844 #[test]
845 fn default_tooltip_contains_all_sections() {
846 let oc = sample_outcome();
847 let theme = Theme::default();
848 let out = render_anthropic(&input(&oc, &theme));
849 assert!(out.tooltip.contains("Claude Max 5x"));
850 assert!(out.tooltip.contains("Session"));
851 assert!(out.tooltip.contains("Weekly"));
852 assert!(out.tooltip.contains("Sonnet only"));
853 assert!(out.tooltip.contains("Extra usage"));
854 assert!(out.tooltip.contains("Updated"));
855 assert!(out.tooltip.contains("62%"));
856 assert!(out.tooltip.contains("27%"));
857 assert!(out.tooltip.contains("$2.50"));
858 assert!(out.tooltip.contains("$50.00"));
859 }
860
861 #[test]
862 fn tooltip_omits_sonnet_and_extra_when_absent() {
863 let mut oc = sample_outcome();
864 oc.snapshot.sonnet = None;
865 oc.snapshot.extra = None;
866 let theme = Theme::default();
867 let out = render_anthropic(&input(&oc, &theme));
868 assert!(!out.tooltip.contains("Sonnet only"));
869 assert!(!out.tooltip.contains("Extra usage"));
870 assert!(out.tooltip.contains("Session"));
872 assert!(out.tooltip.contains("Weekly"));
873 }
874
875 #[test]
876 fn tooltip_reports_banked_resets_and_stays_silent_without_them() {
877 let theme = Theme::default();
878 let mut oc = sample_outcome();
879
880 let out = render_anthropic(&input(&oc, &theme));
882 assert!(!out.tooltip.contains("Reset credits"), "{}", out.tooltip);
883
884 oc.snapshot.reset_credits = crate::usage::ResetCredits {
885 available: 1,
886 credits: vec![crate::usage::ResetCredit {
887 title: Some("Opus 5.5 launch reset".into()),
888 expires_at: Some(now() + chrono::Duration::days(28)),
889 }],
890 };
891 let out = render_anthropic(&input(&oc, &theme));
892 assert!(out.tooltip.contains("Reset credits"), "{}", out.tooltip);
893 assert!(
894 out.tooltip.contains("Opus 5.5 launch reset"),
895 "{}",
896 out.tooltip
897 );
898 assert!(out.tooltip.contains("expires"), "{}", out.tooltip);
903 assert!(out.tooltip.contains("28d"), "{}", out.tooltip);
904
905 let values = build_placeholders(&input(&oc, &theme));
906 assert_eq!(values["resets_available"], "1");
907 assert_eq!(values["resets"], "1 reset available");
908 }
909
910 #[test]
911 fn tooltip_includes_http_error_when_last_error_present() {
912 let mut oc = sample_outcome();
913 oc.last_error = Some((429, "rate limited".into()));
914 let theme = Theme::default();
915 let out = render_anthropic(&input(&oc, &theme));
916 assert!(out.tooltip.contains("HTTP 429"));
917 assert!(out.tooltip.contains("rate limited"));
918 }
919
920 #[test]
921 fn tooltip_omits_http_zero() {
922 let mut oc = sample_outcome();
925 oc.last_error = Some((0, "n/a".into()));
926 let theme = Theme::default();
927 let out = render_anthropic(&input(&oc, &theme));
928 assert!(!out.tooltip.contains("HTTP 0"));
929 }
930
931 #[test]
932 fn worst_window_promotes_class_to_critical() {
933 let mut oc = sample_outcome();
934 oc.snapshot.weekly.utilization_pct = 95;
935 let theme = Theme::default();
936 let out = render_anthropic(&input(&oc, &theme));
937 assert_eq!(out.class, Class::Critical);
938 }
939
940 #[test]
941 fn pace_color_mode_uses_neutral_wrapper() {
942 let oc = sample_outcome();
943 let theme = Theme::default();
944 let mut inp = input(&oc, &theme);
945 inp.format = "{session_pct}% {session_pace}";
946 inp.format_pace_color = true;
947 let out = render_anthropic(&inp);
948 assert!(out.text.contains(&theme.fg));
950 }
951
952 #[test]
953 fn wrap_words_breaks_on_width_boundary() {
954 let lines = wrap_words("aaa bbb ccc ddd eee fff", 8);
955 assert_eq!(lines, vec!["aaa bbb", "ccc ddd", "eee fff"]);
957 }
958
959 #[test]
962 fn tooltip_names_working_and_waiting_sessions_only_when_there_are_some() {
963 use crate::context::activity::SessionActivity;
964 let oc = sample_outcome();
965 let theme = Theme::default();
966
967 let active = SessionActivity {
968 working: 2,
969 waiting: 1,
970 };
971 let mut inp = input(&oc, &theme);
972 inp.claude_sessions = Some(&active);
973 let out = render_anthropic(&inp);
974 assert!(
975 out.tooltip.contains("2 working · 1 waiting"),
976 "{}",
977 out.tooltip
978 );
979
980 let idle = SessionActivity::default();
981 let mut inp = input(&oc, &theme);
982 inp.claude_sessions = Some(&idle);
983 let quiet = render_anthropic(&inp);
984 assert!(!quiet.tooltip.contains("working"), "{}", quiet.tooltip);
985 assert!(!quiet.tooltip.contains("waiting"), "{}", quiet.tooltip);
986 }
987}