1use std::collections::HashMap;
9
10use chrono::{DateTime, Utc};
11
12use crate::countdown;
13use crate::format::{placeholders, substitute, updated_at_hm};
14use crate::pacing::PaceSeverity;
15use crate::pango::{color_span, escape, severity_color, severity_for};
16use crate::theme::Theme;
17use crate::tooltip::{Line as TooltipLine, render_bordered};
18use crate::usage::{MinimaxSnapshot, UsageWindow};
19use crate::vendor::{RenderOpts, VendorOutcome};
20use crate::waybar::{Class, WaybarOutput};
21
22use super::fetch::FetchOutcome;
23
24pub const POOL_GENERAL: &str = "Text";
26pub const POOL_VIDEO: &str = "Video";
28
29pub const DEFAULT_FORMAT: &str = "{minimax_session_pct}% · {minimax_session_reset}";
30
31pub fn build_placeholders(
32 snap: &MinimaxSnapshot,
33 now: DateTime<Utc>,
34) -> HashMap<&'static str, String> {
35 let session_pct = snap.session.utilization_pct;
36 let weekly_pct = snap.weekly.utilization_pct;
37 let session_reset = countdown::format(snap.session.resets_at, now);
38 let weekly_reset = countdown::format(snap.weekly.resets_at, now);
39 let video = |w: &Option<UsageWindow>, pct: bool| -> String {
42 match w {
43 Some(w) if pct => w.utilization_pct.to_string(),
44 Some(w) => countdown::format(w.resets_at, now),
45 None => "—".to_string(),
46 }
47 };
48
49 placeholders(vec![
50 ("icon", "".to_string()),
51 ("vendor_short", "mmx".to_string()),
52 ("plan", snap.plan.clone()),
54 ("session_pct", session_pct.to_string()),
55 ("session_reset", session_reset.clone()),
56 ("weekly_pct", weekly_pct.to_string()),
57 ("weekly_reset", weekly_reset.clone()),
58 ("minimax_plan", snap.plan.clone()),
60 ("minimax_session_pct", session_pct.to_string()),
61 ("minimax_session_reset", session_reset),
62 ("minimax_weekly_pct", weekly_pct.to_string()),
63 ("minimax_weekly_reset", weekly_reset),
64 ("minimax_video_pct", video(&snap.video_session, true)),
65 ("minimax_video_reset", video(&snap.video_session, false)),
66 ("minimax_video_weekly_pct", video(&snap.video_weekly, true)),
67 ])
68}
69
70pub fn severity(snap: &MinimaxSnapshot) -> PaceSeverity {
74 severity_for(
75 snap.session
76 .utilization_pct
77 .max(snap.weekly.utilization_pct),
78 )
79}
80
81pub fn render(
82 outcome: &VendorOutcome,
83 snap: &MinimaxSnapshot,
84 theme: &Theme,
85 opts: &RenderOpts,
86 now: DateTime<Utc>,
87) -> WaybarOutput {
88 let class = Class::from(severity(snap));
89 let format = opts
90 .format
91 .clone()
92 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
93 let values = build_placeholders(snap, now);
94 let mut pango_values = values.clone();
98 for key in ["plan", "minimax_plan"] {
99 if let Some(value) = pango_values.get_mut(key) {
100 *value = escape(value);
101 }
102 }
103
104 let mut text = substitute(&format, &pango_values);
105 if outcome.stale {
106 text.push_str(" ⏸");
107 }
108
109 let wrapper_color = severity_color(severity(snap), theme).to_string();
110 let icon_prefix = match opts.icon.as_deref() {
111 Some(ic) if !ic.is_empty() => format!("{ic} "),
112 _ => String::new(),
113 };
114 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
115
116 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
117 substitute(fmt, &pango_values)
118 } else {
119 render_tooltip(outcome, snap, theme, now)
120 };
121
122 WaybarOutput {
123 text: bar_text,
124 tooltip,
125 class,
126 }
127}
128
129fn render_tooltip(
130 outcome: &VendorOutcome,
131 snap: &MinimaxSnapshot,
132 theme: &Theme,
133 now: DateTime<Utc>,
134) -> String {
135 let blue = &theme.blue;
136 let dim = &theme.dim;
137 let fg = &theme.fg;
138
139 let mut lines: Vec<TooltipLine> = Vec::new();
140 lines.push(TooltipLine::Center(format!(
141 "<span font_weight='bold' foreground='{blue}'>{}</span>",
142 escape(&snap.plan)
143 )));
144 lines.push(TooltipLine::Sep);
145
146 let mut pool = |label: &str, icon: &str, session: &UsageWindow, weekly: &UsageWindow| {
147 lines.push(TooltipLine::Body("".into()));
148 lines.push(TooltipLine::Body(format!(
149 " <span foreground='{fg}'> {icon} {label}</span>"
150 )));
151 for (what, w) in [("Session", session), ("Weekly", weekly)] {
152 let color = severity_color(severity_for(w.utilization_pct), theme);
153 lines.push(TooltipLine::Body(format!(
154 " <span foreground='{dim}'>{what}</span> \
155 <span font_weight='bold' foreground='{color}'>{pct}%</span>",
156 pct = w.utilization_pct
157 )));
158 lines.push(TooltipLine::Body(format!(
159 " <span foreground='{dim}'> reset {}</span>",
160 escape(&countdown::format(w.resets_at, now))
161 )));
162 }
163 };
164
165 pool(POOL_GENERAL, "", &snap.session, &snap.weekly);
166 if let (Some(vs), Some(vw)) = (&snap.video_session, &snap.video_weekly) {
167 pool(POOL_VIDEO, "", vs, vw);
168 }
169
170 if let Some((code, msg)) = outcome.last_error.as_ref() {
171 let (label, icon, ecolor) = if *code == 0 {
172 ("MiniMax error".to_string(), "", theme.red.as_str())
173 } else if *code >= 500 {
174 (format!("HTTP {code}"), "", theme.red.as_str())
175 } else {
176 (format!("HTTP {code}"), "", theme.orange.as_str())
177 };
178 lines.push(TooltipLine::Body("".into()));
179 lines.push(TooltipLine::Sep);
180 lines.push(TooltipLine::Body(format!(
181 " <span foreground='{ecolor}'> {icon} {label}</span>"
182 )));
183 if msg != &label {
184 lines.push(TooltipLine::Body(format!(
185 " <span foreground='{dim}'>{}</span>",
186 escape(msg)
187 )));
188 }
189 }
190
191 let updated = updated_at_hm(now, outcome.cache_age);
192 lines.push(TooltipLine::Body("".into()));
193 lines.push(TooltipLine::Sep);
194 lines.push(TooltipLine::Body(format!(
195 " <span foreground='{dim}'> Updated {updated}</span>"
196 )));
197
198 render_bordered(&lines, theme)
199}
200
201impl From<FetchOutcome> for VendorOutcome {
202 fn from(o: FetchOutcome) -> Self {
203 Self {
204 snapshot: crate::usage::VendorSnapshot::Minimax(o.snapshot),
205 stale: o.stale,
206 last_error: o.last_error,
207 cache_age: o.cache_age,
208 }
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use chrono::TimeZone;
216
217 fn now() -> DateTime<Utc> {
218 Utc.with_ymd_and_hms(2026, 7, 27, 12, 0, 0).unwrap()
219 }
220
221 fn window(pct: i32, mins_ahead: i64, dur: chrono::Duration) -> UsageWindow {
222 UsageWindow {
223 utilization_pct: pct,
224 resets_at: Some(now() + chrono::Duration::minutes(mins_ahead)),
225 window_duration: dur,
226 }
227 }
228
229 fn snap() -> MinimaxSnapshot {
230 MinimaxSnapshot {
231 plan: "MiniMax Token Plan".to_string(),
232 session: window(31, 45, chrono::Duration::hours(5)),
233 weekly: window(62, 3000, chrono::Duration::days(7)),
234 video_session: Some(window(5, 200, chrono::Duration::hours(24))),
235 video_weekly: Some(window(9, 3000, chrono::Duration::days(7))),
236 }
237 }
238
239 fn opts() -> RenderOpts {
240 RenderOpts {
241 format: None,
242 tooltip_format: None,
243 icon: None,
244 pace_tolerance: 5,
245 format_pace_color: false,
246 tooltip_pace_pts: false,
247 }
248 }
249
250 fn outcome(s: &MinimaxSnapshot) -> VendorOutcome {
251 VendorOutcome {
252 snapshot: crate::usage::VendorSnapshot::Minimax(s.clone()),
253 stale: false,
254 last_error: None,
255 cache_age: Some(std::time::Duration::ZERO),
256 }
257 }
258
259 #[test]
260 fn default_format_shows_session_percent_and_reset() {
261 let s = snap();
262 let out = render(&outcome(&s), &s, &Theme::default(), &opts(), now());
263 assert!(out.text.contains("31%"), "bar text was {:?}", out.text);
264 }
265
266 #[test]
269 fn exposes_cross_vendor_aliases() {
270 let v = build_placeholders(&snap(), now());
271 assert_eq!(v.get("session_pct").map(String::as_str), Some("31"));
272 assert_eq!(v.get("weekly_pct").map(String::as_str), Some("62"));
273 assert_eq!(v.get("vendor_short").map(String::as_str), Some("mmx"));
274 assert!(v.contains_key("session_reset"));
275 }
276
277 #[test]
279 fn severity_ignores_the_video_pool() {
280 let mut s = snap();
281 s.session.utilization_pct = 10;
282 s.weekly.utilization_pct = 10;
283 s.video_session = Some(window(99, 10, chrono::Duration::hours(24)));
284 s.video_weekly = Some(window(99, 10, chrono::Duration::days(7)));
285 assert_eq!(severity(&s), severity_for(10));
286 }
287
288 #[test]
289 fn video_placeholders_degrade_to_a_dash_without_the_pool() {
290 let mut s = snap();
291 s.video_session = None;
292 s.video_weekly = None;
293 let v = build_placeholders(&s, now());
294 assert_eq!(v.get("minimax_video_pct").map(String::as_str), Some("—"));
295 assert_eq!(
296 v.get("minimax_video_weekly_pct").map(String::as_str),
297 Some("—")
298 );
299 }
300
301 #[test]
302 fn tooltip_lists_both_pools_when_present() {
303 let s = snap();
304 let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), now());
305 assert!(tip.contains(POOL_GENERAL));
306 assert!(tip.contains(POOL_VIDEO));
307 }
308
309 #[test]
310 fn tooltip_omits_the_video_pool_when_absent() {
311 let mut s = snap();
312 s.video_session = None;
313 s.video_weekly = None;
314 let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), now());
315 assert!(tip.contains(POOL_GENERAL));
316 assert!(!tip.contains(POOL_VIDEO));
317 }
318
319 #[test]
321 fn escapes_the_plan_name_exactly_once() {
322 let mut s = snap();
323 s.plan = "Plan <b>&</b>".to_string();
324 let out = render(
325 &outcome(&s),
326 &s,
327 &Theme::default(),
328 &RenderOpts {
329 format: Some("{minimax_plan}".to_string()),
330 ..opts()
331 },
332 now(),
333 );
334 assert!(
335 out.text.contains("<b>&</b>"),
336 "{:?}",
337 out.text
338 );
339 assert!(
340 !out.text.contains("&lt;"),
341 "double-escaped: {:?}",
342 out.text
343 );
344 }
345
346 #[test]
347 fn stale_marks_the_bar() {
348 let s = snap();
349 let mut o = outcome(&s);
350 o.stale = true;
351 let out = render(&o, &s, &Theme::default(), &opts(), now());
352 assert!(out.text.contains('⏸'));
353 }
354}