Skip to main content

ai_usagebar/deepseek/
vendor.rs

1//! DeepSeek renderer — bar text + bordered Pango tooltip.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6
7use crate::format::{money, placeholders, substitute, updated_at_hm};
8use crate::pacing::PaceSeverity;
9use crate::pango::{color_span, escape, severity_color};
10use crate::theme::Theme;
11use crate::tooltip::{Line as TooltipLine, render_bordered};
12use crate::usage::DeepseekSnapshot;
13use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
14use crate::waybar::{Class, WaybarOutput};
15
16use super::fetch::FetchOutcome;
17
18pub const DEFAULT_FORMAT: &str = "{ds_balance}";
19
20pub fn build_placeholders(snap: &DeepseekSnapshot) -> HashMap<&'static str, String> {
21    let avail = if snap.is_available { "up" } else { "down" };
22    let balance = money(snap.balance, &snap.currency);
23    placeholders(vec![
24        ("icon", "󰧑".to_string()),
25        ("vendor_short", VendorId::Deepseek.short_name().to_string()),
26        // Cross-vendor aliases — DeepSeek has neither rate-limit windows nor a
27        // spend denominator (`/user/balance` reports only money *remaining*),
28        // so these percentages are structurally meaningless for this vendor.
29        //
30        // These remain numeric for compatibility with generic third-party
31        // formats. The bundled native surfaces key off `vendor_short` and hide
32        // both quota rows for DeepSeek, so the aliases never become fake 0%
33        // bars there.
34        ("session_pct", "0".to_string()),
35        ("session_reset", "—".to_string()),
36        ("weekly_pct", "0".to_string()),
37        ("weekly_reset", "—".to_string()),
38        // `plan` is the only generic alias the native surfaces render as free
39        // text (GNOME dropdown title, macOS menu header), so it carries the
40        // headline number a balance vendor actually has. Mirrors OpenRouter's
41        // "OpenRouter — {label}".
42        ("plan", format!("DeepSeek — {balance}")),
43        ("ds_balance", balance),
44        ("ds_granted", money(snap.granted, &snap.currency)),
45        ("ds_topped_up", money(snap.topped_up, &snap.currency)),
46        ("ds_available", avail.to_string()),
47        ("currency", snap.currency.clone()),
48    ])
49}
50
51pub fn severity(snap: &DeepseekSnapshot) -> PaceSeverity {
52    if !snap.is_available {
53        return PaceSeverity::Critical;
54    }
55    crate::pango::balance_severity(snap.balance, &snap.currency)
56}
57
58pub fn render(
59    outcome: &VendorOutcome,
60    snap: &DeepseekSnapshot,
61    theme: &Theme,
62    opts: &RenderOpts,
63    now: DateTime<Utc>,
64) -> WaybarOutput {
65    let class = Class::from(severity(snap));
66    let format = opts
67        .format
68        .clone()
69        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
70    let values = build_placeholders(snap);
71
72    let mut text = substitute(&format, &values);
73    if outcome.stale {
74        text.push_str(" ⏸");
75    }
76
77    let wrapper_color = severity_color(severity(snap), theme).to_string();
78    let icon_prefix = match opts.icon.as_deref() {
79        Some(ic) if !ic.is_empty() => format!("{ic} "),
80        _ => String::new(),
81    };
82    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
83
84    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
85        substitute(fmt, &values)
86    } else {
87        render_tooltip(outcome, snap, theme, now)
88    };
89
90    WaybarOutput {
91        text: bar_text,
92        tooltip,
93        class,
94    }
95}
96
97fn render_tooltip(
98    outcome: &VendorOutcome,
99    snap: &DeepseekSnapshot,
100    theme: &Theme,
101    now: DateTime<Utc>,
102) -> String {
103    let blue = &theme.blue;
104    let dim = &theme.dim;
105    let fg = &theme.fg;
106    let color = severity_color(severity(snap), theme);
107
108    let avail_label = if snap.is_available {
109        "API available"
110    } else {
111        "API unavailable"
112    };
113
114    let mut lines: Vec<TooltipLine> = Vec::new();
115    lines.push(TooltipLine::Center(format!(
116        "<span font_weight='bold' foreground='{blue}'>DeepSeek</span>"
117    )));
118    lines.push(TooltipLine::Sep);
119    lines.push(TooltipLine::Body("".into()));
120
121    lines.push(TooltipLine::Body(format!(
122        " <span foreground='{fg}'>  󰢗  Balance</span>"
123    )));
124    lines.push(TooltipLine::Body(format!(
125        "   <span font_weight='bold' foreground='{color}'>{bal}</span>",
126        bal = escape(&money(snap.balance, &snap.currency))
127    )));
128    lines.push(TooltipLine::Body(format!(
129        " <span foreground='{dim}'>     granted {granted} · topped-up {topped}</span>",
130        granted = escape(&money(snap.granted, &snap.currency)),
131        topped = escape(&money(snap.topped_up, &snap.currency))
132    )));
133
134    lines.push(TooltipLine::Body("".into()));
135    lines.push(TooltipLine::Body(format!(
136        " <span foreground='{dim}'>  󰛴  {avail_label}</span>"
137    )));
138
139    if let Some((code, msg)) = outcome.last_error.as_ref()
140        && *code != 0
141    {
142        let (icon, ecolor) = if *code >= 500 {
143            ("󰅚", theme.red.as_str())
144        } else {
145            ("󰀪", theme.orange.as_str())
146        };
147        lines.push(TooltipLine::Body("".into()));
148        lines.push(TooltipLine::Sep);
149        lines.push(TooltipLine::Body(format!(
150            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
151        )));
152        lines.push(TooltipLine::Body(format!(
153            "     <span foreground='{dim}'>{}</span>",
154            escape(msg)
155        )));
156    }
157
158    let updated = updated_at_hm(now, outcome.cache_age);
159    lines.push(TooltipLine::Body("".into()));
160    lines.push(TooltipLine::Sep);
161    lines.push(TooltipLine::Body(format!(
162        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
163    )));
164
165    render_bordered(&lines, theme)
166}
167
168impl From<FetchOutcome> for VendorOutcome {
169    fn from(o: FetchOutcome) -> Self {
170        Self {
171            snapshot: crate::usage::VendorSnapshot::Deepseek(o.snapshot),
172            stale: o.stale,
173            last_error: o.last_error,
174            cache_age: o.cache_age,
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::usage::DeepseekSnapshot;
183
184    fn sample_snap() -> DeepseekSnapshot {
185        DeepseekSnapshot {
186            is_available: true,
187            balance: 5.50,
188            granted: 5.00,
189            topped_up: 0.50,
190            currency: "USD".into(),
191        }
192    }
193
194    fn sample_outcome(snap: DeepseekSnapshot) -> VendorOutcome {
195        VendorOutcome {
196            snapshot: crate::usage::VendorSnapshot::Deepseek(snap),
197            stale: false,
198            last_error: None,
199            cache_age: Some(std::time::Duration::from_secs(10)),
200        }
201    }
202
203    fn opts() -> RenderOpts {
204        RenderOpts {
205            format: None,
206            tooltip_format: None,
207            icon: None,
208            pace_tolerance: 5,
209            format_pace_color: false,
210            tooltip_pace_pts: false,
211        }
212    }
213
214    #[test]
215    fn default_render_shows_balance() {
216        let snap = sample_snap();
217        let outcome = sample_outcome(snap.clone());
218        let theme = Theme::default();
219        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
220        assert!(out.text.contains("$5.50"));
221    }
222
223    /// A DeepSeek balance can go under, and used to print "$-5.71" here while
224    /// OpenRouter printed "-$5.71" for the same thing. One formatter now.
225    #[test]
226    fn a_negative_balance_carries_its_sign_ahead_of_the_symbol() {
227        let mut snap = sample_snap();
228        snap.balance = -5.71;
229        let out = render(
230            &sample_outcome(snap.clone()),
231            &snap,
232            &Theme::default(),
233            &opts(),
234            Utc::now(),
235        );
236        assert!(out.text.contains("-$5.71"), "{}", out.text);
237        assert!(!out.text.contains("$-5.71"), "{}", out.text);
238
239        snap.currency = "CNY".into();
240        let out = render(
241            &sample_outcome(snap.clone()),
242            &snap,
243            &Theme::default(),
244            &opts(),
245            Utc::now(),
246        );
247        assert!(out.text.contains("-¥5.71"), "{}", out.text);
248    }
249
250    #[test]
251    fn tooltip_includes_balance_and_availability() {
252        let snap = sample_snap();
253        let outcome = sample_outcome(snap.clone());
254        let theme = Theme::default();
255        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
256        assert!(out.tooltip.contains("Balance"));
257        assert!(out.tooltip.contains("$5.50"));
258        assert!(out.tooltip.contains("API available"));
259    }
260
261    #[test]
262    fn unavailable_api_shows_critical_severity() {
263        let mut snap = sample_snap();
264        snap.is_available = false;
265        assert_eq!(severity(&snap), PaceSeverity::Critical);
266    }
267
268    #[test]
269    fn stale_appends_pause() {
270        let snap = sample_snap();
271        let mut outcome = sample_outcome(snap.clone());
272        outcome.stale = true;
273        let theme = Theme::default();
274        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
275        assert!(out.text.contains("⏸"));
276    }
277
278    #[test]
279    fn plan_alias_carries_balance_in_snapshot_currency() {
280        assert_eq!(
281            build_placeholders(&sample_snap())["plan"],
282            "DeepSeek — $5.50"
283        );
284
285        let mut snap = sample_snap();
286        snap.balance = 20.0;
287        snap.currency = "CNY".into();
288        assert_eq!(build_placeholders(&snap)["plan"], "DeepSeek — ¥20.00");
289    }
290
291    // The prefix both native surfaces request verbatim — see the `FORMAT`
292    // constants in gnome-extension/marker-logic.js and macos/ai-usagebar-menubar.swift.
293    // `plan` is the one generic field they render as free text, so it is the
294    // only place a balance vendor can get its headline number onto the panel
295    // without a change on the surface side.
296    #[test]
297    fn desktop_format_header_shows_balance() {
298        let values = build_placeholders(&sample_snap());
299        let out = substitute(
300            "{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset}",
301            &values,
302        );
303        let fields: Vec<&str> = out.split(";;").collect();
304        assert_eq!(fields[0], "DeepSeek — $5.50");
305    }
306
307    #[test]
308    fn cny_format() {
309        let snap = DeepseekSnapshot {
310            is_available: true,
311            balance: 20.0,
312            granted: 20.0,
313            topped_up: 0.0,
314            currency: "CNY".into(),
315        };
316        assert_eq!(money(snap.balance, &snap.currency), "¥20.00");
317    }
318}