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, 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", "mmx".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 Self {
297 snapshot: crate::usage::VendorSnapshot::Minimax(o.snapshot),
298 stale: o.stale,
299 last_error: o.last_error,
300 cache_age: o.cache_age,
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use chrono::TimeZone;
309
310 fn now() -> DateTime<Utc> {
311 Utc.with_ymd_and_hms(2026, 7, 27, 12, 0, 0).unwrap()
312 }
313
314 fn window(pct: i32, mins_ahead: i64, dur: chrono::Duration) -> UsageWindow {
315 UsageWindow {
316 utilization_pct: pct,
317 resets_at: Some(now() + chrono::Duration::minutes(mins_ahead)),
318 window_duration: dur,
319 }
320 }
321
322 fn snap() -> MinimaxSnapshot {
323 MinimaxSnapshot {
324 plan: "MiniMax Token Plan".to_string(),
325 session: window(31, 45, chrono::Duration::hours(5)),
326 weekly: window(62, 3000, chrono::Duration::days(7)),
327 video_session: Some(window(5, 200, chrono::Duration::hours(24))),
328 video_weekly: Some(window(9, 3000, chrono::Duration::days(7))),
329 }
330 }
331
332 fn opts() -> RenderOpts {
333 RenderOpts {
334 format: None,
335 tooltip_format: None,
336 icon: None,
337 pace_tolerance: 5,
338 format_pace_color: false,
339 tooltip_pace_pts: false,
340 }
341 }
342
343 fn placeholders_at(
344 snap: &MinimaxSnapshot,
345 now: DateTime<Utc>,
346 ) -> HashMap<&'static str, String> {
347 build_placeholders_with_tolerance(snap, opts().pace_tolerance, now)
348 }
349
350 fn outcome(s: &MinimaxSnapshot) -> VendorOutcome {
351 VendorOutcome {
352 snapshot: crate::usage::VendorSnapshot::Minimax(s.clone()),
353 stale: false,
354 last_error: None,
355 cache_age: Some(std::time::Duration::ZERO),
356 }
357 }
358
359 #[test]
360 fn default_format_shows_session_percent_and_reset() {
361 let s = snap();
362 let out = render(&outcome(&s), &s, &Theme::default(), &opts(), now());
363 assert!(out.text.contains("31%"), "bar text was {:?}", out.text);
364 }
365
366 #[test]
369 fn exposes_cross_vendor_aliases() {
370 let v = placeholders_at(&snap(), now());
371 assert_eq!(v.get("session_pct").map(String::as_str), Some("31"));
372 assert_eq!(v.get("weekly_pct").map(String::as_str), Some("62"));
373 assert_eq!(v.get("vendor_short").map(String::as_str), Some("mmx"));
374 assert!(v.contains_key("session_reset"));
375 }
376
377 #[test]
379 fn severity_ignores_the_video_pool() {
380 let mut s = snap();
381 s.session.utilization_pct = 10;
382 s.weekly.utilization_pct = 10;
383 s.video_session = Some(window(99, 10, chrono::Duration::hours(24)));
384 s.video_weekly = Some(window(99, 10, chrono::Duration::days(7)));
385 assert_eq!(severity(&s), severity_for(10));
386 }
387
388 #[test]
389 fn video_placeholders_degrade_to_a_dash_without_the_pool() {
390 let mut s = snap();
391 s.video_session = None;
392 s.video_weekly = None;
393 let v = placeholders_at(&s, now());
394 assert_eq!(v.get("minimax_video_pct").map(String::as_str), Some("—"));
395 assert_eq!(
396 v.get("minimax_video_weekly_pct").map(String::as_str),
397 Some("—")
398 );
399 assert_eq!(v.get("minimax_video_pace").map(String::as_str), Some("—"));
400 assert_eq!(
401 v.get("minimax_video_weekly_reset").map(String::as_str),
402 Some("—")
403 );
404 assert_eq!(
405 v.get("minimax_video_weekly_pace").map(String::as_str),
406 Some("—")
407 );
408 }
409
410 #[test]
411 fn public_builder_keeps_the_default_tolerance_api() {
412 let snap = snap();
413 assert_eq!(
414 build_placeholders(&snap, now()),
415 build_placeholders_with_tolerance(&snap, pacing::DEFAULT_TOLERANCE, now())
416 );
417 }
418
419 #[test]
420 fn present_window_without_reset_uses_documented_neutral_pacing() {
421 let mut snap = snap();
422 snap.session.resets_at = None;
423 let values = placeholders_at(&snap, now());
424 assert_eq!(values["minimax_session_reset"], "—");
425 assert_eq!(values["minimax_session_elapsed"], "0");
426 assert_eq!(values["minimax_session_pace"], "→");
427 assert_eq!(values["minimax_session_pace_indicator"], "→");
428 }
429
430 #[test]
431 fn pace_placeholders_follow_usage_vs_elapsed() {
432 let n = now();
435 let mut s = snap();
436 s.session = window(80, 120, chrono::Duration::hours(5));
437 s.weekly = window(15, 3 * 24 * 60, chrono::Duration::days(7));
438 let v = placeholders_at(&s, n);
439 assert_eq!(v["minimax_session_elapsed"], "60");
440 assert_eq!(v["minimax_session_pace"], "↑");
441 assert_eq!(v["minimax_session_pace_indicator"], "↑");
442 assert_eq!(v["minimax_weekly_elapsed"], "57");
443 assert_eq!(v["minimax_weekly_pace"], "↓");
444 assert_eq!(v["minimax_weekly_pace_indicator"], "↓");
445 }
446
447 #[test]
448 fn video_pace_follows_usage_vs_elapsed() {
449 let n = now();
451 let mut s = snap();
452 s.video_session = Some(window(90, 6 * 60, chrono::Duration::hours(24)));
453 let v = placeholders_at(&s, n);
454 assert_eq!(v["minimax_video_elapsed"], "75");
455 assert_eq!(v["minimax_video_pace"], "↑");
456 assert_eq!(v["minimax_video_pace_indicator"], "↑");
457 }
458
459 #[test]
460 fn video_weekly_exposes_the_complete_pacing_family() {
461 let n = now();
462 let mut s = snap();
463 s.video_weekly = Some(window(90, 3 * 24 * 60, chrono::Duration::days(7)));
464 let v = placeholders_at(&s, n);
465 assert_ne!(v["minimax_video_weekly_reset"], "—");
466 assert_eq!(v["minimax_video_weekly_elapsed"], "57");
467 assert_eq!(v["minimax_video_weekly_pace"], "↑");
468 assert_eq!(v["minimax_video_weekly_pace_indicator"], "↑");
469 }
470
471 #[test]
472 fn tooltip_lists_both_pools_when_present() {
473 let s = snap();
474 let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
475 assert!(tip.contains(POOL_GENERAL));
476 assert!(tip.contains(POOL_VIDEO));
477 }
478
479 #[test]
480 fn tooltip_omits_the_video_pool_when_absent() {
481 let mut s = snap();
482 s.video_session = None;
483 s.video_weekly = None;
484 let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
485 assert!(tip.contains(POOL_GENERAL));
486 assert!(!tip.contains(POOL_VIDEO));
487 }
488
489 #[test]
491 fn escapes_the_plan_name_exactly_once() {
492 let mut s = snap();
493 s.plan = "Plan <b>&</b>".to_string();
494 let out = render(
495 &outcome(&s),
496 &s,
497 &Theme::default(),
498 &RenderOpts {
499 format: Some("{minimax_plan}".to_string()),
500 ..opts()
501 },
502 now(),
503 );
504 assert!(
505 out.text.contains("<b>&</b>"),
506 "{:?}",
507 out.text
508 );
509 assert!(
510 !out.text.contains("&lt;"),
511 "double-escaped: {:?}",
512 out.text
513 );
514 }
515
516 #[test]
517 fn stale_marks_the_bar() {
518 let s = snap();
519 let mut o = outcome(&s);
520 o.stale = true;
521 let out = render(&o, &s, &Theme::default(), &opts(), now());
522 assert!(out.text.contains('⏸'));
523 }
524
525 #[test]
528 fn tooltip_draws_a_bar_and_pace_arrow_for_every_pool_row() {
529 let s = snap();
530 let tip = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
531 let cells = tip.matches('░').count() + tip.matches('█').count();
532 assert_eq!(
533 cells,
534 4 * crate::pango::BAR_LEN as usize,
535 "expected a bar for each of the four windows: {tip}"
536 );
537 let arrows = tip.matches('↑').count() + tip.matches('→').count() + tip.matches('↓').count();
538 assert_eq!(arrows, 4, "expected one arrow per window: {tip}");
539 }
540
541 #[test]
542 fn the_elapsed_marker_stays_behind_tooltip_pace_pts() {
543 let s = snap();
544 let plain = render_tooltip(&outcome(&s), &s, &Theme::default(), &opts(), now());
545 let paced = render_tooltip(
546 &outcome(&s),
547 &s,
548 &Theme::default(),
549 &RenderOpts {
550 tooltip_pace_pts: true,
551 ..opts()
552 },
553 now(),
554 );
555 assert_ne!(plain, paced, "the marker should redraw the bars");
556 }
557}