Skip to main content

ai_usagebar/commandcode/
vendor.rs

1use std::collections::HashMap;
2use std::time::Duration;
3
4use chrono::{DateTime, Utc};
5
6use crate::countdown;
7use crate::format::{placeholders, substitute, updated_at_hm, usd};
8use crate::pacing::PaceSeverity;
9use crate::pango::{color_span, escape, severity_color, severity_for};
10use crate::theme::Theme;
11use crate::tooltip::{Line as TooltipLine, render_bordered};
12use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
13use crate::waybar::{Class, WaybarOutput};
14
15use super::fetch::FetchOutcome;
16use super::types::{Snapshot, SpendWindow};
17
18pub const DEFAULT_FORMAT: &str = "{cc_session_pct}% · {cc_session_reset}";
19const DEFAULT_PLAN: &str = "Command Code";
20const UNAVAILABLE: &str = "—";
21
22impl From<FetchOutcome> for VendorOutcome {
23    fn from(outcome: FetchOutcome) -> Self {
24        outcome.map(crate::usage::VendorSnapshot::CommandCode)
25    }
26}
27
28pub fn build_placeholders(snap: &Snapshot, now: DateTime<Utc>) -> HashMap<&'static str, String> {
29    let plan = sanitize(snap.plan.as_deref().unwrap_or(DEFAULT_PLAN));
30    let session = window_values(snap.five_hour.as_ref(), now);
31    let weekly = window_values(snap.weekly.as_ref(), now);
32    let remaining = snap
33        .credits
34        .as_ref()
35        .map(|credits| usd(credits.remaining()))
36        .unwrap_or_else(|| UNAVAILABLE.to_string());
37    let pool = snap
38        .credit_pool
39        .map(usd)
40        .unwrap_or_else(|| UNAVAILABLE.to_string());
41    let spent = snap
42        .credits_spent()
43        .map(usd)
44        .unwrap_or_else(|| UNAVAILABLE.to_string());
45
46    placeholders([
47        (
48            "vendor_short",
49            VendorId::CommandCode.short_name().to_string(),
50        ),
51        ("plan", plan.clone()),
52        ("cc_plan", plan),
53        // Generic names so a shared format string works across vendors.
54        ("session_pct", session.percent.clone()),
55        ("session_reset", session.reset.clone()),
56        ("weekly_pct", weekly.percent.clone()),
57        ("weekly_reset", weekly.reset.clone()),
58        ("cc_session_pct", session.percent),
59        ("cc_session_reset", session.reset),
60        ("cc_session_used", session.used),
61        ("cc_session_cap", session.cap),
62        ("cc_weekly_pct", weekly.percent),
63        ("cc_weekly_reset", weekly.reset),
64        ("cc_weekly_used", weekly.used),
65        ("cc_weekly_cap", weekly.cap),
66        ("cc_credits", remaining),
67        ("cc_credits_pool", pool),
68        ("cc_credits_spent", spent),
69    ])
70}
71
72#[derive(Debug)]
73struct WindowValues {
74    percent: String,
75    reset: String,
76    used: String,
77    cap: String,
78}
79
80fn window_values(window: Option<&SpendWindow>, now: DateTime<Utc>) -> WindowValues {
81    let Some(window) = window else {
82        return WindowValues {
83            percent: UNAVAILABLE.to_string(),
84            reset: UNAVAILABLE.to_string(),
85            used: UNAVAILABLE.to_string(),
86            cap: UNAVAILABLE.to_string(),
87        };
88    };
89    WindowValues {
90        percent: window.pct().to_string(),
91        reset: countdown::format(window.resets_at, now),
92        used: usd(window.used),
93        cap: usd(window.cap),
94    }
95}
96
97fn sanitize(value: &str) -> String {
98    crate::display::sanitize_untrusted_field(value)
99}
100
101pub fn severity(snap: &Snapshot) -> PaceSeverity {
102    severity_for(snap.worst_pct())
103}
104
105pub fn render(
106    outcome: &VendorOutcome,
107    snap: &Snapshot,
108    theme: &Theme,
109    opts: &RenderOpts,
110    now: DateTime<Utc>,
111) -> WaybarOutput {
112    render_with_meta(
113        snap,
114        outcome.stale,
115        outcome.last_error.as_ref(),
116        outcome.cache_age,
117        theme,
118        opts,
119        now,
120    )
121}
122
123fn render_with_meta(
124    snap: &Snapshot,
125    stale: bool,
126    last_error: Option<&(u16, String)>,
127    cache_age: Option<Duration>,
128    theme: &Theme,
129    opts: &RenderOpts,
130    now: DateTime<Utc>,
131) -> WaybarOutput {
132    let sev = severity(snap);
133    let format = opts.format.as_deref().unwrap_or(DEFAULT_FORMAT);
134    let values = escaped_placeholders(snap, now);
135    let mut text = substitute(format, &values);
136    if stale {
137        text.push_str(" ⏸");
138    }
139    let icon_prefix = match opts.icon.as_deref() {
140        Some(icon) if !icon.is_empty() => format!("{} ", escape(icon)),
141        _ => String::new(),
142    };
143    let bar_text = color_span(severity_color(sev, theme), &format!("{icon_prefix}{text}"));
144    let tooltip = opts
145        .tooltip_format
146        .as_deref()
147        .map(|format| substitute(format, &values))
148        .unwrap_or_else(|| render_tooltip(snap, stale, last_error, cache_age, theme, now));
149
150    WaybarOutput {
151        text: bar_text,
152        tooltip,
153        class: Class::from(sev),
154    }
155}
156
157fn escaped_placeholders(snap: &Snapshot, now: DateTime<Utc>) -> HashMap<&'static str, String> {
158    let mut values = build_placeholders(snap, now);
159    for key in ["plan", "cc_plan"] {
160        if let Some(value) = values.get_mut(key) {
161            *value = escape(value);
162        }
163    }
164    values
165}
166
167fn render_tooltip(
168    snap: &Snapshot,
169    stale: bool,
170    last_error: Option<&(u16, String)>,
171    cache_age: Option<Duration>,
172    theme: &Theme,
173    now: DateTime<Utc>,
174) -> String {
175    let plan = snap.plan.as_deref().unwrap_or(DEFAULT_PLAN);
176    let mut lines = vec![TooltipLine::Center(format!(
177        "<span font_weight='bold' foreground='{}'>{}</span>",
178        theme.blue,
179        escape(&sanitize(plan))
180    ))];
181    lines.push(TooltipLine::Sep);
182    lines.push(TooltipLine::Body(String::new()));
183
184    let mut present = false;
185    for (label, window) in [
186        ("Session (5h)", snap.five_hour.as_ref()),
187        ("Weekly", snap.weekly.as_ref()),
188    ] {
189        let Some(window) = window else {
190            continue;
191        };
192        present = true;
193        let values = window_values(Some(window), now);
194        lines.push(TooltipLine::Body(format!(
195            "  {}  {}% · {} of {} · {}",
196            label,
197            escape(&values.percent),
198            escape(&values.used),
199            escape(&values.cap),
200            escape(&values.reset)
201        )));
202    }
203    if !present {
204        lines.push(TooltipLine::Body(format!(
205            " <span foreground='{}'>no usage windows reported</span>",
206            theme.dim
207        )));
208    }
209
210    if let Some(credits) = snap.credits.as_ref() {
211        lines.push(TooltipLine::Body(String::new()));
212        let detail = match (snap.credits_spent(), snap.credit_pool) {
213            (Some(spent), Some(pool)) => {
214                format!(" · {} of {} spent", usd(spent), usd(pool))
215            }
216            _ => String::new(),
217        };
218        lines.push(TooltipLine::Body(format!(
219            "  Credits  {}{}",
220            escape(&usd(credits.remaining())),
221            escape(&detail)
222        )));
223    }
224
225    if stale {
226        lines.push(TooltipLine::Body(String::new()));
227        lines.push(TooltipLine::Body(format!(
228            " <span foreground='{}'>  ⏸  Showing cached data</span>",
229            theme.orange
230        )));
231    }
232    if let Some((code, message)) = last_error
233        && *code != 0
234    {
235        lines.push(TooltipLine::Body(String::new()));
236        lines.push(TooltipLine::Sep);
237        lines.push(TooltipLine::Body(format!(
238            " <span foreground='{}'>  HTTP {code}: {}</span>",
239            theme.orange,
240            escape(message)
241        )));
242    }
243
244    lines.push(TooltipLine::Body(String::new()));
245    lines.push(TooltipLine::Sep);
246    lines.push(TooltipLine::Body(format!(
247        " <span foreground='{}'>  Updated {}</span>",
248        theme.dim,
249        updated_at_hm(now, cache_age)
250    )));
251    render_bordered(&lines, theme)
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use crate::commandcode::types::{Credits, Snapshot, SpendWindow};
258
259    fn at(value: &str) -> DateTime<Utc> {
260        value.parse().expect("RFC3339 timestamp")
261    }
262
263    fn sample() -> Snapshot {
264        Snapshot {
265            plan: Some("GOAT".into()),
266            five_hour: Some(SpendWindow {
267                used: 1.23,
268                cap: 14.0,
269                resets_at: Some(at("2026-08-27T04:40:19Z")),
270            }),
271            weekly: Some(SpendWindow {
272                used: 5.24,
273                cap: 35.0,
274                resets_at: Some(at("2026-09-02T18:36:12Z")),
275            }),
276            credits: Some(Credits {
277                monthly: 49.28,
278                purchased: 0.0,
279                free: 0.0,
280            }),
281            credit_pool: Some(70.0),
282        }
283    }
284
285    #[test]
286    fn exposes_exact_and_generic_placeholders() {
287        let values = build_placeholders(&sample(), at("2026-08-27T02:30:00Z"));
288
289        assert_eq!(values["vendor_short"], "cmc");
290        assert_eq!(values["plan"], "GOAT");
291        assert_eq!(values["cc_session_pct"], "9");
292        assert_eq!(values["cc_weekly_pct"], "15");
293        // Generic aliases keep a shared format string working across vendors.
294        assert_eq!(values["session_pct"], "9");
295        assert_eq!(values["weekly_pct"], "15");
296    }
297
298    #[test]
299    fn spend_figures_use_the_shared_money_formatter() {
300        let values = build_placeholders(&sample(), at("2026-08-27T02:30:00Z"));
301
302        assert_eq!(values["cc_session_used"], "$1.23");
303        assert_eq!(values["cc_session_cap"], "$14.00");
304        assert_eq!(values["cc_credits"], "$49.28");
305        assert_eq!(values["cc_credits_pool"], "$70.00");
306        assert_eq!(values["cc_credits_spent"], "$20.72");
307    }
308
309    #[test]
310    fn default_format_leads_with_the_session_window() {
311        assert_eq!(DEFAULT_FORMAT, "{cc_session_pct}% · {cc_session_reset}");
312    }
313
314    #[test]
315    fn absent_windows_and_ledger_are_unavailable_not_zero() {
316        let values = build_placeholders(&Snapshot::default(), at("2026-08-27T02:30:00Z"));
317
318        for key in [
319            "session_pct",
320            "weekly_pct",
321            "cc_session_pct",
322            "cc_session_reset",
323            "cc_weekly_pct",
324            "cc_session_used",
325            "cc_credits",
326            "cc_credits_pool",
327            "cc_credits_spent",
328        ] {
329            assert_eq!(values[key], UNAVAILABLE, "{key} should be unavailable");
330            assert_ne!(values[key], "0");
331        }
332        // With no plan, the vendor name stands in.
333        assert_eq!(values["plan"], DEFAULT_PLAN);
334    }
335
336    #[test]
337    fn severity_follows_the_window_closest_to_its_cap() {
338        let mut snapshot = sample();
339        assert_eq!(severity(&snapshot), severity_for(15));
340
341        snapshot.weekly = Some(SpendWindow {
342            used: 34.0,
343            cap: 35.0,
344            resets_at: None,
345        });
346        assert_eq!(severity(&snapshot), severity_for(97));
347    }
348
349    #[test]
350    fn plan_is_sanitized_before_it_reaches_the_bar() {
351        let snapshot = Snapshot {
352            plan: Some("GO\u{1b}[31mAT\u{7}".into()),
353            ..sample()
354        };
355
356        let values = build_placeholders(&snapshot, at("2026-08-27T02:30:00Z"));
357
358        assert!(!values["plan"].contains('\u{1b}'));
359        assert!(!values["plan"].contains('\u{7}'));
360        assert!(!values["cc_plan"].contains('\u{1b}'));
361    }
362
363    #[test]
364    fn tooltip_shows_both_windows_and_the_credit_ledger() {
365        let theme = Theme::default();
366        let tooltip = render_tooltip(
367            &sample(),
368            false,
369            None,
370            None,
371            &theme,
372            at("2026-08-27T02:30:00Z"),
373        );
374
375        assert!(tooltip.contains("GOAT"), "{tooltip}");
376        assert!(tooltip.contains("Session (5h)"), "{tooltip}");
377        assert!(tooltip.contains("$1.23 of $14.00"), "{tooltip}");
378        assert!(tooltip.contains("Weekly"), "{tooltip}");
379        assert!(tooltip.contains("$49.28"), "{tooltip}");
380        assert!(tooltip.contains("$20.72 of $70.00 spent"), "{tooltip}");
381    }
382
383    #[test]
384    fn tooltip_says_so_when_the_vendor_reports_no_windows() {
385        let theme = Theme::default();
386        let tooltip = render_tooltip(
387            &Snapshot::default(),
388            false,
389            None,
390            None,
391            &theme,
392            at("2026-08-27T02:30:00Z"),
393        );
394
395        assert!(tooltip.contains("no usage windows reported"), "{tooltip}");
396    }
397
398    #[test]
399    fn stale_and_http_errors_surface_in_the_tooltip() {
400        let theme = Theme::default();
401        let tooltip = render_tooltip(
402            &sample(),
403            true,
404            Some(&(503, "service unavailable".to_string())),
405            None,
406            &theme,
407            at("2026-08-27T02:30:00Z"),
408        );
409
410        assert!(tooltip.contains("Showing cached data"), "{tooltip}");
411        assert!(tooltip.contains("HTTP 503"), "{tooltip}");
412    }
413
414    #[test]
415    fn an_unknown_plan_still_renders_without_an_allowance_line() {
416        let snapshot = Snapshot {
417            plan: Some("individual-future".into()),
418            credit_pool: None,
419            ..sample()
420        };
421        let theme = Theme::default();
422
423        let tooltip = render_tooltip(
424            &snapshot,
425            false,
426            None,
427            None,
428            &theme,
429            at("2026-08-27T02:30:00Z"),
430        );
431
432        assert!(tooltip.contains("$49.28"), "{tooltip}");
433        assert!(!tooltip.contains("spent"), "{tooltip}");
434    }
435}