1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6
7use crate::format::{placeholders, substitute, updated_at_hm};
8use crate::pacing::PaceSeverity;
9use crate::pango::{self, color_span, escape, severity_color, severity_for};
10use crate::theme::Theme;
11use crate::tooltip::{Line as TooltipLine, render_bordered};
12use crate::usage::OpenRouterSnapshot;
13use crate::vendor::{RenderOpts, VendorOutcome};
14use crate::waybar::{Class, WaybarOutput};
15
16use super::fetch::FetchOutcome;
17
18pub const DEFAULT_FORMAT: &str = "${or_balance} · ${or_used_today}";
19
20pub fn build_placeholders(snap: &OpenRouterSnapshot) -> HashMap<&'static str, String> {
22 placeholders(vec![
23 ("icon", "".to_string()),
24 ("vendor_short", "opr".to_string()),
25 ("session_pct", snap.consumed_pct().to_string()),
28 ("session_reset", "—".to_string()),
29 ("weekly_pct", snap.consumed_pct().to_string()),
30 ("weekly_reset", "—".to_string()),
31 ("plan", snap.label.clone()),
32 ("or_label", snap.label.clone()),
33 ("or_balance", format_money(snap.balance())),
34 ("or_total", format_money(snap.total_credits)),
35 ("or_used", format_money(snap.total_usage)),
36 ("or_used_today", format_money(snap.usage_daily)),
37 ("or_used_week", format_money(snap.usage_weekly)),
38 ("or_used_month", format_money(snap.usage_monthly)),
39 ("or_consumed_pct", snap.consumed_pct().to_string()),
40 (
41 "or_free_tier",
42 (if snap.is_free_tier { "free" } else { "paid" }).into(),
43 ),
44 (
45 "or_limit",
46 snap.limit
47 .map(format_money)
48 .unwrap_or_else(|| "unlimited".into()),
49 ),
50 (
51 "or_limit_remaining",
52 snap.limit_remaining
53 .map(format_money)
54 .unwrap_or_else(|| "unlimited".into()),
55 ),
56 ])
57}
58
59fn format_money(v: f64) -> String {
60 if v < 0.0 {
61 format!("-${:.2}", -v)
62 } else {
63 format!("${v:.2}")
64 }
65}
66
67pub fn render(
69 outcome: &VendorOutcome,
70 snap: &OpenRouterSnapshot,
71 theme: &Theme,
72 opts: &RenderOpts,
73 now: DateTime<Utc>,
74) -> WaybarOutput {
75 let class = Class::from(severity(snap));
76 let format = opts
77 .format
78 .clone()
79 .unwrap_or_else(|| DEFAULT_FORMAT.replace('$', ""));
80 let mut values = build_placeholders(snap);
81 values.insert("or_balance_bar", or_balance_bar(snap, theme));
84 for key in ["plan", "or_label"] {
89 if let Some(value) = values.get_mut(key) {
90 *value = escape(value);
91 }
92 }
93
94 let format_clean = format.replace("${", "{");
95 let mut text = substitute(&format_clean, &values);
96 if outcome.stale {
97 text.push_str(" ⏸");
98 }
99 let wrapper_color = severity_color(severity(snap), theme).to_string();
100 let icon_prefix = match opts.icon.as_deref() {
101 Some(ic) if !ic.is_empty() => format!("{ic} "),
102 _ => String::new(),
103 };
104 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
105
106 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
107 substitute(&fmt.replace("${", "{"), &values)
108 } else {
109 render_tooltip(outcome, snap, theme, now)
110 };
111
112 WaybarOutput {
113 text: bar_text,
114 tooltip,
115 class,
116 }
117}
118
119fn or_balance_bar(snap: &OpenRouterSnapshot, theme: &Theme) -> String {
120 let pct = snap.consumed_pct();
121 let color = severity_color(severity_for(pct), theme);
122 pango::progress_bar(pct, color, theme, None)
123}
124
125pub fn severity(snap: &OpenRouterSnapshot) -> PaceSeverity {
127 severity_for(snap.consumed_pct())
128}
129
130fn render_tooltip(
131 outcome: &VendorOutcome,
132 snap: &OpenRouterSnapshot,
133 theme: &Theme,
134 now: DateTime<Utc>,
135) -> String {
136 let blue = &theme.blue;
137 let dim = &theme.dim;
138 let fg = &theme.fg;
139
140 let color = severity_color(severity(snap), theme);
141 let bar = or_balance_bar(snap, theme);
142
143 let mut lines: Vec<TooltipLine> = Vec::new();
144 lines.push(TooltipLine::Center(format!(
145 "<span font_weight='bold' foreground='{blue}'>{label}</span>",
146 label = escape(&snap.label)
147 )));
148 lines.push(TooltipLine::Sep);
149 lines.push(TooltipLine::Body("".into()));
150
151 lines.push(TooltipLine::Body(format!(
152 " <span foreground='{fg}'> Balance</span>"
153 )));
154 lines.push(TooltipLine::Body(format!(
155 " {bar} <span font_weight='bold' foreground='{color}'>{bal}</span>",
156 bal = escape(&format_money(snap.balance()))
157 )));
158 lines.push(TooltipLine::Body(format!(
159 " <span foreground='{dim}'> {used} of {total} used ({pct}%)</span>",
160 used = escape(&format_money(snap.total_usage)),
161 total = escape(&format_money(snap.total_credits)),
162 pct = snap.consumed_pct()
163 )));
164
165 lines.push(TooltipLine::Body("".into()));
166 lines.push(TooltipLine::Body(format!(
167 " <span foreground='{fg}'> Usage</span>"
168 )));
169 lines.push(TooltipLine::Body(format!(
170 " <span foreground='{dim}'> today {today} · week {week} · month {month}</span>",
171 today = escape(&format_money(snap.usage_daily)),
172 week = escape(&format_money(snap.usage_weekly)),
173 month = escape(&format_money(snap.usage_monthly))
174 )));
175
176 if let Some(limit) = snap.limit {
177 let rem = snap.limit_remaining.unwrap_or(0.0);
178 lines.push(TooltipLine::Body("".into()));
179 lines.push(TooltipLine::Body(format!(
180 " <span foreground='{fg}'> Per-key limit</span>"
181 )));
182 lines.push(TooltipLine::Body(format!(
183 " <span foreground='{dim}'> {rem} of {tot} remaining</span>",
184 rem = escape(&format_money(rem)),
185 tot = escape(&format_money(limit))
186 )));
187 }
188
189 let tier_label = if snap.is_free_tier {
190 "free tier"
191 } else {
192 "paid tier"
193 };
194 lines.push(TooltipLine::Body("".into()));
195 lines.push(TooltipLine::Body(format!(
196 " <span foreground='{dim}'> {tier_label}</span>"
197 )));
198
199 if let Some((code, msg)) = outcome.last_error.as_ref()
200 && *code != 0
201 {
202 let (icon, ecolor) = if *code >= 500 {
203 ("", theme.red.as_str())
204 } else {
205 ("", theme.orange.as_str())
206 };
207 lines.push(TooltipLine::Body("".into()));
208 lines.push(TooltipLine::Sep);
209 lines.push(TooltipLine::Body(format!(
210 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
211 )));
212 lines.push(TooltipLine::Body(format!(
213 " <span foreground='{dim}'>{}</span>",
214 escape(msg)
215 )));
216 }
217
218 let updated = updated_at_hm(now, outcome.cache_age);
219 lines.push(TooltipLine::Body("".into()));
220 lines.push(TooltipLine::Sep);
221 lines.push(TooltipLine::Body(format!(
222 " <span foreground='{dim}'> Updated {updated}</span>"
223 )));
224
225 render_bordered(&lines, theme)
226}
227
228impl From<FetchOutcome> for VendorOutcome {
229 fn from(o: FetchOutcome) -> Self {
230 Self {
231 snapshot: crate::usage::VendorSnapshot::Openrouter(o.snapshot),
232 stale: o.stale,
233 last_error: o.last_error,
234 cache_age: o.cache_age,
235 }
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::usage::OpenRouterSnapshot;
243
244 fn sample_snap() -> OpenRouterSnapshot {
245 OpenRouterSnapshot {
246 label: "OpenRouter — prod".into(),
247 total_credits: 100.0,
248 total_usage: 25.5,
249 usage_daily: 1.0,
250 usage_weekly: 7.0,
251 usage_monthly: 25.5,
252 is_free_tier: false,
253 limit: Some(50.0),
254 limit_remaining: Some(24.5),
255 }
256 }
257
258 fn sample_outcome(snap: OpenRouterSnapshot) -> VendorOutcome {
259 VendorOutcome {
260 snapshot: crate::usage::VendorSnapshot::Openrouter(snap),
261 stale: false,
262 last_error: None,
263 cache_age: Some(std::time::Duration::from_secs(15)),
264 }
265 }
266
267 fn opts() -> RenderOpts {
268 RenderOpts {
269 format: None,
270 tooltip_format: None,
271 icon: None,
272 pace_tolerance: 5,
273 format_pace_color: false,
274 tooltip_pace_pts: false,
275 }
276 }
277
278 #[test]
279 fn api_controlled_label_is_pango_escaped_in_custom_formats() {
280 let mut snap = sample_snap();
285 snap.label = "A & B <b>spoof</b>".into();
286
287 let mut o = opts();
288 o.format = Some("{plan}|{or_label}".into());
289 let out = render(
290 &sample_outcome(snap.clone()),
291 &snap,
292 &Theme::default(),
293 &o,
294 Utc::now(),
295 );
296 assert!(
297 out.text.contains("A & B <b>spoof</b>"),
298 "label was not escaped: {}",
299 out.text
300 );
301 assert!(!out.text.contains("<b>spoof</b>"));
302
303 let mut o2 = opts();
305 o2.tooltip_format = Some("{or_label}".into());
306 let out2 = render(
307 &sample_outcome(snap.clone()),
308 &snap,
309 &Theme::default(),
310 &o2,
311 Utc::now(),
312 );
313 assert!(out2.tooltip.contains("A & B"));
314 assert!(!out2.tooltip.contains("<b>spoof</b>"));
315 }
316
317 #[test]
318 fn default_render_contains_balance_and_today_usage() {
319 let snap = sample_snap();
320 let outcome = sample_outcome(snap.clone());
321 let theme = Theme::default();
322 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
323 assert!(out.text.contains("$74.50"));
324 assert!(out.text.contains("$1.00"));
325 }
326
327 #[test]
328 fn tooltip_includes_balance_usage_tier() {
329 let snap = sample_snap();
330 let outcome = sample_outcome(snap.clone());
331 let theme = Theme::default();
332 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
333 assert!(out.tooltip.contains("Balance"));
334 assert!(out.tooltip.contains("$25.50 of $100.00 used"));
335 assert!(out.tooltip.contains("paid tier"));
336 assert!(out.tooltip.contains("Per-key limit"));
337 assert!(out.tooltip.contains("$24.50 of $50.00"));
338 }
339
340 #[test]
341 fn free_tier_label() {
342 let mut snap = sample_snap();
343 snap.is_free_tier = true;
344 let outcome = sample_outcome(snap.clone());
345 let theme = Theme::default();
346 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
347 assert!(out.tooltip.contains("free tier"));
348 }
349
350 #[test]
351 fn stale_appends_pause() {
352 let snap = sample_snap();
353 let mut outcome = sample_outcome(snap.clone());
354 outcome.stale = true;
355 let theme = Theme::default();
356 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
357 assert!(out.text.contains("⏸"));
358 }
359
360 #[test]
361 fn custom_tooltip_uses_placeholders() {
362 let snap = sample_snap();
363 let outcome = sample_outcome(snap.clone());
364 let theme = Theme::default();
365 let mut o = opts();
366 o.tooltip_format = Some("bal: {or_balance} | mtd: {or_used_month}".into());
367 let out = render(&outcome, &snap, &theme, &o, Utc::now());
368 assert_eq!(out.tooltip, "bal: $74.50 | mtd: $25.50");
369 }
370
371 #[test]
372 fn severity_keys_on_consumed_pct() {
373 let mut snap = sample_snap();
374 snap.total_usage = 92.0; assert_eq!(severity(&snap), PaceSeverity::Critical);
376 snap.total_usage = 60.0; assert_eq!(severity(&snap), PaceSeverity::Mid);
378 }
379}