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, 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", "dsk".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    // Thresholds scaled by currency. CNY ≈ 7× USD (rough parity).
56    // critical / high / mid boundaries in each currency unit.
57    let (t_critical, t_high, t_mid) = match snap.currency.as_str() {
58        "CNY" => (7.0_f64, 35.0, 140.0),
59        _ => (1.0_f64, 5.0, 20.0), // USD and unknowns treated as USD-scale
60    };
61    if snap.balance < t_critical {
62        PaceSeverity::Critical
63    } else if snap.balance < t_high {
64        PaceSeverity::High
65    } else if snap.balance < t_mid {
66        PaceSeverity::Mid
67    } else {
68        PaceSeverity::Low
69    }
70}
71
72pub fn render(
73    outcome: &VendorOutcome,
74    snap: &DeepseekSnapshot,
75    theme: &Theme,
76    opts: &RenderOpts,
77    now: DateTime<Utc>,
78) -> WaybarOutput {
79    let class = Class::from(severity(snap));
80    let format = opts
81        .format
82        .clone()
83        .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
84    let values = build_placeholders(snap);
85
86    let mut text = substitute(&format, &values);
87    if outcome.stale {
88        text.push_str(" ⏸");
89    }
90
91    let wrapper_color = severity_color(severity(snap), theme).to_string();
92    let icon_prefix = match opts.icon.as_deref() {
93        Some(ic) if !ic.is_empty() => format!("{ic} "),
94        _ => String::new(),
95    };
96    let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
97
98    let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
99        substitute(fmt, &values)
100    } else {
101        render_tooltip(outcome, snap, theme, now)
102    };
103
104    WaybarOutput {
105        text: bar_text,
106        tooltip,
107        class,
108    }
109}
110
111fn render_tooltip(
112    outcome: &VendorOutcome,
113    snap: &DeepseekSnapshot,
114    theme: &Theme,
115    now: DateTime<Utc>,
116) -> String {
117    let blue = &theme.blue;
118    let dim = &theme.dim;
119    let fg = &theme.fg;
120    let color = severity_color(severity(snap), theme);
121
122    let avail_label = if snap.is_available {
123        "API available"
124    } else {
125        "API unavailable"
126    };
127
128    let mut lines: Vec<TooltipLine> = Vec::new();
129    lines.push(TooltipLine::Center(format!(
130        "<span font_weight='bold' foreground='{blue}'>DeepSeek</span>"
131    )));
132    lines.push(TooltipLine::Sep);
133    lines.push(TooltipLine::Body("".into()));
134
135    lines.push(TooltipLine::Body(format!(
136        " <span foreground='{fg}'>  󰢗  Balance</span>"
137    )));
138    lines.push(TooltipLine::Body(format!(
139        "   <span font_weight='bold' foreground='{color}'>{bal}</span>",
140        bal = escape(&money(snap.balance, &snap.currency))
141    )));
142    lines.push(TooltipLine::Body(format!(
143        " <span foreground='{dim}'>     granted {granted} · topped-up {topped}</span>",
144        granted = escape(&money(snap.granted, &snap.currency)),
145        topped = escape(&money(snap.topped_up, &snap.currency))
146    )));
147
148    lines.push(TooltipLine::Body("".into()));
149    lines.push(TooltipLine::Body(format!(
150        " <span foreground='{dim}'>  󰛴  {avail_label}</span>"
151    )));
152
153    if let Some((code, msg)) = outcome.last_error.as_ref()
154        && *code != 0
155    {
156        let (icon, ecolor) = if *code >= 500 {
157            ("󰅚", theme.red.as_str())
158        } else {
159            ("󰀪", theme.orange.as_str())
160        };
161        lines.push(TooltipLine::Body("".into()));
162        lines.push(TooltipLine::Sep);
163        lines.push(TooltipLine::Body(format!(
164            " <span foreground='{ecolor}'>  {icon}  HTTP {code}</span>"
165        )));
166        lines.push(TooltipLine::Body(format!(
167            "     <span foreground='{dim}'>{}</span>",
168            escape(msg)
169        )));
170    }
171
172    let updated = updated_at_hm(now, outcome.cache_age);
173    lines.push(TooltipLine::Body("".into()));
174    lines.push(TooltipLine::Sep);
175    lines.push(TooltipLine::Body(format!(
176        " <span foreground='{dim}'>  󰅐  Updated {updated}</span>"
177    )));
178
179    render_bordered(&lines, theme)
180}
181
182impl From<FetchOutcome> for VendorOutcome {
183    fn from(o: FetchOutcome) -> Self {
184        Self {
185            snapshot: crate::usage::VendorSnapshot::Deepseek(o.snapshot),
186            stale: o.stale,
187            last_error: o.last_error,
188            cache_age: o.cache_age,
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::usage::DeepseekSnapshot;
197
198    fn sample_snap() -> DeepseekSnapshot {
199        DeepseekSnapshot {
200            is_available: true,
201            balance: 5.50,
202            granted: 5.00,
203            topped_up: 0.50,
204            currency: "USD".into(),
205        }
206    }
207
208    fn sample_outcome(snap: DeepseekSnapshot) -> VendorOutcome {
209        VendorOutcome {
210            snapshot: crate::usage::VendorSnapshot::Deepseek(snap),
211            stale: false,
212            last_error: None,
213            cache_age: Some(std::time::Duration::from_secs(10)),
214        }
215    }
216
217    fn opts() -> RenderOpts {
218        RenderOpts {
219            format: None,
220            tooltip_format: None,
221            icon: None,
222            pace_tolerance: 5,
223            format_pace_color: false,
224            tooltip_pace_pts: false,
225        }
226    }
227
228    #[test]
229    fn default_render_shows_balance() {
230        let snap = sample_snap();
231        let outcome = sample_outcome(snap.clone());
232        let theme = Theme::default();
233        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
234        assert!(out.text.contains("$5.50"));
235    }
236
237    /// A DeepSeek balance can go under, and used to print "$-5.71" here while
238    /// OpenRouter printed "-$5.71" for the same thing. One formatter now.
239    #[test]
240    fn a_negative_balance_carries_its_sign_ahead_of_the_symbol() {
241        let mut snap = sample_snap();
242        snap.balance = -5.71;
243        let out = render(
244            &sample_outcome(snap.clone()),
245            &snap,
246            &Theme::default(),
247            &opts(),
248            Utc::now(),
249        );
250        assert!(out.text.contains("-$5.71"), "{}", out.text);
251        assert!(!out.text.contains("$-5.71"), "{}", out.text);
252
253        snap.currency = "CNY".into();
254        let out = render(
255            &sample_outcome(snap.clone()),
256            &snap,
257            &Theme::default(),
258            &opts(),
259            Utc::now(),
260        );
261        assert!(out.text.contains("-¥5.71"), "{}", out.text);
262    }
263
264    #[test]
265    fn tooltip_includes_balance_and_availability() {
266        let snap = sample_snap();
267        let outcome = sample_outcome(snap.clone());
268        let theme = Theme::default();
269        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
270        assert!(out.tooltip.contains("Balance"));
271        assert!(out.tooltip.contains("$5.50"));
272        assert!(out.tooltip.contains("API available"));
273    }
274
275    #[test]
276    fn unavailable_api_shows_critical_severity() {
277        let mut snap = sample_snap();
278        snap.is_available = false;
279        assert_eq!(severity(&snap), PaceSeverity::Critical);
280    }
281
282    #[test]
283    fn stale_appends_pause() {
284        let snap = sample_snap();
285        let mut outcome = sample_outcome(snap.clone());
286        outcome.stale = true;
287        let theme = Theme::default();
288        let out = render(&outcome, &snap, &theme, &opts(), Utc::now());
289        assert!(out.text.contains("⏸"));
290    }
291
292    #[test]
293    fn plan_alias_carries_balance_in_snapshot_currency() {
294        assert_eq!(
295            build_placeholders(&sample_snap())["plan"],
296            "DeepSeek — $5.50"
297        );
298
299        let mut snap = sample_snap();
300        snap.balance = 20.0;
301        snap.currency = "CNY".into();
302        assert_eq!(build_placeholders(&snap)["plan"], "DeepSeek — ¥20.00");
303    }
304
305    // The prefix both native surfaces request verbatim — see the `FORMAT`
306    // constants in gnome-extension/marker-logic.js and macos/ai-usagebar-menubar.swift.
307    // `plan` is the one generic field they render as free text, so it is the
308    // only place a balance vendor can get its headline number onto the panel
309    // without a change on the surface side.
310    #[test]
311    fn desktop_format_header_shows_balance() {
312        let values = build_placeholders(&sample_snap());
313        let out = substitute(
314            "{plan};;{session_pct};;{session_reset};;{weekly_pct};;{weekly_reset}",
315            &values,
316        );
317        let fields: Vec<&str> = out.split(";;").collect();
318        assert_eq!(fields[0], "DeepSeek — $5.50");
319    }
320
321    #[test]
322    fn cny_format() {
323        let snap = DeepseekSnapshot {
324            is_available: true,
325            balance: 20.0,
326            granted: 20.0,
327            topped_up: 0.0,
328            currency: "CNY".into(),
329        };
330        assert_eq!(money(snap.balance, &snap.currency), "¥20.00");
331    }
332}