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