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