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