1use std::collections::HashMap;
9
10use chrono::{DateTime, Utc};
11
12use crate::countdown;
13use crate::format::{placeholders, substitute, updated_at_hm};
14use crate::pacing::{self, PaceSeverity};
15use crate::pango::{color_span, escape, severity_color, severity_for};
16use crate::theme::Theme;
17use crate::tooltip::{Line as TooltipLine, WindowRow, push_window_with_row, render_bordered};
18use crate::usage::{MinimaxSnapshot, UsageWindow};
19use crate::vendor::{RenderOpts, VendorId, 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(
36 snap: &MinimaxSnapshot,
37 now: DateTime<Utc>,
38) -> HashMap<&'static str, String> {
39 build_placeholders_with_tolerance(snap, pacing::DEFAULT_TOLERANCE, now)
40}
41
42fn build_placeholders_with_tolerance(
43 snap: &MinimaxSnapshot,
44 pace_tolerance: u32,
45 now: DateTime<Utc>,
46) -> HashMap<&'static str, String> {
47 let session_pct = snap.session.utilization_pct;
48 let weekly_pct = snap.weekly.utilization_pct;
49 let session_reset = countdown::format(snap.session.resets_at, now);
50 let weekly_reset = countdown::format(snap.weekly.resets_at, now);
51 let video = |w: &Option<UsageWindow>, pct: bool| -> String {
54 match w {
55 Some(w) if pct => w.utilization_pct.to_string(),
56 Some(w) => countdown::format(w.resets_at, now),
57 None => "—".to_string(),
58 }
59 };
60 let session = window_pacing(&snap.session, pace_tolerance, now);
64 let weekly = window_pacing(&snap.weekly, pace_tolerance, now);
65 let video_pacing = optional_window_pacing(snap.video_session.as_ref(), pace_tolerance, now);
66 let video_weekly_pacing =
67 optional_window_pacing(snap.video_weekly.as_ref(), pace_tolerance, now);
68
69 placeholders(vec![
70 ("icon", "".to_string()),
71 ("vendor_short", VendorId::Minimax.short_name().to_string()),
72 ("plan", snap.plan.clone()),
74 ("session_pct", session_pct.to_string()),
75 ("session_reset", session_reset.clone()),
76 ("weekly_pct", weekly_pct.to_string()),
77 ("weekly_reset", weekly_reset.clone()),
78 ("session_elapsed", session.elapsed.clone()),
79 ("weekly_elapsed", weekly.elapsed.clone()),
80 ("minimax_plan", snap.plan.clone()),
82 ("minimax_session_pct", session_pct.to_string()),
83 ("minimax_session_reset", session_reset),
84 ("minimax_weekly_pct", weekly_pct.to_string()),
85 ("minimax_weekly_reset", weekly_reset),
86 ("minimax_session_elapsed", session.elapsed),
87 ("minimax_session_pace", session.ratio_pace),
88 ("minimax_session_pace_indicator", session.point_pace),
89 ("minimax_weekly_elapsed", weekly.elapsed),
90 ("minimax_weekly_pace", weekly.ratio_pace),
91 ("minimax_weekly_pace_indicator", weekly.point_pace),
92 ("minimax_video_pct", video(&snap.video_session, true)),
93 ("minimax_video_reset", video(&snap.video_session, false)),
94 ("minimax_video_elapsed", video_pacing.elapsed),
95 ("minimax_video_pace", video_pacing.ratio_pace),
96 ("minimax_video_pace_indicator", video_pacing.point_pace),
97 ("minimax_video_weekly_pct", video(&snap.video_weekly, true)),
98 (
99 "minimax_video_weekly_reset",
100 video(&snap.video_weekly, false),
101 ),
102 ("minimax_video_weekly_elapsed", video_weekly_pacing.elapsed),
103 ("minimax_video_weekly_pace", video_weekly_pacing.ratio_pace),
104 (
105 "minimax_video_weekly_pace_indicator",
106 video_weekly_pacing.point_pace,
107 ),
108 ])
109}
110
111struct WindowPacing {
114 elapsed: String,
115 ratio_pace: String,
116 point_pace: String,
117}
118
119impl WindowPacing {
120 fn unavailable() -> Self {
121 Self {
122 elapsed: "—".to_string(),
123 ratio_pace: "—".to_string(),
124 point_pace: "—".to_string(),
125 }
126 }
127}
128
129fn optional_window_pacing(
130 window: Option<&UsageWindow>,
131 pace_tolerance: u32,
132 now: DateTime<Utc>,
133) -> WindowPacing {
134 window.map_or_else(WindowPacing::unavailable, |window| {
135 window_pacing(window, pace_tolerance, now)
136 })
137}
138
139fn window_pacing(w: &UsageWindow, pace_tolerance: u32, now: DateTime<Utc>) -> WindowPacing {
140 let p = pacing::calc(
141 w.utilization_pct,
142 w.resets_at,
143 now,
144 w.window_duration,
145 pace_tolerance,
146 );
147 WindowPacing {
148 elapsed: p.elapsed_pct.to_string(),
149 ratio_pace: p.ratio_pace.glyph().to_string(),
150 point_pace: p.point_pace.glyph().to_string(),
151 }
152}
153
154pub fn severity(snap: &MinimaxSnapshot) -> PaceSeverity {
158 severity_for(
159 snap.session
160 .utilization_pct
161 .max(snap.weekly.utilization_pct),
162 )
163}
164
165pub fn render(
166 outcome: &VendorOutcome,
167 snap: &MinimaxSnapshot,
168 theme: &Theme,
169 opts: &RenderOpts,
170 now: DateTime<Utc>,
171) -> WaybarOutput {
172 let class = Class::from(severity(snap));
173 let format = opts
174 .format
175 .clone()
176 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
177 let values = build_placeholders_with_tolerance(snap, opts.pace_tolerance, now);
178 let mut pango_values = values.clone();
182 for key in ["plan", "minimax_plan"] {
183 if let Some(value) = pango_values.get_mut(key) {
184 *value = escape(value);
185 }
186 }
187
188 let mut text = substitute(&format, &pango_values);
189 if outcome.stale {
190 text.push_str(" ⏸");
191 }
192
193 let wrapper_color = severity_color(severity(snap), theme).to_string();
194 let icon_prefix = match opts.icon.as_deref() {
195 Some(ic) if !ic.is_empty() => format!("{ic} "),
196 _ => String::new(),
197 };
198 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
199
200 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
201 substitute(fmt, &pango_values)
202 } else {
203 render_tooltip(outcome, snap, theme, opts, now)
204 };
205
206 WaybarOutput {
207 text: bar_text,
208 tooltip,
209 class,
210 }
211}
212
213fn render_tooltip(
214 outcome: &VendorOutcome,
215 snap: &MinimaxSnapshot,
216 theme: &Theme,
217 opts: &RenderOpts,
218 now: DateTime<Utc>,
219) -> String {
220 let blue = &theme.blue;
221 let dim = &theme.dim;
222 let fg = &theme.fg;
223
224 let mut lines: Vec<TooltipLine> = Vec::new();
225 lines.push(TooltipLine::Center(format!(
226 "<span font_weight='bold' foreground='{blue}'>{}</span>",
227 escape(&snap.plan)
228 )));
229 lines.push(TooltipLine::Sep);
230
231 let mut pool = |label: &str, icon: &str, session: &UsageWindow, weekly: &UsageWindow| {
236 lines.push(TooltipLine::Body("".into()));
237 lines.push(TooltipLine::Body(format!(
238 " <span font_weight='bold' foreground='{fg}'> {icon} {label}</span>"
239 )));
240 for (slot, (what, w)) in [(" Session", session), (" Weekly", weekly)]
241 .into_iter()
242 .enumerate()
243 {
244 if slot > 0 {
245 lines.push(TooltipLine::Body("".into()));
246 }
247 push_window_with_row(
248 &mut lines,
249 what,
250 w,
251 theme,
252 now,
253 WindowRow::paced(w, now, opts.pace_tolerance, opts.tooltip_pace_pts),
254 );
255 }
256 };
257
258 pool(POOL_GENERAL, "", &snap.session, &snap.weekly);
259 if let (Some(vs), Some(vw)) = (&snap.video_session, &snap.video_weekly) {
260 pool(POOL_VIDEO, "", vs, vw);
261 }
262
263 if let Some((code, msg)) = outcome.last_error.as_ref() {
264 let (label, icon, ecolor) = if *code == 0 {
265 ("MiniMax error".to_string(), "", theme.red.as_str())
266 } else if *code >= 500 {
267 (format!("HTTP {code}"), "", theme.red.as_str())
268 } else {
269 (format!("HTTP {code}"), "", theme.orange.as_str())
270 };
271 lines.push(TooltipLine::Body("".into()));
272 lines.push(TooltipLine::Sep);
273 lines.push(TooltipLine::Body(format!(
274 " <span foreground='{ecolor}'> {icon} {label}</span>"
275 )));
276 if msg != &label {
277 lines.push(TooltipLine::Body(format!(
278 " <span foreground='{dim}'>{}</span>",
279 escape(msg)
280 )));
281 }
282 }
283
284 let updated = updated_at_hm(now, outcome.cache_age);
285 lines.push(TooltipLine::Body("".into()));
286 lines.push(TooltipLine::Sep);
287 lines.push(TooltipLine::Body(format!(
288 " <span foreground='{dim}'> Updated {updated}</span>"
289 )));
290
291 render_bordered(&lines, theme)
292}
293
294impl From<FetchOutcome> for VendorOutcome {
295 fn from(o: FetchOutcome) -> Self {
296 o.map(crate::usage::VendorSnapshot::Minimax)
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use chrono::TimeZone;
304
305 fn now() -> DateTime<Utc> {
306 Utc.with_ymd_and_hms(2026, 7, 27, 12, 0, 0).unwrap()
307 }
308
309 fn window(pct: i32, mins_ahead: i64, dur: chrono::Duration) -> UsageWindow {
310 UsageWindow {
311 utilization_pct: pct,
312 resets_at: Some(now() + chrono::Duration::minutes(mins_ahead)),
313 window_duration: dur,
314 }
315 }
316
317 fn snap() -> MinimaxSnapshot {
318 MinimaxSnapshot {
319 plan: "MiniMax Token Plan".to_string(),
320 session: window(31, 45, chrono::Duration::hours(5)),
321 weekly: window(62, 3000, chrono::Duration::days(7)),
322 video_session: Some(window(5, 200, chrono::Duration::hours(24))),
323 video_weekly: Some(window(9, 3000, chrono::Duration::days(7))),
324 }
325 }
326
327 fn opts() -> RenderOpts {
328 RenderOpts {
329 format: None,
330 tooltip_format: None,
331 icon: None,
332 pace_tolerance: 5,
333 format_pace_color: false,
334 tooltip_pace_pts: false,
335 }
336 }
337
338 fn placeholders_at(
339 snap: &MinimaxSnapshot,
340 now: DateTime<Utc>,
341 ) -> HashMap<&'static str, String> {
342 build_placeholders_with_tolerance(snap, opts().pace_tolerance, now)
343 }
344
345 fn outcome(s: &MinimaxSnapshot) -> VendorOutcome {
346 VendorOutcome {
347 snapshot: crate::usage::VendorSnapshot::Minimax(s.clone()),
348 stale: false,
349 last_error: None,
350 cache_age: Some(std::time::Duration::ZERO),
351 }
352 }
353
354 #[test]
355 fn default_format_shows_session_percent_and_reset() {
356 let s = snap();
357 let out = render(&outcome(&s), &s, &Theme::default(), &opts(), now());
358 assert!(out.text.contains("31%"), "bar text was {:?}", out.text);
359 }
360
361 #[test]
364 fn exposes_cross_vendor_aliases() {
365 let v = placeholders_at(&snap(), now());
366 assert_eq!(v.get("session_pct").map(String::as_str), Some("31"));
367 assert_eq!(v.get("weekly_pct").map(String::as_str), Some("62"));
368 assert_eq!(v.get("vendor_short").map(String::as_str), Some("mmx"));
369 assert!(v.contains_key("session_reset"));
370 }
371
372 #[test]
374 fn severity_ignores_the_video_pool() {
375 let mut s = snap();
376 s.session.utilization_pct = 10;
377 s.weekly.utilization_pct = 10;
378 s.video_session = Some(window(99, 10, chrono::Duration::hours(24)));
379 s.video_weekly = Some(window(99, 10, chrono::Duration::days(7)));
380 assert_eq!(severity(&s), severity_for(10));
381 }
382
383 #[test]
384 fn video_placeholders_degrade_to_a_dash_without_the_pool() {
385 let mut s = snap();
386 s.video_session = None;
387 s.video_weekly = None;
388 let v = placeholders_at(&s, now());
389 assert_eq!(v.get("minimax_video_pct").map(String::as_str), Some("—"));
390 assert_eq!(
391 v.get("minimax_video_weekly_pct").map(String::as_str),
392 Some("—")
393 );
394 assert_eq!(v.get("minimax_video_pace").map(String::as_str), Some("—"));
395 assert_eq!(
396 v.get("minimax_video_weekly_reset").map(String::as_str),
397 Some("—")
398 );
399 assert_eq!(
400 v.get("minimax_video_weekly_pace").map(String::as_str),
401 Some("—")
402 );
403 }
404
405 #[test]
406 fn public_builder_keeps_the_default_tolerance_api() {
407 let snap = snap();
408 assert_eq!(
409 build_placeholders(&snap, now()),
410 build_placeholders_with_tolerance(&snap, pacing::DEFAULT_TOLERANCE, now())
411 );
412 }
413
414 #[test]
415 fn present_window_without_reset_uses_documented_neutral_pacing() {
416 let mut snap = snap();
417 snap.session.resets_at = None;
418 let values = placeholders_at(&snap, now());
419 assert_eq!(values["minimax_session_reset"], "—");
420 assert_eq!(values["minimax_session_elapsed"], "0");
421 assert_eq!(values["minimax_session_pace"], "→");
422 assert_eq!(values["minimax_session_pace_indicator"], "→");
423 }
424
425 #[test]
426 fn pace_placeholders_follow_usage_vs_elapsed() {
427 let n = now();
430 let mut s = snap();
431 s.session = window(80, 120, chrono::Duration::hours(5));
432 s.weekly = window(15, 3 * 24 * 60, chrono::Duration::days(7));
433 let v = placeholders_at(&s, n);
434 assert_eq!(v["minimax_session_elapsed"], "60");
435 assert_eq!(v["minimax_session_pace"], "↑");
436 assert_eq!(v["minimax_session_pace_indicator"], "↑");
437 assert_eq!(v["minimax_weekly_elapsed"], "57");
438 assert_eq!(v["minimax_weekly_pace"], "↓");
439 assert_eq!(v["minimax_weekly_pace_indicator"], "↓");
440 }
441
442 #[test]
443 fn video_pace_follows_usage_vs_elapsed() {
444 let n = now();
446 let mut s = snap();
447 s.video_session = Some(window(90, 6 * 60, chrono::Duration::hours(24)));
448 let v = placeholders_at(&s, n);
449 assert_eq!(v["minimax_video_elapsed"], "75");
450 assert_eq!(v["minimax_video_pace"], "↑");
451 assert_eq!(v["minimax_video_pace_indicator"], "↑");
452 }
453
454 #[test]
455 fn video_weekly_exposes_the_complete_pacing_family() {
456 let n = now();
457 let mut s = snap();
458 s.video_weekly = Some(window(90, 3 * 24 * 60, chrono::Duration::days(7)));
459 let v = placeholders_at(&s, n);
460 assert_ne!(v["minimax_video_weekly_reset"], "—");
461 assert_eq!(v["minimax_video_weekly_elapsed"], "57");
462 assert_eq!(v["minimax_video_weekly_pace"], "↑");
463 assert_eq!(v["minimax_video_weekly_pace_indicator"], "↑");
464 }
465
466 #[test]
467 fn tooltip_lists_both_pools_when_present() {
468 let s = snap();
469 let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
470 assert!(tip.contains(POOL_GENERAL));
471 assert!(tip.contains(POOL_VIDEO));
472 }
473
474 #[test]
475 fn tooltip_omits_the_video_pool_when_absent() {
476 let mut s = snap();
477 s.video_session = None;
478 s.video_weekly = None;
479 let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
480 assert!(tip.contains(POOL_GENERAL));
481 assert!(!tip.contains(POOL_VIDEO));
482 }
483
484 #[test]
486 fn escapes_the_plan_name_exactly_once() {
487 let mut s = snap();
488 s.plan = "Plan <b>&</b>".to_string();
489 let out = render(
490 &outcome(&s),
491 &s,
492 &Theme::default(),
493 &RenderOpts {
494 format: Some("{minimax_plan}".to_string()),
495 ..opts()
496 },
497 now(),
498 );
499 assert!(
500 out.text.contains("<b>&</b>"),
501 "{:?}",
502 out.text
503 );
504 assert!(
505 !out.text.contains("&lt;"),
506 "double-escaped: {:?}",
507 out.text
508 );
509 }
510
511 #[test]
512 fn stale_marks_the_bar() {
513 let s = snap();
514 let mut o = outcome(&s);
515 o.stale = true;
516 let out = render(&o, &s, &Theme::default(), &opts(), now());
517 assert!(out.text.contains('⏸'));
518 }
519
520 #[test]
523 fn tooltip_draws_a_bar_and_pace_arrow_for_every_pool_row() {
524 let s = snap();
525 let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
526 let cells = tip.matches('░').count() + tip.matches('█').count();
527 assert_eq!(
528 cells,
529 4 * crate::pango::BAR_LEN as usize,
530 "expected a bar for each of the four windows: {tip}"
531 );
532 let arrows = tip.matches('↑').count() + tip.matches('→').count() + tip.matches('↓').count();
533 assert_eq!(arrows, 4, "expected one arrow per window: {tip}");
534 }
535
536 #[test]
537 fn the_elapsed_marker_stays_behind_tooltip_pace_pts() {
538 let s = snap();
539 let plain = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
540 let paced = render_tooltip(
541 &outcome(&s),
542 &s,
543 &Theme::default(),
544 &RenderOpts {
545 tooltip_pace_pts: true,
546 ..opts()
547 },
548 now(),
549 );
550 assert_ne!(plain, paced, "the marker should redraw the bars");
551 }
552}