1use serde_json::{Value, json};
7
8use super::icon::Severity;
9use crate::display::sanitize_untrusted_field;
10
11pub const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_secs(300);
15
16#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct HostFacts {
21 pub refresh_secs: u64,
23 pub shortcut: String,
25 pub shortcut_error: String,
27 pub startup_enabled: bool,
28 pub update: Option<UpdateFact>,
30 pub update_checked_at: i64,
32 pub updates: String,
34 pub version: String,
35}
36
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct UpdateFact {
40 pub error: String,
42 pub state: String,
44 pub url: String,
46 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
76pub 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
133fn 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
171pub 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 #[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 assert_eq!(
236 entries[1]["sign_in"],
237 json!(crate::vendor::VendorId::Openai.sign_in_hint())
238 );
239 assert_eq!(
241 entries[2]["sign_in"],
242 json!(crate::vendor::VendorId::Cursor.sign_in_hint())
243 );
244 assert!(entries[3].get("sign_in").is_none(), "{:?}", entries[3]);
246 }
247
248 #[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}