1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6
7use crate::format::{placeholders, substitute, updated_at_hm, usd};
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", usd(snap.balance())),
34 ("or_total", usd(snap.total_credits)),
35 ("or_used", usd(snap.total_usage)),
36 ("or_used_today", usd(snap.usage_daily)),
37 ("or_used_week", usd(snap.usage_weekly)),
38 ("or_used_month", usd(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.map(usd).unwrap_or_else(|| "unlimited".into()),
47 ),
48 (
49 "or_limit_remaining",
50 snap.limit_remaining
51 .map(usd)
52 .unwrap_or_else(|| "unlimited".into()),
53 ),
54 ])
55}
56
57pub fn render(
59 outcome: &VendorOutcome,
60 snap: &OpenRouterSnapshot,
61 theme: &Theme,
62 opts: &RenderOpts,
63 now: DateTime<Utc>,
64) -> WaybarOutput {
65 let class = Class::from(severity(snap));
66 let format = opts
67 .format
68 .clone()
69 .unwrap_or_else(|| DEFAULT_FORMAT.replace('$', ""));
70 let mut values = build_placeholders(snap);
71 values.insert("or_balance_bar", or_balance_bar(snap, theme));
74 for key in ["plan", "or_label"] {
79 if let Some(value) = values.get_mut(key) {
80 *value = escape(value);
81 }
82 }
83
84 let format_clean = format.replace("${", "{");
85 let mut text = substitute(&format_clean, &values);
86 if outcome.stale {
87 text.push_str(" ⏸");
88 }
89 let wrapper_color = severity_color(severity(snap), theme).to_string();
90 let icon_prefix = match opts.icon.as_deref() {
91 Some(ic) if !ic.is_empty() => format!("{ic} "),
92 _ => String::new(),
93 };
94 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
95
96 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
97 substitute(&fmt.replace("${", "{"), &values)
98 } else {
99 render_tooltip(outcome, snap, theme, now)
100 };
101
102 WaybarOutput {
103 text: bar_text,
104 tooltip,
105 class,
106 }
107}
108
109fn or_balance_bar(snap: &OpenRouterSnapshot, theme: &Theme) -> String {
110 let color = severity_color(severity(snap), theme);
111 pango::progress_bar(snap.consumed_pct(), color, theme, None)
112}
113
114pub fn severity(snap: &OpenRouterSnapshot) -> PaceSeverity {
120 if snap.balance() < 0.0 {
121 return PaceSeverity::Critical;
122 }
123 severity_for(snap.consumed_pct())
124}
125
126fn render_tooltip(
127 outcome: &VendorOutcome,
128 snap: &OpenRouterSnapshot,
129 theme: &Theme,
130 now: DateTime<Utc>,
131) -> String {
132 let blue = &theme.blue;
133 let dim = &theme.dim;
134 let fg = &theme.fg;
135
136 let color = severity_color(severity(snap), theme);
137 let bar = or_balance_bar(snap, theme);
138
139 let mut lines: Vec<TooltipLine> = Vec::new();
140 lines.push(TooltipLine::Center(format!(
141 "<span font_weight='bold' foreground='{blue}'>{label}</span>",
142 label = escape(&snap.label)
143 )));
144 lines.push(TooltipLine::Sep);
145 lines.push(TooltipLine::Body("".into()));
146
147 lines.push(TooltipLine::Body(format!(
148 " <span foreground='{fg}'> Balance</span>"
149 )));
150 lines.push(TooltipLine::Body(format!(
151 " {bar} <span font_weight='bold' foreground='{color}'>{bal}</span>",
152 bal = escape(&usd(snap.balance()))
153 )));
154 lines.push(TooltipLine::Body(format!(
155 " <span foreground='{dim}'> {used} of {total} used ({pct}%)</span>",
156 used = escape(&usd(snap.total_usage)),
157 total = escape(&usd(snap.total_credits)),
158 pct = snap.consumed_pct()
159 )));
160
161 lines.push(TooltipLine::Body("".into()));
162 lines.push(TooltipLine::Body(format!(
163 " <span foreground='{fg}'> Usage</span>"
164 )));
165 lines.push(TooltipLine::Body(format!(
166 " <span foreground='{dim}'> today {today} · week {week} · month {month}</span>",
167 today = escape(&usd(snap.usage_daily)),
168 week = escape(&usd(snap.usage_weekly)),
169 month = escape(&usd(snap.usage_monthly))
170 )));
171
172 if let Some(limit) = snap.limit {
173 let rem = snap.limit_remaining.unwrap_or(0.0);
174 lines.push(TooltipLine::Body("".into()));
175 lines.push(TooltipLine::Body(format!(
176 " <span foreground='{fg}'> Per-key limit</span>"
177 )));
178 lines.push(TooltipLine::Body(format!(
179 " <span foreground='{dim}'> {rem} of {tot} remaining</span>",
180 rem = escape(&usd(rem)),
181 tot = escape(&usd(limit))
182 )));
183 }
184
185 let tier_label = if snap.is_free_tier {
186 "free tier"
187 } else {
188 "paid tier"
189 };
190 lines.push(TooltipLine::Body("".into()));
191 lines.push(TooltipLine::Body(format!(
192 " <span foreground='{dim}'> {tier_label}</span>"
193 )));
194
195 if let Some((code, msg)) = outcome.last_error.as_ref()
196 && *code != 0
197 {
198 let (icon, ecolor) = if *code >= 500 {
199 ("", theme.red.as_str())
200 } else {
201 ("", theme.orange.as_str())
202 };
203 lines.push(TooltipLine::Body("".into()));
204 lines.push(TooltipLine::Sep);
205 lines.push(TooltipLine::Body(format!(
206 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
207 )));
208 lines.push(TooltipLine::Body(format!(
209 " <span foreground='{dim}'>{}</span>",
210 escape(msg)
211 )));
212 }
213
214 let updated = updated_at_hm(now, outcome.cache_age);
215 lines.push(TooltipLine::Body("".into()));
216 lines.push(TooltipLine::Sep);
217 lines.push(TooltipLine::Body(format!(
218 " <span foreground='{dim}'> Updated {updated}</span>"
219 )));
220
221 render_bordered(&lines, theme)
222}
223
224impl From<FetchOutcome> for VendorOutcome {
225 fn from(o: FetchOutcome) -> Self {
226 Self {
227 snapshot: crate::usage::VendorSnapshot::Openrouter(o.snapshot),
228 stale: o.stale,
229 last_error: o.last_error,
230 cache_age: o.cache_age,
231 }
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::usage::OpenRouterSnapshot;
239
240 fn sample_snap() -> OpenRouterSnapshot {
241 OpenRouterSnapshot {
242 label: "OpenRouter — prod".into(),
243 total_credits: 100.0,
244 total_usage: 25.5,
245 usage_daily: 1.0,
246 usage_weekly: 7.0,
247 usage_monthly: 25.5,
248 is_free_tier: false,
249 limit: Some(50.0),
250 limit_remaining: Some(24.5),
251 }
252 }
253
254 fn sample_outcome(snap: OpenRouterSnapshot) -> VendorOutcome {
255 VendorOutcome {
256 snapshot: crate::usage::VendorSnapshot::Openrouter(snap),
257 stale: false,
258 last_error: None,
259 cache_age: Some(std::time::Duration::from_secs(15)),
260 }
261 }
262
263 fn opts() -> RenderOpts {
264 RenderOpts {
265 format: None,
266 tooltip_format: None,
267 icon: None,
268 pace_tolerance: 5,
269 format_pace_color: false,
270 tooltip_pace_pts: false,
271 }
272 }
273
274 #[test]
275 fn api_controlled_label_is_pango_escaped_in_custom_formats() {
276 let mut snap = sample_snap();
281 snap.label = "A & B <b>spoof</b>".into();
282
283 let mut o = opts();
284 o.format = Some("{plan}|{or_label}".into());
285 let out = render(
286 &sample_outcome(snap.clone()),
287 &snap,
288 &Theme::default(),
289 &o,
290 Utc::now(),
291 );
292 assert!(
293 out.text.contains("A & B <b>spoof</b>"),
294 "label was not escaped: {}",
295 out.text
296 );
297 assert!(!out.text.contains("<b>spoof</b>"));
298
299 let mut o2 = opts();
301 o2.tooltip_format = Some("{or_label}".into());
302 let out2 = render(
303 &sample_outcome(snap.clone()),
304 &snap,
305 &Theme::default(),
306 &o2,
307 Utc::now(),
308 );
309 assert!(out2.tooltip.contains("A & B"));
310 assert!(!out2.tooltip.contains("<b>spoof</b>"));
311 }
312
313 #[test]
314 fn default_render_contains_balance_and_today_usage() {
315 let snap = sample_snap();
316 let outcome = sample_outcome(snap.clone());
317 let theme = Theme::default();
318 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
319 assert!(out.text.contains("$74.50"));
320 assert!(out.text.contains("$1.00"));
321 }
322
323 #[test]
324 fn tooltip_includes_balance_usage_tier() {
325 let snap = sample_snap();
326 let outcome = sample_outcome(snap.clone());
327 let theme = Theme::default();
328 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
329 assert!(out.tooltip.contains("Balance"));
330 assert!(out.tooltip.contains("$25.50 of $100.00 used"));
331 assert!(out.tooltip.contains("paid tier"));
332 assert!(out.tooltip.contains("Per-key limit"));
333 assert!(out.tooltip.contains("$24.50 of $50.00"));
334 }
335
336 #[test]
341 fn a_debt_is_shown_as_a_debt_not_as_zero() {
342 let mut snap = sample_snap();
343 snap.total_credits = 0.0;
344 snap.total_usage = 5.71;
345 assert!((snap.balance() + 5.71).abs() < 1e-9);
346 assert_eq!(snap.consumed_pct(), 0);
347 assert_eq!(severity(&snap), PaceSeverity::Critical);
348
349 let out = render(
350 &sample_outcome(snap.clone()),
351 &snap,
352 &Theme::default(),
353 &opts(),
354 Utc::now(),
355 );
356 assert!(out.text.contains("-$5.71"), "{}", out.text);
357 assert!(!out.text.contains("$0.00"), "{}", out.text);
358 assert!(out.tooltip.contains("-$5.71"), "{}", out.tooltip);
359 }
360
361 #[test]
365 fn overrunning_purchased_credit_is_also_a_debt() {
366 let mut snap = sample_snap();
367 snap.total_credits = 10.0;
368 snap.total_usage = 12.5;
369 assert!((snap.balance() + 2.5).abs() < 1e-9);
370 assert_eq!(snap.consumed_pct(), 100);
371 assert_eq!(severity(&snap), PaceSeverity::Critical);
372
373 let out = render(
374 &sample_outcome(snap.clone()),
375 &snap,
376 &Theme::default(),
377 &opts(),
378 Utc::now(),
379 );
380 assert!(out.text.contains("-$2.50"), "{}", out.text);
381 }
382
383 #[test]
386 fn a_zero_credit_account_with_no_usage_stays_low() {
387 let mut snap = sample_snap();
388 snap.total_credits = 0.0;
389 snap.total_usage = 0.0;
390 assert_eq!(snap.balance(), 0.0);
391 assert_eq!(severity(&snap), PaceSeverity::Low);
392 }
393
394 #[test]
395 fn free_tier_label() {
396 let mut snap = sample_snap();
397 snap.is_free_tier = true;
398 let outcome = sample_outcome(snap.clone());
399 let theme = Theme::default();
400 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
401 assert!(out.tooltip.contains("free tier"));
402 }
403
404 #[test]
405 fn stale_appends_pause() {
406 let snap = sample_snap();
407 let mut outcome = sample_outcome(snap.clone());
408 outcome.stale = true;
409 let theme = Theme::default();
410 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
411 assert!(out.text.contains("⏸"));
412 }
413
414 #[test]
415 fn custom_tooltip_uses_placeholders() {
416 let snap = sample_snap();
417 let outcome = sample_outcome(snap.clone());
418 let theme = Theme::default();
419 let mut o = opts();
420 o.tooltip_format = Some("bal: {or_balance} | mtd: {or_used_month}".into());
421 let out = render(&outcome, &snap, &theme, &o, Utc::now());
422 assert_eq!(out.tooltip, "bal: $74.50 | mtd: $25.50");
423 }
424
425 #[test]
426 fn severity_keys_on_consumed_pct() {
427 let mut snap = sample_snap();
428 snap.total_usage = 92.0; assert_eq!(severity(&snap), PaceSeverity::Critical);
430 snap.total_usage = 60.0; assert_eq!(severity(&snap), PaceSeverity::Mid);
432 }
433}