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