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, push_window, 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, 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 now: DateTime<Utc>,
179) -> String {
180 let blue = &theme.blue;
181 let dim = &theme.dim;
182 let mut lines: Vec<TooltipLine> = Vec::new();
183 lines.push(TooltipLine::Center(format!(
184 "<span font_weight='bold' foreground='{blue}'>{plan}</span>",
185 plan = escape(&snap.plan)
186 )));
187 lines.push(TooltipLine::Sep);
188 lines.push(TooltipLine::Body("".into()));
189
190 if let Some(w) = snap.session.as_ref() {
191 push_window(&mut lines, " Session (5h)", w, theme, now, None);
192 }
193 if let Some(w) = snap.weekly.as_ref() {
194 if snap.session.is_some() {
195 lines.push(TooltipLine::Body("".into()));
196 }
197 push_window(&mut lines, " Weekly", w, theme, now, None);
198 }
199 if let Some(w) = snap.mcp.as_ref() {
200 lines.push(TooltipLine::Body("".into()));
201 lines.push(TooltipLine::Sep);
202 push_window(&mut lines, " MCP tools (monthly)", w, theme, now, None);
203 }
204 if snap.session.is_none() && snap.weekly.is_none() && snap.mcp.is_none() {
205 lines.push(TooltipLine::Body(format!(
206 " <span foreground='{dim}'>no usage windows reported</span>"
207 )));
208 }
209
210 if let Some((code, msg)) = outcome.last_error.as_ref()
211 && *code != 0
212 {
213 let (icon, ecolor) = if *code >= 500 {
214 ("", theme.red.as_str())
215 } else {
216 ("", theme.orange.as_str())
217 };
218 lines.push(TooltipLine::Body("".into()));
219 lines.push(TooltipLine::Sep);
220 lines.push(TooltipLine::Body(format!(
221 " <span foreground='{ecolor}'> {icon} HTTP {code}</span>"
222 )));
223 lines.push(TooltipLine::Body(format!(
224 " <span foreground='{dim}'>{}</span>",
225 escape(msg)
226 )));
227 }
228
229 let updated = updated_at_hm(now, outcome.cache_age);
230 lines.push(TooltipLine::Body("".into()));
231 lines.push(TooltipLine::Sep);
232 lines.push(TooltipLine::Body(format!(
233 " <span foreground='{dim}'> Updated {updated}</span>"
234 )));
235
236 render_bordered(&lines, theme)
237}
238
239impl From<FetchOutcome> for VendorOutcome {
240 fn from(o: FetchOutcome) -> Self {
241 Self {
242 snapshot: crate::usage::VendorSnapshot::Zai(o.snapshot),
243 stale: o.stale,
244 last_error: o.last_error,
245 cache_age: o.cache_age,
246 }
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::usage::{UsageWindow, ZaiSnapshot};
254
255 fn sample_snap() -> ZaiSnapshot {
256 let now = Utc::now();
257 ZaiSnapshot {
258 plan: "GLM Coding Pro".into(),
259 session: Some(UsageWindow {
260 utilization_pct: 42,
261 resets_at: Some(now + chrono::Duration::hours(2)),
262 window_duration: chrono::Duration::hours(5),
263 }),
264 weekly: Some(UsageWindow {
265 utilization_pct: 15,
266 resets_at: Some(now + chrono::Duration::days(3)),
267 window_duration: chrono::Duration::days(7),
268 }),
269 mcp: None,
270 }
271 }
272
273 fn outcome(s: ZaiSnapshot) -> VendorOutcome {
274 VendorOutcome {
275 snapshot: crate::usage::VendorSnapshot::Zai(s),
276 stale: false,
277 last_error: None,
278 cache_age: Some(std::time::Duration::from_secs(10)),
279 }
280 }
281
282 fn opts() -> RenderOpts {
283 RenderOpts {
284 format: None,
285 tooltip_format: None,
286 icon: None,
287 pace_tolerance: 5,
288 format_pace_color: false,
289 tooltip_pace_pts: false,
290 }
291 }
292
293 fn placeholders_at(snap: &ZaiSnapshot, now: DateTime<Utc>) -> HashMap<&'static str, String> {
294 build_placeholders_with_tolerance(snap, opts().pace_tolerance, now)
295 }
296
297 #[test]
298 fn default_format_renders_session_pct() {
299 let snap = sample_snap();
300 let oc = outcome(snap.clone());
301 let out = render(&oc, &snap, &Theme::default(), &opts(), Utc::now());
302 assert!(out.text.contains("42%"));
303 }
304
305 #[test]
306 fn elapsed_placeholders_follow_window_progress() {
307 let now = Utc::now();
310 let snap = ZaiSnapshot {
311 plan: "GLM Coding Pro".into(),
312 session: Some(UsageWindow {
313 utilization_pct: 42,
314 resets_at: Some(now + chrono::Duration::hours(2)),
315 window_duration: chrono::Duration::hours(5),
316 }),
317 weekly: Some(UsageWindow {
318 utilization_pct: 15,
319 resets_at: Some(now + chrono::Duration::days(3)),
320 window_duration: chrono::Duration::days(7),
321 }),
322 mcp: None,
323 };
324 let values = placeholders_at(&snap, now);
325 assert_eq!(values["session_elapsed"], "60");
326 assert_eq!(values["weekly_elapsed"], "57");
327 }
328
329 #[test]
330 fn pace_placeholders_follow_usage_vs_elapsed() {
331 let now = Utc::now();
334 let snap = ZaiSnapshot {
335 plan: "GLM Coding Pro".into(),
336 session: Some(UsageWindow {
337 utilization_pct: 80,
338 resets_at: Some(now + chrono::Duration::hours(2)),
339 window_duration: chrono::Duration::hours(5),
340 }),
341 weekly: Some(UsageWindow {
342 utilization_pct: 15,
343 resets_at: Some(now + chrono::Duration::days(3)),
344 window_duration: chrono::Duration::days(7),
345 }),
346 mcp: None,
347 };
348 let values = placeholders_at(&snap, now);
349 assert_eq!(values["zai_session_pace"], "↑");
350 assert_eq!(values["zai_session_pace_indicator"], "↑");
351 assert_eq!(values["zai_weekly_pace"], "↓");
352 assert_eq!(values["zai_weekly_pace_indicator"], "↓");
353 assert_eq!(values["zai_session_elapsed"], "60");
354 assert_eq!(values["zai_weekly_elapsed"], "57");
355 }
356
357 #[test]
358 fn public_builder_keeps_the_default_tolerance_api() {
359 let now = Utc::now();
360 let snap = sample_snap();
361 assert_eq!(
362 build_placeholders(&snap, now),
363 build_placeholders_with_tolerance(&snap, pacing::DEFAULT_TOLERANCE, now)
364 );
365 }
366
367 #[test]
368 fn custom_tolerance_changes_the_ratio_pace() {
369 let now = Utc::now();
370 let snap = ZaiSnapshot {
371 plan: "GLM Coding Pro".into(),
372 session: Some(UsageWindow {
373 utilization_pct: 53,
374 resets_at: Some(now + chrono::Duration::minutes(150)),
375 window_duration: chrono::Duration::hours(5),
376 }),
377 weekly: None,
378 mcp: None,
379 };
380 assert_eq!(
381 build_placeholders_with_tolerance(&snap, 5, now)["zai_session_pace"],
382 "↑"
383 );
384 assert_eq!(
385 build_placeholders_with_tolerance(&snap, 10, now)["zai_session_pace"],
386 "→"
387 );
388 }
389
390 #[test]
391 fn present_window_without_reset_uses_documented_neutral_pacing() {
392 let now = Utc::now();
393 let mut snap = sample_snap();
394 snap.session.as_mut().unwrap().resets_at = None;
395 let values = placeholders_at(&snap, now);
396 assert_eq!(values["zai_session_reset"], "—");
397 assert_eq!(values["zai_session_elapsed"], "0");
398 assert_eq!(values["zai_session_pace"], "→");
399 assert_eq!(values["zai_session_pace_indicator"], "→");
400 }
401
402 #[test]
403 fn mcp_pace_placeholders_follow_usage_vs_elapsed() {
404 let now = Utc::now();
406 let snap = ZaiSnapshot {
407 plan: "GLM Coding Pro".into(),
408 session: None,
409 weekly: None,
410 mcp: Some(UsageWindow {
411 utilization_pct: 70,
412 resets_at: Some(now + chrono::Duration::days(15)),
413 window_duration: chrono::Duration::days(30),
414 }),
415 };
416 let values = placeholders_at(&snap, now);
417 assert_eq!(values["zai_mcp_elapsed"], "50");
418 assert_eq!(values["zai_mcp_pace"], "↑");
419 assert_eq!(values["zai_mcp_pace_indicator"], "↑");
420 }
421
422 #[test]
423 fn elapsed_placeholders_empty_without_window() {
424 let snap = ZaiSnapshot {
425 plan: "GLM Coding Unknown".into(),
426 session: None,
427 weekly: None,
428 mcp: None,
429 };
430 let values = placeholders_at(&snap, Utc::now());
431 assert_eq!(values["session_elapsed"], "");
432 assert_eq!(values["weekly_elapsed"], "");
433 assert_eq!(values["zai_session_pace"], "");
434 assert_eq!(values["zai_mcp_elapsed"], "");
435 assert_eq!(values["zai_mcp_pace"], "");
436 }
437
438 #[test]
439 fn tooltip_contains_all_windows_present() {
440 let snap = sample_snap();
441 let oc = outcome(snap.clone());
442 let out = render(&oc, &snap, &Theme::default(), &opts(), Utc::now());
443 assert!(out.tooltip.contains("Session"));
444 assert!(out.tooltip.contains("Weekly"));
445 assert!(!out.tooltip.contains("MCP"));
446 }
447
448 #[test]
449 fn empty_snapshot_renders_no_windows_message() {
450 let snap = ZaiSnapshot {
451 plan: "GLM Coding Unknown".into(),
452 session: None,
453 weekly: None,
454 mcp: None,
455 };
456 let oc = outcome(snap.clone());
457 let out = render(&oc, &snap, &Theme::default(), &opts(), Utc::now());
458 assert!(out.tooltip.contains("no usage windows reported"));
459 }
460
461 #[test]
462 fn severity_picks_worst_window() {
463 let mut snap = sample_snap();
464 snap.weekly.as_mut().unwrap().utilization_pct = 95;
465 assert_eq!(severity(&snap), PaceSeverity::Critical);
466 }
467
468 #[test]
469 fn custom_tooltip_uses_placeholders() {
470 let snap = sample_snap();
471 let oc = outcome(snap.clone());
472 let mut o = opts();
473 o.tooltip_format = Some("S:{zai_session_pct} W:{zai_weekly_pct}".into());
474 let out = render(&oc, &snap, &Theme::default(), &o, Utc::now());
475 assert_eq!(out.tooltip, "S:42 W:15");
476 }
477}