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