Skip to main content

ai_usagebar/antigravity/
fetch.rs

1//! Fetch a Google Antigravity usage snapshot from a local language server.
2//!
3//! Google ships three separate Antigravity products — Antigravity 2.0, the
4//! `agy` CLI, and the Antigravity IDE — and they all draw on the **same**
5//! account-wide quota. A machine may have any combination of them installed and
6//! running, so this module probes every local server it can find and trusts the
7//! first that answers; there is no need to prefer one product over another.
8//!
9//! Each exposes a CSRF-guarded JSON-RPC surface on a **dynamically assigned**
10//! loopback port (`--https_server_port 0`), so the port cannot be hardcoded.
11//! Quota lives behind `RetrieveUserQuotaSummary`, which reports two model groups
12//! — Gemini, and Claude/GPT — each holding a 5-hour and a weekly bucket.
13//! `GetUserStatus` carries only the plan name; its per-model `quotaInfo` mirrors
14//! whichever bucket is scarcest and must not be read as a window in its own
15//! right.
16
17use 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
60/// Clock seam for [`fetch_snapshot`], so window expiry can be exercised at
61/// fixed instants instead of against the wall clock.
62pub 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    // Resolve the signed-in account first so a fresh cache can be attributed.
72    // Unlike Grok — where the same check would cost a remote round-trip on
73    // every poll — this is loopback, and it is the call that would supply the
74    // plan name anyway, so verification is effectively free.
75    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
114/// A local server that answered `GetUserStatus`: where it lives, how to talk to
115/// it, and whose account it is signed in as.
116struct Session {
117    base: String,
118    csrf: Option<String>,
119    plan: String,
120    account: String,
121}
122
123/// Walk every candidate language server until one identifies itself. A machine
124/// can host more than one — the desktop app, the IDE and an interactive `agy`
125/// session each run their own — and only some of them are signed in.
126async 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(&quota, session.plan)?;
164    snap.account = session.account;
165    Ok(snap)
166}
167
168/// Identity of the signed-in account, fingerprinted rather than stored in
169/// clear — the cache only needs a change detector, not the address itself.
170/// An unidentifiable response yields a stable "unknown" bucket so two such
171/// responses still compare equal.
172fn 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
187/// The Antigravity 2.0 server embeds a CSRF token in the HTML it serves at `/`
188/// and rejects the RPC without it. The `agy` CLI serves no such page — it 404s
189/// at `/` and answers the RPC unauthenticated — so a missing token is not an
190/// error here, just a server that does not use one.
191async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
192    let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
193    // Bounded like every other response this crate reads: a local server is
194    // still an untrusted source of unbounded bytes.
195    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    // Cap error bodies too. A local endpoint is still untrusted, and reading a
224    // non-2xx response with `text()` would bypass the invariant enforced for
225    // successful JSON responses.
226    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
248// ---------------------------------------------------------------------------
249// Quota parsing
250// ---------------------------------------------------------------------------
251
252/// Map a `RetrieveUserQuotaSummary` payload onto the four usage windows.
253///
254/// Buckets are keyed by `bucketId` (`gemini-5h`, `gemini-weekly`, `3p-5h`,
255/// `3p-weekly`), falling back to the group display name plus the `window`
256/// discriminator so a renamed bucket id still lands in the right slot.
257pub 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                // A new cadence is not a 5-hour bucket by default. Ignore it
282                // so it cannot overwrite a known slot.
283                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                // Likewise, an unrelated future group is not implicitly the
295                // third-party pool.
296                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        // Stamped by the caller, which is what knows the session's identity.
324        account: String::new(),
325        session,
326        weekly,
327        third_party_session: tp_5h,
328        third_party_weekly: tp_weekly,
329    })
330}
331
332/// `remainingFraction` is required and must be finite: defaulting a missing or
333/// drifted value to 1.0 would report a reassuring "0% used" for a window whose
334/// real state is unknown, and cache it.
335fn 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
356/// The API reports how much is *left*; every other vendor here reports how much
357/// is *spent*.
358fn 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
375// ---------------------------------------------------------------------------
376// Language server discovery
377// ---------------------------------------------------------------------------
378
379/// Base URLs worth probing, most specific first.
380fn 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
387/// Test seam for [`candidate_bases`] — takes the address override and the
388/// discovered ports instead of reading the environment and `/proc`.
389fn 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    // No hardcoded fallback port on purpose: the server always binds with
398    // `--https_server_port 0`, so its port is drawn from the ephemeral range
399    // and cannot be guessed. Probing a fixed one would just poke whatever
400    // unrelated process happens to own it. Discovery or the explicit override.
401    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
415/// Does this process look like one of the three Antigravity products?
416///
417/// Antigravity 2.0 and the IDE spawn a separate `language_server` child, while
418/// the `agy` CLI embeds the same CSRF/RPC surface in its own process — so
419/// matching on the server binary alone would miss a CLI-only install. `comm` is
420/// truncated to 15 bytes by the kernel, which `language_server` exactly fills.
421fn 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/// Loopback ports listened on by any running Antigravity product.
433///
434/// Reads `/proc` directly rather than shelling out to `ss`/`lsof`: find the
435/// candidate pids, collect their socket inodes, then keep the listening TCP
436/// entries owning one of those inodes. All three products report the *same*
437/// shared quota, so whichever answers first is authoritative.
438#[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/// macOS has no `/proc`, so fall back to `lsof` (present on every macOS
495/// install by default, unlike Linux where shelling out was deliberately
496/// avoided — see the doc comment above). `-F pcn` asks for machine-parsable
497/// output: one `p<pid>` line per process, one `c<command>` line for its name,
498/// then an `n<address>` line per matching socket already filtered down to
499/// listening TCP sockets by `-iTCP -sTCP:LISTEN`.
500#[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    // A non-zero exit still emits usable output for the fds it *could* read,
509    // so parse regardless of status — an empty/garbled stdout just parses to
510    // an empty port list.
511    parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
512}
513
514/// Pure parser for `lsof -F pcn` output, kept separate from process spawning
515/// so the parsing logic is unit-testable without shelling out.
516#[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/// Pull `(local_port, inode)` out of a listening row of `/proc/net/tcp`.
544/// Columns: `sl local_address rem_address st ... uid timeout inode`.
545#[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    // 0x0A == TCP_LISTEN. Anything else is an established/closing socket.
552    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
560// ---------------------------------------------------------------------------
561// Cache
562// ---------------------------------------------------------------------------
563
564fn 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
571/// Serve the stale cache when there is one. With no cache to fall back on,
572/// surface `reason` — the actual diagnosis, e.g. "no local language server
573/// found" — rather than a generic cache-miss that tells the user nothing about
574/// what to do. This is the first-run path: no cache yet and Antigravity closed.
575fn 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
607/// `account` is the fingerprint of the currently signed-in account, or `None`
608/// when no local server answered. A payload belonging to a different account is
609/// rejected so a Google-account switch cannot show the previous account's
610/// quota. With `None` we cannot verify — but nothing is consuming quota while
611/// Antigravity is down, so the last known figures are the best available truth.
612pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
613    parse_cache_at(bytes, account, Utc::now())
614}
615
616/// A cached window whose reset has already passed describes a period that has
617/// since rolled over: the real figure is back near zero while the payload still
618/// carries the old one, and its countdown is pinned at "now". Serving that is
619/// presenting a known-obsolete number as current, so the payload is refused and
620/// the caller reports that Antigravity needs to be running.
621///
622/// This matters more here than for other vendors: "nothing running" is the
623/// normal state for Antigravity, and `MAX_STALE` is seven days — far past the
624/// five hours after which the session window is guaranteed wrong.
625fn 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    // The Gemini windows are required. Defaulting a missing or truncated field
654    // to 0 would render a confident "0% used" and keep serving it for the rest
655    // of the TTL; returning an error makes the caller fall through to a live
656    // fetch instead of displaying a fabricated snapshot.
657    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    /// Captured from a real `RetrieveUserQuotaSummary` response on 2026-07-22
739    /// (Antigravity 2.0 build 2.3.1, `agy` 1.1.5), then trimmed. Percentages
740    /// were edited to distinct non-zero values so a slot mix-up cannot pass.
741    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    /// Fixed instant, earlier than every reset in the fixture. Using the wall
769    /// clock here would make the suite start failing once those resets pass.
770    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        // remainingFraction is inverted into "used".
786        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        // Regression: weekly must never be a copy of the 5h window.
806        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        // A future pool or cadence is ignored, not silently treated as the
853        // Claude/GPT 5h slot.
854        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    /// A drifted bucket must fail the parse rather than report a reassuring
873    /// "0% used" for a window whose real state is unknown.
874    #[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    /// A truncated payload must fail so the caller refetches. Defaulting the
912    /// missing field to 0 would serve a confident "0% used" for the rest of the
913    /// TTL — the fabricated-placeholder defect corrected in PR #26.
914    #[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        // A wholly empty object is not a zero-usage snapshot either.
925        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    /// With Antigravity closed the cache is served for up to `MAX_STALE`, but a
955    /// window whose reset has passed has since rolled over — the real figure is
956    /// back near zero while the payload still carries the old one. Serving that
957    /// would present a known-obsolete number as current.
958    #[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        // Before every reset: served.
964        assert!(parse_cache_at(&bytes, None, now()).is_ok());
965        // One second before the earliest (the two 5h windows, 17:47:00Z).
966        assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
967
968        // The reset instant itself already counts as rolled over.
969        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        // Well past it — this is the reboot-with-nothing-running case.
973        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
974    }
975
976    /// The weekly windows outlive the 5-hour ones, so expiry must be reported
977    /// per window rather than assuming the shortest one speaks for all four.
978    #[test]
979    fn expiry_names_the_window_that_rolled_over() {
980        let mut snap = parsed();
981        // Drop the 5h windows so only the weeklies can expire.
982        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        // Past the 5h resets but before either weekly: still usable.
988        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
989
990        // Past the Gemini weekly (28th) but not the third-party one (29th).
991        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    /// A window with no reset time is unknown, not expired.
996    #[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    /// Switching Google accounts must not show the previous account's quota.
1012    #[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        // A payload written before the account was recorded is unattributable.
1025        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    /// With no local server there is nothing to compare against — and nothing
1032    /// is consuming quota either, so the last known figures still stand.
1033    #[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        // An unidentifiable response still compares equal to itself.
1050        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    /// The third-party pool is genuinely optional — a plan without it caches a
1056    /// null and must still read back, unlike the required Gemini windows.
1057    #[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        // An address that already carries a scheme is left alone.
1120        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    /// The server's port is drawn from the ephemeral range, so there is nothing
1138    /// sensible to guess when discovery comes up empty. Probing a hardcoded
1139    /// port would contact an unrelated process; callers get the "start
1140    /// Antigravity or set ANTIGRAVITY_LS_ADDRESS" error instead.
1141    #[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        // Antigravity 2.0 / IDE: a separate language_server child.
1150        assert!(is_antigravity_process(
1151            "language_server\n",
1152            Some("/opt/antigravity/resources/bin/language_server")
1153        ));
1154        // agy CLI: embeds the RPC surface in its own process.
1155        assert!(is_antigravity_process(
1156            "agy\n",
1157            Some("/home/u/.local/bin/agy")
1158        ));
1159        // Recognised by path even when the process name says nothing.
1160        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        // "legacy" ends in a substring of "/agy" but is not the CLI.
1172        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        // `agy` (pid 74101) has three listening sockets; `sshd` (pid 200) has
1180        // one that must be excluded even though it sorts right after `c`.
1181        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    /// First run with Antigravity closed: no cache to serve, so the user must
1201    /// be told what to start — not "no usable cache", which says nothing.
1202    #[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}