ai_usagebar/grok/
vendor.rs1use std::collections::HashMap;
5
6use chrono::{DateTime, Utc};
7
8use crate::format::{placeholders, substitute, updated_at_hm, usd};
9use crate::pacing::PaceSeverity;
10use crate::pango::{color_span, escape, severity_color};
11use crate::theme::Theme;
12use crate::tooltip::{Line as TooltipLine, render_bordered};
13use crate::usage::GrokSnapshot;
14use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::FetchOutcome;
18
19pub const DEFAULT_FORMAT: &str = "{grok_balance}";
20
21pub fn build_placeholders(snap: &GrokSnapshot) -> HashMap<&'static str, String> {
22 placeholders(vec![
23 ("icon", "".to_string()),
24 ("vendor_short", VendorId::Grok.short_name().to_string()),
25 ("session_pct", "0".to_string()),
26 ("session_reset", "—".to_string()),
27 ("weekly_pct", "0".to_string()),
28 ("weekly_reset", "—".to_string()),
29 ("plan", "Grok".to_string()),
30 ("grok_balance", usd(snap.balance)),
31 ])
32}
33
34pub fn severity(snap: &GrokSnapshot) -> PaceSeverity {
36 crate::pango::balance_severity(snap.balance, "USD")
37}
38
39pub fn render(
40 outcome: &VendorOutcome,
41 snap: &GrokSnapshot,
42 theme: &Theme,
43 opts: &RenderOpts,
44 now: DateTime<Utc>,
45) -> WaybarOutput {
46 let class = Class::from(severity(snap));
47 let format = opts
48 .format
49 .clone()
50 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
51 let values = build_placeholders(snap);
52
53 let mut text = substitute(&format, &values);
54 if outcome.stale {
55 text.push_str(" ⏸");
56 }
57
58 let wrapper_color = severity_color(severity(snap), theme).to_string();
59 let icon_prefix = match opts.icon.as_deref() {
60 Some(ic) if !ic.is_empty() => format!("{ic} "),
61 _ => String::new(),
62 };
63 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
64
65 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
66 substitute(fmt, &values)
67 } else {
68 render_tooltip(outcome, snap, theme, now)
69 };
70
71 WaybarOutput {
72 text: bar_text,
73 tooltip,
74 class,
75 }
76}
77
78fn render_tooltip(
79 outcome: &VendorOutcome,
80 snap: &GrokSnapshot,
81 theme: &Theme,
82 now: DateTime<Utc>,
83) -> String {
84 let blue = &theme.blue;
85 let dim = &theme.dim;
86 let fg = &theme.fg;
87 let color = severity_color(severity(snap), theme);
88
89 let mut lines: Vec<TooltipLine> = Vec::new();
90 lines.push(TooltipLine::Center(format!(
91 "<span font_weight='bold' foreground='{blue}'>Grok (xAI)</span>"
92 )));
93 lines.push(TooltipLine::Sep);
94 lines.push(TooltipLine::Body("".into()));
95
96 lines.push(TooltipLine::Body(format!(
97 " <span foreground='{fg}'> Prepaid balance</span>"
98 )));
99 lines.push(TooltipLine::Body(format!(
100 " <span font_weight='bold' foreground='{color}'>{bal}</span>",
101 bal = escape(&usd(snap.balance))
102 )));
103
104 if let Some((code, msg)) = outcome.last_error.as_ref()
105 && *code != 0
106 {
107 let (icon, ecolor) = if *code >= 500 {
108 ("", theme.red.as_str())
109 } else {
110 ("", theme.orange.as_str())
111 };
112 lines.push(TooltipLine::Body("".into()));
113 lines.push(TooltipLine::Sep);
114 lines.push(TooltipLine::Body(format!(
115 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
116 )));
117 lines.push(TooltipLine::Body(format!(
118 " <span foreground='{dim}'>{}</span>",
119 escape(msg)
120 )));
121 }
122
123 let updated = updated_at_hm(now, outcome.cache_age);
124 lines.push(TooltipLine::Body("".into()));
125 lines.push(TooltipLine::Sep);
126 lines.push(TooltipLine::Body(format!(
127 " <span foreground='{dim}'> Updated {updated}</span>"
128 )));
129
130 render_bordered(&lines, theme)
131}
132
133impl From<FetchOutcome> for VendorOutcome {
134 fn from(o: FetchOutcome) -> Self {
135 o.map(crate::usage::VendorSnapshot::Grok)
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::usage::GrokSnapshot;
143
144 fn outcome(balance: f64) -> (GrokSnapshot, VendorOutcome) {
145 let snap = GrokSnapshot { balance };
146 let o = VendorOutcome {
147 snapshot: crate::usage::VendorSnapshot::Grok(snap.clone()),
148 stale: false,
149 last_error: None,
150 cache_age: Some(std::time::Duration::from_secs(10)),
151 };
152 (snap, o)
153 }
154
155 fn opts() -> RenderOpts {
156 RenderOpts {
157 format: None,
158 tooltip_format: None,
159 icon: None,
160 pace_tolerance: 5,
161 format_pace_color: false,
162 tooltip_pace_pts: false,
163 }
164 }
165
166 #[test]
167 fn renders_balance() {
168 let (snap, o) = outcome(25.0);
169 let out = render(&o, &snap, &Theme::default(), &opts(), Utc::now());
170 assert!(out.text.contains("$25.00"));
171 assert!(out.tooltip.contains("Prepaid balance"));
172 }
173
174 #[test]
175 fn low_balance_is_critical() {
176 let (snap, _) = outcome(0.5);
177 assert_eq!(severity(&snap), PaceSeverity::Critical);
178 }
179}