1use 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 ("session_pct", "0".to_string()),
35 ("session_reset", "—".to_string()),
36 ("weekly_pct", "0".to_string()),
37 ("weekly_reset", "—".to_string()),
38 ("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 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), };
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 #[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 #[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}