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}
38
39pub fn render_anthropic(input: &RenderInput) -> WaybarOutput {
41 let snap = &input.outcome.snapshot;
42 let class = Class::from(anthropic_severity(snap));
43 let bar_text = render_bar_text(input, class);
44 let tooltip = if let Some(fmt) = input.tooltip_format {
45 let values = build_placeholders(input);
47 substitute(fmt, &values)
48 } else {
49 render_default_tooltip(input)
50 };
51
52 WaybarOutput {
53 text: bar_text,
54 tooltip,
55 class,
56 }
57}
58
59fn render_bar_text(input: &RenderInput, class: Class) -> String {
62 let values = build_placeholders(input);
63 let mut text = substitute(input.format, &values);
64
65 if input.outcome.stale {
67 text.push_str(" ⏸");
68 }
69
70 let wrapper_color = if input.format_pace_color && input.format.contains("_pace") {
73 input.theme.fg.clone()
74 } else {
75 bar_color_for(class, input.theme).to_string()
76 };
77 let icon_prefix = match input.icon {
78 Some(ic) if !ic.is_empty() => format!("{ic} "),
79 _ => String::new(),
80 };
81 color_span(&wrapper_color, &format!("{icon_prefix}{text}"))
82}
83
84fn bar_color_for(class: Class, theme: &Theme) -> &str {
85 match class {
86 Class::Low => &theme.green,
87 Class::Mid => &theme.yellow,
88 Class::High => &theme.orange,
89 Class::Critical => &theme.red,
90 }
91}
92
93fn build_placeholders(input: &RenderInput) -> HashMap<&'static str, String> {
99 let snap = &input.outcome.snapshot;
100 let theme = input.theme;
101
102 let session = pacing::calc(
103 snap.session.utilization_pct,
104 snap.session.resets_at,
105 input.now,
106 snap.session.window_duration,
107 input.pace_tolerance,
108 );
109 let weekly = pacing::calc(
110 snap.weekly.utilization_pct,
111 snap.weekly.resets_at,
112 input.now,
113 snap.weekly.window_duration,
114 input.pace_tolerance,
115 );
116 let sonnet_window = snap.sonnet.as_ref();
117 let sonnet = sonnet_window.map(|w| {
118 pacing::calc(
119 w.utilization_pct,
120 w.resets_at,
121 input.now,
122 w.window_duration,
123 input.pace_tolerance,
124 )
125 });
126
127 let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
128 let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
129 let sonnet_color =
130 sonnet_window.map(|w| pango::severity_color(severity_for(w.utilization_pct), theme));
131 let extra_color = snap
132 .extra
133 .as_ref()
134 .map(|e| pango::severity_color(severity_for(e.percent()), theme));
135
136 let session_bar = pango::progress_bar(snap.session.utilization_pct, session_color, theme, None);
137 let weekly_bar = pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None);
138 let sonnet_bar = if let (Some(w), Some(c)) = (sonnet_window, sonnet_color) {
139 pango::progress_bar(w.utilization_pct, c, theme, None)
140 } else {
141 String::new()
142 };
143 let extra_bar = if let (Some(e), Some(c)) = (snap.extra.as_ref(), extra_color) {
144 pango::progress_bar(e.percent(), c, theme, None)
145 } else {
146 String::new()
147 };
148
149 let scoped0 = snap.scoped.first();
156 let scoped0_pacing = scoped0.map(|s| {
157 pacing::calc(
158 s.window.utilization_pct,
159 s.window.resets_at,
160 input.now,
161 s.window.window_duration,
162 input.pace_tolerance,
163 )
164 });
165 let scoped0_bar = if let Some(s) = scoped0 {
166 let c = pango::severity_color(severity_for(s.window.utilization_pct), theme);
167 pango::progress_bar(s.window.utilization_pct, c, theme, None)
168 } else {
169 String::new()
170 };
171
172 let mut v = placeholders(vec![
173 ("icon", "".to_string()),
174 ("vendor_short", VendorId::Anthropic.short_name().to_string()),
175 ("plan", snap.plan.clone()),
176 ("session_pct", snap.session.utilization_pct.to_string()),
177 (
178 "session_reset",
179 countdown::format(snap.session.resets_at, input.now),
180 ),
181 ("session_elapsed", session.elapsed_pct.to_string()),
182 ("session_bar", session_bar.clone()),
183 ("weekly_pct", snap.weekly.utilization_pct.to_string()),
184 (
185 "weekly_reset",
186 countdown::format(snap.weekly.resets_at, input.now),
187 ),
188 ("weekly_elapsed", weekly.elapsed_pct.to_string()),
189 ("weekly_bar", weekly_bar.clone()),
190 (
191 "sonnet_pct",
192 sonnet_window
193 .map(|w| w.utilization_pct.to_string())
194 .unwrap_or_else(|| "0".into()),
195 ),
196 (
197 "sonnet_reset",
198 sonnet_window
199 .map(|w| countdown::format(w.resets_at, input.now))
200 .unwrap_or_else(|| "—".into()),
201 ),
202 (
203 "sonnet_elapsed",
204 sonnet
205 .as_ref()
206 .map(|s| s.elapsed_pct.to_string())
207 .unwrap_or_else(|| "0".into()),
208 ),
209 ("sonnet_bar", sonnet_bar.clone()),
210 (
212 "scoped_model",
213 scoped0.map(|s| s.label.clone()).unwrap_or_default(),
214 ),
215 (
216 "scoped_pct",
217 scoped0
218 .map(|s| s.window.utilization_pct.to_string())
219 .unwrap_or_else(|| "0".into()),
220 ),
221 (
222 "scoped_reset",
223 scoped0
224 .map(|s| countdown::format(s.window.resets_at, input.now))
225 .unwrap_or_else(|| "—".into()),
226 ),
227 (
228 "scoped_elapsed",
229 scoped0_pacing
230 .as_ref()
231 .map(|p| p.elapsed_pct.to_string())
232 .unwrap_or_else(|| "0".into()),
233 ),
234 ("scoped_bar", scoped0_bar.clone()),
235 (
236 "extra_spent",
237 snap.extra
238 .as_ref()
239 .map(ExtraUsage::fmt_spent)
240 .unwrap_or_default(),
241 ),
242 (
243 "extra_limit",
244 snap.extra
248 .as_ref()
249 .map(|e| e.fmt_limit().unwrap_or_else(|| "—".into()))
250 .unwrap_or_default(),
251 ),
252 (
253 "extra_pct",
254 snap.extra
255 .as_ref()
256 .map(|e| e.percent().to_string())
257 .unwrap_or_else(|| "0".into()),
258 ),
259 ("extra_bar", extra_bar),
260 ]);
261
262 insert_pace(&mut v, "session", &session, input.format_pace_color, theme);
263 insert_pace(&mut v, "weekly", &weekly, input.format_pace_color, theme);
264 if let Some(sp) = sonnet.as_ref() {
265 insert_pace(&mut v, "sonnet", sp, input.format_pace_color, theme);
266 } else {
267 insert_pace(
270 &mut v,
271 "sonnet",
272 &pacing::Pacing::neutral(),
273 input.format_pace_color,
274 theme,
275 );
276 }
277 v
278}
279
280fn insert_pace(
281 map: &mut HashMap<&'static str, String>,
282 prefix: &'static str,
283 p: &pacing::Pacing,
284 pace_color: bool,
285 theme: &Theme,
286) {
287 let pace_glyph = p.ratio_pace.glyph();
288 let indicator_glyph = p.point_pace.glyph();
289 let delta = p.delta.to_string();
290 let abs_delta = p.delta.unsigned_abs().to_string();
291 let pct = &p.ratio_label;
292 let pts = &p.point_label;
293
294 let wrap = |s: &str| -> String {
295 if pace_color {
296 let sev = pacing::pace_severity(p.delta);
297 let color = pango::severity_color(sev, theme);
298 color_span(color, s)
299 } else {
300 s.to_string()
301 }
302 };
303
304 let keys: [(&'static str, String); 6] = match prefix {
305 "session" => [
306 ("session_pace", wrap(pace_glyph)),
307 ("session_pace_indicator", wrap(indicator_glyph)),
308 ("session_pace_pct", wrap(pct)),
309 ("session_pace_pts", wrap(pts)),
310 ("session_pace_delta", wrap(&delta)),
311 ("session_pace_abs_delta", wrap(&abs_delta)),
312 ],
313 "weekly" => [
314 ("weekly_pace", wrap(pace_glyph)),
315 ("weekly_pace_indicator", wrap(indicator_glyph)),
316 ("weekly_pace_pct", wrap(pct)),
317 ("weekly_pace_pts", wrap(pts)),
318 ("weekly_pace_delta", wrap(&delta)),
319 ("weekly_pace_abs_delta", wrap(&abs_delta)),
320 ],
321 "sonnet" => [
322 ("sonnet_pace", wrap(pace_glyph)),
323 ("sonnet_pace_indicator", wrap(indicator_glyph)),
324 ("sonnet_pace_pct", wrap(pct)),
325 ("sonnet_pace_pts", wrap(pts)),
326 ("sonnet_pace_delta", wrap(&delta)),
327 ("sonnet_pace_abs_delta", wrap(&abs_delta)),
328 ],
329 _ => return,
330 };
331 for (k, v) in keys {
332 map.insert(k, v);
333 }
334}
335
336fn render_default_tooltip(input: &RenderInput) -> String {
338 let snap = &input.outcome.snapshot;
339 let theme = input.theme;
340 let blue = &theme.blue;
341 let dim = &theme.dim;
342 let fg = &theme.fg;
343
344 let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
345 let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
346
347 let session_pacing = pacing::calc(
348 snap.session.utilization_pct,
349 snap.session.resets_at,
350 input.now,
351 snap.session.window_duration,
352 input.pace_tolerance,
353 );
354 let weekly_pacing = pacing::calc(
355 snap.weekly.utilization_pct,
356 snap.weekly.resets_at,
357 input.now,
358 snap.weekly.window_duration,
359 input.pace_tolerance,
360 );
361
362 let session_bar = if input.tooltip_pace_pts {
363 pango::progress_bar(
364 snap.session.utilization_pct,
365 session_color,
366 theme,
367 Some(session_pacing.elapsed_pct),
368 )
369 } else {
370 pango::progress_bar(snap.session.utilization_pct, session_color, theme, None)
371 };
372 let weekly_bar = if input.tooltip_pace_pts {
373 pango::progress_bar(
374 snap.weekly.utilization_pct,
375 weekly_color,
376 theme,
377 Some(weekly_pacing.elapsed_pct),
378 )
379 } else {
380 pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None)
381 };
382
383 let session_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &session_pacing);
384 let weekly_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &weekly_pacing);
385
386 let mut lines: Vec<Line> = Vec::new();
387 let _ = pango::severity_color; lines.push(Line::Center(format!(
389 "<span font_weight='bold' foreground='{blue}'>Claude {plan}</span>",
390 plan = escape(&snap.plan)
391 )));
392 lines.push(Line::Sep);
393 lines.push(Line::Body("".into()));
394
395 lines.push(Line::Body(format!(
396 " <span foreground='{fg}'> Session</span>"
397 )));
398 lines.push(Line::Body(format!(
399 " {bar} <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
400 bar = session_bar,
401 color = session_color,
402 pct = snap.session.utilization_pct,
403 glyph = session_pace_glyph
404 )));
405 lines.push(Line::Body(format!(
406 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
407 cd = escape(&countdown::format(snap.session.resets_at, input.now))
408 )));
409 lines.push(Line::Body("".into()));
410
411 lines.push(Line::Body(format!(
412 " <span foreground='{fg}'> Weekly</span>"
413 )));
414 lines.push(Line::Body(format!(
415 " {bar} <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
416 bar = weekly_bar,
417 color = weekly_color,
418 pct = snap.weekly.utilization_pct,
419 glyph = weekly_pace_glyph
420 )));
421 lines.push(Line::Body(format!(
422 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
423 cd = escape(&countdown::format(snap.weekly.resets_at, input.now))
424 )));
425
426 if let Some(sw) = snap.sonnet.as_ref() {
427 let sonnet_color = pango::severity_color(severity_for(sw.utilization_pct), theme);
428 let sonnet_pacing = pacing::calc(
429 sw.utilization_pct,
430 sw.resets_at,
431 input.now,
432 sw.window_duration,
433 input.pace_tolerance,
434 );
435 let sonnet_bar = if input.tooltip_pace_pts {
436 pango::progress_bar(
437 sw.utilization_pct,
438 sonnet_color,
439 theme,
440 Some(sonnet_pacing.elapsed_pct),
441 )
442 } else {
443 pango::progress_bar(sw.utilization_pct, sonnet_color, theme, None)
444 };
445 lines.push(Line::Body("".into()));
446 lines.push(Line::Body(format!(
447 " <span foreground='{fg}'> Sonnet only</span>"
448 )));
449 lines.push(Line::Body(format!(
450 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>",
451 bar = sonnet_bar,
452 color = sonnet_color,
453 pct = sw.utilization_pct
454 )));
455 lines.push(Line::Body(format!(
456 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
457 cd = escape(&countdown::format(sw.resets_at, input.now))
458 )));
459 }
460
461 for sw in &snap.scoped {
462 let scoped_color = pango::severity_color(severity_for(sw.window.utilization_pct), theme);
463 let scoped_pacing = pacing::calc(
464 sw.window.utilization_pct,
465 sw.window.resets_at,
466 input.now,
467 sw.window.window_duration,
468 input.pace_tolerance,
469 );
470 let scoped_bar = if input.tooltip_pace_pts {
471 pango::progress_bar(
472 sw.window.utilization_pct,
473 scoped_color,
474 theme,
475 Some(scoped_pacing.elapsed_pct),
476 )
477 } else {
478 pango::progress_bar(sw.window.utilization_pct, scoped_color, theme, None)
479 };
480 lines.push(Line::Body("".into()));
481 lines.push(Line::Body(format!(
482 " <span foreground='{fg}'> {label} weekly</span>",
483 label = escape(&sw.label)
484 )));
485 lines.push(Line::Body(format!(
486 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>",
487 bar = scoped_bar,
488 color = scoped_color,
489 pct = sw.window.utilization_pct
490 )));
491 lines.push(Line::Body(format!(
492 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
493 cd = escape(&countdown::format(sw.window.resets_at, input.now))
494 )));
495 }
496
497 if let Some(extra) = snap.extra.as_ref() {
498 let extra_color = pango::severity_color(severity_for(extra.percent()), theme);
499 let extra_bar = pango::progress_bar(extra.percent(), extra_color, theme, None);
500 lines.push(Line::Body("".into()));
501 lines.push(Line::Sep);
502 lines.push(Line::Body(format!(
503 " <span foreground='{fg}'> Extra usage</span>"
504 )));
505 lines.push(Line::Body(format!(
506 " {bar} <span font_weight='bold' foreground='{color}'>{spent}</span>",
507 bar = extra_bar,
508 color = extra_color,
509 spent = escape(&extra.fmt_spent())
510 )));
511 let lim = match extra.fmt_limit() {
512 Some(l) => l,
513 None => "none reported".into(),
518 };
519 lines.push(Line::Body(format!(
520 " <span foreground='{dim}'> Limit: {lim}</span>",
521 lim = escape(&lim)
522 )));
523 }
524
525 if let Some((code, msg)) = input.outcome.last_error.as_ref()
526 && *code != 0
527 {
528 let (icon, color) = if *code >= 500 {
529 ("", theme.red.as_str())
530 } else {
531 ("", theme.orange.as_str())
532 };
533 lines.push(Line::Body("".into()));
534 lines.push(Line::Sep);
535 lines.push(Line::Body(format!(
536 " <span foreground='{color}'> {icon} HTTP {code}</span>"
537 )));
538 for wrapped in wrap_words(&escape(msg), 35) {
539 lines.push(Line::Body(format!(
540 " <span foreground='{dim}'>{wrapped}</span>"
541 )));
542 }
543 }
544
545 let updated = updated_at_hm(input.now, input.outcome.cache_age);
546 lines.push(Line::Body("".into()));
547 lines.push(Line::Sep);
548 lines.push(Line::Body(format!(
549 " <span foreground='{dim}'> Updated {updated}</span>"
550 )));
551
552 tooltip::render_bordered(&lines, theme)
553}
554
555fn pick_pace_glyph(point_mode: bool, p: &pacing::Pacing) -> &'static str {
556 if point_mode {
557 p.point_pace.glyph()
558 } else {
559 p.ratio_pace.glyph()
560 }
561}
562
563fn wrap_words(s: &str, width: usize) -> Vec<String> {
566 let mut out = Vec::new();
567 let mut buf = String::new();
568 for word in s.split_whitespace() {
569 if buf.is_empty() {
570 buf = word.into();
571 } else if buf.len() + 1 + word.len() <= width {
572 buf.push(' ');
573 buf.push_str(word);
574 } else {
575 out.push(std::mem::take(&mut buf));
576 buf = word.into();
577 }
578 }
579 if !buf.is_empty() {
580 out.push(buf);
581 }
582 out
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use crate::anthropic::fetch::FetchOutcome;
589 use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, UsageWindow};
590 use chrono::TimeZone;
591
592 fn now() -> DateTime<Utc> {
593 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
594 }
595
596 fn sample_outcome() -> FetchOutcome {
597 let session = UsageWindow {
598 utilization_pct: 62,
599 resets_at: Some(now() + chrono::Duration::minutes(90)),
600 window_duration: chrono::Duration::hours(5),
601 };
602 let weekly = UsageWindow {
603 utilization_pct: 27,
604 resets_at: Some(now() + chrono::Duration::days(4) + chrono::Duration::hours(1)),
605 window_duration: chrono::Duration::days(7),
606 };
607 let sonnet = UsageWindow {
608 utilization_pct: 4,
609 resets_at: Some(now() + chrono::Duration::hours(2) + chrono::Duration::minutes(24)),
610 window_duration: chrono::Duration::days(7),
611 };
612 let snap = AnthropicSnapshot {
613 plan: "Max 5x".into(),
614 session,
615 weekly,
616 sonnet: Some(sonnet),
617 scoped: vec![],
618 extra: Some(ExtraUsage {
619 limit: Some(Cents(5000)),
620 spent: Cents(250),
621 currency: None,
622 decimal_places: Some(2),
623 }),
624 };
625 FetchOutcome {
626 snapshot: snap,
627 stale: false,
628 last_error: None,
629 cache_age: Some(std::time::Duration::from_secs(30)),
630 }
631 }
632
633 fn input<'a>(outcome: &'a FetchOutcome, theme: &'a Theme) -> RenderInput<'a> {
634 RenderInput {
635 outcome,
636 theme,
637 format: DEFAULT_FORMAT,
638 tooltip_format: None,
639 icon: None,
640 pace_tolerance: 5,
641 format_pace_color: false,
642 tooltip_pace_pts: false,
643 now: now(),
644 }
645 }
646
647 #[test]
648 fn uncapped_extra_usage_renders_spend_with_dash_limit() {
649 let mut oc = sample_outcome();
654 if let Some(e) = oc.snapshot.extra.as_mut() {
655 e.limit = None;
656 e.spent = Cents(14157);
657 }
658 let theme = Theme::default();
659 let mut inp = input(&oc, &theme);
660 inp.format = "{extra_spent}|{extra_limit}|{extra_pct}";
661 let out = render_anthropic(&inp);
662 assert!(out.text.contains("$141.57|—|0"), "got: {}", out.text);
663
664 let inp2 = input(&oc, &theme);
667 let out2 = render_anthropic(&inp2);
668 assert!(out2.tooltip.contains("$141.57"));
669 assert!(out2.tooltip.contains("none reported"));
670 assert!(!out2.tooltip.contains("Limit: $0.00"));
671 }
672
673 #[test]
674 fn extra_usage_placeholders_and_tooltip_use_the_blocks_currency() {
675 let mut oc = sample_outcome();
679 if let Some(e) = oc.snapshot.extra.as_mut() {
680 e.limit = None;
681 e.spent = Cents(14157);
682 e.currency = Some("BRL".into());
683 }
684 let theme = Theme::default();
685 let mut inp = input(&oc, &theme);
686 inp.format = "{extra_spent}";
687 let out = render_anthropic(&inp);
688 assert!(out.text.contains("R$141.57"), "got: {}", out.text);
689 assert!(!out.text.contains("$141.57|"), "got: {}", out.text);
690
691 let inp2 = input(&oc, &theme);
692 let out2 = render_anthropic(&inp2);
693 assert!(
694 out2.tooltip.contains("R$141.57"),
695 "tooltip must carry the block's currency"
696 );
697 }
698
699 #[test]
700 fn default_format_renders_pct_and_reset() {
701 let oc = sample_outcome();
702 let theme = Theme::default();
703 let out = render_anthropic(&input(&oc, &theme));
704 assert!(out.text.contains("62%"));
707 assert!(out.text.contains("1h 30m"));
708 assert_eq!(out.class, Class::Mid); }
710
711 #[test]
712 fn stale_appends_pause_indicator() {
713 let mut oc = sample_outcome();
714 oc.stale = true;
715 let theme = Theme::default();
716 let out = render_anthropic(&input(&oc, &theme));
717 assert!(out.text.contains("⏸"));
718 }
719
720 #[test]
721 fn icon_prepends() {
722 let oc = sample_outcome();
723 let theme = Theme::default();
724 let mut inp = input(&oc, &theme);
725 inp.icon = Some("");
726 let out = render_anthropic(&inp);
727 assert!(out.text.contains(" "));
728 }
729
730 #[test]
731 fn custom_tooltip_format_uses_placeholders() {
732 let oc = sample_outcome();
733 let theme = Theme::default();
734 let mut inp = input(&oc, &theme);
735 inp.tooltip_format = Some("S:{session_pct} W:{weekly_pct}");
736 let out = render_anthropic(&inp);
737 assert_eq!(out.tooltip, "S:62 W:27");
738 }
739
740 #[test]
741 fn scoped_placeholders_expose_model_scoped_window() {
742 let mut oc = sample_outcome();
745 oc.snapshot.scoped = vec![crate::usage::ScopedWindow {
746 label: "Fable".into(),
747 window: UsageWindow {
748 utilization_pct: 84,
749 resets_at: Some(now() + chrono::Duration::days(5)),
750 window_duration: chrono::Duration::days(7),
751 },
752 }];
753 let theme = Theme::default();
754 let mut inp = input(&oc, &theme);
755 inp.tooltip_format = Some(
756 "M:{scoped_model} P:{scoped_pct} R:{scoped_reset} E:{scoped_elapsed} B:{scoped_bar}",
757 );
758 let out = render_anthropic(&inp);
759 assert!(out.tooltip.starts_with("M:Fable P:84 R:"));
760 for placeholder in [
761 "{scoped_model}",
762 "{scoped_pct}",
763 "{scoped_reset}",
764 "{scoped_elapsed}",
765 "{scoped_bar}",
766 ] {
767 assert!(!out.tooltip.contains(placeholder));
768 }
769 }
770
771 #[test]
772 fn scoped_placeholders_are_neutral_when_absent() {
773 let oc = sample_outcome(); let theme = Theme::default();
775 let mut inp = input(&oc, &theme);
776 inp.tooltip_format = Some("[{scoped_model}] {scoped_pct} {scoped_reset}");
777 let out = render_anthropic(&inp);
778 assert_eq!(out.tooltip, "[] 0 —");
779 }
780
781 #[test]
782 fn default_tooltip_contains_all_sections() {
783 let oc = sample_outcome();
784 let theme = Theme::default();
785 let out = render_anthropic(&input(&oc, &theme));
786 assert!(out.tooltip.contains("Claude Max 5x"));
787 assert!(out.tooltip.contains("Session"));
788 assert!(out.tooltip.contains("Weekly"));
789 assert!(out.tooltip.contains("Sonnet only"));
790 assert!(out.tooltip.contains("Extra usage"));
791 assert!(out.tooltip.contains("Updated"));
792 assert!(out.tooltip.contains("62%"));
793 assert!(out.tooltip.contains("27%"));
794 assert!(out.tooltip.contains("$2.50"));
795 assert!(out.tooltip.contains("$50.00"));
796 }
797
798 #[test]
799 fn tooltip_omits_sonnet_and_extra_when_absent() {
800 let mut oc = sample_outcome();
801 oc.snapshot.sonnet = None;
802 oc.snapshot.extra = None;
803 let theme = Theme::default();
804 let out = render_anthropic(&input(&oc, &theme));
805 assert!(!out.tooltip.contains("Sonnet only"));
806 assert!(!out.tooltip.contains("Extra usage"));
807 assert!(out.tooltip.contains("Session"));
809 assert!(out.tooltip.contains("Weekly"));
810 }
811
812 #[test]
813 fn tooltip_includes_http_error_when_last_error_present() {
814 let mut oc = sample_outcome();
815 oc.last_error = Some((429, "rate limited".into()));
816 let theme = Theme::default();
817 let out = render_anthropic(&input(&oc, &theme));
818 assert!(out.tooltip.contains("HTTP 429"));
819 assert!(out.tooltip.contains("rate limited"));
820 }
821
822 #[test]
823 fn tooltip_omits_http_zero() {
824 let mut oc = sample_outcome();
827 oc.last_error = Some((0, "n/a".into()));
828 let theme = Theme::default();
829 let out = render_anthropic(&input(&oc, &theme));
830 assert!(!out.tooltip.contains("HTTP 0"));
831 }
832
833 #[test]
834 fn worst_window_promotes_class_to_critical() {
835 let mut oc = sample_outcome();
836 oc.snapshot.weekly.utilization_pct = 95;
837 let theme = Theme::default();
838 let out = render_anthropic(&input(&oc, &theme));
839 assert_eq!(out.class, Class::Critical);
840 }
841
842 #[test]
843 fn pace_color_mode_uses_neutral_wrapper() {
844 let oc = sample_outcome();
845 let theme = Theme::default();
846 let mut inp = input(&oc, &theme);
847 inp.format = "{session_pct}% {session_pace}";
848 inp.format_pace_color = true;
849 let out = render_anthropic(&inp);
850 assert!(out.text.contains(&theme.fg));
852 }
853
854 #[test]
855 fn wrap_words_breaks_on_width_boundary() {
856 let lines = wrap_words("aaa bbb ccc ddd eee fff", 8);
857 assert_eq!(lines, vec!["aaa bbb", "ccc ddd", "eee fff"]);
859 }
860}