1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6
7use crate::countdown;
8use crate::format::{
9 placeholders, reset_credit_lines, reset_credits, substitute, updated_at_hm, usd,
10};
11use crate::pacing::PaceSeverity;
12use crate::pango::{self, color_span, escape, severity_color, severity_for};
13use crate::theme::Theme;
14use crate::tooltip::{Line as TooltipLine, render_bordered};
15use crate::usage::SuperGrokSnapshot;
16use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
17use crate::waybar::{Class, WaybarOutput};
18
19use super::fetch::FetchOutcome;
20
21pub const DEFAULT_FORMAT: &str = "{sgk_pct}% · {sgk_reset}";
22
23const DEFAULT_ICON: &str = "";
24
25pub fn build_placeholders(
26 snap: &SuperGrokSnapshot,
27 now: DateTime<Utc>,
28) -> HashMap<&'static str, String> {
29 let pct = snap.weekly_pct.to_string();
30 let reset = countdown::format(snap.reset_at, now);
31 let prepaid = snap.prepaid_balance.map(usd).unwrap_or_else(|| "—".into());
32
33 placeholders(vec![
34 ("icon", DEFAULT_ICON.to_string()),
35 ("vendor_short", VendorId::Supergrok.short_name().to_string()),
36 ("plan", snap.plan.clone()),
38 ("session_pct", pct.clone()),
39 ("session_reset", reset.clone()),
40 ("weekly_pct", pct.clone()),
41 ("weekly_reset", reset.clone()),
42 ("sgk_plan", snap.plan.clone()),
44 ("sgk_pct", pct),
45 ("sgk_reset", reset),
46 ("sgk_period", snap.period.label().to_string()),
47 ("sgk_prepaid", prepaid),
48 (
49 "sgk_resets_available",
50 snap.reset_credits.available.to_string(),
51 ),
52 ("sgk_resets", reset_credits(&snap.reset_credits)),
53 ])
54}
55
56pub fn severity(snap: &SuperGrokSnapshot) -> PaceSeverity {
57 severity_for(snap.weekly_pct)
58}
59
60pub fn render(
61 outcome: &VendorOutcome,
62 snap: &SuperGrokSnapshot,
63 theme: &Theme,
64 opts: &RenderOpts,
65 now: DateTime<Utc>,
66) -> WaybarOutput {
67 let class = Class::from(severity(snap));
68 let format = opts
69 .format
70 .clone()
71 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
72 let mut values = build_placeholders(snap, now);
73 for key in ["plan", "sgk_plan"] {
74 if let Some(value) = values.get_mut(key) {
75 *value = escape(value);
76 }
77 }
78
79 let mut text = substitute(&format, &values);
80 if outcome.stale {
81 text.push_str(" ⏸");
82 }
83
84 let wrapper_color = severity_color(severity(snap), theme).to_string();
85 let icon_prefix = match opts.icon.as_deref() {
86 Some(ic) if !ic.is_empty() => format!("{ic} "),
87 _ => String::new(),
88 };
89 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
90
91 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
92 substitute(fmt, &values)
93 } else {
94 render_tooltip(outcome, snap, theme, now)
95 };
96
97 WaybarOutput {
98 text: bar_text,
99 tooltip,
100 class,
101 }
102}
103
104fn push_pct_row(
107 lines: &mut Vec<TooltipLine>,
108 theme: &Theme,
109 label: &str,
110 pct: i32,
111 reset_at: Option<DateTime<Utc>>,
112 now: DateTime<Utc>,
113) {
114 let fg = &theme.fg;
115 let dim = &theme.dim;
116 let color = severity_color(severity_for(pct), theme);
117 let bar = pango::progress_bar(pct, color, theme, None);
118 lines.push(TooltipLine::Body(format!(
119 " <span foreground='{fg}'>{label}</span>"
120 )));
121 lines.push(TooltipLine::Body(format!(
122 " {bar} <span font_weight='bold' foreground='{color}'>{pct}%</span>"
123 )));
124 if reset_at.is_some() {
125 lines.push(TooltipLine::Body(format!(
126 " <span foreground='{dim}'> ⏱ Resets in {}</span>",
127 escape(&countdown::format(reset_at, now))
128 )));
129 }
130}
131
132fn push_product_row(
139 lines: &mut Vec<TooltipLine>,
140 theme: &Theme,
141 product: &crate::usage::SuperGrokProduct,
142 label_width: usize,
143) {
144 let dim = &theme.dim;
145 let label = crate::display::pad_end(&product.label, label_width);
146 let pct = format!("{:>4}%", product.percent);
147 lines.push(TooltipLine::Body(format!(
148 " <span foreground='{dim}'> {} </span><span foreground='{dim}' font_weight='bold'>{pct}</span>",
149 escape(&label)
150 )));
151}
152
153fn render_tooltip(
154 outcome: &VendorOutcome,
155 snap: &SuperGrokSnapshot,
156 theme: &Theme,
157 now: DateTime<Utc>,
158) -> String {
159 let blue = &theme.blue;
160 let dim = &theme.dim;
161 let fg = &theme.fg;
162
163 let mut lines: Vec<TooltipLine> = Vec::new();
164 lines.push(TooltipLine::Center(format!(
165 "<span font_weight='bold' foreground='{blue}'>{}</span>",
166 escape(&snap.plan)
167 )));
168 lines.push(TooltipLine::Sep);
169 lines.push(TooltipLine::Body("".into()));
170
171 let period_label = format!(" {} usage", snap.period.label());
172 push_pct_row(
173 &mut lines,
174 theme,
175 &period_label,
176 snap.weekly_pct,
177 snap.reset_at,
178 now,
179 );
180 if !snap.products.is_empty() {
181 let label_width = snap
182 .products
183 .iter()
184 .map(|product| crate::display::text_width(&product.label))
185 .max()
186 .unwrap_or(0);
187 for product in &snap.products {
188 push_product_row(&mut lines, theme, product, label_width);
189 }
190 }
191
192 if let Some(bal) = snap.prepaid_balance.filter(|bal| *bal > 0.0) {
196 let bal_s = usd(bal);
197 lines.push(TooltipLine::Body("".into()));
198 lines.push(TooltipLine::Body(format!(
199 " <span foreground='{dim}'> Prepaid API {}</span>",
200 escape(&bal_s)
201 )));
202 }
203
204 if snap.reset_credits.available > 0 {
205 lines.push(TooltipLine::Body("".into()));
206 lines.push(TooltipLine::Body(format!(
207 " <span foreground='{fg}'> Reset credits</span>"
208 )));
209 for line in reset_credit_lines(&snap.reset_credits, now) {
210 lines.push(TooltipLine::Body(format!(
211 " <span foreground='{dim}'> {}</span>",
212 escape(&line)
213 )));
214 }
215 }
216
217 if let Some((code, msg)) = outcome.last_error.as_ref() {
218 let (icon, ecolor) = if *code >= 500 {
219 ("", theme.red.as_str())
220 } else {
221 ("", theme.orange.as_str())
222 };
223 let label = if *code == 0 {
224 "Refresh error".to_string()
225 } else {
226 format!("HTTP {code}")
227 };
228 lines.push(TooltipLine::Body("".into()));
229 lines.push(TooltipLine::Sep);
230 lines.push(TooltipLine::Body(format!(
231 " <span foreground='{ecolor}'> {icon} {label}</span>"
232 )));
233 lines.push(TooltipLine::Body(format!(
234 " <span foreground='{dim}'>{}</span>",
235 escape(msg)
236 )));
237 }
238
239 let updated = updated_at_hm(now, outcome.cache_age);
240 lines.push(TooltipLine::Body("".into()));
241 lines.push(TooltipLine::Sep);
242 lines.push(TooltipLine::Body(format!(
243 " <span foreground='{dim}'> Updated {updated}</span>"
244 )));
245
246 render_bordered(&lines, theme)
247}
248
249impl From<FetchOutcome> for VendorOutcome {
250 fn from(o: FetchOutcome) -> Self {
251 o.map(crate::usage::VendorSnapshot::SuperGrok)
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::usage::SuperGrokPeriod;
259 use chrono::TimeZone;
260
261 fn now() -> DateTime<Utc> {
262 Utc.with_ymd_and_hms(2026, 8, 5, 12, 0, 0).unwrap()
263 }
264
265 fn sample_snap() -> SuperGrokSnapshot {
266 SuperGrokSnapshot {
267 plan: "SuperGrok".into(),
268 account: "user-1".into(),
269 weekly_pct: 34,
270 period: SuperGrokPeriod::Weekly,
271 reset_at: Some(now() + chrono::Duration::hours(20)),
272 prepaid_balance: Some(0.0),
273 reset_credits: Default::default(),
274 products: Vec::new(),
275 }
276 }
277
278 fn sample_outcome(snap: SuperGrokSnapshot) -> VendorOutcome {
279 VendorOutcome {
280 snapshot: crate::usage::VendorSnapshot::SuperGrok(snap),
281 stale: false,
282 last_error: None,
283 cache_age: Some(std::time::Duration::from_secs(10)),
284 }
285 }
286
287 fn opts() -> RenderOpts {
288 RenderOpts {
289 format: None,
290 tooltip_format: None,
291 icon: None,
292 pace_tolerance: 5,
293 format_pace_color: false,
294 tooltip_pace_pts: false,
295 }
296 }
297
298 #[test]
299 fn renders_weekly_pct_and_reset() {
300 let snap = sample_snap();
301 let o = sample_outcome(snap.clone());
302 let out = render(&o, &snap, &Theme::default(), &opts(), now());
303 assert!(out.text.contains("34%"));
304 assert!(out.tooltip.contains("usage"));
305 assert!(!out.tooltip.contains("Build credits"));
306 assert!(out.tooltip.contains("Weekly"));
307 assert!(out.tooltip.contains("SuperGrok"));
308 assert!(
311 out.tooltip.contains('█') || out.tooltip.contains('░'),
312 "tooltip missing progress bar cells: {}",
313 out.tooltip
314 );
315 assert!(out.tooltip.contains("Resets in"));
316 }
317
318 #[test]
319 fn tooltip_lists_product_slices() {
320 let mut snap = sample_snap();
321 snap.products = vec![
322 crate::usage::SuperGrokProduct {
323 label: "Grok Build".into(),
324 percent: 20,
325 },
326 crate::usage::SuperGrokProduct {
327 label: "Grok Chat".into(),
328 percent: 14,
329 },
330 ];
331 let o = sample_outcome(snap.clone());
332 let out = render(&o, &snap, &Theme::default(), &opts(), now());
333 assert!(out.tooltip.contains("Grok Build"));
334 assert!(out.tooltip.contains("Grok Chat"));
335 let product_lines: Vec<&str> = out
339 .tooltip
340 .lines()
341 .filter(|line| line.contains("Grok Build") || line.contains("Grok Chat"))
342 .collect();
343 assert_eq!(product_lines.len(), 2, "{}", out.tooltip);
344 for line in &product_lines {
345 assert!(!line.contains('█') && !line.contains('░'), "{line}");
346 assert!(!line.contains("Resets"), "{line}");
347 }
348 assert!(product_lines[0].contains(" 20%</span>"));
349 assert!(product_lines[1].contains(" 14%</span>"));
350 assert_eq!(
351 out.tooltip
352 .lines()
353 .filter(|line| line.contains('█'))
354 .count(),
355 1,
356 "{}",
357 out.tooltip
358 );
359 }
360
361 #[test]
364 fn tooltip_escapes_product_labels() {
365 let mut snap = sample_snap();
366 snap.products = vec![crate::usage::SuperGrokProduct {
367 label: "Grok<b>Build</b>".into(),
368 percent: 7,
369 }];
370 let o = sample_outcome(snap.clone());
371 let out = render(&o, &snap, &Theme::default(), &opts(), now());
372 assert!(out.tooltip.contains("Grok<b>"), "{}", out.tooltip);
373 assert!(!out.tooltip.contains("<b>Build"), "{}", out.tooltip);
374 }
375
376 #[test]
381 fn tooltip_lists_prepaid_only_when_there_is_credit() {
382 let mut snap = sample_snap();
383 snap.prepaid_balance = Some(0.0);
384 let zero = render(
385 &sample_outcome(snap.clone()),
386 &snap,
387 &Theme::default(),
388 &opts(),
389 now(),
390 );
391 assert!(!zero.tooltip.contains("Prepaid API"), "{}", zero.tooltip);
392 assert_eq!(
393 build_placeholders(&snap, now())
394 .get("sgk_prepaid")
395 .map(String::as_str),
396 Some("$0.00")
397 );
398
399 snap.prepaid_balance = Some(4.22);
400 let out = render(
401 &sample_outcome(snap.clone()),
402 &snap,
403 &Theme::default(),
404 &opts(),
405 now(),
406 );
407 assert!(out.tooltip.contains("Prepaid API"), "{}", out.tooltip);
408 assert!(out.tooltip.contains("$4.22"), "{}", out.tooltip);
409 }
410
411 #[test]
412 fn tooltip_reports_banked_resets_and_stays_silent_without_them() {
413 let snap = sample_snap();
414 let quiet = render(
415 &sample_outcome(snap.clone()),
416 &snap,
417 &Theme::default(),
418 &opts(),
419 now(),
420 );
421 assert!(!quiet.tooltip.contains("available"), "{}", quiet.tooltip);
422
423 let mut snap = snap;
424 snap.reset_credits = crate::usage::ResetCredits {
425 available: 1,
426 credits: vec![crate::usage::ResetCredit {
427 title: None,
428 expires_at: Some(now() + chrono::Duration::days(7)),
429 }],
430 };
431 let out = render(
432 &sample_outcome(snap.clone()),
433 &snap,
434 &Theme::default(),
435 &opts(),
436 now(),
437 );
438 assert!(out.tooltip.contains("Reset credits"), "{}", out.tooltip);
439 assert!(out.tooltip.contains("Expires"), "{}", out.tooltip);
440
441 let ph = build_placeholders(&snap, now());
442 assert_eq!(
443 ph.get("sgk_resets_available").map(String::as_str),
444 Some("1")
445 );
446 assert!(ph["sgk_resets"].starts_with("1 reset available"));
447 }
448
449 #[test]
450 fn high_usage_is_critical() {
451 let mut snap = sample_snap();
452 snap.weekly_pct = 95;
453 assert_eq!(severity(&snap), PaceSeverity::Critical);
454 }
455
456 #[test]
457 fn placeholders_include_generic_aliases() {
458 let snap = sample_snap();
459 let ph = build_placeholders(&snap, now());
460 assert_eq!(ph.get("vendor_short").map(String::as_str), Some("sgk"));
461 assert_eq!(ph.get("weekly_pct").map(String::as_str), Some("34"));
462 assert_eq!(ph.get("session_pct").map(String::as_str), Some("34"));
463 assert_eq!(ph.get("sgk_period").map(String::as_str), Some("Weekly"));
464 assert_eq!(ph.get("sgk_prepaid").map(String::as_str), Some("$0.00"));
465 }
466}