Skip to main content

ai_usagebar/zai/
vendor.rs

1//! Z.AI renderer — bar text + bordered Pango tooltip.
2
3use 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
21/// Build placeholders with the historical default pacing tolerance.
22///
23/// Keep this signature stable for library callers. Rendering uses the private
24/// tolerance-aware helper so `--pace-tolerance` still applies.
25pub 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    // A present window normally carries a reset, but the upstream response may
42    // omit it. `pacing::calc` deliberately returns neutral 0/arrow values in
43    // that case, matching the established OpenAI placeholder contract.
44    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        // Cross-vendor aliases for scroll-cycle friendly formats.
51        ("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/// Elapsed fraction and pace glyphs for one window, as ready placeholder
97/// values. Empty strings when the window is absent, mirroring the OpenAI
98/// renderer's convention; `pacing::calc` degrades to neutral 0/glyphs when
99/// the reset is unreported.
100#[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        // Fixed `now` on both sides so the fraction is exact: 2h left of a 5h
308        // window → 60% elapsed; 3d left of a 7d window → 57%.
309        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        // Session: 80% used vs 60% elapsed → ahead of pace. Weekly: 15% used
332        // vs 57% elapsed → under pace.
333        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        // 70% used vs 15d/30d = 50% elapsed → ahead of pace.
405        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}