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
85 let format_clean = format.replace("${", "{");
86 let mut text = substitute(&format_clean, &values);
87 if outcome.stale {
88 text.push_str(" ⏸");
89 }
90 let wrapper_color = severity_color(severity(snap), theme).to_string();
91 let icon_prefix = match opts.icon.as_deref() {
92 Some(ic) if !ic.is_empty() => format!("{ic} "),
93 _ => String::new(),
94 };
95 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
96
97 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
98 substitute(&fmt.replace("${", "{"), &values)
99 } else {
100 render_tooltip(outcome, snap, theme, now)
101 };
102
103 WaybarOutput {
104 text: bar_text,
105 tooltip,
106 class,
107 }
108}
109
110fn or_balance_bar(snap: &OpenRouterSnapshot, theme: &Theme) -> String {
111 let pct = snap.consumed_pct();
112 let color = severity_color(severity_for(pct), theme);
113 pango::progress_bar(pct, color, theme, None)
114}
115
116pub fn severity(snap: &OpenRouterSnapshot) -> PaceSeverity {
118 severity_for(snap.consumed_pct())
119}
120
121fn render_tooltip(
122 outcome: &VendorOutcome,
123 snap: &OpenRouterSnapshot,
124 theme: &Theme,
125 now: DateTime<Utc>,
126) -> String {
127 let blue = &theme.blue;
128 let dim = &theme.dim;
129 let fg = &theme.fg;
130
131 let color = severity_color(severity(snap), theme);
132 let bar = or_balance_bar(snap, theme);
133
134 let mut lines: Vec<TooltipLine> = Vec::new();
135 lines.push(TooltipLine::Center(format!(
136 "<span font_weight='bold' foreground='{blue}'>{label}</span>",
137 label = escape(&snap.label)
138 )));
139 lines.push(TooltipLine::Sep);
140 lines.push(TooltipLine::Body("".into()));
141
142 lines.push(TooltipLine::Body(format!(
143 " <span foreground='{fg}'> Balance</span>"
144 )));
145 lines.push(TooltipLine::Body(format!(
146 " {bar} <span font_weight='bold' foreground='{color}'>{bal}</span>",
147 bal = escape(&format_money(snap.balance()))
148 )));
149 lines.push(TooltipLine::Body(format!(
150 " <span foreground='{dim}'> {used} of {total} used ({pct}%)</span>",
151 used = escape(&format_money(snap.total_usage)),
152 total = escape(&format_money(snap.total_credits)),
153 pct = snap.consumed_pct()
154 )));
155
156 lines.push(TooltipLine::Body("".into()));
157 lines.push(TooltipLine::Body(format!(
158 " <span foreground='{fg}'> Usage</span>"
159 )));
160 lines.push(TooltipLine::Body(format!(
161 " <span foreground='{dim}'> today {today} · week {week} · month {month}</span>",
162 today = escape(&format_money(snap.usage_daily)),
163 week = escape(&format_money(snap.usage_weekly)),
164 month = escape(&format_money(snap.usage_monthly))
165 )));
166
167 if let Some(limit) = snap.limit {
168 let rem = snap.limit_remaining.unwrap_or(0.0);
169 lines.push(TooltipLine::Body("".into()));
170 lines.push(TooltipLine::Body(format!(
171 " <span foreground='{fg}'> Per-key limit</span>"
172 )));
173 lines.push(TooltipLine::Body(format!(
174 " <span foreground='{dim}'> {rem} of {tot} remaining</span>",
175 rem = escape(&format_money(rem)),
176 tot = escape(&format_money(limit))
177 )));
178 }
179
180 let tier_label = if snap.is_free_tier {
181 "free tier"
182 } else {
183 "paid tier"
184 };
185 lines.push(TooltipLine::Body("".into()));
186 lines.push(TooltipLine::Body(format!(
187 " <span foreground='{dim}'> {tier_label}</span>"
188 )));
189
190 if let Some((code, msg)) = outcome.last_error.as_ref()
191 && *code != 0
192 {
193 let (icon, ecolor) = if *code >= 500 {
194 ("", theme.red.as_str())
195 } else {
196 ("", theme.orange.as_str())
197 };
198 lines.push(TooltipLine::Body("".into()));
199 lines.push(TooltipLine::Sep);
200 lines.push(TooltipLine::Body(format!(
201 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
202 )));
203 lines.push(TooltipLine::Body(format!(
204 " <span foreground='{dim}'>{}</span>",
205 escape(msg)
206 )));
207 }
208
209 let updated = updated_at_hm(now, outcome.cache_age);
210 lines.push(TooltipLine::Body("".into()));
211 lines.push(TooltipLine::Sep);
212 lines.push(TooltipLine::Body(format!(
213 " <span foreground='{dim}'> Updated {updated}</span>"
214 )));
215
216 render_bordered(&lines, theme)
217}
218
219impl From<FetchOutcome> for VendorOutcome {
220 fn from(o: FetchOutcome) -> Self {
221 Self {
222 snapshot: crate::usage::VendorSnapshot::Openrouter(o.snapshot),
223 stale: o.stale,
224 last_error: o.last_error,
225 cache_age: o.cache_age,
226 }
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::usage::OpenRouterSnapshot;
234
235 fn sample_snap() -> OpenRouterSnapshot {
236 OpenRouterSnapshot {
237 label: "OpenRouter — prod".into(),
238 total_credits: 100.0,
239 total_usage: 25.5,
240 usage_daily: 1.0,
241 usage_weekly: 7.0,
242 usage_monthly: 25.5,
243 is_free_tier: false,
244 limit: Some(50.0),
245 limit_remaining: Some(24.5),
246 }
247 }
248
249 fn sample_outcome(snap: OpenRouterSnapshot) -> VendorOutcome {
250 VendorOutcome {
251 snapshot: crate::usage::VendorSnapshot::Openrouter(snap),
252 stale: false,
253 last_error: None,
254 cache_age: Some(std::time::Duration::from_secs(15)),
255 }
256 }
257
258 fn opts() -> RenderOpts {
259 RenderOpts {
260 format: None,
261 tooltip_format: None,
262 icon: None,
263 pace_tolerance: 5,
264 format_pace_color: false,
265 tooltip_pace_pts: false,
266 }
267 }
268
269 #[test]
270 fn default_render_contains_balance_and_today_usage() {
271 let snap = sample_snap();
272 let outcome = sample_outcome(snap.clone());
273 let theme = Theme::default();
274 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
275 assert!(out.text.contains("$74.50"));
276 assert!(out.text.contains("$1.00"));
277 }
278
279 #[test]
280 fn tooltip_includes_balance_usage_tier() {
281 let snap = sample_snap();
282 let outcome = sample_outcome(snap.clone());
283 let theme = Theme::default();
284 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
285 assert!(out.tooltip.contains("Balance"));
286 assert!(out.tooltip.contains("$25.50 of $100.00 used"));
287 assert!(out.tooltip.contains("paid tier"));
288 assert!(out.tooltip.contains("Per-key limit"));
289 assert!(out.tooltip.contains("$24.50 of $50.00"));
290 }
291
292 #[test]
293 fn free_tier_label() {
294 let mut snap = sample_snap();
295 snap.is_free_tier = true;
296 let outcome = sample_outcome(snap.clone());
297 let theme = Theme::default();
298 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
299 assert!(out.tooltip.contains("free tier"));
300 }
301
302 #[test]
303 fn stale_appends_pause() {
304 let snap = sample_snap();
305 let mut outcome = sample_outcome(snap.clone());
306 outcome.stale = true;
307 let theme = Theme::default();
308 let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
309 assert!(out.text.contains("⏸"));
310 }
311
312 #[test]
313 fn custom_tooltip_uses_placeholders() {
314 let snap = sample_snap();
315 let outcome = sample_outcome(snap.clone());
316 let theme = Theme::default();
317 let mut o = opts();
318 o.tooltip_format = Some("bal: {or_balance} | mtd: {or_used_month}".into());
319 let out = render(&outcome, &snap, &theme, &o, Utc::now());
320 assert_eq!(out.tooltip, "bal: $74.50 | mtd: $25.50");
321 }
322
323 #[test]
324 fn severity_keys_on_consumed_pct() {
325 let mut snap = sample_snap();
326 snap.total_usage = 92.0; assert_eq!(severity(&snap), PaceSeverity::Critical);
328 snap.total_usage = 60.0; assert_eq!(severity(&snap), PaceSeverity::Mid);
330 }
331}