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 v
283}
284
285fn insert_pace(
286 map: &mut HashMap<&'static str, String>,
287 prefix: &'static str,
288 p: &pacing::Pacing,
289 pace_color: bool,
290 theme: &Theme,
291) {
292 let pace_glyph = p.ratio_pace.glyph();
293 let indicator_glyph = p.point_pace.glyph();
294 let delta = p.delta.to_string();
295 let abs_delta = p.delta.unsigned_abs().to_string();
296 let pct = &p.ratio_label;
297 let pts = &p.point_label;
298
299 let wrap = |s: &str| -> String {
300 if pace_color {
301 let sev = pacing::pace_severity(p.delta);
302 let color = pango::severity_color(sev, theme);
303 color_span(color, s)
304 } else {
305 s.to_string()
306 }
307 };
308
309 let keys: [(&'static str, String); 6] = match prefix {
310 "session" => [
311 ("session_pace", wrap(pace_glyph)),
312 ("session_pace_indicator", wrap(indicator_glyph)),
313 ("session_pace_pct", wrap(pct)),
314 ("session_pace_pts", wrap(pts)),
315 ("session_pace_delta", wrap(&delta)),
316 ("session_pace_abs_delta", wrap(&abs_delta)),
317 ],
318 "weekly" => [
319 ("weekly_pace", wrap(pace_glyph)),
320 ("weekly_pace_indicator", wrap(indicator_glyph)),
321 ("weekly_pace_pct", wrap(pct)),
322 ("weekly_pace_pts", wrap(pts)),
323 ("weekly_pace_delta", wrap(&delta)),
324 ("weekly_pace_abs_delta", wrap(&abs_delta)),
325 ],
326 "sonnet" => [
327 ("sonnet_pace", wrap(pace_glyph)),
328 ("sonnet_pace_indicator", wrap(indicator_glyph)),
329 ("sonnet_pace_pct", wrap(pct)),
330 ("sonnet_pace_pts", wrap(pts)),
331 ("sonnet_pace_delta", wrap(&delta)),
332 ("sonnet_pace_abs_delta", wrap(&abs_delta)),
333 ],
334 _ => return,
335 };
336 for (k, v) in keys {
337 map.insert(k, v);
338 }
339}
340
341fn render_default_tooltip(input: &RenderInput) -> String {
343 let snap = &input.outcome.snapshot;
344 let theme = input.theme;
345 let blue = &theme.blue;
346 let dim = &theme.dim;
347 let fg = &theme.fg;
348
349 let session_color = pango::severity_color(severity_for(snap.session.utilization_pct), theme);
350 let weekly_color = pango::severity_color(severity_for(snap.weekly.utilization_pct), theme);
351
352 let session_pacing = pacing::calc(
353 snap.session.utilization_pct,
354 snap.session.resets_at,
355 input.now,
356 snap.session.window_duration,
357 input.pace_tolerance,
358 );
359 let weekly_pacing = pacing::calc(
360 snap.weekly.utilization_pct,
361 snap.weekly.resets_at,
362 input.now,
363 snap.weekly.window_duration,
364 input.pace_tolerance,
365 );
366
367 let session_bar = if input.tooltip_pace_pts {
368 pango::progress_bar(
369 snap.session.utilization_pct,
370 session_color,
371 theme,
372 Some(session_pacing.elapsed_pct),
373 )
374 } else {
375 pango::progress_bar(snap.session.utilization_pct, session_color, theme, None)
376 };
377 let weekly_bar = if input.tooltip_pace_pts {
378 pango::progress_bar(
379 snap.weekly.utilization_pct,
380 weekly_color,
381 theme,
382 Some(weekly_pacing.elapsed_pct),
383 )
384 } else {
385 pango::progress_bar(snap.weekly.utilization_pct, weekly_color, theme, None)
386 };
387
388 let session_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &session_pacing);
389 let weekly_pace_glyph = pick_pace_glyph(input.tooltip_pace_pts, &weekly_pacing);
390
391 let mut lines: Vec<Line> = Vec::new();
392 let _ = pango::severity_color; lines.push(Line::Center(format!(
394 "<span font_weight='bold' foreground='{blue}'>Claude {plan}</span>",
395 plan = escape(&snap.plan)
396 )));
397 lines.push(Line::Sep);
398 lines.push(Line::Body("".into()));
399
400 lines.push(Line::Body(format!(
401 " <span foreground='{fg}'> Session</span>"
402 )));
403 lines.push(Line::Body(format!(
404 " {bar} <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
405 bar = session_bar,
406 color = session_color,
407 pct = snap.session.utilization_pct,
408 glyph = session_pace_glyph
409 )));
410 lines.push(Line::Body(format!(
411 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
412 cd = escape(&countdown::format(snap.session.resets_at, input.now))
413 )));
414 lines.push(Line::Body("".into()));
415
416 lines.push(Line::Body(format!(
417 " <span foreground='{fg}'> Weekly</span>"
418 )));
419 lines.push(Line::Body(format!(
420 " {bar} <span font_weight='bold' foreground='{color}'>{pct}% {glyph}</span>",
421 bar = weekly_bar,
422 color = weekly_color,
423 pct = snap.weekly.utilization_pct,
424 glyph = weekly_pace_glyph
425 )));
426 lines.push(Line::Body(format!(
427 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
428 cd = escape(&countdown::format(snap.weekly.resets_at, input.now))
429 )));
430
431 if let Some(sw) = snap.sonnet.as_ref() {
432 let sonnet_color = pango::severity_color(severity_for(sw.utilization_pct), theme);
433 let sonnet_pacing = pacing::calc(
434 sw.utilization_pct,
435 sw.resets_at,
436 input.now,
437 sw.window_duration,
438 input.pace_tolerance,
439 );
440 let sonnet_bar = if input.tooltip_pace_pts {
441 pango::progress_bar(
442 sw.utilization_pct,
443 sonnet_color,
444 theme,
445 Some(sonnet_pacing.elapsed_pct),
446 )
447 } else {
448 pango::progress_bar(sw.utilization_pct, sonnet_color, theme, None)
449 };
450 lines.push(Line::Body("".into()));
451 lines.push(Line::Body(format!(
452 " <span foreground='{fg}'> Sonnet only</span>"
453 )));
454 lines.push(Line::Body(format!(
455 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>",
456 bar = sonnet_bar,
457 color = sonnet_color,
458 pct = sw.utilization_pct
459 )));
460 lines.push(Line::Body(format!(
461 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
462 cd = escape(&countdown::format(sw.resets_at, input.now))
463 )));
464 }
465
466 for sw in &snap.scoped {
467 let scoped_color = pango::severity_color(severity_for(sw.window.utilization_pct), theme);
468 let scoped_pacing = pacing::calc(
469 sw.window.utilization_pct,
470 sw.window.resets_at,
471 input.now,
472 sw.window.window_duration,
473 input.pace_tolerance,
474 );
475 let scoped_bar = if input.tooltip_pace_pts {
476 pango::progress_bar(
477 sw.window.utilization_pct,
478 scoped_color,
479 theme,
480 Some(scoped_pacing.elapsed_pct),
481 )
482 } else {
483 pango::progress_bar(sw.window.utilization_pct, scoped_color, theme, None)
484 };
485 lines.push(Line::Body("".into()));
486 lines.push(Line::Body(format!(
487 " <span foreground='{fg}'> {label} weekly</span>",
488 label = escape(&sw.label)
489 )));
490 lines.push(Line::Body(format!(
491 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>",
492 bar = scoped_bar,
493 color = scoped_color,
494 pct = sw.window.utilization_pct
495 )));
496 lines.push(Line::Body(format!(
497 " <span foreground='{dim}'> ⏱ Resets in {cd}</span>",
498 cd = escape(&countdown::format(sw.window.resets_at, input.now))
499 )));
500 }
501
502 if let Some(extra) = snap.extra.as_ref() {
503 let extra_color = pango::severity_color(severity_for(extra.percent()), theme);
504 let extra_bar = pango::progress_bar(extra.percent(), extra_color, theme, None);
505 lines.push(Line::Body("".into()));
506 lines.push(Line::Sep);
507 lines.push(Line::Body(format!(
508 " <span foreground='{fg}'> Extra usage</span>"
509 )));
510 lines.push(Line::Body(format!(
511 " {bar} <span font_weight='bold' foreground='{color}'>{spent}</span>",
512 bar = extra_bar,
513 color = extra_color,
514 spent = escape(&extra.fmt_spent())
515 )));
516 let lim = match extra.fmt_limit() {
517 Some(l) => l,
518 None => "none reported".into(),
523 };
524 lines.push(Line::Body(format!(
525 " <span foreground='{dim}'> Limit: {lim}</span>",
526 lim = escape(&lim)
527 )));
528 }
529
530 if snap.reset_credits.available > 0 {
531 lines.push(Line::Body("".into()));
532 lines.push(Line::Sep);
533 lines.push(Line::Body(format!(
534 " <span foreground='{fg}'> Reset credits</span>"
535 )));
536 for line in crate::format::reset_credit_lines(&snap.reset_credits, input.now) {
537 lines.push(Line::Body(format!(
538 " <span foreground='{dim}'> {}</span>",
539 escape(&line)
540 )));
541 }
542 }
543
544 if let Some((code, msg)) = input.outcome.last_error.as_ref()
545 && *code != 0
546 {
547 let (icon, color) = if *code >= 500 {
548 ("", theme.red.as_str())
549 } else {
550 ("", theme.orange.as_str())
551 };
552 lines.push(Line::Body("".into()));
553 lines.push(Line::Sep);
554 lines.push(Line::Body(format!(
555 " <span foreground='{color}'> {icon} HTTP {code}</span>"
556 )));
557 for wrapped in wrap_words(&escape(msg), 35) {
558 lines.push(Line::Body(format!(
559 " <span foreground='{dim}'>{wrapped}</span>"
560 )));
561 }
562 }
563
564 if let Some(text) = input.claude_sessions.and_then(|s| s.summary()) {
565 lines.push(Line::Body("".into()));
566 lines.push(Line::Body(format!(
567 " <span foreground='{dim}'> {text}</span>"
568 )));
569 }
570
571 let updated = updated_at_hm(input.now, input.outcome.cache_age);
572 lines.push(Line::Body("".into()));
573 lines.push(Line::Sep);
574 lines.push(Line::Body(format!(
575 " <span foreground='{dim}'> Updated {updated}</span>"
576 )));
577
578 tooltip::render_bordered(&lines, theme)
579}
580
581fn pick_pace_glyph(point_mode: bool, p: &pacing::Pacing) -> &'static str {
582 if point_mode {
583 p.point_pace.glyph()
584 } else {
585 p.ratio_pace.glyph()
586 }
587}
588
589fn wrap_words(s: &str, width: usize) -> Vec<String> {
592 let mut out = Vec::new();
593 let mut buf = String::new();
594 for word in s.split_whitespace() {
595 if buf.is_empty() {
596 buf = word.into();
597 } else if buf.len() + 1 + word.len() <= width {
598 buf.push(' ');
599 buf.push_str(word);
600 } else {
601 out.push(std::mem::take(&mut buf));
602 buf = word.into();
603 }
604 }
605 if !buf.is_empty() {
606 out.push(buf);
607 }
608 out
609}
610
611#[cfg(test)]
612mod tests {
613 use super::*;
614 use crate::anthropic::fetch::FetchOutcome;
615 use crate::usage::{AnthropicSnapshot, Cents, ExtraUsage, UsageWindow};
616 use chrono::TimeZone;
617
618 fn now() -> DateTime<Utc> {
619 Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap()
620 }
621
622 fn sample_outcome() -> FetchOutcome {
623 let session = UsageWindow {
624 utilization_pct: 62,
625 resets_at: Some(now() + chrono::Duration::minutes(90)),
626 window_duration: chrono::Duration::hours(5),
627 };
628 let weekly = UsageWindow {
629 utilization_pct: 27,
630 resets_at: Some(now() + chrono::Duration::days(4) + chrono::Duration::hours(1)),
631 window_duration: chrono::Duration::days(7),
632 };
633 let sonnet = UsageWindow {
634 utilization_pct: 4,
635 resets_at: Some(now() + chrono::Duration::hours(2) + chrono::Duration::minutes(24)),
636 window_duration: chrono::Duration::days(7),
637 };
638 let snap = AnthropicSnapshot {
639 plan: "Max 5x".into(),
640 session,
641 weekly,
642 sonnet: Some(sonnet),
643 scoped: vec![],
644 extra: Some(ExtraUsage {
645 limit: Some(Cents(5000)),
646 spent: Cents(250),
647 currency: None,
648 decimal_places: Some(2),
649 }),
650 reset_credits: Default::default(),
651 };
652 FetchOutcome {
653 snapshot: snap,
654 stale: false,
655 last_error: None,
656 cache_age: Some(std::time::Duration::from_secs(30)),
657 }
658 }
659
660 fn input<'a>(outcome: &'a FetchOutcome, theme: &'a Theme) -> RenderInput<'a> {
661 RenderInput {
662 claude_sessions: None,
663 outcome,
664 theme,
665 format: DEFAULT_FORMAT,
666 tooltip_format: None,
667 icon: None,
668 pace_tolerance: 5,
669 format_pace_color: false,
670 tooltip_pace_pts: false,
671 now: now(),
672 }
673 }
674
675 #[test]
676 fn uncapped_extra_usage_renders_spend_with_dash_limit() {
677 let mut oc = sample_outcome();
682 if let Some(e) = oc.snapshot.extra.as_mut() {
683 e.limit = None;
684 e.spent = Cents(14157);
685 }
686 let theme = Theme::default();
687 let mut inp = input(&oc, &theme);
688 inp.format = "{extra_spent}|{extra_limit}|{extra_pct}";
689 let out = render_anthropic(&inp);
690 assert!(out.text.contains("$141.57|—|0"), "got: {}", out.text);
691
692 let inp2 = input(&oc, &theme);
695 let out2 = render_anthropic(&inp2);
696 assert!(out2.tooltip.contains("$141.57"));
697 assert!(out2.tooltip.contains("none reported"));
698 assert!(!out2.tooltip.contains("Limit: $0.00"));
699 }
700
701 #[test]
702 fn extra_usage_placeholders_and_tooltip_use_the_blocks_currency() {
703 let mut oc = sample_outcome();
707 if let Some(e) = oc.snapshot.extra.as_mut() {
708 e.limit = None;
709 e.spent = Cents(14157);
710 e.currency = Some("BRL".into());
711 }
712 let theme = Theme::default();
713 let mut inp = input(&oc, &theme);
714 inp.format = "{extra_spent}";
715 let out = render_anthropic(&inp);
716 assert!(out.text.contains("R$141.57"), "got: {}", out.text);
717 assert!(!out.text.contains("$141.57|"), "got: {}", out.text);
718
719 let inp2 = input(&oc, &theme);
720 let out2 = render_anthropic(&inp2);
721 assert!(
722 out2.tooltip.contains("R$141.57"),
723 "tooltip must carry the block's currency"
724 );
725 }
726
727 #[test]
728 fn default_format_renders_pct_and_reset() {
729 let oc = sample_outcome();
730 let theme = Theme::default();
731 let out = render_anthropic(&input(&oc, &theme));
732 assert!(out.text.contains("62%"));
735 assert!(out.text.contains("1h 30m"));
736 assert_eq!(out.class, Class::Mid); }
738
739 #[test]
740 fn stale_appends_pause_indicator() {
741 let mut oc = sample_outcome();
742 oc.stale = true;
743 let theme = Theme::default();
744 let out = render_anthropic(&input(&oc, &theme));
745 assert!(out.text.contains("⏸"));
746 }
747
748 #[test]
749 fn icon_prepends() {
750 let oc = sample_outcome();
751 let theme = Theme::default();
752 let mut inp = input(&oc, &theme);
753 inp.icon = Some("");
754 let out = render_anthropic(&inp);
755 assert!(out.text.contains(" "));
756 }
757
758 #[test]
759 fn custom_tooltip_format_uses_placeholders() {
760 let oc = sample_outcome();
761 let theme = Theme::default();
762 let mut inp = input(&oc, &theme);
763 inp.tooltip_format = Some("S:{session_pct} W:{weekly_pct}");
764 let out = render_anthropic(&inp);
765 assert_eq!(out.tooltip, "S:62 W:27");
766 }
767
768 #[test]
769 fn scoped_placeholders_expose_model_scoped_window() {
770 let mut oc = sample_outcome();
773 oc.snapshot.scoped = vec![crate::usage::ScopedWindow {
774 label: "Fable".into(),
775 window: UsageWindow {
776 utilization_pct: 84,
777 resets_at: Some(now() + chrono::Duration::days(5)),
778 window_duration: chrono::Duration::days(7),
779 },
780 }];
781 let theme = Theme::default();
782 let mut inp = input(&oc, &theme);
783 inp.tooltip_format = Some(
784 "M:{scoped_model} P:{scoped_pct} R:{scoped_reset} E:{scoped_elapsed} B:{scoped_bar}",
785 );
786 let out = render_anthropic(&inp);
787 assert!(out.tooltip.starts_with("M:Fable P:84 R:"));
788 for placeholder in [
789 "{scoped_model}",
790 "{scoped_pct}",
791 "{scoped_reset}",
792 "{scoped_elapsed}",
793 "{scoped_bar}",
794 ] {
795 assert!(!out.tooltip.contains(placeholder));
796 }
797 }
798
799 #[test]
800 fn scoped_placeholders_are_neutral_when_absent() {
801 let oc = sample_outcome(); let theme = Theme::default();
803 let mut inp = input(&oc, &theme);
804 inp.tooltip_format = Some("[{scoped_model}] {scoped_pct} {scoped_reset}");
805 let out = render_anthropic(&inp);
806 assert_eq!(out.tooltip, "[] 0 —");
807 }
808
809 #[test]
810 fn default_tooltip_contains_all_sections() {
811 let oc = sample_outcome();
812 let theme = Theme::default();
813 let out = render_anthropic(&input(&oc, &theme));
814 assert!(out.tooltip.contains("Claude Max 5x"));
815 assert!(out.tooltip.contains("Session"));
816 assert!(out.tooltip.contains("Weekly"));
817 assert!(out.tooltip.contains("Sonnet only"));
818 assert!(out.tooltip.contains("Extra usage"));
819 assert!(out.tooltip.contains("Updated"));
820 assert!(out.tooltip.contains("62%"));
821 assert!(out.tooltip.contains("27%"));
822 assert!(out.tooltip.contains("$2.50"));
823 assert!(out.tooltip.contains("$50.00"));
824 }
825
826 #[test]
827 fn tooltip_omits_sonnet_and_extra_when_absent() {
828 let mut oc = sample_outcome();
829 oc.snapshot.sonnet = None;
830 oc.snapshot.extra = None;
831 let theme = Theme::default();
832 let out = render_anthropic(&input(&oc, &theme));
833 assert!(!out.tooltip.contains("Sonnet only"));
834 assert!(!out.tooltip.contains("Extra usage"));
835 assert!(out.tooltip.contains("Session"));
837 assert!(out.tooltip.contains("Weekly"));
838 }
839
840 #[test]
841 fn tooltip_reports_banked_resets_and_stays_silent_without_them() {
842 let theme = Theme::default();
843 let mut oc = sample_outcome();
844
845 let out = render_anthropic(&input(&oc, &theme));
847 assert!(!out.tooltip.contains("Reset credits"), "{}", out.tooltip);
848
849 oc.snapshot.reset_credits = crate::usage::ResetCredits {
850 available: 1,
851 credits: vec![crate::usage::ResetCredit {
852 title: Some("Opus 5.5 launch reset".into()),
853 expires_at: Some(now() + chrono::Duration::days(28)),
854 }],
855 };
856 let out = render_anthropic(&input(&oc, &theme));
857 assert!(out.tooltip.contains("Reset credits"), "{}", out.tooltip);
858 assert!(
859 out.tooltip.contains("Opus 5.5 launch reset"),
860 "{}",
861 out.tooltip
862 );
863 assert!(out.tooltip.contains("expires"), "{}", out.tooltip);
868 assert!(out.tooltip.contains("28d"), "{}", out.tooltip);
869
870 let values = build_placeholders(&input(&oc, &theme));
871 assert_eq!(values["resets_available"], "1");
872 assert_eq!(values["resets"], "1 reset available");
873 }
874
875 #[test]
876 fn tooltip_includes_http_error_when_last_error_present() {
877 let mut oc = sample_outcome();
878 oc.last_error = Some((429, "rate limited".into()));
879 let theme = Theme::default();
880 let out = render_anthropic(&input(&oc, &theme));
881 assert!(out.tooltip.contains("HTTP 429"));
882 assert!(out.tooltip.contains("rate limited"));
883 }
884
885 #[test]
886 fn tooltip_omits_http_zero() {
887 let mut oc = sample_outcome();
890 oc.last_error = Some((0, "n/a".into()));
891 let theme = Theme::default();
892 let out = render_anthropic(&input(&oc, &theme));
893 assert!(!out.tooltip.contains("HTTP 0"));
894 }
895
896 #[test]
897 fn worst_window_promotes_class_to_critical() {
898 let mut oc = sample_outcome();
899 oc.snapshot.weekly.utilization_pct = 95;
900 let theme = Theme::default();
901 let out = render_anthropic(&input(&oc, &theme));
902 assert_eq!(out.class, Class::Critical);
903 }
904
905 #[test]
906 fn pace_color_mode_uses_neutral_wrapper() {
907 let oc = sample_outcome();
908 let theme = Theme::default();
909 let mut inp = input(&oc, &theme);
910 inp.format = "{session_pct}% {session_pace}";
911 inp.format_pace_color = true;
912 let out = render_anthropic(&inp);
913 assert!(out.text.contains(&theme.fg));
915 }
916
917 #[test]
918 fn wrap_words_breaks_on_width_boundary() {
919 let lines = wrap_words("aaa bbb ccc ddd eee fff", 8);
920 assert_eq!(lines, vec!["aaa bbb", "ccc ddd", "eee fff"]);
922 }
923
924 #[test]
927 fn tooltip_names_working_and_waiting_sessions_only_when_there_are_some() {
928 use crate::context::activity::SessionActivity;
929 let oc = sample_outcome();
930 let theme = Theme::default();
931
932 let active = SessionActivity {
933 working: 2,
934 waiting: 1,
935 };
936 let mut inp = input(&oc, &theme);
937 inp.claude_sessions = Some(&active);
938 let out = render_anthropic(&inp);
939 assert!(
940 out.tooltip.contains("2 working · 1 waiting"),
941 "{}",
942 out.tooltip
943 );
944
945 let idle = SessionActivity::default();
946 let mut inp = input(&oc, &theme);
947 inp.claude_sessions = Some(&idle);
948 let quiet = render_anthropic(&inp);
949 assert!(!quiet.tooltip.contains("working"), "{}", quiet.tooltip);
950 assert!(!quiet.tooltip.contains("waiting"), "{}", quiet.tooltip);
951 }
952}