Skip to main content

ai_usagebar/
waybar.rs

1//! Waybar JSON output: `{text, tooltip, class}`.
2//!
3//! Per the project's contract (claudebar's CLAUDE.md), the widget MUST always
4//! exit 0. This struct + its serializer never panic on valid input; the
5//! callers handle the "always emit something" invariant.
6
7use serde::Serialize;
8
9/// Waybar refresh signal used by the sample module config (`signal: 13`).
10pub const REFRESH_SIGNAL: &str = "-RTMIN+13";
11
12/// Process name used for best-effort refreshes after cycling/saving settings.
13pub const PROCESS_NAME: &str = "waybar";
14
15/// Best-effort Waybar refresh. Failing is harmless when Waybar is not running.
16pub fn request_refresh() {
17    let _ = std::process::Command::new("pkill")
18        .args([REFRESH_SIGNAL, PROCESS_NAME])
19        .status();
20}
21
22#[derive(Debug, Clone, Serialize)]
23pub struct WaybarOutput {
24    pub text: String,
25    pub tooltip: String,
26    pub class: Class,
27}
28
29#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
30#[serde(rename_all = "lowercase")]
31pub enum Class {
32    Low,
33    Mid,
34    High,
35    Critical,
36}
37
38impl From<crate::pacing::PaceSeverity> for Class {
39    fn from(s: crate::pacing::PaceSeverity) -> Self {
40        match s {
41            crate::pacing::PaceSeverity::Low => Class::Low,
42            crate::pacing::PaceSeverity::Mid => Class::Mid,
43            crate::pacing::PaceSeverity::High => Class::High,
44            crate::pacing::PaceSeverity::Critical => Class::Critical,
45        }
46    }
47}
48
49impl WaybarOutput {
50    /// One-line JSON suitable for Waybar `return-type: "json"`.
51    pub fn to_json_line(&self) -> String {
52        // serde_json never produces newlines for `to_string`; the trailing
53        // `\n` is what Waybar splits on.
54        format!("{}\n", serde_json::to_string(self).unwrap_or_default())
55    }
56
57    /// Fallback for catastrophic errors — claudebar's `die()` (claudebar:177-185).
58    /// Always produces a valid Waybar JSON document.
59    pub fn error(msg: &str) -> Self {
60        Self {
61            text: "⚠".into(),
62            tooltip: msg.into(),
63            class: Class::Critical,
64        }
65    }
66
67    /// Neutral "Loading…" widget — claudebar's `loading_network`
68    /// (claudebar:190-196). Used when a transient network failure leaves us
69    /// with no usable cache.
70    pub fn loading(prefix_icon: Option<&str>) -> Self {
71        let text = match prefix_icon {
72            Some(ic) if !ic.is_empty() => format!("{ic} Loading…"),
73            _ => "Loading…".to_string(),
74        };
75        Self {
76            text,
77            tooltip: "Usage data is waiting for network.\nWill retry on the next Waybar update."
78                .into(),
79            class: Class::Low,
80        }
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use crate::pacing::PaceSeverity;
88
89    #[test]
90    fn serializes_compact_json() {
91        let out = WaybarOutput {
92            text: "42% · 1h 30m".into(),
93            tooltip: "<b>Claude Max 5x</b>".into(),
94            class: Class::Mid,
95        };
96        let line = out.to_json_line();
97        assert!(line.ends_with('\n'));
98        assert!(line.contains(r#""class":"mid""#));
99        assert!(line.contains(r#""text":"42% · 1h 30m""#));
100    }
101
102    #[test]
103    fn class_serializes_lowercase() {
104        assert_eq!(
105            serde_json::to_string(&Class::Critical).unwrap(),
106            r#""critical""#
107        );
108    }
109
110    #[test]
111    fn severity_maps_to_class() {
112        assert_eq!(Class::from(PaceSeverity::Low), Class::Low);
113        assert_eq!(Class::from(PaceSeverity::Mid), Class::Mid);
114        assert_eq!(Class::from(PaceSeverity::High), Class::High);
115        assert_eq!(Class::from(PaceSeverity::Critical), Class::Critical);
116    }
117
118    #[test]
119    fn error_helper_always_valid() {
120        let line = WaybarOutput::error("Token expired\nRun claude").to_json_line();
121        // Should round-trip back to JSON without errors.
122        let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
123        assert_eq!(v["text"], "⚠");
124        assert_eq!(v["class"], "critical");
125        assert!(v["tooltip"].as_str().unwrap().contains("Token expired"));
126    }
127
128    #[test]
129    fn loading_with_icon_prepends() {
130        let l = WaybarOutput::loading(Some("󰚩"));
131        assert!(l.text.starts_with("󰚩 "));
132        assert!(l.text.contains("Loading"));
133    }
134
135    #[test]
136    fn loading_without_icon_is_plain() {
137        let l = WaybarOutput::loading(None);
138        assert_eq!(l.text, "Loading…");
139    }
140}