1use std::time::Duration;
18
19use chrono::{DateTime, Utc};
20
21use crate::cache::{Cache, MAX_STALE, acquire_lock_async};
22use crate::error::{AppError, Result};
23use crate::usage::{AntigravitySnapshot, UsageWindow};
24
25const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
26const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
27
28const QUOTA_RPC: &str = "exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary";
29const STATUS_RPC: &str = "exa.language_server_pb.LanguageServerService/GetUserStatus";
30
31const DEFAULT_PLAN: &str = "Antigravity";
32
33#[derive(Debug, Clone)]
34pub struct FetchOutcome {
35 pub snapshot: AntigravitySnapshot,
36 pub stale: bool,
37 pub last_error: Option<(u16, String)>,
38 pub cache_age: Option<Duration>,
39}
40
41impl From<FetchOutcome> for crate::vendor::VendorOutcome {
42 fn from(o: FetchOutcome) -> Self {
43 Self {
44 snapshot: crate::usage::VendorSnapshot::Antigravity(o.snapshot),
45 stale: o.stale,
46 last_error: o.last_error,
47 cache_age: o.cache_age,
48 }
49 }
50}
51
52pub async fn fetch_snapshot(
53 client: &reqwest::Client,
54 cache: &Cache,
55 cache_ttl: Duration,
56) -> Result<FetchOutcome> {
57 fetch_snapshot_at(client, cache, cache_ttl, Utc::now()).await
58}
59
60pub async fn fetch_snapshot_at(
63 client: &reqwest::Client,
64 cache: &Cache,
65 cache_ttl: Duration,
66 now: DateTime<Utc>,
67) -> Result<FetchOutcome> {
68 cache.ensure_dir()?;
69 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
70
71 let session = open_session(client).await;
76 let account = session.as_ref().ok().map(|s| s.account.as_str());
77
78 if let Some(bytes) = cache.fresh_payload(cache_ttl)?
79 && let Ok(outcome) = reuse_cache(bytes, cache, false, account, now)
80 {
81 return Ok(outcome);
82 }
83
84 match fetch_live(client, session).await {
85 Ok(snap) => {
86 let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
87 cache.write_payload(&bytes)?;
88 Ok(FetchOutcome {
89 snapshot: snap,
90 stale: false,
91 last_error: None,
92 cache_age: Some(Duration::ZERO),
93 })
94 }
95 Err(e) if e.is_transient() => fallback_silent(cache, now, e),
96 Err(AppError::Http { status, body }) => {
97 cache.mark_stale();
98 cache.write_last_error(status, &body);
99 let reason = AppError::Http {
100 status,
101 body: body.clone(),
102 };
103 fallback_with_error(cache, Some((status, body)), reason, now)
104 }
105 Err(e) => {
106 cache.mark_stale();
107 cache.write_last_error(0, &e.to_string());
108 let last_error = Some((0, e.to_string()));
109 fallback_with_error(cache, last_error, e, now)
110 }
111 }
112}
113
114struct Session {
117 base: String,
118 csrf: Option<String>,
119 plan: String,
120 account: String,
121}
122
123async fn open_session(client: &reqwest::Client) -> Result<Session> {
127 let bases = candidate_bases();
128 if bases.is_empty() {
129 return Err(AppError::Credentials(
130 "Antigravity: no local server found. Quota is only served while Antigravity is \
131 running — open the Antigravity app, or an interactive `agy` session, or point \
132 ANTIGRAVITY_LS_ADDRESS at a host:port."
133 .into(),
134 ));
135 }
136
137 let mut last_err = None;
138 for base in bases {
139 let csrf = fetch_csrf(client, &base).await;
140 match post_rpc(client, &base, csrf.as_deref(), STATUS_RPC).await {
141 Ok(v) => {
142 return Ok(Session {
143 base,
144 csrf,
145 plan: plan_from_status(&v),
146 account: account_key(&v),
147 });
148 }
149 Err(e) => last_err = Some(e),
150 }
151 }
152 Err(last_err.unwrap_or_else(|| {
153 AppError::Other("antigravity: no local server answered GetUserStatus".into())
154 }))
155}
156
157async fn fetch_live(
158 client: &reqwest::Client,
159 session: Result<Session>,
160) -> Result<AntigravitySnapshot> {
161 let session = session?;
162 let quota = post_rpc(client, &session.base, session.csrf.as_deref(), QUOTA_RPC).await?;
163 let mut snap = parse_quota_summary("a, session.plan)?;
164 snap.account = session.account;
165 Ok(snap)
166}
167
168fn account_key(user_status: &serde_json::Value) -> String {
173 let email = user_status["userStatus"]["email"]
174 .as_str()
175 .filter(|s| !s.is_empty());
176 match email {
177 Some(e) => {
178 use std::hash::{Hash, Hasher};
179 let mut h = std::collections::hash_map::DefaultHasher::new();
180 e.hash(&mut h);
181 format!("acct:{:016x}", h.finish())
182 }
183 None => "acct:unknown".to_string(),
184 }
185}
186
187async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
192 let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
193 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
196 .await
197 .ok()?;
198 let html = String::from_utf8_lossy(&bytes);
199 html.split("csrfToken\":\"")
200 .nth(1)
201 .and_then(|s| s.split('"').next())
202 .filter(|t| !t.is_empty())
203 .map(|t| t.to_string())
204}
205
206async fn post_rpc(
207 client: &reqwest::Client,
208 base: &str,
209 csrf: Option<&str>,
210 rpc: &str,
211) -> Result<serde_json::Value> {
212 let mut req = client
213 .post(format!("{base}/{rpc}"))
214 .header("Content-Type", "application/json")
215 .body("{}")
216 .timeout(HTTP_TIMEOUT);
217 if let Some(token) = csrf {
218 req = req.header("x-codeium-csrf-token", token);
219 }
220 let resp = req.send().await?;
221
222 let status = resp.status();
223 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
227 if !status.is_success() {
228 let body = String::from_utf8_lossy(&bytes).into_owned();
229 return Err(AppError::Http {
230 status: status.as_u16(),
231 body,
232 });
233 }
234
235 Ok(serde_json::from_slice(&bytes)?)
236}
237
238pub fn plan_from_status(v: &serde_json::Value) -> String {
239 v["userStatus"]["userTier"]["name"]
240 .as_str()
241 .or_else(|| v["userStatus"]["userTier"]["description"].as_str())
242 .or_else(|| v["userStatus"]["planStatus"]["planInfo"]["planName"].as_str())
243 .filter(|s| !s.is_empty())
244 .unwrap_or(DEFAULT_PLAN)
245 .to_string()
246}
247
248pub fn parse_quota_summary(v: &serde_json::Value, plan: String) -> Result<AntigravitySnapshot> {
258 let groups = v["response"]["groups"]
259 .as_array()
260 .or_else(|| v["groups"].as_array())
261 .ok_or_else(|| AppError::Other("antigravity: quota summary has no groups".into()))?;
262
263 let mut gemini_5h = None;
264 let mut gemini_weekly = None;
265 let mut tp_5h = None;
266 let mut tp_weekly = None;
267
268 for group in groups {
269 let group_name = group["displayName"].as_str().unwrap_or_default();
270 let Some(buckets) = group["buckets"].as_array() else {
271 continue;
272 };
273 for bucket in buckets {
274 let id = bucket["bucketId"].as_str().unwrap_or_default();
275 let window = bucket["window"].as_str().unwrap_or_default();
276 let is_weekly = if id.ends_with("weekly") || window == "weekly" {
277 true
278 } else if id.ends_with("5h") || window == "5h" {
279 false
280 } else {
281 continue;
284 };
285 let is_gemini = if id.starts_with("gemini") {
286 true
287 } else if id.starts_with("3p") {
288 false
289 } else if group_name.contains("Gemini") {
290 true
291 } else if group_name.contains("Claude") || group_name.contains("GPT") {
292 false
293 } else {
294 continue;
297 };
298
299 let (slot, slot_name) = match (is_gemini, is_weekly) {
300 (true, false) => (&mut gemini_5h, "Gemini 5h"),
301 (true, true) => (&mut gemini_weekly, "Gemini weekly"),
302 (false, false) => (&mut tp_5h, "Claude/GPT 5h"),
303 (false, true) => (&mut tp_weekly, "Claude/GPT weekly"),
304 };
305 let parsed = usage_window(bucket, is_weekly)?;
306 if slot.replace(parsed).is_some() {
307 return Err(AppError::Schema(format!(
308 "antigravity: duplicate {slot_name} bucket"
309 )));
310 }
311 }
312 }
313
314 let session = gemini_5h.ok_or_else(|| {
315 AppError::Other("antigravity: quota summary has no Gemini 5h bucket".into())
316 })?;
317 let weekly = gemini_weekly.ok_or_else(|| {
318 AppError::Other("antigravity: quota summary has no Gemini weekly bucket".into())
319 })?;
320
321 Ok(AntigravitySnapshot {
322 plan,
323 account: String::new(),
325 session,
326 weekly,
327 third_party_session: tp_5h,
328 third_party_weekly: tp_weekly,
329 })
330}
331
332fn usage_window(bucket: &serde_json::Value, is_weekly: bool) -> Result<UsageWindow> {
336 let remaining = bucket["remainingFraction"]
337 .as_f64()
338 .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
339 .ok_or_else(|| {
340 AppError::Schema(format!(
341 "antigravity: bucket {} has no valid remainingFraction in 0..=1",
342 bucket["bucketId"].as_str().unwrap_or("<unnamed>")
343 ))
344 })?;
345 Ok(UsageWindow {
346 utilization_pct: pct_used(remaining),
347 resets_at: parse_reset(&bucket["resetTime"], "quota resetTime")?,
348 window_duration: if is_weekly {
349 chrono::Duration::days(7)
350 } else {
351 chrono::Duration::hours(5)
352 },
353 })
354}
355
356fn pct_used(remaining_fraction: f64) -> i32 {
359 let used = (1.0 - remaining_fraction) * 100.0;
360 used.round() as i32
361}
362
363fn parse_reset(value: &serde_json::Value, field: &str) -> Result<Option<DateTime<Utc>>> {
364 match value {
365 serde_json::Value::Null => Ok(None),
366 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
367 .map(|dt| Some(dt.with_timezone(&Utc)))
368 .map_err(|_| AppError::Schema(format!("antigravity: invalid {field}"))),
369 _ => Err(AppError::Schema(format!(
370 "antigravity: {field} must be a timestamp or null"
371 ))),
372 }
373}
374
375fn candidate_bases() -> Vec<String> {
381 candidate_bases_with(
382 std::env::var("ANTIGRAVITY_LS_ADDRESS").ok().as_deref(),
383 discover_ls_ports(),
384 )
385}
386
387fn candidate_bases_with(override_addr: Option<&str>, discovered: Vec<u16>) -> Vec<String> {
390 if let Some(addr) = override_addr {
391 let addr = addr.trim();
392 if !addr.is_empty() {
393 return vec![normalize_base(addr)];
394 }
395 }
396
397 discovered
402 .into_iter()
403 .map(|p| format!("http://127.0.0.1:{p}"))
404 .collect()
405}
406
407fn normalize_base(addr: &str) -> String {
408 if addr.starts_with("http://") || addr.starts_with("https://") {
409 addr.to_string()
410 } else {
411 format!("http://{addr}")
412 }
413}
414
415fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
422 let comm = comm.trim().to_lowercase();
423 if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
424 return true;
425 }
426 exe.is_some_and(|p| {
427 let p = p.to_lowercase();
428 p.contains("antigravity") || p.ends_with("/agy")
429 })
430}
431
432#[cfg(target_os = "linux")]
439fn discover_ls_ports() -> Vec<u16> {
440 use std::collections::HashSet;
441
442 let mut inodes: HashSet<u64> = HashSet::new();
443 let Ok(entries) = std::fs::read_dir("/proc") else {
444 return Vec::new();
445 };
446 for entry in entries.flatten() {
447 let pid_dir = entry.path();
448 let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
449 continue;
450 };
451 let exe = std::fs::read_link(pid_dir.join("exe")).ok();
452 if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
453 continue;
454 }
455 let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
456 continue;
457 };
458 for fd in fds.flatten() {
459 let Ok(target) = std::fs::read_link(fd.path()) else {
460 continue;
461 };
462 if let Some(ino) = target
463 .to_str()
464 .and_then(|s| s.strip_prefix("socket:["))
465 .and_then(|s| s.strip_suffix(']'))
466 .and_then(|s| s.parse::<u64>().ok())
467 {
468 inodes.insert(ino);
469 }
470 }
471 }
472
473 if inodes.is_empty() {
474 return Vec::new();
475 }
476
477 let mut ports = Vec::new();
478 for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
479 let Ok(contents) = std::fs::read_to_string(table) else {
480 continue;
481 };
482 for line in contents.lines().skip(1) {
483 if let Some((port, ino)) = parse_proc_net_line(line)
484 && inodes.contains(&ino)
485 && !ports.contains(&port)
486 {
487 ports.push(port);
488 }
489 }
490 }
491 ports
492}
493
494#[cfg(target_os = "macos")]
501fn discover_ls_ports() -> Vec<u16> {
502 let Ok(output) = std::process::Command::new("lsof")
503 .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
504 .output()
505 else {
506 return Vec::new();
507 };
508 parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
512}
513
514#[cfg(target_os = "macos")]
517fn parse_lsof_pcn(output: &str) -> Vec<u16> {
518 let mut ports = Vec::new();
519 let mut current_matches = false;
520 for line in output.lines() {
521 let Some(rest) = line.get(1..) else { continue };
522 match line.as_bytes().first() {
523 Some(b'p') => current_matches = false,
524 Some(b'c') => current_matches = is_antigravity_process(rest, None),
525 Some(b'n') if current_matches => {
526 if let Some(port) = rest.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
527 && !ports.contains(&port)
528 {
529 ports.push(port);
530 }
531 }
532 _ => {}
533 }
534 }
535 ports
536}
537
538#[cfg(not(any(target_os = "linux", target_os = "macos")))]
539fn discover_ls_ports() -> Vec<u16> {
540 Vec::new()
541}
542
543#[cfg(target_os = "linux")]
546fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
547 let cols: Vec<&str> = line.split_whitespace().collect();
548 if cols.len() < 10 {
549 return None;
550 }
551 if cols[3] != "0A" {
553 return None;
554 }
555 let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
556 let inode = cols[9].parse::<u64>().ok()?;
557 Some((port, inode))
558}
559
560fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
565 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
566 return Err(original);
567 };
568 reuse_cache(bytes, cache, true, None, now).or(Err(original))
569}
570
571fn fallback_with_error(
576 cache: &Cache,
577 last_error: Option<(u16, String)>,
578 reason: AppError,
579 now: DateTime<Utc>,
580) -> Result<FetchOutcome> {
581 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
582 return Err(reason);
583 };
584 let Ok(mut outcome) = reuse_cache(bytes, cache, true, None, now) else {
585 return Err(reason);
586 };
587 outcome.last_error = last_error;
588 Ok(outcome)
589}
590
591fn reuse_cache(
592 bytes: Vec<u8>,
593 cache: &Cache,
594 stale: bool,
595 account: Option<&str>,
596 now: DateTime<Utc>,
597) -> Result<FetchOutcome> {
598 let snap = parse_cache_at(&bytes, account, now)?;
599 Ok(FetchOutcome {
600 snapshot: snap,
601 stale,
602 last_error: cache.read_last_error(),
603 cache_age: cache.payload_age(),
604 })
605}
606
607pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
613 parse_cache_at(bytes, account, Utc::now())
614}
615
616fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
626 [
627 ("Gemini 5h", Some(&snap.session)),
628 ("Gemini weekly", Some(&snap.weekly)),
629 ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
630 ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
631 ]
632 .into_iter()
633 .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
634 .map(|(name, _)| name)
635}
636
637pub fn parse_cache_at(
638 bytes: &[u8],
639 account: Option<&str>,
640 now: DateTime<Utc>,
641) -> Result<AntigravitySnapshot> {
642 let v: serde_json::Value = serde_json::from_slice(bytes)?;
643
644 let cached_account = v.get("account").and_then(serde_json::Value::as_str);
645 if let Some(expected) = account
646 && cached_account != Some(expected)
647 {
648 return Err(AppError::Schema(
649 "antigravity cache belongs to a different account; refetching".into(),
650 ));
651 }
652
653 let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
658 match v.get(pct_key) {
659 None | Some(serde_json::Value::Null) => Ok(None),
660 Some(value) => value
661 .as_i64()
662 .filter(|pct| (0..=100).contains(pct))
663 .map(|pct| Some(pct as i32))
664 .ok_or_else(|| {
665 AppError::Schema(format!(
666 "antigravity: cached {pct_key} must be an integer in 0..=100"
667 ))
668 }),
669 }
670 };
671
672 let window = |pct_key: &'static str, reset_key: &str, weekly: bool| {
673 let pct = cached_pct(pct_key)?.ok_or_else(|| {
674 AppError::Schema(format!("antigravity: cached payload missing {pct_key}"))
675 })?;
676 Ok::<_, AppError>(UsageWindow {
677 utilization_pct: pct,
678 resets_at: parse_reset(&v[reset_key], reset_key)?,
679 window_duration: if weekly {
680 chrono::Duration::days(7)
681 } else {
682 chrono::Duration::hours(5)
683 },
684 })
685 };
686
687 let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
688 let Some(pct) = cached_pct(pct_key)? else {
689 return Ok(None);
690 };
691 Ok::<_, AppError>(Some(UsageWindow {
692 utilization_pct: pct,
693 resets_at: parse_reset(&v[reset_key], reset_key)?,
694 window_duration: if weekly {
695 chrono::Duration::days(7)
696 } else {
697 chrono::Duration::hours(5)
698 },
699 }))
700 };
701
702 let snap = AntigravitySnapshot {
703 plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
704 account: cached_account.unwrap_or_default().to_string(),
705 session: window("session_pct", "session_reset", false)?,
706 weekly: window("weekly_pct", "weekly_reset", true)?,
707 third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
708 third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
709 };
710
711 if let Some(window) = expired_window(&snap, now) {
712 return Err(AppError::Schema(format!(
713 "antigravity cache is past its {window} reset; refetching"
714 )));
715 }
716 Ok(snap)
717}
718
719pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
720 serde_json::json!({
721 "plan": snap.plan,
722 "account": snap.account,
723 "session_pct": snap.session.utilization_pct,
724 "session_reset": snap.session.resets_at.map(|dt| dt.to_rfc3339()),
725 "weekly_pct": snap.weekly.utilization_pct,
726 "weekly_reset": snap.weekly.resets_at.map(|dt| dt.to_rfc3339()),
727 "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
728 "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
729 "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
730 "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
731 })
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737
738 const QUOTA_JSON: &str = r#"{
742 "response": {
743 "groups": [
744 {
745 "displayName": "Gemini Models",
746 "buckets": [
747 {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
748 "window": "weekly", "remainingFraction": 0.9191212,
749 "resetTime": "2026-07-28T17:39:58Z"},
750 {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
751 "window": "5h", "remainingFraction": 0.5672253,
752 "resetTime": "2026-07-22T17:47:00Z"}
753 ]
754 },
755 {
756 "displayName": "Claude and GPT models",
757 "buckets": [
758 {"bucketId": "3p-weekly", "window": "weekly",
759 "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
760 {"bucketId": "3p-5h", "window": "5h",
761 "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
762 ]
763 }
764 ]
765 }
766 }"#;
767
768 fn now() -> DateTime<Utc> {
771 DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
772 .unwrap()
773 .with_timezone(&Utc)
774 }
775
776 fn parsed() -> AntigravitySnapshot {
777 let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
778 parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
779 }
780
781 #[test]
782 fn quota_summary_maps_four_distinct_windows() {
783 let snap = parsed();
784 assert_eq!(snap.plan, "Google AI Pro");
785 assert_eq!(snap.session.utilization_pct, 43);
787 assert_eq!(snap.weekly.utilization_pct, 8);
788 assert_eq!(
789 snap.third_party_session.as_ref().unwrap().utilization_pct,
790 75
791 );
792 assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
793 }
794
795 #[test]
796 fn each_window_keeps_its_own_reset_time() {
797 let snap = parsed();
798 let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
799 assert_eq!(snap.session.resets_at, at("2026-07-22T17:47:00Z"));
800 assert_eq!(snap.weekly.resets_at, at("2026-07-28T17:39:58Z"));
801 assert_eq!(
802 snap.third_party_weekly.as_ref().unwrap().resets_at,
803 at("2026-07-29T12:47:00Z")
804 );
805 assert_ne!(snap.session.resets_at, snap.weekly.resets_at);
807 }
808
809 #[test]
810 fn window_durations_match_their_bucket() {
811 let snap = parsed();
812 assert_eq!(snap.session.window_duration, chrono::Duration::hours(5));
813 assert_eq!(snap.weekly.window_duration, chrono::Duration::days(7));
814 assert_eq!(
815 snap.third_party_weekly.as_ref().unwrap().window_duration,
816 chrono::Duration::days(7)
817 );
818 }
819
820 #[test]
821 fn groups_are_matched_by_display_name_when_bucket_ids_change() {
822 let v: serde_json::Value = serde_json::from_str(
823 r#"{"response":{"groups":[
824 {"displayName":"Gemini Models","buckets":[
825 {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
826 {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
827 {"displayName":"Claude and GPT models","buckets":[
828 {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
829 ]}}"#,
830 )
831 .unwrap();
832 let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
833 assert_eq!(snap.session.utilization_pct, 50);
834 assert_eq!(snap.weekly.utilization_pct, 10);
835 assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
836 assert!(snap.third_party_weekly.is_none());
837 }
838
839 #[test]
840 fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
841 let duplicate: serde_json::Value = serde_json::from_str(
842 r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
843 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
844 {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
845 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
846 ]}]}}"#,
847 )
848 .unwrap();
849 let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
850 assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
851
852 let unrelated: serde_json::Value = serde_json::from_str(
855 r#"{"response":{"groups":[
856 {"displayName":"Gemini Models","buckets":[
857 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
858 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
859 {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
860 ]},
861 {"displayName":"Future Models","buckets":[
862 {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
863 ]}
864 ]}}"#,
865 )
866 .unwrap();
867 let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
868 assert!(snap.third_party_session.is_none());
869 assert!(snap.third_party_weekly.is_none());
870 }
871
872 #[test]
875 fn a_bucket_without_a_usable_fraction_is_rejected() {
876 for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
877 let v: serde_json::Value = serde_json::from_str(&format!(
878 r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
879 {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
880 {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
881 ))
882 .unwrap();
883 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
884 assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
885 }
886 }
887
888 #[test]
889 fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
890 for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
891 let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
892 v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
893 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
894 assert!(err.to_string().contains("resetTime"), "{err}");
895 }
896 }
897
898 #[test]
899 fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
900 let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
901 assert!(parse_quota_summary(&v, "Pro".into()).is_err());
902 }
903
904 #[test]
905 fn cache_round_trip_preserves_every_window() {
906 let snap = parsed();
907 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
908 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
909 }
910
911 #[test]
915 fn a_truncated_cached_payload_is_rejected_not_zeroed() {
916 let full = snap_to_json(&parsed());
917 for missing in ["session_pct", "weekly_pct"] {
918 let mut v = full.clone();
919 v.as_object_mut().unwrap().remove(missing);
920 let bytes = serde_json::to_vec(&v).unwrap();
921 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
922 assert!(err.to_string().contains(missing), "{missing}: {err}");
923 }
924 assert!(parse_cache_at(b"{}", None, now()).is_err());
926 }
927
928 #[test]
929 fn cached_percentages_are_range_checked_before_narrowing() {
930 let full = snap_to_json(&parsed());
931 for (key, bad) in [
932 ("session_pct", serde_json::json!(-1)),
933 ("weekly_pct", serde_json::json!(101)),
934 ("session_pct", serde_json::json!(i64::MAX)),
935 ("tp_session_pct", serde_json::json!("75")),
936 ] {
937 let mut v = full.clone();
938 v[key] = bad;
939 let bytes = serde_json::to_vec(&v).unwrap();
940 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
941 assert!(err.to_string().contains(key), "{key}: {err}");
942 }
943 }
944
945 #[test]
946 fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
947 let mut v = snap_to_json(&parsed());
948 v["session_reset"] = serde_json::json!("not-a-time");
949 let bytes = serde_json::to_vec(&v).unwrap();
950 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
951 assert!(err.to_string().contains("session_reset"), "{err}");
952 }
953
954 #[test]
959 fn a_cache_past_its_reset_is_refused() {
960 let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
961 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
962
963 assert!(parse_cache_at(&bytes, None, now()).is_ok());
965 assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
967
968 let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
970 assert!(err.to_string().contains("5h"), "{err}");
971
972 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
974 }
975
976 #[test]
979 fn expiry_names_the_window_that_rolled_over() {
980 let mut snap = parsed();
981 snap.session.resets_at = None;
983 snap.third_party_session = None;
984 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
985 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
986
987 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
989
990 let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
992 assert!(err.to_string().contains("Gemini weekly"), "{err}");
993 }
994
995 #[test]
997 fn a_window_without_a_reset_never_expires() {
998 let mut snap = parsed();
999 for w in [&mut snap.session, &mut snap.weekly] {
1000 w.resets_at = None;
1001 }
1002 snap.third_party_session = None;
1003 snap.third_party_weekly = None;
1004 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1005 let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
1006 .unwrap()
1007 .with_timezone(&Utc);
1008 assert!(parse_cache_at(&bytes, None, far_future).is_ok());
1009 }
1010
1011 #[test]
1013 fn a_cache_from_another_account_is_rejected() {
1014 let mut snap = parsed();
1015 snap.account = "acct:aaaa".into();
1016 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1017
1018 assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
1019 assert_eq!(
1020 parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
1021 snap
1022 );
1023
1024 let mut legacy = snap_to_json(&snap);
1026 legacy.as_object_mut().unwrap().remove("account");
1027 let legacy = serde_json::to_vec(&legacy).unwrap();
1028 assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
1029 }
1030
1031 #[test]
1034 fn an_unverifiable_cache_is_served_rather_than_discarded() {
1035 let mut snap = parsed();
1036 snap.account = "acct:aaaa".into();
1037 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1038 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1039 }
1040
1041 #[test]
1042 fn account_key_fingerprints_rather_than_storing_the_address() {
1043 let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
1044 let a = with("someone@example.com");
1045 assert!(!a.contains("someone"), "{a}");
1046 assert!(!a.contains('@'), "{a}");
1047 assert_eq!(a, with("someone@example.com"), "must be stable");
1048 assert_ne!(a, with("other@example.com"));
1049 let unknown = account_key(&serde_json::json!({}));
1051 assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
1052 assert_ne!(unknown, a);
1053 }
1054
1055 #[test]
1058 fn absent_third_party_windows_are_not_treated_as_corruption() {
1059 let mut snap = parsed();
1060 snap.third_party_session = None;
1061 snap.third_party_weekly = None;
1062 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1063 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1064 }
1065
1066 #[test]
1067 fn cache_round_trip_preserves_absent_third_party_windows() {
1068 let mut snap = parsed();
1069 snap.third_party_session = None;
1070 snap.third_party_weekly = None;
1071 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1072 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1073 }
1074
1075 #[test]
1076 fn pct_used_inverts_valid_fractions() {
1077 assert_eq!(pct_used(1.0), 0);
1078 assert_eq!(pct_used(0.0), 100);
1079 assert_eq!(pct_used(0.5), 50);
1080 }
1081
1082 #[test]
1083 fn plan_falls_back_through_the_status_payload() {
1084 let tier: serde_json::Value =
1085 serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
1086 .unwrap();
1087 assert_eq!(plan_from_status(&tier), "Google AI Pro");
1088
1089 let plan_only: serde_json::Value = serde_json::from_str(
1090 r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
1091 )
1092 .unwrap();
1093 assert_eq!(plan_from_status(&plan_only), "Pro");
1094
1095 let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
1096 assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
1097 }
1098
1099 #[cfg(target_os = "linux")]
1100 #[test]
1101 fn proc_net_parser_keeps_only_listening_rows() {
1102 let listen = " 0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
1103 00:00000000 00000000 1000 0 123456 1 0000 100 0";
1104 assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
1105
1106 let established = " 1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
1107 00:00000000 00000000 1000 0 123457 1 0000 100 0";
1108 assert_eq!(parse_proc_net_line(established), None);
1109
1110 assert_eq!(parse_proc_net_line("garbage"), None);
1111 }
1112
1113 #[test]
1114 fn explicit_address_wins_and_gets_a_scheme() {
1115 assert_eq!(
1116 candidate_bases_with(Some("127.0.0.1:1234"), vec![5678]),
1117 vec!["http://127.0.0.1:1234".to_string()]
1118 );
1119 assert_eq!(
1121 candidate_bases_with(Some("https://host:9"), vec![]),
1122 vec!["https://host:9".to_string()]
1123 );
1124 }
1125
1126 #[test]
1127 fn every_discovered_port_is_probed_in_order() {
1128 assert_eq!(
1129 candidate_bases_with(None, vec![33875, 37435]),
1130 vec![
1131 "http://127.0.0.1:33875".to_string(),
1132 "http://127.0.0.1:37435".to_string(),
1133 ]
1134 );
1135 }
1136
1137 #[test]
1142 fn empty_discovery_yields_no_candidates() {
1143 assert!(candidate_bases_with(None, vec![]).is_empty());
1144 assert!(candidate_bases_with(Some(""), vec![]).is_empty());
1145 }
1146
1147 #[test]
1148 fn every_antigravity_product_is_recognised() {
1149 assert!(is_antigravity_process(
1151 "language_server\n",
1152 Some("/opt/antigravity/resources/bin/language_server")
1153 ));
1154 assert!(is_antigravity_process(
1156 "agy\n",
1157 Some("/home/u/.local/bin/agy")
1158 ));
1159 assert!(is_antigravity_process(
1161 "node",
1162 Some("/opt/antigravity/bin/helper")
1163 ));
1164 assert!(is_antigravity_process("antigravity", None));
1165 }
1166
1167 #[test]
1168 fn unrelated_processes_are_not_probed() {
1169 assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
1170 assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
1171 assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
1173 assert!(!is_antigravity_process("", None));
1174 }
1175
1176 #[cfg(target_os = "macos")]
1177 #[test]
1178 fn lsof_parser_keeps_only_ports_owned_by_antigravity_processes() {
1179 let output = "p74101\ncagy\nf10\nn127.0.0.1:8829\nf11\nn127.0.0.1:61289\nf12\nn127.0.0.1:61290\np200\ncsshd\nf5\nn*:22\n";
1182 assert_eq!(parse_lsof_pcn(output), vec![8829, 61289, 61290]);
1183 }
1184
1185 #[cfg(target_os = "macos")]
1186 #[test]
1187 fn lsof_parser_matches_the_capitalised_macos_app_name() {
1188 let output = "p900\ncAntigravity\nf7\nn127.0.0.1:54321\n";
1189 assert_eq!(parse_lsof_pcn(output), vec![54321]);
1190 }
1191
1192 #[cfg(target_os = "macos")]
1193 #[test]
1194 fn lsof_parser_deduplicates_and_handles_empty_output() {
1195 let output = "p1\ncagy\nf3\nn127.0.0.1:9000\nf4\nn127.0.0.1:9000\n";
1196 assert_eq!(parse_lsof_pcn(output), vec![9000]);
1197 assert!(parse_lsof_pcn("").is_empty());
1198 }
1199
1200 #[test]
1203 fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
1204 let dir = tempfile::tempdir().unwrap();
1205 let cache = Cache::at(dir.path().join("usage.json"));
1206 let reason = AppError::Credentials("Antigravity: no local language server found".into());
1207
1208 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1209 let msg = err.to_string();
1210 assert!(msg.contains("no local language server found"), "{msg}");
1211 assert!(!msg.contains("no usable cache"), "{msg}");
1212 }
1213
1214 #[test]
1215 fn unusable_cache_does_not_replace_the_live_diagnosis() {
1216 let dir = tempfile::tempdir().unwrap();
1217 let cache = Cache::at(dir.path().join("antigravity"));
1218 cache.write_payload(b"{}").unwrap();
1219
1220 let reason = AppError::Credentials("Antigravity must be running".into());
1221 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1222 assert!(err.to_string().contains("must be running"), "{err}");
1223
1224 let original = AppError::Transport("original loopback failure".into());
1225 let err = fallback_silent(&cache, now(), original).unwrap_err();
1226 assert!(
1227 err.to_string().contains("original loopback failure"),
1228 "{err}"
1229 );
1230 }
1231
1232 #[tokio::test]
1233 async fn rpc_error_bodies_are_bounded_too() {
1234 let mut server = mockito::Server::new_async().await;
1235 let path = format!("/{STATUS_RPC}");
1236 server
1237 .mock("POST", path.as_str())
1238 .with_status(500)
1239 .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
1240 .create_async()
1241 .await;
1242
1243 let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
1244 .await
1245 .unwrap_err();
1246 assert!(err.to_string().contains("exceeds"), "{err}");
1247 }
1248
1249 #[test]
1250 fn blank_override_falls_through_to_discovery() {
1251 assert_eq!(
1252 candidate_bases_with(Some(" "), vec![4242]),
1253 vec!["http://127.0.0.1:4242".to_string()]
1254 );
1255 }
1256}