1use std::collections::HashMap;
5
6use chrono::{DateTime, Utc};
7
8use crate::format::{placeholders, substitute, updated_at_hm, usd};
9use crate::pacing::PaceSeverity;
10use crate::pango::{color_span, escape, severity_color};
11use crate::theme::Theme;
12use crate::tooltip::{Line as TooltipLine, render_bordered};
13use crate::usage::NovitaSnapshot;
14use crate::vendor::{RenderOpts, VendorId, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::FetchOutcome;
18
19pub const DEFAULT_FORMAT: &str = "{nv_balance}";
20
21pub fn build_placeholders(snap: &NovitaSnapshot) -> HashMap<&'static str, String> {
22 placeholders(vec![
23 ("icon", "".to_string()),
24 ("vendor_short", VendorId::Novita.short_name().to_string()),
25 ("session_pct", "0".to_string()),
27 ("session_reset", "—".to_string()),
28 ("weekly_pct", "0".to_string()),
29 ("weekly_reset", "—".to_string()),
30 ("plan", "Novita".to_string()),
31 ("nv_balance", usd(snap.available)),
32 ("nv_cash", usd(snap.cash)),
33 ("nv_credit_limit", usd(snap.credit_limit)),
34 ("nv_owed", usd(snap.outstanding)),
35 ])
36}
37
38pub fn severity(snap: &NovitaSnapshot) -> PaceSeverity {
41 crate::pango::balance_severity(snap.available, "USD")
42}
43
44pub fn render(
45 outcome: &VendorOutcome,
46 snap: &NovitaSnapshot,
47 theme: &Theme,
48 opts: &RenderOpts,
49 now: DateTime<Utc>,
50) -> WaybarOutput {
51 let class = Class::from(severity(snap));
52 let format = opts
53 .format
54 .clone()
55 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
56 let values = build_placeholders(snap);
57
58 let mut text = substitute(&format, &values);
59 if outcome.stale {
60 text.push_str(" ⏸");
61 }
62
63 let wrapper_color = severity_color(severity(snap), theme).to_string();
64 let icon_prefix = match opts.icon.as_deref() {
65 Some(ic) if !ic.is_empty() => format!("{ic} "),
66 _ => String::new(),
67 };
68 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
69
70 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
71 substitute(fmt, &values)
72 } else {
73 render_tooltip(outcome, snap, theme, now)
74 };
75
76 WaybarOutput {
77 text: bar_text,
78 tooltip,
79 class,
80 }
81}
82
83fn render_tooltip(
84 outcome: &VendorOutcome,
85 snap: &NovitaSnapshot,
86 theme: &Theme,
87 now: DateTime<Utc>,
88) -> String {
89 let blue = &theme.blue;
90 let dim = &theme.dim;
91 let fg = &theme.fg;
92 let color = severity_color(severity(snap), theme);
93
94 let mut lines: Vec<TooltipLine> = Vec::new();
95 lines.push(TooltipLine::Center(format!(
96 "<span font_weight='bold' foreground='{blue}'>Novita</span>"
97 )));
98 lines.push(TooltipLine::Sep);
99 lines.push(TooltipLine::Body("".into()));
100
101 lines.push(TooltipLine::Body(format!(
102 " <span foreground='{fg}'> Balance</span>"
103 )));
104 lines.push(TooltipLine::Body(format!(
105 " <span font_weight='bold' foreground='{color}'>{bal}</span>",
106 bal = escape(&usd(snap.available))
107 )));
108 lines.push(TooltipLine::Body(format!(
109 " <span foreground='{dim}'> top-up {cash} · credit limit {lim}</span>",
110 cash = escape(&usd(snap.cash)),
111 lim = escape(&usd(snap.credit_limit))
112 )));
113
114 if snap.outstanding > 0.0 {
115 lines.push(TooltipLine::Body("".into()));
116 lines.push(TooltipLine::Body(format!(
117 " <span foreground='{dim}'> owed {owed}</span>",
118 owed = escape(&usd(snap.outstanding))
119 )));
120 }
121
122 if let Some((code, msg)) = outcome.last_error.as_ref()
123 && *code != 0
124 {
125 let (icon, ecolor) = if *code >= 500 {
126 ("", theme.red.as_str())
127 } else {
128 ("", theme.orange.as_str())
129 };
130 lines.push(TooltipLine::Body("".into()));
131 lines.push(TooltipLine::Sep);
132 lines.push(TooltipLine::Body(format!(
133 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
134 )));
135 lines.push(TooltipLine::Body(format!(
136 " <span foreground='{dim}'>{}</span>",
137 escape(msg)
138 )));
139 }
140
141 let updated = updated_at_hm(now, outcome.cache_age);
142 lines.push(TooltipLine::Body("".into()));
143 lines.push(TooltipLine::Sep);
144 lines.push(TooltipLine::Body(format!(
145 " <span foreground='{dim}'> Updated {updated}</span>"
146 )));
147
148 render_bordered(&lines, theme)
149}
150
151impl From<FetchOutcome> for VendorOutcome {
152 fn from(o: FetchOutcome) -> Self {
153 o.map(crate::usage::VendorSnapshot::Novita)
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::usage::NovitaSnapshot;
161
162 fn sample_snap() -> NovitaSnapshot {
163 NovitaSnapshot {
164 available: 100.0,
165 cash: 80.0,
166 credit_limit: 20.0,
167 outstanding: 0.0,
168 }
169 }
170
171 fn sample_outcome(snap: NovitaSnapshot) -> VendorOutcome {
172 VendorOutcome {
173 snapshot: crate::usage::VendorSnapshot::Novita(snap),
174 stale: false,
175 last_error: None,
176 cache_age: Some(std::time::Duration::from_secs(10)),
177 }
178 }
179
180 fn opts() -> RenderOpts {
181 RenderOpts {
182 format: None,
183 tooltip_format: None,
184 icon: None,
185 pace_tolerance: 5,
186 format_pace_color: false,
187 tooltip_pace_pts: false,
188 }
189 }
190
191 #[test]
192 fn default_render_shows_available_balance() {
193 let snap = sample_snap();
194 let out = render(
195 &sample_outcome(snap.clone()),
196 &snap,
197 &Theme::default(),
198 &opts(),
199 Utc::now(),
200 );
201 assert!(out.text.contains("$100.00"));
202 }
203
204 #[test]
205 fn tooltip_includes_balance_and_breakdown() {
206 let snap = sample_snap();
207 let out = render(
208 &sample_outcome(snap.clone()),
209 &snap,
210 &Theme::default(),
211 &opts(),
212 Utc::now(),
213 );
214 assert!(out.tooltip.contains("Balance"));
215 assert!(out.tooltip.contains("$100.00"));
216 assert!(out.tooltip.contains("top-up $80.00"));
217 assert!(out.tooltip.contains("credit limit $20.00"));
218 }
219
220 #[test]
221 fn owed_line_only_when_positive() {
222 let mut snap = sample_snap();
223 snap.outstanding = 3.0;
224 let out = render(
225 &sample_outcome(snap.clone()),
226 &snap,
227 &Theme::default(),
228 &opts(),
229 Utc::now(),
230 );
231 assert!(out.tooltip.contains("owed $3.00"));
232 }
233
234 #[test]
235 fn severity_scales_with_balance() {
236 let mk = |b: f64| NovitaSnapshot {
237 available: b,
238 cash: 0.0,
239 credit_limit: 0.0,
240 outstanding: 0.0,
241 };
242 assert_eq!(severity(&mk(0.5)), PaceSeverity::Critical);
243 assert_eq!(severity(&mk(3.0)), PaceSeverity::High);
244 assert_eq!(severity(&mk(12.0)), PaceSeverity::Mid);
245 assert_eq!(severity(&mk(50.0)), PaceSeverity::Low);
246 }
247
248 #[test]
249 fn custom_tooltip_uses_placeholder() {
250 let snap = sample_snap();
251 let mut o = opts();
252 o.tooltip_format = Some("bal: {nv_balance} · owed {nv_owed}".into());
253 let out = render(
254 &sample_outcome(snap.clone()),
255 &snap,
256 &Theme::default(),
257 &o,
258 Utc::now(),
259 );
260 assert_eq!(out.tooltip, "bal: $100.00 · owed $0.00");
261 }
262}