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