1use 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 ("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", 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 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), };
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 #[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}