1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6
7use crate::countdown;
8use crate::format::{placeholders, substitute, updated_at_hm};
9use crate::pacing::{self, PaceSeverity};
10use crate::pango::{color_span, escape, severity_color, severity_for};
11use crate::theme::Theme;
12use crate::tooltip::{Line as TooltipLine, WindowRow, push_window_with_row, render_bordered};
13use crate::usage::{UsageWindow, ZaiSnapshot};
14use crate::vendor::{RenderOpts, VendorOutcome};
15use crate::waybar::{Class, WaybarOutput};
16
17use super::fetch::FetchOutcome;
18
19pub const DEFAULT_FORMAT: &str = "{zai_session_pct}% · {zai_session_reset}";
20
21pub fn build_placeholders(snap: &ZaiSnapshot, now: DateTime<Utc>) -> HashMap<&'static str, String> {
26 build_placeholders_with_tolerance(snap, pacing::DEFAULT_TOLERANCE, now)
27}
28
29fn build_placeholders_with_tolerance(
30 snap: &ZaiSnapshot,
31 pace_tolerance: u32,
32 now: DateTime<Utc>,
33) -> HashMap<&'static str, String> {
34 let session_pct = snap
35 .session
36 .as_ref()
37 .map(|w| w.utilization_pct)
38 .unwrap_or(0);
39 let weekly_pct = snap.weekly.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
40 let mcp_pct = snap.mcp.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
41 let session = window_pacing(snap.session.as_ref(), pace_tolerance, now);
45 let weekly = window_pacing(snap.weekly.as_ref(), pace_tolerance, now);
46 let mcp = window_pacing(snap.mcp.as_ref(), pace_tolerance, now);
47 placeholders(vec![
48 ("icon", "".to_string()),
49 ("vendor_short", "zai".to_string()),
50 ("session_pct", session_pct.to_string()),
52 (
53 "session_reset",
54 countdown::format(window_reset(&snap.session), now),
55 ),
56 ("weekly_pct", weekly_pct.to_string()),
57 (
58 "weekly_reset",
59 countdown::format(window_reset(&snap.weekly), now),
60 ),
61 ("session_elapsed", session.elapsed.clone()),
62 ("weekly_elapsed", weekly.elapsed.clone()),
63 ("plan", snap.plan.clone()),
64 ("zai_plan", snap.plan.clone()),
65 ("zai_session_pct", session_pct.to_string()),
66 (
67 "zai_session_reset",
68 countdown::format(window_reset(&snap.session), now),
69 ),
70 ("zai_weekly_pct", weekly_pct.to_string()),
71 (
72 "zai_weekly_reset",
73 countdown::format(window_reset(&snap.weekly), now),
74 ),
75 ("zai_session_elapsed", session.elapsed),
76 ("zai_session_pace", session.ratio_pace),
77 ("zai_session_pace_indicator", session.point_pace),
78 ("zai_weekly_elapsed", weekly.elapsed),
79 ("zai_weekly_pace", weekly.ratio_pace),
80 ("zai_weekly_pace_indicator", weekly.point_pace),
81 ("zai_mcp_pct", mcp_pct.to_string()),
82 (
83 "zai_mcp_reset",
84 countdown::format(window_reset(&snap.mcp), now),
85 ),
86 ("zai_mcp_elapsed", mcp.elapsed),
87 ("zai_mcp_pace", mcp.ratio_pace),
88 ("zai_mcp_pace_indicator", mcp.point_pace),
89 ])
90}
91
92fn window_reset(w: &Option<UsageWindow>) -> Option<DateTime<Utc>> {
93 w.as_ref().and_then(|w| w.resets_at)
94}
95
96#[derive(Default)]
101struct WindowPacing {
102 elapsed: String,
103 ratio_pace: String,
104 point_pace: String,
105}
106
107fn window_pacing(w: Option<&UsageWindow>, pace_tolerance: u32, now: DateTime<Utc>) -> WindowPacing {
108 let Some(w) = w else {
109 return WindowPacing::default();
110 };
111 let p = pacing::calc(
112 w.utilization_pct,
113 w.resets_at,
114 now,
115 w.window_duration,
116 pace_tolerance,
117 );
118 WindowPacing {
119 elapsed: p.elapsed_pct.to_string(),
120 ratio_pace: p.ratio_pace.glyph().to_string(),
121 point_pace: p.point_pace.glyph().to_string(),
122 }
123}
124
125pub fn severity(snap: &ZaiSnapshot) -> PaceSeverity {
126 let session = snap
127 .session
128 .as_ref()
129 .map(|w| w.utilization_pct)
130 .unwrap_or(0);
131 let weekly = snap.weekly.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
132 let mcp = snap.mcp.as_ref().map(|w| w.utilization_pct).unwrap_or(0);
133 severity_for([session, weekly, mcp].into_iter().max().unwrap_or(0))
134}
135
136pub fn render(
137 outcome: &VendorOutcome,
138 snap: &ZaiSnapshot,
139 theme: &Theme,
140 opts: &RenderOpts,
141 now: DateTime<Utc>,
142) -> WaybarOutput {
143 let class = Class::from(severity(snap));
144 let format = opts
145 .format
146 .clone()
147 .unwrap_or_else(|| DEFAULT_FORMAT.to_string());
148 let values = build_placeholders_with_tolerance(snap, opts.pace_tolerance, now);
149
150 let mut text = substitute(&format, &values);
151 if outcome.stale {
152 text.push_str(" ⏸");
153 }
154 let wrapper_color = severity_color(severity(snap), theme).to_string();
155 let icon_prefix = match opts.icon.as_deref() {
156 Some(ic) if !ic.is_empty() => format!("{ic} "),
157 _ => String::new(),
158 };
159 let bar_text = color_span(&wrapper_color, &format!("{icon_prefix}{text}"));
160
161 let tooltip = if let Some(fmt) = opts.tooltip_format.as_deref() {
162 substitute(fmt, &values)
163 } else {
164 render_tooltip(outcome, snap, theme, opts, now)
165 };
166
167 WaybarOutput {
168 text: bar_text,
169 tooltip,
170 class,
171 }
172}
173
174fn render_tooltip(
175 outcome: &VendorOutcome,
176 snap: &ZaiSnapshot,
177 theme: &Theme,
178 opts: &RenderOpts,
179 now: DateTime<Utc>,
180) -> String {
181 let blue = &theme.blue;
182 let dim = &theme.dim;
183 let row =
186 |w: &UsageWindow| WindowRow::paced(w, now, opts.pace_tolerance, opts.tooltip_pace_pts);
187 let mut lines: Vec<TooltipLine> = Vec::new();
188 lines.push(TooltipLine::Center(format!(
189 "<span font_weight='bold' foreground='{blue}'>{plan}</span>",
190 plan = escape(&snap.plan)
191 )));
192 lines.push(TooltipLine::Sep);
193 lines.push(TooltipLine::Body("".into()));
194
195 if let Some(w) = snap.session.as_ref() {
196 push_window_with_row(&mut lines, " Session (5h)", w, theme, now, row(w));
197 }
198 if let Some(w) = snap.weekly.as_ref() {
199 if snap.session.is_some() {
200 lines.push(TooltipLine::Body("".into()));
201 }
202 push_window_with_row(&mut lines, " Weekly", w, theme, now, row(w));
203 }
204 if let Some(w) = snap.mcp.as_ref() {
205 lines.push(TooltipLine::Body("".into()));
206 lines.push(TooltipLine::Sep);
207 push_window_with_row(
208 &mut lines,
209 " MCP tools (monthly)",
210 w,
211 theme,
212 now,
213 row(w),
214 );
215 }
216 if snap.session.is_none() && snap.weekly.is_none() && snap.mcp.is_none() {
217 lines.push(TooltipLine::Body(format!(
218 " <span foreground='{dim}'>no usage windows reported</span>"
219 )));
220 }
221
222 if let Some((code, msg)) = outcome.last_error.as_ref()
223 && *code != 0
224 {
225 let (icon, ecolor) = if *code >= 500 {
226 ("", theme.red.as_str())
227 } else {
228 ("", theme.orange.as_str())
229 };
230 lines.push(TooltipLine::Body("".into()));
231 lines.push(TooltipLine::Sep);
232 lines.push(TooltipLine::Body(format!(
233 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
234 )));
235 lines.push(TooltipLine::Body(format!(
236 " <span foreground='{dim}'>{}</span>",
237 escape(msg)
238 )));
239 }
240
241 let updated = updated_at_hm(now, outcome.cache_age);
242 lines.push(TooltipLine::Body("".into()));
243 lines.push(TooltipLine::Sep);
244 lines.push(TooltipLine::Body(format!(
245 " <span foreground='{dim}'> Updated {updated}</span>"
246 )));
247
248 render_bordered(&lines, theme)
249}
250
251impl From<FetchOutcome> for VendorOutcome {
252 fn from(o: FetchOutcome) -> Self {
253 Self {
254 snapshot: crate::usage::VendorSnapshot::Zai(o.snapshot),
255 stale: o.stale,
256 last_error: o.last_error,
257 cache_age: o.cache_age,
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265 use crate::usage::{UsageWindow, ZaiSnapshot};
266
267 fn sample_snap() -> ZaiSnapshot {
268 let now = Utc::now();
269 ZaiSnapshot {
270 plan: "GLM Coding Pro".into(),
271 session: Some(UsageWindow {
272 utilization_pct: 42,
273 resets_at: Some(now + chrono::Duration::hours(2)),
274 window_duration: chrono::Duration::hours(5),
275 }),
276 weekly: Some(UsageWindow {
277 utilization_pct: 15,
278 resets_at: Some(now + chrono::Duration::days(3)),
279 window_duration: chrono::Duration::days(7),
280 }),
281 mcp: None,
282 }
283 }
284
285 fn outcome(s: ZaiSnapshot) -> VendorOutcome {
286 VendorOutcome {
287 snapshot: crate::usage::VendorSnapshot::Zai(s),
288 stale: false,
289 last_error: None,
290 cache_age: Some(std::time::Duration::from_secs(10)),
291 }
292 }
293
294 fn opts() -> RenderOpts {
295 RenderOpts {
296 format: None,
297 tooltip_format: None,
298 icon: None,
299 pace_tolerance: 5,
300 format_pace_color: false,
301 tooltip_pace_pts: false,
302 }
303 }
304
305 fn placeholders_at(snap: &ZaiSnapshot, now: DateTime<Utc>) -> HashMap<&'static str, String> {
306 build_placeholders_with_tolerance(snap, opts().pace_tolerance, now)
307 }
308
309 #[test]
310 fn default_format_renders_session_pct() {
311 let snap = sample_snap();
312 let oc = outcome(snap.clone());
313 let out = render(&oc, &snap, &Theme::default(), &opts(), Utc::now());
314 assert!(out.text.contains("42%"));
315 }
316
317 #[test]
318 fn elapsed_placeholders_follow_window_progress() {
319 let now = Utc::now();
322 let snap = ZaiSnapshot {
323 plan: "GLM Coding Pro".into(),
324 session: Some(UsageWindow {
325 utilization_pct: 42,
326 resets_at: Some(now + chrono::Duration::hours(2)),
327 window_duration: chrono::Duration::hours(5),
328 }),
329 weekly: Some(UsageWindow {
330 utilization_pct: 15,
331 resets_at: Some(now + chrono::Duration::days(3)),
332 window_duration: chrono::Duration::days(7),
333 }),
334 mcp: None,
335 };
336 let values = placeholders_at(&snap, now);
337 assert_eq!(values["session_elapsed"], "60");
338 assert_eq!(values["weekly_elapsed"], "57");
339 }
340
341 #[test]
342 fn pace_placeholders_follow_usage_vs_elapsed() {
343 let now = Utc::now();
346 let snap = ZaiSnapshot {
347 plan: "GLM Coding Pro".into(),
348 session: Some(UsageWindow {
349 utilization_pct: 80,
350 resets_at: Some(now + chrono::Duration::hours(2)),
351 window_duration: chrono::Duration::hours(5),
352 }),
353 weekly: Some(UsageWindow {
354 utilization_pct: 15,
355 resets_at: Some(now + chrono::Duration::days(3)),
356 window_duration: chrono::Duration::days(7),
357 }),
358 mcp: None,
359 };
360 let values = placeholders_at(&snap, now);
361 assert_eq!(values["zai_session_pace"], "↑");
362 assert_eq!(values["zai_session_pace_indicator"], "↑");
363 assert_eq!(values["zai_weekly_pace"], "↓");
364 assert_eq!(values["zai_weekly_pace_indicator"], "↓");
365 assert_eq!(values["zai_session_elapsed"], "60");
366 assert_eq!(values["zai_weekly_elapsed"], "57");
367 }
368
369 #[test]
370 fn public_builder_keeps_the_default_tolerance_api() {
371 let now = Utc::now();
372 let snap = sample_snap();
373 assert_eq!(
374 build_placeholders(&snap, now),
375 build_placeholders_with_tolerance(&snap, pacing::DEFAULT_TOLERANCE, now)
376 );
377 }
378
379 #[test]
380 fn custom_tolerance_changes_the_ratio_pace() {
381 let now = Utc::now();
382 let snap = ZaiSnapshot {
383 plan: "GLM Coding Pro".into(),
384 session: Some(UsageWindow {
385 utilization_pct: 53,
386 resets_at: Some(now + chrono::Duration::minutes(150)),
387 window_duration: chrono::Duration::hours(5),
388 }),
389 weekly: None,
390 mcp: None,
391 };
392 assert_eq!(
393 build_placeholders_with_tolerance(&snap, 5, now)["zai_session_pace"],
394 "↑"
395 );
396 assert_eq!(
397 build_placeholders_with_tolerance(&snap, 10, now)["zai_session_pace"],
398 "→"
399 );
400 }
401
402 #[test]
403 fn present_window_without_reset_uses_documented_neutral_pacing() {
404 let now = Utc::now();
405 let mut snap = sample_snap();
406 snap.session.as_mut().unwrap().resets_at = None;
407 let values = placeholders_at(&snap, now);
408 assert_eq!(values["zai_session_reset"], "—");
409 assert_eq!(values["zai_session_elapsed"], "0");
410 assert_eq!(values["zai_session_pace"], "→");
411 assert_eq!(values["zai_session_pace_indicator"], "→");
412 }
413
414 #[test]
415 fn mcp_pace_placeholders_follow_usage_vs_elapsed() {
416 let now = Utc::now();
418 let snap = ZaiSnapshot {
419 plan: "GLM Coding Pro".into(),
420 session: None,
421 weekly: None,
422 mcp: Some(UsageWindow {
423 utilization_pct: 70,
424 resets_at: Some(now + chrono::Duration::days(15)),
425 window_duration: chrono::Duration::days(30),
426 }),
427 };
428 let values = placeholders_at(&snap, now);
429 assert_eq!(values["zai_mcp_elapsed"], "50");
430 assert_eq!(values["zai_mcp_pace"], "↑");
431 assert_eq!(values["zai_mcp_pace_indicator"], "↑");
432 }
433
434 #[test]
435 fn elapsed_placeholders_empty_without_window() {
436 let snap = ZaiSnapshot {
437 plan: "GLM Coding Unknown".into(),
438 session: None,
439 weekly: None,
440 mcp: None,
441 };
442 let values = placeholders_at(&snap, Utc::now());
443 assert_eq!(values["session_elapsed"], "");
444 assert_eq!(values["weekly_elapsed"], "");
445 assert_eq!(values["zai_session_pace"], "");
446 assert_eq!(values["zai_mcp_elapsed"], "");
447 assert_eq!(values["zai_mcp_pace"], "");
448 }
449
450 #[test]
451 fn tooltip_contains_all_windows_present() {
452 let snap = sample_snap();
453 let oc = outcome(snap.clone());
454 let out = render(&oc, &snap, &Theme::default(), &opts(), Utc::now());
455 assert!(out.tooltip.contains("Session"));
456 assert!(out.tooltip.contains("Weekly"));
457 assert!(!out.tooltip.contains("MCP"));
458 }
459
460 #[test]
461 fn empty_snapshot_renders_no_windows_message() {
462 let snap = ZaiSnapshot {
463 plan: "GLM Coding Unknown".into(),
464 session: None,
465 weekly: None,
466 mcp: None,
467 };
468 let oc = outcome(snap.clone());
469 let out = render(&oc, &snap, &Theme::default(), &opts(), Utc::now());
470 assert!(out.tooltip.contains("no usage windows reported"));
471 }
472
473 #[test]
474 fn severity_picks_worst_window() {
475 let mut snap = sample_snap();
476 snap.weekly.as_mut().unwrap().utilization_pct = 95;
477 assert_eq!(severity(&snap), PaceSeverity::Critical);
478 }
479
480 #[test]
481 fn custom_tooltip_uses_placeholders() {
482 let snap = sample_snap();
483 let oc = outcome(snap.clone());
484 let mut o = opts();
485 o.tooltip_format = Some("S:{zai_session_pct} W:{zai_weekly_pct}".into());
486 let out = render(&oc, &snap, &Theme::default(), &o, Utc::now());
487 assert_eq!(out.tooltip, "S:42 W:15");
488 }
489
490 fn fixed_now() -> DateTime<Utc> {
491 use chrono::TimeZone;
492 Utc.with_ymd_and_hms(2026, 8, 25, 12, 0, 0).unwrap()
493 }
494
495 fn paced_snap() -> ZaiSnapshot {
496 let now = fixed_now();
497 ZaiSnapshot {
498 plan: "GLM Coding Pro".into(),
499 session: Some(UsageWindow {
501 utilization_pct: 80,
502 resets_at: Some(now + chrono::Duration::hours(3)),
503 window_duration: chrono::Duration::hours(5),
504 }),
505 weekly: Some(UsageWindow {
507 utilization_pct: 3,
508 resets_at: None,
509 window_duration: chrono::Duration::days(7),
510 }),
511 mcp: None,
512 }
513 }
514
515 #[test]
519 fn tooltip_shows_the_pace_arrow_next_to_each_percentage() {
520 let snap = paced_snap();
521 let out = render(
522 &outcome(snap.clone()),
523 &snap,
524 &Theme::default(),
525 &opts(),
526 fixed_now(),
527 );
528 assert!(out.tooltip.contains("80% ↑"), "{}", out.tooltip);
529 assert!(out.tooltip.contains("3% →"), "{}", out.tooltip);
531 }
532
533 #[test]
535 fn the_elapsed_marker_stays_behind_tooltip_pace_pts() {
536 let snap = paced_snap();
537 let plain = render(
538 &outcome(snap.clone()),
539 &snap,
540 &Theme::default(),
541 &opts(),
542 fixed_now(),
543 );
544 let paced = render(
545 &outcome(snap.clone()),
546 &snap,
547 &Theme::default(),
548 &RenderOpts {
549 tooltip_pace_pts: true,
550 ..opts()
551 },
552 fixed_now(),
553 );
554 assert_ne!(
555 plain.tooltip, paced.tooltip,
556 "the marker should redraw the bars"
557 );
558 assert!(paced.tooltip.contains("80% ↑"), "{}", paced.tooltip);
559 }
560}