1use std::collections::HashMap;
2use std::time::Duration;
3
4use chrono::{DateTime, Utc};
5
6use crate::countdown;
7use crate::format::{placeholders, substitute, updated_at_hm, usd};
8use crate::pacing::PaceSeverity;
9use crate::pango::{color_span, escape, severity_color, severity_for};
10use crate::theme::Theme;
11use crate::tooltip::{Line as TooltipLine, render_bordered};
12use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
13use crate::waybar::{Class, WaybarOutput};
14
15use super::fetch::FetchOutcome;
16use super::types::{Snapshot, SpendWindow};
17
18pub const DEFAULT_FORMAT: &str = "{cc_session_pct}% · {cc_session_reset}";
19const DEFAULT_PLAN: &str = "Command Code";
20const UNAVAILABLE: &str = "—";
21
22impl From<FetchOutcome> for VendorOutcome {
23 fn from(outcome: FetchOutcome) -> Self {
24 outcome.map(crate::usage::VendorSnapshot::CommandCode)
25 }
26}
27
28pub fn build_placeholders(snap: &Snapshot, now: DateTime<Utc>) -> HashMap<&'static str, String> {
29 let plan = sanitize(snap.plan.as_deref().unwrap_or(DEFAULT_PLAN));
30 let session = window_values(snap.five_hour.as_ref(), now);
31 let weekly = window_values(snap.weekly.as_ref(), now);
32 let monthly = snap.monthly_window();
35 let monthly_values = window_values(monthly.as_ref(), now);
36 let remaining = snap
37 .credits
38 .as_ref()
39 .map(|credits| usd(credits.remaining()))
40 .unwrap_or_else(|| UNAVAILABLE.to_string());
41 let pool = snap
42 .credit_pool
43 .map(usd)
44 .unwrap_or_else(|| UNAVAILABLE.to_string());
45 let spent = snap
46 .credits_spent()
47 .map(usd)
48 .unwrap_or_else(|| UNAVAILABLE.to_string());
49 let credits_reset = snap
52 .period_end
53 .map(|at| countdown::format(Some(at), now))
54 .unwrap_or_else(|| UNAVAILABLE.to_string());
55
56 placeholders([
57 (
58 "vendor_short",
59 VendorId::CommandCode.short_name().to_string(),
60 ),
61 ("plan", plan.clone()),
62 ("cc_plan", plan),
63 ("session_pct", session.percent.clone()),
65 ("session_reset", session.reset.clone()),
66 ("weekly_pct", weekly.percent.clone()),
67 ("weekly_reset", weekly.reset.clone()),
68 ("cc_session_pct", session.percent),
69 ("cc_session_reset", session.reset),
70 ("cc_session_used", session.used),
71 ("cc_session_cap", session.cap),
72 ("cc_weekly_pct", weekly.percent),
73 ("cc_weekly_reset", weekly.reset),
74 ("cc_weekly_used", weekly.used),
75 ("cc_weekly_cap", weekly.cap),
76 ("cc_monthly_pct", monthly_values.percent.clone()),
77 ("cc_monthly_reset", monthly_values.reset.clone()),
78 ("cc_monthly_used", monthly_values.used.clone()),
79 ("cc_monthly_cap", monthly_values.cap.clone()),
80 ("cc_credits", remaining),
81 ("cc_credits_pool", pool),
82 ("cc_credits_spent", spent),
83 ("cc_credits_reset", credits_reset),
84 ])
85}
86
87#[derive(Debug)]
88struct WindowValues {
89 percent: String,
90 reset: String,
91 used: String,
92 cap: String,
93}
94
95fn window_values(window: Option<&SpendWindow>, now: DateTime<Utc>) -> WindowValues {
96 let Some(window) = window else {
97 return WindowValues {
98 percent: UNAVAILABLE.to_string(),
99 reset: UNAVAILABLE.to_string(),
100 used: UNAVAILABLE.to_string(),
101 cap: UNAVAILABLE.to_string(),
102 };
103 };
104 WindowValues {
105 percent: window.pct().to_string(),
106 reset: countdown::format(window.resets_at, now),
107 used: usd(window.used),
108 cap: usd(window.cap),
109 }
110}
111
112fn sanitize(value: &str) -> String {
113 crate::display::sanitize_untrusted_field(value)
114}
115
116pub fn severity(snap: &Snapshot) -> PaceSeverity {
117 severity_for(snap.worst_pct())
118}
119
120pub fn render(
121 outcome: &VendorOutcome,
122 snap: &Snapshot,
123 theme: &Theme,
124 opts: &RenderOpts,
125 now: DateTime<Utc>,
126) -> WaybarOutput {
127 render_with_meta(
128 snap,
129 outcome.stale,
130 outcome.last_error.as_ref(),
131 outcome.cache_age,
132 theme,
133 opts,
134 now,
135 )
136}
137
138fn render_with_meta(
139 snap: &Snapshot,
140 stale: bool,
141 last_error: Option<&(u16, String)>,
142 cache_age: Option<Duration>,
143 theme: &Theme,
144 opts: &RenderOpts,
145 now: DateTime<Utc>,
146) -> WaybarOutput {
147 let sev = severity(snap);
148 let format = opts.format.as_deref().unwrap_or(DEFAULT_FORMAT);
149 let values = escaped_placeholders(snap, now);
150 let mut text = substitute(format, &values);
151 if stale {
152 text.push_str(" ⏸");
153 }
154 let icon_prefix = match opts.icon.as_deref() {
155 Some(icon) if !icon.is_empty() => format!("{} ", escape(icon)),
156 _ => String::new(),
157 };
158 let bar_text = color_span(severity_color(sev, theme), &format!("{icon_prefix}{text}"));
159 let tooltip = opts
160 .tooltip_format
161 .as_deref()
162 .map(|format| substitute(format, &values))
163 .unwrap_or_else(|| render_tooltip(snap, stale, last_error, cache_age, theme, now));
164
165 WaybarOutput {
166 text: bar_text,
167 tooltip,
168 class: Class::from(sev),
169 }
170}
171
172fn escaped_placeholders(snap: &Snapshot, now: DateTime<Utc>) -> HashMap<&'static str, String> {
173 let mut values = build_placeholders(snap, now);
174 for key in ["plan", "cc_plan"] {
175 if let Some(value) = values.get_mut(key) {
176 *value = escape(value);
177 }
178 }
179 values
180}
181
182fn render_tooltip(
183 snap: &Snapshot,
184 stale: bool,
185 last_error: Option<&(u16, String)>,
186 cache_age: Option<Duration>,
187 theme: &Theme,
188 now: DateTime<Utc>,
189) -> String {
190 let plan = snap.plan.as_deref().unwrap_or(DEFAULT_PLAN);
191 let mut lines = vec![TooltipLine::Center(format!(
192 "<span font_weight='bold' foreground='{}'>{}</span>",
193 theme.blue,
194 escape(&sanitize(plan))
195 ))];
196 lines.push(TooltipLine::Sep);
197 lines.push(TooltipLine::Body(String::new()));
198
199 let mut present = false;
200 for (label, window) in [
201 ("Session (5h)", snap.five_hour.as_ref()),
202 ("Weekly", snap.weekly.as_ref()),
203 ("Monthly", snap.monthly_window().as_ref()),
204 ] {
205 let Some(window) = window else {
206 continue;
207 };
208 present = true;
209 let values = window_values(Some(window), now);
210 lines.push(TooltipLine::Body(format!(
211 " {} {}% · {} of {} · {}",
212 label,
213 escape(&values.percent),
214 escape(&values.used),
215 escape(&values.cap),
216 escape(&values.reset)
217 )));
218 }
219 if !present {
220 lines.push(TooltipLine::Body(format!(
221 " <span foreground='{}'>no usage windows reported</span>",
222 theme.dim
223 )));
224 }
225
226 if let Some(credits) = snap.credits.as_ref() {
227 lines.push(TooltipLine::Body(String::new()));
228 let reset = match snap.period_end {
231 Some(at) => format!(" · resets in {}", countdown::format(Some(at), now)),
232 None => String::new(),
233 };
234 lines.push(TooltipLine::Body(format!(
235 " Credits {}{}",
236 escape(&usd(credits.remaining())),
237 escape(&reset)
238 )));
239 }
240
241 if stale {
242 lines.push(TooltipLine::Body(String::new()));
243 lines.push(TooltipLine::Body(format!(
244 " <span foreground='{}'> ⏸ Showing cached data</span>",
245 theme.orange
246 )));
247 }
248 if let Some((code, message)) = last_error
249 && *code != 0
250 {
251 lines.push(TooltipLine::Body(String::new()));
252 lines.push(TooltipLine::Sep);
253 lines.push(TooltipLine::Body(format!(
254 " <span foreground='{}'> HTTP {code}: {}</span>",
255 theme.orange,
256 escape(message)
257 )));
258 }
259
260 lines.push(TooltipLine::Body(String::new()));
261 lines.push(TooltipLine::Sep);
262 lines.push(TooltipLine::Body(format!(
263 " <span foreground='{}'> Updated {}</span>",
264 theme.dim,
265 updated_at_hm(now, cache_age)
266 )));
267 render_bordered(&lines, theme)
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::commandcode::types::{Credits, Snapshot, SpendWindow};
274
275 fn at(value: &str) -> DateTime<Utc> {
276 value.parse().expect("RFC3339 timestamp")
277 }
278
279 fn sample() -> Snapshot {
280 Snapshot {
281 plan: Some("GOAT".into()),
282 five_hour: Some(SpendWindow {
283 used: 1.23,
284 cap: 14.0,
285 resets_at: Some(at("2026-08-27T04:40:19Z")),
286 }),
287 weekly: Some(SpendWindow {
288 used: 5.24,
289 cap: 35.0,
290 resets_at: Some(at("2026-09-02T18:36:12Z")),
291 }),
292 credits: Some(Credits {
293 monthly: 49.28,
294 purchased: 0.0,
295 free: 0.0,
296 }),
297 credit_pool: Some(70.0),
298 period_end: Some(at("2026-09-17T14:28:52Z")),
299 }
300 }
301
302 #[test]
303 fn exposes_exact_and_generic_placeholders() {
304 let values = build_placeholders(&sample(), at("2026-08-27T02:30:00Z"));
305
306 assert_eq!(values["vendor_short"], "cmc");
307 assert_eq!(values["plan"], "GOAT");
308 assert_eq!(values["cc_session_pct"], "9");
309 assert_eq!(values["cc_weekly_pct"], "15");
310 assert_eq!(values["cc_monthly_pct"], "30");
312 assert_eq!(values["cc_monthly_used"], "$20.72");
313 assert_eq!(values["cc_monthly_cap"], "$70.00");
314 assert_eq!(values["cc_monthly_reset"], "21d 11h");
315 assert_eq!(values["session_pct"], "9");
317 assert_eq!(values["weekly_pct"], "15");
318 }
319
320 #[test]
321 fn spend_figures_use_the_shared_money_formatter() {
322 let values = build_placeholders(&sample(), at("2026-08-27T02:30:00Z"));
323
324 assert_eq!(values["cc_session_used"], "$1.23");
325 assert_eq!(values["cc_session_cap"], "$14.00");
326 assert_eq!(values["cc_credits"], "$49.28");
327 assert_eq!(values["cc_credits_pool"], "$70.00");
328 assert_eq!(values["cc_credits_spent"], "$20.72");
329 assert_eq!(values["cc_credits_reset"], "21d 11h");
331 }
332
333 #[test]
334 fn monthly_window_needs_ledger_and_a_recognised_plan() {
335 let no_ledger = Snapshot {
337 credits: None,
338 credit_pool: Some(70.0),
339 period_end: Some(at("2026-09-17T14:28:52Z")),
340 ..sample()
341 };
342 assert!(no_ledger.monthly_window().is_none());
343 assert_eq!(no_ledger.worst_pct(), 15);
344
345 let no_pool = Snapshot {
347 credit_pool: None,
348 ..sample()
349 };
350 assert!(no_pool.monthly_window().is_none());
351 }
352
353 #[test]
354 fn default_format_leads_with_the_session_window() {
355 assert_eq!(DEFAULT_FORMAT, "{cc_session_pct}% · {cc_session_reset}");
356 }
357
358 #[test]
359 fn absent_windows_and_ledger_are_unavailable_not_zero() {
360 let values = build_placeholders(&Snapshot::default(), at("2026-08-27T02:30:00Z"));
361
362 for key in [
363 "session_pct",
364 "weekly_pct",
365 "cc_session_pct",
366 "cc_session_reset",
367 "cc_weekly_pct",
368 "cc_session_used",
369 "cc_credits",
370 "cc_credits_pool",
371 "cc_credits_spent",
372 ] {
373 assert_eq!(values[key], UNAVAILABLE, "{key} should be unavailable");
374 assert_ne!(values[key], "0");
375 }
376 assert_eq!(values["plan"], DEFAULT_PLAN);
378 }
379
380 #[test]
381 fn severity_follows_the_window_closest_to_its_cap() {
382 let mut snapshot = sample();
383 assert_eq!(severity(&snapshot), severity_for(15));
384
385 snapshot.weekly = Some(SpendWindow {
386 used: 34.0,
387 cap: 35.0,
388 resets_at: None,
389 });
390 assert_eq!(severity(&snapshot), severity_for(97));
391 }
392
393 #[test]
394 fn plan_is_sanitized_before_it_reaches_the_bar() {
395 let snapshot = Snapshot {
396 plan: Some("GO\u{1b}[31mAT\u{7}".into()),
397 ..sample()
398 };
399
400 let values = build_placeholders(&snapshot, at("2026-08-27T02:30:00Z"));
401
402 assert!(!values["plan"].contains('\u{1b}'));
403 assert!(!values["plan"].contains('\u{7}'));
404 assert!(!values["cc_plan"].contains('\u{1b}'));
405 }
406
407 #[test]
408 fn tooltip_shows_both_windows_and_the_credit_ledger() {
409 let theme = Theme::default();
410 let tooltip = render_tooltip(
411 &sample(),
412 false,
413 None,
414 None,
415 &theme,
416 at("2026-08-27T02:30:00Z"),
417 );
418
419 assert!(tooltip.contains("GOAT"), "{tooltip}");
420 assert!(tooltip.contains("Session (5h)"), "{tooltip}");
421 assert!(tooltip.contains("$1.23 of $14.00"), "{tooltip}");
422 assert!(tooltip.contains("Weekly"), "{tooltip}");
423 assert!(tooltip.contains("Monthly"), "{tooltip}");
425 assert!(tooltip.contains("$20.72 of $70.00"), "{tooltip}");
426 assert!(tooltip.contains("$49.28"), "{tooltip}");
427 assert!(tooltip.contains("resets in 21d 11h"), "{tooltip}");
428 }
429
430 #[test]
431 fn tooltip_says_so_when_the_vendor_reports_no_windows() {
432 let theme = Theme::default();
433 let tooltip = render_tooltip(
434 &Snapshot::default(),
435 false,
436 None,
437 None,
438 &theme,
439 at("2026-08-27T02:30:00Z"),
440 );
441
442 assert!(tooltip.contains("no usage windows reported"), "{tooltip}");
443 }
444
445 #[test]
446 fn stale_and_http_errors_surface_in_the_tooltip() {
447 let theme = Theme::default();
448 let tooltip = render_tooltip(
449 &sample(),
450 true,
451 Some(&(503, "service unavailable".to_string())),
452 None,
453 &theme,
454 at("2026-08-27T02:30:00Z"),
455 );
456
457 assert!(tooltip.contains("Showing cached data"), "{tooltip}");
458 assert!(tooltip.contains("HTTP 503"), "{tooltip}");
459 }
460
461 #[test]
462 fn an_unknown_plan_still_renders_without_an_allowance_line() {
463 let snapshot = Snapshot {
464 plan: Some("individual-future".into()),
465 credit_pool: None,
466 ..sample()
467 };
468 let theme = Theme::default();
469
470 let tooltip = render_tooltip(
471 &snapshot,
472 false,
473 None,
474 None,
475 &theme,
476 at("2026-08-27T02:30:00Z"),
477 );
478
479 assert!(tooltip.contains("$49.28"), "{tooltip}");
480 assert!(!tooltip.contains("Monthly"), "{tooltip}");
482 assert!(!tooltip.contains("$20.72 of $70.00"), "{tooltip}");
483 }
484}