Skip to main content

ai_usagebar/tray/
payload.rs

1//! Wrap `usage --json` for the popover and derive the tray icon severity.
2//!
3//! Pure JSON in, JSON out — no HWND, no `$HOME`, no clock. The host supplies
4//! `now_ms` so tests pin the countdown.
5
6use serde_json::{Value, json};
7
8use super::icon::Severity;
9use crate::display::sanitize_untrusted_field;
10
11/// Default poll interval when `[tray] refresh_minutes` is unset. The provider
12/// cache TTL stays 60 s; the tray just asks less often. The live value is
13/// `HostFacts::refresh_secs`.
14pub const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(300);
15
16/// Everything the host knows that is not part of the usage report: its own
17/// version, the Run-key state, the registered shortcut and the update
18/// machinery. One struct so a new fact does not grow `wrap_report`'s arity.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HostFacts {
21    /// Seconds between full reports; `[tray] refresh_minutes` × 60.
22    pub refresh_secs: u64,
23    /// Canonical "Ctrl+Shift+U" spelling of the registered shortcut, or empty.
24    pub shortcut: String,
25    /// Why the last `set-shortcut` was refused (already taken, unparsable), or empty.
26    pub shortcut_error: String,
27    pub startup_enabled: bool,
28    /// Latest known release when it is newer than `version`.
29    pub update: Option<UpdateFact>,
30    /// Wall-clock ms of the last successful or failed release check, 0 = never.
31    pub update_checked_at: i64,
32    /// "auto" | "notify" | "off".
33    pub updates: String,
34    pub version: String,
35}
36
37/// State of a newer release as the popover renders it.
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct UpdateFact {
40    /// Human-readable reason when `state` is "failed", or empty.
41    pub error: String,
42    /// "checking" | "available" | "downloading" | "installing" | "failed".
43    pub state: String,
44    /// Release page for the human; never opened by the host itself.
45    pub url: String,
46    /// Bare "X.Y.Z".
47    pub version: String,
48}
49
50impl HostFacts {
51    pub fn new(version: &str, startup_enabled: bool) -> Self {
52        Self {
53            startup_enabled,
54            updates: "notify".into(),
55            version: version.into(),
56            ..Self::default()
57        }
58    }
59}
60
61impl Default for HostFacts {
62    fn default() -> Self {
63        Self {
64            refresh_secs: POLL_INTERVAL.as_secs(),
65            shortcut: String::new(),
66            shortcut_error: String::new(),
67            startup_enabled: false,
68            update: None,
69            update_checked_at: 0,
70            updates: String::new(),
71            version: String::new(),
72        }
73    }
74}
75
76/// Build the object the WebView's `apply` function consumes.
77pub fn wrap_report(
78    report_json: &str,
79    facts: &HostFacts,
80    now_ms: i64,
81    host_error: Option<&str>,
82) -> Value {
83    let poll_ms = i64::try_from(facts.refresh_secs)
84        .unwrap_or(i64::MAX / 1_000)
85        .saturating_mul(1_000);
86    let update = facts.update.as_ref().map(|u| {
87        json!({
88            "version": sanitize_untrusted_field(&u.version),
89            "url": sanitize_untrusted_field(&u.url),
90            "state": u.state,
91            "error": sanitize_untrusted_field(&u.error),
92        })
93    });
94    let mut payload = json!({
95        "version": facts.version,
96        "generated_at": now_ms,
97        "next_refresh_at": now_ms.saturating_add(poll_ms),
98        "refresh_minutes": facts.refresh_secs / 60,
99        "startup_enabled": facts.startup_enabled,
100        "shortcut": facts.shortcut,
101        "shortcut_error": sanitize_untrusted_field(&facts.shortcut_error),
102        "updates": facts.updates,
103        "update": update,
104        "update_checked_at": facts.update_checked_at,
105        "host_error": host_error.map(sanitize_untrusted_field),
106        "primary": Value::Null,
107        "entries": [],
108    });
109    if host_error.is_some() {
110        return payload;
111    }
112    match serde_json::from_str::<Value>(report_json) {
113        Ok(Value::Object(map)) => {
114            if let Some(obj) = payload.as_object_mut() {
115                if let Some(primary) = map.get("primary") {
116                    obj.insert("primary".into(), primary.clone());
117                }
118                if let Some(entries) = map.get("entries") {
119                    obj.insert("entries".into(), with_sign_in_hints(entries));
120                }
121            }
122            payload
123        }
124        _ => {
125            payload["host_error"] = json!(sanitize_untrusted_field(
126                "The usage report did not contain valid JSON."
127            ));
128            payload
129        }
130    }
131}
132
133/// Attach each entry's sign-in sentence from [`VendorId::sign_in_hint`].
134///
135/// The popover needs a "how do I fix this?" line on an error card. It must not
136/// keep its own table for that: a JS object literal silently omits a provider
137/// nobody remembered, while the Rust match cannot compile without one. The
138/// entry id is `vendor` or `vendor@account`, so the slug is the part before
139/// `@`; an id that matches no vendor is left without a hint rather than guessed.
140fn with_sign_in_hints(entries: &Value) -> Value {
141    let Some(list) = entries.as_array() else {
142        return entries.clone();
143    };
144    Value::Array(
145        list.iter()
146            .map(|entry| {
147                let mut entry = entry.clone();
148                let slug = entry
149                    .get("id")
150                    .and_then(Value::as_str)
151                    .map(|id| id.split('@').next().unwrap_or(id).to_ascii_lowercase());
152                let hint = slug.and_then(|slug| {
153                    crate::vendor::VendorId::all()
154                        .iter()
155                        .find(|v| v.slug() == slug)
156                        .map(|v| v.sign_in_hint())
157                });
158                if let (Some(hint), Some(obj)) = (hint, entry.as_object_mut()) {
159                    obj.insert("sign_in".into(), json!(hint));
160                }
161                entry
162            })
163            .collect(),
164    )
165}
166
167pub fn host_payload(value: &Value) -> String {
168    value.to_string()
169}
170
171/// Worst severity across every metric, treating fetch/host errors as critical.
172pub fn worst_severity(payload: &Value) -> Severity {
173    if payload
174        .get("host_error")
175        .and_then(Value::as_str)
176        .is_some_and(|s| !s.is_empty())
177    {
178        return Severity::Critical;
179    }
180    let mut worst = Severity::Low;
181    let Some(entries) = payload.get("entries").and_then(Value::as_array) else {
182        return worst;
183    };
184    for entry in entries {
185        if entry.get("status").and_then(Value::as_str) == Some("error")
186            || entry
187                .get("error")
188                .and_then(Value::as_str)
189                .is_some_and(|s| !s.is_empty())
190        {
191            return Severity::Critical;
192        }
193        let Some(sections) = entry.get("sections").and_then(Value::as_array) else {
194            continue;
195        };
196        for section in sections {
197            if section.get("type").and_then(Value::as_str) != Some("metric") {
198                continue;
199            }
200            if let Some(sev) = section
201                .get("severity")
202                .and_then(Value::as_str)
203                .and_then(Severity::from_report_str)
204                && sev.rank() > worst.rank()
205            {
206                worst = sev;
207            }
208        }
209    }
210    worst
211}
212
213#[cfg(test)]
214mod tests {
215
216    /// The popover renders "how do I fix this?" on an error card. That sentence
217    /// must come from the host: the frontend kept its own table first, and it
218    /// disagreed with `VendorId` for five of eight providers before shipping.
219    #[test]
220    fn every_entry_carries_its_sign_in_hint_from_the_vendor() {
221        let report = r#"{"primary":"anthropic","entries":[
222            {"id":"anthropic","status":"error","error":"not signed in"},
223            {"id":"openai@work","status":"error","error":"not signed in"},
224            {"id":"cursor","status":"ready"},
225            {"id":"custom:mytool","status":"ready"}
226        ]}"#;
227        let payload = wrap_report(report, &facts("1.0.0", false), 0, None);
228        let entries = payload["entries"].as_array().expect("entries");
229
230        assert_eq!(
231            entries[0]["sign_in"],
232            json!(crate::vendor::VendorId::Anthropic.sign_in_hint())
233        );
234        // "vendor@account" resolves on the vendor half.
235        assert_eq!(
236            entries[1]["sign_in"],
237            json!(crate::vendor::VendorId::Openai.sign_in_hint())
238        );
239        // A vendor that has no CLI login still says how to sign in.
240        assert_eq!(
241            entries[2]["sign_in"],
242            json!(crate::vendor::VendorId::Cursor.sign_in_hint())
243        );
244        // An id that is not a built-in vendor gets no invented hint.
245        assert!(entries[3].get("sign_in").is_none(), "{:?}", entries[3]);
246    }
247
248    /// Every id `usage --json` can emit must resolve, or the popover shows a
249    /// generic line for a provider we do know how to sign in.
250    #[test]
251    fn every_vendor_slug_resolves_to_a_hint() {
252        for vendor in crate::vendor::VendorId::all() {
253            let report = format!(
254                r#"{{"entries":[{{"id":"{}","status":"error","error":"x"}}]}}"#,
255                vendor.slug()
256            );
257            let payload = wrap_report(&report, &facts("1.0.0", false), 0, None);
258            let hint = payload["entries"][0]["sign_in"].as_str().unwrap_or("");
259            assert!(!hint.is_empty(), "{} has no sign-in hint", vendor.slug());
260        }
261    }
262    use super::*;
263
264    fn sample_report() -> String {
265        json!({
266            "primary": "anthropic",
267            "entries": [
268                {
269                    "id": "anthropic",
270                    "name": "anthropic",
271                    "display_name": "Claude",
272                    "short_name": "cld",
273                    "plan": "Team 5x",
274                    "status": "ready",
275                    "error": null,
276                    "stale": false,
277                    "sections": [
278                        {
279                            "type": "metric",
280                            "label": "Weekly",
281                            "percent": 19,
282                            "value": "19%",
283                            "detail": "Resets in 1d 16h",
284                            "severity": "low",
285                            "reset_at": null
286                        },
287                        {
288                            "type": "metric",
289                            "label": "Session",
290                            "percent": 0,
291                            "value": "0%",
292                            "detail": "",
293                            "severity": "low",
294                            "reset_at": null
295                        }
296                    ]
297                },
298                {
299                    "id": "openai",
300                    "short_name": "gpt",
301                    "status": "ready",
302                    "error": null,
303                    "sections": [{
304                        "type": "metric",
305                        "label": "Session",
306                        "percent": 91,
307                        "value": "91%",
308                        "detail": "",
309                        "severity": "critical",
310                        "reset_at": null
311                    }]
312                }
313            ]
314        })
315        .to_string()
316    }
317
318    fn facts(version: &str, startup_enabled: bool) -> HostFacts {
319        HostFacts::new(version, startup_enabled)
320    }
321
322    #[test]
323    fn wrap_copies_entries_and_stamps_refresh() {
324        let payload = wrap_report(&sample_report(), &facts("1.10.0", true), 1_000, None);
325        assert_eq!(payload["version"], "1.10.0");
326        assert_eq!(payload["generated_at"], 1_000);
327        assert_eq!(payload["next_refresh_at"], 301_000);
328        assert_eq!(payload["refresh_minutes"], 5);
329        assert_eq!(payload["startup_enabled"], true);
330        assert_eq!(payload["shortcut"], "");
331        assert_eq!(payload["shortcut_error"], "");
332        assert_eq!(payload["updates"], "notify");
333        assert!(payload["update"].is_null());
334        assert_eq!(payload["update_checked_at"], 0);
335        assert!(payload["host_error"].is_null());
336        assert_eq!(payload["primary"], "anthropic");
337        assert_eq!(payload["entries"][0]["short_name"], "cld");
338    }
339
340    #[test]
341    fn wrap_carries_shortcut_and_update_facts_sanitized() {
342        let mut host = facts("1.10.0", false);
343        host.shortcut = "Ctrl+Shift+U".into();
344        host.shortcut_error = "already taken\u{1b}[31m".into();
345        host.updates = "auto".into();
346        host.update_checked_at = 42;
347        host.update = Some(UpdateFact {
348            error: String::new(),
349            state: "available".into(),
350            url: "https://github.com/akitaonrails/ai-usagebar/releases/tag/v1.11.0".into(),
351            version: "1.11.0".into(),
352        });
353        let payload = wrap_report(&sample_report(), &host, 0, None);
354        assert_eq!(payload["shortcut"], "Ctrl+Shift+U");
355        assert!(
356            !payload["shortcut_error"]
357                .as_str()
358                .unwrap()
359                .contains('\u{1b}')
360        );
361        assert_eq!(payload["updates"], "auto");
362        assert_eq!(payload["update_checked_at"], 42);
363        assert_eq!(payload["update"]["version"], "1.11.0");
364        assert_eq!(payload["update"]["state"], "available");
365        assert_eq!(payload["update"]["error"], "");
366    }
367
368    #[test]
369    fn host_error_wins_over_report_body() {
370        let payload = wrap_report(
371            "not-json",
372            &facts("1.0.0", false),
373            0,
374            Some("no vendors enabled"),
375        );
376        assert_eq!(payload["host_error"], "no vendors enabled");
377        assert_eq!(payload["entries"].as_array().unwrap().len(), 0);
378        assert_eq!(worst_severity(&payload), Severity::Critical);
379    }
380
381    #[test]
382    fn invalid_report_json_becomes_a_host_error() {
383        let payload = wrap_report("{", &facts("1.0.0", false), 0, None);
384        assert!(
385            payload["host_error"]
386                .as_str()
387                .unwrap()
388                .contains("valid JSON")
389        );
390        assert_eq!(worst_severity(&payload), Severity::Critical);
391    }
392
393    #[test]
394    fn worst_severity_is_the_hottest_metric_not_the_primary() {
395        let payload = wrap_report(&sample_report(), &facts("1.10.0", false), 0, None);
396        assert_eq!(worst_severity(&payload), Severity::Critical);
397    }
398
399    #[test]
400    fn entry_error_is_critical_and_lands_in_the_tooltip() {
401        let report = json!({
402            "primary": "openai",
403            "entries": [{
404                "id": "openai",
405                "short_name": "gpt",
406                "status": "error",
407                "error": "not signed in",
408                "sections": []
409            }]
410        })
411        .to_string();
412        let payload = wrap_report(&report, &facts("1.10.0", false), 0, None);
413        assert_eq!(worst_severity(&payload), Severity::Critical);
414    }
415
416    #[test]
417    fn empty_report_uses_a_generic_tooltip() {
418        let payload = wrap_report("{}", &facts("1.0.0", false), 0, None);
419        assert_eq!(worst_severity(&payload), Severity::Low);
420        assert_eq!(POLL_INTERVAL.as_secs(), 300);
421    }
422
423    #[test]
424    fn next_refresh_follows_the_configured_interval() {
425        let mut host = facts("1.10.0", false);
426        host.refresh_secs = 60;
427        let payload = wrap_report(&sample_report(), &host, 1_000, None);
428        assert_eq!(payload["next_refresh_at"], 61_000);
429        assert_eq!(payload["refresh_minutes"], 1);
430
431        host.refresh_secs = 600;
432        let payload = wrap_report(&sample_report(), &host, 1_000, None);
433        assert_eq!(payload["next_refresh_at"], 601_000);
434        assert_eq!(payload["refresh_minutes"], 10);
435    }
436}