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 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 #[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 #[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}