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.
16///
17/// Shells out to `pkill -RTMIN+13 waybar` on Unix. Waybar is a Wayland-only
18/// program, so off Unix (e.g. Windows) there is no process to signal and no
19/// `pkill`; the body is gated out and the call becomes a no-op — consumers
20/// there (such as a tray app) refresh on their own polling interval instead.
21pub fn request_refresh() {
22    #[cfg(unix)]
23    {
24        let _ = std::process::Command::new("pkill")
25            .args([REFRESH_SIGNAL, PROCESS_NAME])
26            .status();
27    }
28}
29
30#[derive(Debug, Clone, Serialize)]
31pub struct WaybarOutput {
32    pub text: String,
33    pub tooltip: String,
34    pub class: Class,
35}
36
37#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
38#[serde(rename_all = "lowercase")]
39pub enum Class {
40    Low,
41    Mid,
42    High,
43    Critical,
44}
45
46impl From<crate::pacing::PaceSeverity> for Class {
47    fn from(s: crate::pacing::PaceSeverity) -> Self {
48        match s {
49            crate::pacing::PaceSeverity::Low => Class::Low,
50            crate::pacing::PaceSeverity::Mid => Class::Mid,
51            crate::pacing::PaceSeverity::High => Class::High,
52            crate::pacing::PaceSeverity::Critical => Class::Critical,
53        }
54    }
55}
56
57impl WaybarOutput {
58    /// One-line JSON suitable for Waybar `return-type: "json"`.
59    pub fn to_json_line(&self) -> String {
60        // serde_json never produces newlines for `to_string`; the trailing
61        // `\n` is what Waybar splits on.
62        format!("{}\n", serde_json::to_string(self).unwrap_or_default())
63    }
64
65    /// Fallback for catastrophic errors — claudebar's `die()` (claudebar:177-185).
66    /// Always produces a valid Waybar JSON document.
67    pub fn error(msg: &str) -> Self {
68        Self {
69            text: "⚠".into(),
70            tooltip: msg.into(),
71            class: Class::Critical,
72        }
73    }
74
75    /// Neutral "Loading…" widget — claudebar's `loading_network`
76    /// (claudebar:190-196). Used when a transient network failure leaves us
77    /// with no usable cache.
78    pub fn loading(prefix_icon: Option<&str>) -> Self {
79        let text = match prefix_icon {
80            Some(ic) if !ic.is_empty() => format!("{ic} Loading…"),
81            _ => "Loading…".to_string(),
82        };
83        Self {
84            text,
85            tooltip: "Usage data is waiting for network.\nWill retry on the next Waybar update."
86                .into(),
87            class: Class::Low,
88        }
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::pacing::PaceSeverity;
96
97    #[test]
98    fn serializes_compact_json() {
99        let out = WaybarOutput {
100            text: "42% · 1h 30m".into(),
101            tooltip: "<b>Claude Max 5x</b>".into(),
102            class: Class::Mid,
103        };
104        let line = out.to_json_line();
105        assert!(line.ends_with('\n'));
106        assert!(line.contains(r#""class":"mid""#));
107        assert!(line.contains(r#""text":"42% · 1h 30m""#));
108    }
109
110    #[test]
111    fn class_serializes_lowercase() {
112        assert_eq!(
113            serde_json::to_string(&Class::Critical).unwrap(),
114            r#""critical""#
115        );
116    }
117
118    #[test]
119    fn severity_maps_to_class() {
120        assert_eq!(Class::from(PaceSeverity::Low), Class::Low);
121        assert_eq!(Class::from(PaceSeverity::Mid), Class::Mid);
122        assert_eq!(Class::from(PaceSeverity::High), Class::High);
123        assert_eq!(Class::from(PaceSeverity::Critical), Class::Critical);
124    }
125
126    #[test]
127    fn error_helper_always_valid() {
128        let line = WaybarOutput::error("Token expired\nRun claude").to_json_line();
129        // Should round-trip back to JSON without errors.
130        let v: serde_json::Value = serde_json::from_str(line.trim()).unwrap();
131        assert_eq!(v["text"], "⚠");
132        assert_eq!(v["class"], "critical");
133        assert!(v["tooltip"].as_str().unwrap().contains("Token expired"));
134    }
135
136    #[test]
137    fn loading_with_icon_prepends() {
138        let l = WaybarOutput::loading(Some("󰚩"));
139        assert!(l.text.starts_with("󰚩 "));
140        assert!(l.text.contains("Loading"));
141    }
142
143    #[test]
144    fn loading_without_icon_is_plain() {
145        let l = WaybarOutput::loading(None);
146        assert_eq!(l.text, "Loading…");
147    }
148}