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 pub accounts: Vec<AccountSwitchFact>,
38}
39
40#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct AccountSwitchFact {
44 pub vendor: String,
46 pub active: Option<String>,
49 pub labels: Vec<String>,
51 pub target: String,
54 pub switching: bool,
56 pub error: String,
58}
59
60#[derive(Debug, Clone, Default, PartialEq, Eq)]
62pub struct UpdateFact {
63 pub error: String,
65 pub state: String,
67 pub url: String,
69 pub version: String,
71}
72
73impl HostFacts {
74 pub fn new(version: &str, startup_enabled: bool) -> Self {
75 Self {
76 startup_enabled,
77 updates: "notify".into(),
78 version: version.into(),
79 ..Self::default()
80 }
81 }
82}
83
84impl Default for HostFacts {
85 fn default() -> Self {
86 Self {
87 refresh_secs: POLL_INTERVAL.as_secs(),
88 shortcut: String::new(),
89 shortcut_error: String::new(),
90 startup_enabled: false,
91 update: None,
92 update_checked_at: 0,
93 updates: String::new(),
94 version: String::new(),
95 accounts: Vec::new(),
96 }
97 }
98}
99
100fn repository_page() -> String {
103 let raw = crate::update::SOURCE_REPOSITORY
104 .trim()
105 .trim_end_matches('/');
106 let raw = raw.strip_suffix(".git").unwrap_or(raw);
107 if raw.starts_with("https://github.com/") {
108 raw.to_string()
109 } else {
110 String::new()
111 }
112}
113
114#[cfg(target_os = "macos")]
120pub fn fact_after_check(
121 outcome: Result<Option<crate::update::Release>, String>,
122) -> Option<UpdateFact> {
123 match outcome {
124 Ok(Some(release)) => Some(UpdateFact {
125 error: String::new(),
126 state: "available".into(),
127 url: release.html_url,
128 version: release.version,
129 }),
130 Ok(None) => None,
131 Err(error) => Some(UpdateFact {
132 error,
133 state: "failed".into(),
134 url: String::new(),
135 version: String::new(),
136 }),
137 }
138}
139
140fn host_os() -> &'static str {
141 if cfg!(target_os = "macos") {
142 "macos"
143 } else if cfg!(windows) {
144 "windows"
145 } else {
146 "linux"
147 }
148}
149
150pub fn wrap_report(
152 report_json: &str,
153 facts: &HostFacts,
154 now_ms: i64,
155 host_error: Option<&str>,
156) -> Value {
157 let poll_ms = i64::try_from(facts.refresh_secs)
158 .unwrap_or(i64::MAX / 1_000)
159 .saturating_mul(1_000);
160 let update = facts.update.as_ref().map(|u| {
161 json!({
162 "version": sanitize_untrusted_field(&u.version),
163 "url": sanitize_untrusted_field(&u.url),
164 "state": u.state,
165 "error": sanitize_untrusted_field(&u.error),
166 })
167 });
168 let accounts: serde_json::Map<String, Value> = facts
169 .accounts
170 .iter()
171 .map(|fact| {
172 (
173 fact.vendor.clone(),
174 json!({
175 "active": fact.active.as_deref().map(sanitize_untrusted_field),
176 "labels": fact
177 .labels
178 .iter()
179 .map(|label| sanitize_untrusted_field(label))
180 .collect::<Vec<_>>(),
181 "target": sanitize_untrusted_field(&fact.target),
182 "switching": fact.switching,
183 "error": sanitize_untrusted_field(&fact.error),
184 }),
185 )
186 })
187 .collect();
188 let mut payload = json!({
189 "version": facts.version,
190 "generated_at": now_ms,
191 "next_refresh_at": now_ms.saturating_add(poll_ms),
192 "refresh_minutes": facts.refresh_secs / 60,
193 "startup_enabled": facts.startup_enabled,
194 "os": host_os(),
195 "shortcut": facts.shortcut,
196 "shortcut_error": sanitize_untrusted_field(&facts.shortcut_error),
197 "updates": facts.updates,
198 "update": update,
199 "update_checked_at": facts.update_checked_at,
200 "repository": repository_page(),
201 "accounts": accounts,
202 "host_error": host_error.map(sanitize_untrusted_field),
203 "primary": Value::Null,
204 "entries": [],
205 });
206 if host_error.is_some() {
207 return payload;
208 }
209 match serde_json::from_str::<Value>(report_json) {
210 Ok(Value::Object(map)) => {
211 if let Some(obj) = payload.as_object_mut() {
212 if let Some(primary) = map.get("primary") {
213 obj.insert("primary".into(), primary.clone());
214 }
215 if let Some(entries) = map.get("entries") {
216 obj.insert("entries".into(), with_sign_in_hints(entries));
217 }
218 }
219 payload
220 }
221 _ => {
222 payload["host_error"] = json!(sanitize_untrusted_field(
223 "The usage report did not contain valid JSON."
224 ));
225 payload
226 }
227 }
228}
229
230fn with_sign_in_hints(entries: &Value) -> Value {
238 let Some(list) = entries.as_array() else {
239 return entries.clone();
240 };
241 Value::Array(
242 list.iter()
243 .map(|entry| {
244 let mut entry = entry.clone();
245 let slug = entry
246 .get("id")
247 .and_then(Value::as_str)
248 .map(|id| id.split('@').next().unwrap_or(id).to_ascii_lowercase());
249 let hint = slug.and_then(|slug| {
250 crate::vendor::VendorId::all()
251 .iter()
252 .find(|v| v.slug() == slug)
253 .map(|v| v.sign_in_hint())
254 });
255 if let (Some(hint), Some(obj)) = (hint, entry.as_object_mut()) {
256 obj.insert("sign_in".into(), json!(hint));
257 }
258 entry
259 })
260 .collect(),
261 )
262}
263
264pub fn host_payload(value: &Value) -> String {
265 value.to_string()
266}
267
268pub fn worst_severity(payload: &Value) -> Severity {
270 if payload
271 .get("host_error")
272 .and_then(Value::as_str)
273 .is_some_and(|s| !s.is_empty())
274 {
275 return Severity::Critical;
276 }
277 let mut worst = Severity::Low;
278 let Some(entries) = payload.get("entries").and_then(Value::as_array) else {
279 return worst;
280 };
281 for entry in entries {
282 if entry.get("status").and_then(Value::as_str) == Some("error")
283 || entry
284 .get("error")
285 .and_then(Value::as_str)
286 .is_some_and(|s| !s.is_empty())
287 {
288 return Severity::Critical;
289 }
290 let Some(sections) = entry.get("sections").and_then(Value::as_array) else {
291 continue;
292 };
293 for section in sections {
294 if section.get("type").and_then(Value::as_str) != Some("metric") {
295 continue;
296 }
297 if let Some(sev) = section
298 .get("severity")
299 .and_then(Value::as_str)
300 .and_then(Severity::from_report_str)
301 && sev.rank() > worst.rank()
302 {
303 worst = sev;
304 }
305 }
306 }
307 worst
308}
309
310#[cfg(test)]
311mod tests {
312
313 #[test]
317 fn every_entry_carries_its_sign_in_hint_from_the_vendor() {
318 let report = r#"{"primary":"anthropic","entries":[
319 {"id":"anthropic","status":"error","error":"not signed in"},
320 {"id":"openai@work","status":"error","error":"not signed in"},
321 {"id":"cursor","status":"ready"},
322 {"id":"custom:mytool","status":"ready"}
323 ]}"#;
324 let payload = wrap_report(report, &facts("1.0.0", false), 0, None);
325 let entries = payload["entries"].as_array().expect("entries");
326
327 assert_eq!(
328 entries[0]["sign_in"],
329 json!(crate::vendor::VendorId::Anthropic.sign_in_hint())
330 );
331 assert_eq!(
333 entries[1]["sign_in"],
334 json!(crate::vendor::VendorId::Openai.sign_in_hint())
335 );
336 assert_eq!(
338 entries[2]["sign_in"],
339 json!(crate::vendor::VendorId::Cursor.sign_in_hint())
340 );
341 assert!(entries[3].get("sign_in").is_none(), "{:?}", entries[3]);
343 }
344
345 #[test]
348 fn every_vendor_slug_resolves_to_a_hint() {
349 for vendor in crate::vendor::VendorId::all() {
350 let report = format!(
351 r#"{{"entries":[{{"id":"{}","status":"error","error":"x"}}]}}"#,
352 vendor.slug()
353 );
354 let payload = wrap_report(&report, &facts("1.0.0", false), 0, None);
355 let hint = payload["entries"][0]["sign_in"].as_str().unwrap_or("");
356 assert!(!hint.is_empty(), "{} has no sign-in hint", vendor.slug());
357 }
358 }
359 use super::*;
360
361 fn sample_report() -> String {
362 json!({
363 "primary": "anthropic",
364 "entries": [
365 {
366 "id": "anthropic",
367 "name": "anthropic",
368 "display_name": "Claude",
369 "short_name": "cld",
370 "plan": "Team 5x",
371 "status": "ready",
372 "error": null,
373 "stale": false,
374 "sections": [
375 {
376 "type": "metric",
377 "label": "Weekly",
378 "percent": 19,
379 "value": "19%",
380 "detail": "Resets in 1d 16h",
381 "severity": "low",
382 "reset_at": null
383 },
384 {
385 "type": "metric",
386 "label": "Session",
387 "percent": 0,
388 "value": "0%",
389 "detail": "",
390 "severity": "low",
391 "reset_at": null
392 }
393 ]
394 },
395 {
396 "id": "openai",
397 "short_name": "gpt",
398 "status": "ready",
399 "error": null,
400 "sections": [{
401 "type": "metric",
402 "label": "Session",
403 "percent": 91,
404 "value": "91%",
405 "detail": "",
406 "severity": "critical",
407 "reset_at": null
408 }]
409 }
410 ]
411 })
412 .to_string()
413 }
414
415 fn facts(version: &str, startup_enabled: bool) -> HostFacts {
416 HostFacts::new(version, startup_enabled)
417 }
418
419 #[test]
420 fn wrap_copies_entries_and_stamps_refresh() {
421 let payload = wrap_report(&sample_report(), &facts("1.10.0", true), 1_000, None);
422 assert_eq!(payload["version"], "1.10.0");
423 assert_eq!(payload["generated_at"], 1_000);
424 assert_eq!(payload["next_refresh_at"], 301_000);
425 assert_eq!(payload["refresh_minutes"], 5);
426 assert_eq!(payload["startup_enabled"], true);
427 let os = payload["os"].as_str().unwrap_or("");
428 assert!(
429 os == "macos" || os == "windows" || os == "linux",
430 "unexpected os {os}"
431 );
432 assert_eq!(payload["shortcut"], "");
433 assert_eq!(payload["shortcut_error"], "");
434 assert_eq!(payload["updates"], "notify");
435 assert!(payload["update"].is_null());
436 assert_eq!(payload["update_checked_at"], 0);
437 assert!(
438 payload["repository"]
439 .as_str()
440 .unwrap_or("")
441 .starts_with("https://github.com/")
442 );
443 assert!(payload["host_error"].is_null());
444 assert_eq!(payload["primary"], "anthropic");
445 assert_eq!(payload["entries"][0]["short_name"], "cld");
446 }
447
448 #[test]
449 fn wrap_carries_shortcut_and_update_facts_sanitized() {
450 let mut host = facts("1.10.0", false);
451 host.shortcut = "Ctrl+Shift+U".into();
452 host.shortcut_error = "already taken\u{1b}[31m".into();
453 host.updates = "auto".into();
454 host.update_checked_at = 42;
455 host.update = Some(UpdateFact {
456 error: String::new(),
457 state: "available".into(),
458 url: "https://github.com/akitaonrails/ai-usagebar/releases/tag/v1.11.0".into(),
459 version: "1.11.0".into(),
460 });
461 let payload = wrap_report(&sample_report(), &host, 0, None);
462 assert_eq!(payload["shortcut"], "Ctrl+Shift+U");
463 assert!(
464 !payload["shortcut_error"]
465 .as_str()
466 .unwrap()
467 .contains('\u{1b}')
468 );
469 assert_eq!(payload["updates"], "auto");
470 assert_eq!(payload["update_checked_at"], 42);
471 assert_eq!(payload["update"]["version"], "1.11.0");
472 assert_eq!(payload["update"]["state"], "available");
473 assert_eq!(payload["update"]["error"], "");
474 }
475
476 #[cfg(target_os = "macos")]
477 #[test]
478 fn fact_after_check_maps_newer_current_and_failure() {
479 use crate::update::Release;
480
481 let newer = super::fact_after_check(Ok(Some(Release {
482 assets: Vec::new(),
483 html_url: "https://github.com/akitaonrails/ai-usagebar/releases/tag/v9.0.0".into(),
484 version: "9.0.0".into(),
485 })))
486 .expect("a newer release is a fact");
487 assert_eq!(newer.state, "available");
488 assert_eq!(newer.version, "9.0.0");
489 assert!(super::fact_after_check(Ok(None)).is_none());
490 let failed = super::fact_after_check(Err("offline".into())).expect("a failure is a fact");
491 assert_eq!(failed.state, "failed");
492 assert_eq!(failed.error, "offline");
493 }
494
495 #[test]
496 fn host_error_wins_over_report_body() {
497 let payload = wrap_report(
498 "not-json",
499 &facts("1.0.0", false),
500 0,
501 Some("no vendors enabled"),
502 );
503 assert_eq!(payload["host_error"], "no vendors enabled");
504 assert_eq!(payload["entries"].as_array().unwrap().len(), 0);
505 assert_eq!(worst_severity(&payload), Severity::Critical);
506 }
507
508 #[test]
509 fn invalid_report_json_becomes_a_host_error() {
510 let payload = wrap_report("{", &facts("1.0.0", false), 0, None);
511 assert!(
512 payload["host_error"]
513 .as_str()
514 .unwrap()
515 .contains("valid JSON")
516 );
517 assert_eq!(worst_severity(&payload), Severity::Critical);
518 }
519
520 #[test]
521 fn worst_severity_is_the_hottest_metric_not_the_primary() {
522 let payload = wrap_report(&sample_report(), &facts("1.10.0", false), 0, None);
523 assert_eq!(worst_severity(&payload), Severity::Critical);
524 }
525
526 #[test]
527 fn entry_error_is_critical_and_lands_in_the_tooltip() {
528 let report = json!({
529 "primary": "openai",
530 "entries": [{
531 "id": "openai",
532 "short_name": "gpt",
533 "status": "error",
534 "error": "not signed in",
535 "sections": []
536 }]
537 })
538 .to_string();
539 let payload = wrap_report(&report, &facts("1.10.0", false), 0, None);
540 assert_eq!(worst_severity(&payload), Severity::Critical);
541 }
542
543 #[test]
544 fn empty_report_uses_a_generic_tooltip() {
545 let payload = wrap_report("{}", &facts("1.0.0", false), 0, None);
546 assert_eq!(worst_severity(&payload), Severity::Low);
547 assert_eq!(POLL_INTERVAL.as_secs(), 300);
548 }
549
550 #[test]
551 fn next_refresh_follows_the_configured_interval() {
552 let mut host = facts("1.10.0", false);
553 host.refresh_secs = 60;
554 let payload = wrap_report(&sample_report(), &host, 1_000, None);
555 assert_eq!(payload["next_refresh_at"], 61_000);
556 assert_eq!(payload["refresh_minutes"], 1);
557
558 host.refresh_secs = 600;
559 let payload = wrap_report(&sample_report(), &host, 1_000, None);
560 assert_eq!(payload["next_refresh_at"], 601_000);
561 assert_eq!(payload["refresh_minutes"], 10);
562 }
563
564 #[test]
565 fn switchable_accounts_are_keyed_by_vendor() {
566 let mut host = facts("1.10.0", false);
567 host.accounts = vec![AccountSwitchFact {
568 vendor: "openai".into(),
569 active: Some("main".into()),
570 labels: vec!["main".into(), "work\u{1b}[31m".into()],
571 target: "work".into(),
572 switching: false,
573 error: "no stored Codex login".into(),
574 }];
575 let payload = wrap_report(&sample_report(), &host, 0, None);
576 let openai = &payload["accounts"]["openai"];
577 assert_eq!(openai["active"], "main");
578 assert_eq!(openai["labels"][0], "main");
579 assert!(!openai["labels"][1].as_str().unwrap().contains('\u{1b}'));
580 assert_eq!(openai["target"], "work");
581 assert_eq!(openai["switching"], false);
582 assert_eq!(openai["error"], "no stored Codex login");
583 assert!(payload["accounts"].get("anthropic").is_none());
584 }
585
586 #[test]
587 fn no_switchable_accounts_is_an_empty_object() {
588 let payload = wrap_report(&sample_report(), &facts("1.10.0", false), 0, None);
589 assert_eq!(payload["accounts"], json!({}));
590 }
591}