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