1use std::collections::HashMap;
6
7use chrono::{DateTime, Utc};
8
9use crate::countdown;
10use crate::format::{placeholders, substitute, updated_at_hm, usd};
11use crate::pacing::PaceSeverity;
12use crate::pango::{self, color_span, escape, severity_color};
13use crate::theme::Theme;
14use crate::tooltip::{Line as TooltipLine, render_bordered};
15use crate::usage::OrcaRouterSnapshot;
16use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
17use crate::waybar::{Class, WaybarOutput};
18
19use super::fetch::FetchOutcome;
20
21pub const DEFAULT_FORMAT: &str = "{orc_remaining}";
22pub const SPEND_ONLY_FORMAT: &str = "{orc_spend} spent";
26
27pub fn build_placeholders(
29 snap: &OrcaRouterSnapshot,
30 now: DateTime<Utc>,
31) -> HashMap<&'static str, String> {
32 let consumed_pct = snap.consumed_pct();
33 placeholders(vec![
34 ("icon", VendorId::OrcaRouter.bar_icon().to_string()),
35 (
36 "vendor_short",
37 VendorId::OrcaRouter.short_name().to_string(),
38 ),
39 (
43 "session_pct",
44 consumed_pct
45 .map(|p| p.to_string())
46 .unwrap_or_else(|| "—".into()),
47 ),
48 ("session_reset", "—".to_string()),
49 (
50 "weekly_pct",
51 consumed_pct
52 .map(|p| p.to_string())
53 .unwrap_or_else(|| "—".into()),
54 ),
55 ("weekly_reset", "—".to_string()),
56 ("plan", "OrcaRouter".to_string()),
57 ("orc_spend", usd(snap.spent_usd())),
58 (
59 "orc_limit",
60 snap.limit_usd()
61 .map(usd)
62 .unwrap_or_else(|| "unlimited".into()),
63 ),
64 (
65 "orc_remaining",
66 snap.remaining_usd()
67 .map(usd)
68 .unwrap_or_else(|| "unlimited".into()),
69 ),
70 (
71 "orc_consumed_pct",
72 consumed_pct
73 .map(|p| p.to_string())
74 .unwrap_or_else(|| "—".into()),
75 ),
76 ("orc_expires", countdown::format(snap.access_until, now)),
77 ])
78}
79
80pub fn severity(snap: &OrcaRouterSnapshot) -> PaceSeverity {
84 match snap.remaining_usd() {
85 Some(remaining) => crate::pango::balance_severity(remaining, "USD"),
86 None => PaceSeverity::Low,
87 }
88}
89
90pub fn render(
92 outcome: &VendorOutcome,
93 snap: &OrcaRouterSnapshot,
94 theme: &Theme,
95 opts: &RenderOpts,
96 now: DateTime<Utc>,
97) -> WaybarOutput {
98 let class = Class::from(severity(snap));
99 let format = opts.format.clone().unwrap_or_else(|| {
100 if snap.limit_cents.is_some() {
101 DEFAULT_FORMAT.to_string()
102 } else {
103 SPEND_ONLY_FORMAT.to_string()
104 }
105 });
106 let mut values = build_placeholders(snap, now);
107 values.insert("orc_bar", orc_bar(snap, theme));
108
109 let mut text = substitute(&format, &values);
110 if outcome.stale {
111 text.push_str(" ⏸");
112 }
113
114 let wrapper_color = severity_color(severity(snap), theme).to_string();
115 let icon_prefix = match opts.icon.as_deref() {
116 Some(ic) if !ic.is_empty() => format!("{ic} "),
117 _ => String::new(),
118 };
119 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
120
121 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
122 substitute(fmt, &values)
123 } else {
124 render_tooltip(outcome, snap, theme, now)
125 };
126
127 WaybarOutput {
128 text: bar_text,
129 tooltip,
130 class,
131 }
132}
133
134fn orc_bar(snap: &OrcaRouterSnapshot, theme: &Theme) -> String {
135 let color = severity_color(severity(snap), theme);
136 pango::progress_bar(snap.consumed_pct().unwrap_or(0), color, theme, None)
137}
138
139fn render_tooltip(
140 outcome: &VendorOutcome,
141 snap: &OrcaRouterSnapshot,
142 theme: &Theme,
143 now: DateTime<Utc>,
144) -> String {
145 let blue = &theme.blue;
146 let dim = &theme.dim;
147 let fg = &theme.fg;
148
149 let color = severity_color(severity(snap), theme);
150
151 let mut lines: Vec<TooltipLine> = Vec::new();
152 lines.push(TooltipLine::Center(format!(
153 "<span font_weight='bold' foreground='{blue}'>OrcaRouter</span>"
154 )));
155 lines.push(TooltipLine::Sep);
156 lines.push(TooltipLine::Body("".into()));
157
158 lines.push(TooltipLine::Body(format!(
159 " <span foreground='{fg}'> Credit</span>"
160 )));
161 match snap.remaining_usd() {
162 Some(remaining) => {
163 lines.push(TooltipLine::Body(format!(
164 " {bar} <span font_weight='bold' foreground='{color}'>{rem}</span>",
165 bar = orc_bar(snap, theme),
166 rem = escape(&usd(remaining))
167 )));
168 lines.push(TooltipLine::Body(format!(
169 " <span foreground='{dim}'> {spent} of {limit} used ({pct}%)</span>",
170 spent = escape(&usd(snap.spent_usd())),
171 limit = escape(&usd(snap.limit_usd().unwrap_or_default())),
172 pct = snap.consumed_pct().unwrap_or(0)
173 )));
174 }
175 None => {
176 lines.push(TooltipLine::Body(format!(
178 " <span font_weight='bold' foreground='{color}'>{spent} spent</span>",
179 spent = escape(&usd(snap.spent_usd()))
180 )));
181 lines.push(TooltipLine::Body(format!(
182 " <span foreground='{dim}'> no credit limit on this key</span>"
183 )));
184 }
185 }
186
187 if let Some(access_until) = snap.access_until {
188 lines.push(TooltipLine::Body("".into()));
189 lines.push(TooltipLine::Body(format!(
190 " <span foreground='{dim}'> Key expires in {left}</span>",
191 left = escape(&countdown::format(Some(access_until), now))
192 )));
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 o.map(crate::usage::VendorSnapshot::OrcaRouter)
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::usage::OrcaRouterSnapshot;
234
235 fn sample_snap() -> OrcaRouterSnapshot {
236 OrcaRouterSnapshot {
237 spent_cents: 275,
238 limit_cents: Some(1250),
239 access_until: None,
240 }
241 }
242
243 fn sample_outcome(snap: OrcaRouterSnapshot) -> VendorOutcome {
244 VendorOutcome {
245 snapshot: crate::usage::VendorSnapshot::OrcaRouter(snap),
246 stale: false,
247 last_error: None,
248 cache_age: Some(std::time::Duration::from_secs(15)),
249 }
250 }
251
252 fn opts() -> RenderOpts {
253 RenderOpts {
254 format: None,
255 tooltip_format: None,
256 icon: None,
257 pace_tolerance: 5,
258 format_pace_color: false,
259 tooltip_pace_pts: false,
260 }
261 }
262
263 #[test]
264 fn default_render_shows_remaining() {
265 let snap = sample_snap();
266 let outcome = sample_outcome(snap.clone());
267 let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
268 assert!(out.text.contains("$9.75"), "{}", out.text);
269 }
270
271 #[test]
274 fn spend_is_rendered_in_dollars_not_cents() {
275 let snap = sample_snap();
276 let outcome = sample_outcome(snap.clone());
277 let mut o = opts();
278 o.format = Some("{orc_spend}".into());
279 let out = render(&outcome, &snap, &Theme::default(), &o, Utc::now());
280 assert!(out.text.contains("$2.75"), "{}", out.text);
281 assert!(!out.text.contains("$275"), "{}", out.text);
282 }
283
284 #[test]
287 fn unlimited_key_renders_spend_only_not_a_hundred_million_wallet() {
288 let snap = OrcaRouterSnapshot {
289 spent_cents: 275,
290 limit_cents: None,
291 access_until: None,
292 };
293 let outcome = sample_outcome(snap.clone());
294 let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
295 assert!(out.text.contains("$2.75"), "{}", out.text);
296 assert!(!out.text.contains("100,000,000"), "{}", out.text);
297 assert!(!out.text.contains("$100000000"), "{}", out.text);
298 assert!(!out.text.contains("$100,000,000.00"), "{}", out.text);
299 assert_eq!(severity(&snap), PaceSeverity::Low);
300
301 let mut o = opts();
302 o.format = Some("{orc_limit}|{orc_remaining}".into());
303 let out = render(&outcome, &snap, &Theme::default(), &o, Utc::now());
304 assert!(out.text.contains("unlimited|unlimited"), "{}", out.text);
305
306 let tooltip = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now()).tooltip;
307 assert!(tooltip.contains("no credit limit"), "{tooltip}");
308 assert!(!tooltip.contains("100,000,000"), "{tooltip}");
309 }
310
311 #[test]
312 fn tooltip_shows_spend_limit_remaining_and_expiry() {
313 let mut snap = sample_snap();
314 snap.access_until = Some(Utc::now() + chrono::Duration::days(30));
315 let outcome = sample_outcome(snap.clone());
316 let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
317 assert!(out.tooltip.contains("$2.75"), "{}", out.tooltip);
318 assert!(out.tooltip.contains("of $12.50 used"), "{}", out.tooltip);
319 assert!(out.tooltip.contains("$9.75"), "{}", out.tooltip);
320 assert!(out.tooltip.contains("expires"), "{}", out.tooltip);
321 }
322
323 #[test]
325 fn access_until_zero_means_no_expiry_row() {
326 let snap = sample_snap();
327 let outcome = sample_outcome(snap.clone());
328 let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
329 assert!(!out.tooltip.contains("expires"), "{}", out.tooltip);
330 let values = build_placeholders(&snap, Utc::now());
331 assert_eq!(values["orc_expires"], "—");
332 }
333
334 #[test]
335 fn custom_tooltip_uses_placeholders() {
336 let snap = sample_snap();
337 let outcome = sample_outcome(snap.clone());
338 let mut o = opts();
339 o.tooltip_format = Some("left: {orc_remaining} of {orc_limit}".into());
340 let out = render(&outcome, &snap, &Theme::default(), &o, Utc::now());
341 assert_eq!(out.tooltip, "left: $9.75 of $12.50");
342 }
343
344 #[test]
345 fn stale_appends_pause() {
346 let snap = sample_snap();
347 let mut outcome = sample_outcome(snap.clone());
348 outcome.stale = true;
349 let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
350 assert!(out.text.contains("⏸"));
351 }
352
353 #[test]
356 fn overrun_is_a_debt_not_zero() {
357 let snap = OrcaRouterSnapshot {
358 spent_cents: 1300,
359 limit_cents: Some(1250),
360 access_until: None,
361 };
362 let outcome = sample_outcome(snap.clone());
363 let out = render(&outcome, &snap, &Theme::default(), &opts(), Utc::now());
364 assert!(out.text.contains("-$0.50"), "{}", out.text);
365 assert_eq!(severity(&snap), PaceSeverity::Critical);
366 }
367
368 #[test]
369 fn severity_scales_with_remaining() {
370 assert_eq!(
371 severity(&OrcaRouterSnapshot {
372 spent_cents: 11950,
373 limit_cents: Some(12000),
374 access_until: None
375 }),
376 PaceSeverity::Critical
377 );
378 assert_eq!(
379 severity(&OrcaRouterSnapshot {
380 spent_cents: 11600,
381 limit_cents: Some(12000),
382 access_until: None
383 }),
384 PaceSeverity::High
385 );
386 assert_eq!(
387 severity(&OrcaRouterSnapshot {
388 spent_cents: 0,
389 limit_cents: Some(12000),
390 access_until: None
391 }),
392 PaceSeverity::Low
393 );
394 }
395}