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