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