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 errors = Vec::new();
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) => errors.push(e),
150        }
151    }
152    Err(select_probe_error(errors))
153}
154
155/// Which failure to report when no candidate answered.
156///
157/// A server that replies `401`/`403` is running and reachable but signed out —
158/// the user can act on that, so it outranks the connection refusals from the
159/// products that simply are not up. Without this, a stale
160/// `ANTIGRAVITY_LS_ADDRESS` (or a second product on another port) would mask
161/// the one message worth reading behind transport noise.
162///
163/// Note that this also decides *visibility*: transport errors are transient and
164/// fall back silently to cache, while the `401` surfaces in the widget.
165fn select_probe_error(errors: Vec<AppError>) -> AppError {
166    let mut actionable = None;
167    let mut last = None;
168    for e in errors {
169        if actionable.is_none() && is_actionable(&e) {
170            actionable = Some(e);
171        } else {
172            last = Some(e);
173        }
174    }
175    actionable.or(last).unwrap_or_else(|| {
176        AppError::Other("antigravity: no local server answered GetUserStatus".into())
177    })
178}
179
180/// An error the user can do something about, as opposed to "that product is not
181/// running". `post_rpc` only ever yields `Http`/`Transport`/`Other`, so the
182/// authentication statuses are the whole set.
183fn is_actionable(e: &AppError) -> bool {
184    matches!(e, AppError::Http { status, .. } if *status == 401 || *status == 403)
185}
186
187async fn fetch_live(
188    client: &reqwest::Client,
189    session: Result<Session>,
190) -> Result<AntigravitySnapshot> {
191    let session = session?;
192    let quota = post_rpc(client, &session.base, session.csrf.as_deref(), QUOTA_RPC).await?;
193    let mut snap = parse_quota_summary(&quota, session.plan)?;
194    snap.account = session.account;
195    Ok(snap)
196}
197
198/// Identity of the signed-in account, fingerprinted rather than stored in
199/// clear — the cache only needs a change detector, not the address itself.
200/// An unidentifiable response yields a stable "unknown" bucket so two such
201/// responses still compare equal.
202fn account_key(user_status: &serde_json::Value) -> String {
203    let email = user_status["userStatus"]["email"]
204        .as_str()
205        .filter(|s| !s.is_empty());
206    match email {
207        Some(e) => {
208            use std::hash::{Hash, Hasher};
209            let mut h = std::collections::hash_map::DefaultHasher::new();
210            e.hash(&mut h);
211            format!("acct:{:016x}", h.finish())
212        }
213        None => "acct:unknown".to_string(),
214    }
215}
216
217/// The Antigravity 2.0 server embeds a CSRF token in the HTML it serves at `/`
218/// and rejects the RPC without it. The `agy` CLI serves no such page — it 404s
219/// at `/` and answers the RPC unauthenticated — so a missing token is not an
220/// error here, just a server that does not use one.
221async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
222    let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
223    // Bounded like every other response this crate reads: a local server is
224    // still an untrusted source of unbounded bytes.
225    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
226        .await
227        .ok()?;
228    let html = String::from_utf8_lossy(&bytes);
229    html.split("csrfToken\":\"")
230        .nth(1)
231        .and_then(|s| s.split('"').next())
232        .filter(|t| !t.is_empty())
233        .map(|t| t.to_string())
234}
235
236async fn post_rpc(
237    client: &reqwest::Client,
238    base: &str,
239    csrf: Option<&str>,
240    rpc: &str,
241) -> Result<serde_json::Value> {
242    let mut req = client
243        .post(format!("{base}/{rpc}"))
244        .header("Content-Type", "application/json")
245        .body("{}")
246        .timeout(HTTP_TIMEOUT);
247    if let Some(token) = csrf {
248        req = req.header("x-codeium-csrf-token", token);
249    }
250    let resp = req.send().await?;
251
252    let status = resp.status();
253    // Cap error bodies too. A local endpoint is still untrusted, and reading a
254    // non-2xx response with `text()` would bypass the invariant enforced for
255    // successful JSON responses.
256    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
257    if !status.is_success() {
258        let body = String::from_utf8_lossy(&bytes).into_owned();
259        return Err(AppError::Http {
260            status: status.as_u16(),
261            body,
262        });
263    }
264
265    Ok(serde_json::from_slice(&bytes)?)
266}
267
268pub fn plan_from_status(v: &serde_json::Value) -> String {
269    v["userStatus"]["userTier"]["name"]
270        .as_str()
271        .or_else(|| v["userStatus"]["userTier"]["description"].as_str())
272        .or_else(|| v["userStatus"]["planStatus"]["planInfo"]["planName"].as_str())
273        .filter(|s| !s.is_empty())
274        .unwrap_or(DEFAULT_PLAN)
275        .to_string()
276}
277
278// ---------------------------------------------------------------------------
279// Quota parsing
280// ---------------------------------------------------------------------------
281
282/// Map a `RetrieveUserQuotaSummary` payload onto the four usage windows.
283///
284/// Buckets are keyed by `bucketId` (`gemini-5h`, `gemini-weekly`, `3p-5h`,
285/// `3p-weekly`), falling back to the group display name plus the `window`
286/// discriminator so a renamed bucket id still lands in the right slot.
287pub fn parse_quota_summary(v: &serde_json::Value, plan: String) -> Result<AntigravitySnapshot> {
288    let groups = v["response"]["groups"]
289        .as_array()
290        .or_else(|| v["groups"].as_array())
291        .ok_or_else(|| AppError::Other("antigravity: quota summary has no groups".into()))?;
292
293    let mut gemini_5h = None;
294    let mut gemini_weekly = None;
295    let mut tp_5h = None;
296    let mut tp_weekly = None;
297
298    for group in groups {
299        let group_name = group["displayName"].as_str().unwrap_or_default();
300        let Some(buckets) = group["buckets"].as_array() else {
301            continue;
302        };
303        for bucket in buckets {
304            let id = bucket["bucketId"].as_str().unwrap_or_default();
305            let window = bucket["window"].as_str().unwrap_or_default();
306            let is_weekly = if id.ends_with("weekly") || window == "weekly" {
307                true
308            } else if id.ends_with("5h") || window == "5h" {
309                false
310            } else {
311                // A new cadence is not a 5-hour bucket by default. Ignore it
312                // so it cannot overwrite a known slot.
313                continue;
314            };
315            let is_gemini = if id.starts_with("gemini") {
316                true
317            } else if id.starts_with("3p") {
318                false
319            } else if group_name.contains("Gemini") {
320                true
321            } else if group_name.contains("Claude") || group_name.contains("GPT") {
322                false
323            } else {
324                // Likewise, an unrelated future group is not implicitly the
325                // third-party pool.
326                continue;
327            };
328
329            let (slot, slot_name) = match (is_gemini, is_weekly) {
330                (true, false) => (&mut gemini_5h, "Gemini 5h"),
331                (true, true) => (&mut gemini_weekly, "Gemini weekly"),
332                (false, false) => (&mut tp_5h, "Claude/GPT 5h"),
333                (false, true) => (&mut tp_weekly, "Claude/GPT weekly"),
334            };
335            let parsed = usage_window(bucket, is_weekly)?;
336            if slot.replace(parsed).is_some() {
337                return Err(AppError::Schema(format!(
338                    "antigravity: duplicate {slot_name} bucket"
339                )));
340            }
341        }
342    }
343
344    let session = gemini_5h.ok_or_else(|| {
345        AppError::Other("antigravity: quota summary has no Gemini 5h bucket".into())
346    })?;
347    let weekly = gemini_weekly.ok_or_else(|| {
348        AppError::Other("antigravity: quota summary has no Gemini weekly bucket".into())
349    })?;
350
351    Ok(AntigravitySnapshot {
352        plan,
353        // Stamped by the caller, which is what knows the session's identity.
354        account: String::new(),
355        session,
356        weekly,
357        third_party_session: tp_5h,
358        third_party_weekly: tp_weekly,
359    })
360}
361
362/// `remainingFraction` is required and must be finite: defaulting a missing or
363/// drifted value to 1.0 would report a reassuring "0% used" for a window whose
364/// real state is unknown, and cache it.
365fn usage_window(bucket: &serde_json::Value, is_weekly: bool) -> Result<UsageWindow> {
366    let remaining = bucket["remainingFraction"]
367        .as_f64()
368        .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
369        .ok_or_else(|| {
370            AppError::Schema(format!(
371                "antigravity: bucket {} has no valid remainingFraction in 0..=1",
372                bucket["bucketId"].as_str().unwrap_or("<unnamed>")
373            ))
374        })?;
375    Ok(UsageWindow {
376        utilization_pct: pct_used(remaining),
377        resets_at: parse_reset(&bucket["resetTime"], "quota resetTime")?,
378        window_duration: if is_weekly {
379            chrono::Duration::days(7)
380        } else {
381            chrono::Duration::hours(5)
382        },
383    })
384}
385
386/// The API reports how much is *left*; every other vendor here reports how much
387/// is *spent*.
388fn pct_used(remaining_fraction: f64) -> i32 {
389    let used = (1.0 - remaining_fraction) * 100.0;
390    used.round() as i32
391}
392
393fn parse_reset(value: &serde_json::Value, field: &str) -> Result<Option<DateTime<Utc>>> {
394    match value {
395        serde_json::Value::Null => Ok(None),
396        serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
397            .map(|dt| Some(dt.with_timezone(&Utc)))
398            .map_err(|_| AppError::Schema(format!("antigravity: invalid {field}"))),
399        _ => Err(AppError::Schema(format!(
400            "antigravity: {field} must be a timestamp or null"
401        ))),
402    }
403}
404
405// ---------------------------------------------------------------------------
406// Language server discovery
407// ---------------------------------------------------------------------------
408
409/// Base URLs worth probing, most specific first.
410fn candidate_bases() -> Vec<String> {
411    candidate_bases_with(
412        std::env::var("ANTIGRAVITY_LS_ADDRESS").ok().as_deref(),
413        discover_ls_ports(),
414    )
415}
416
417/// Test seam for [`candidate_bases`] — takes the address override and the
418/// discovered ports instead of reading the environment and `/proc`.
419fn candidate_bases_with(override_addr: Option<&str>, discovered: Vec<u16>) -> Vec<String> {
420    let mut bases = Vec::new();
421    if let Some(base) = override_addr.and_then(normalize_base) {
422        bases.push(base);
423    }
424
425    // No hardcoded fallback port on purpose: the server always binds with
426    // `--https_server_port 0`, so its port is drawn from the ephemeral range
427    // and cannot be guessed. Probing a fixed one would just poke whatever
428    // unrelated process happens to own it. Discovered ports follow any
429    // explicit override as fallback, with duplicates omitted.
430    for p in discovered {
431        let candidate = format!("http://127.0.0.1:{p}");
432        if !bases.contains(&candidate) {
433            bases.push(candidate);
434        }
435    }
436
437    bases
438}
439
440/// Turn a configured address into a base URL: trim surrounding whitespace,
441/// supply the default scheme when it is missing, and drop trailing slashes so
442/// the RPC paths built on top do not come out with a double slash.
443///
444/// Returns `None` when nothing but a scheme survives. `ANTIGRAVITY_LS_ADDRESS`
445/// is user input, and a value like `"/"` carries no authority to connect to;
446/// admitting it as a candidate would spend a probe to learn what is already
447/// knowable here.
448fn normalize_base(addr: &str) -> Option<String> {
449    let trimmed = addr.trim();
450    let (scheme, authority) = match trimmed.split_once("://") {
451        Some((scheme @ ("http" | "https"), rest)) => (scheme, rest),
452        _ => ("http", trimmed),
453    };
454    let authority = authority.trim_end_matches('/');
455    (!authority.is_empty()).then(|| format!("{scheme}://{authority}"))
456}
457
458/// Does this process look like one of the three Antigravity products?
459///
460/// Antigravity 2.0 and the IDE spawn a separate `language_server` child, while
461/// the `agy` CLI embeds the same CSRF/RPC surface in its own process — so
462/// matching on the server binary alone would miss a CLI-only install. `comm` is
463/// truncated to 15 bytes by the kernel, which `language_server` exactly fills.
464fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
465    let comm = comm.trim().to_lowercase();
466    let comm = comm.strip_suffix(".exe").unwrap_or(&comm);
467    if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
468        return true;
469    }
470    exe.is_some_and(|p| {
471        let p = p.to_lowercase().replace('\\', "/");
472        let p = p.strip_suffix(".exe").unwrap_or(&p);
473        p.contains("antigravity") || p.ends_with("/agy")
474    })
475}
476
477/// Flatten per-process listener ports into the order they should be probed.
478///
479/// Each Antigravity product binds an HTTPS/TLS listener and the unencrypted
480/// HTTP JSON-RPC listener that serves `GetUserStatus` and
481/// `RetrieveUserQuotaSummary`. Both are ephemeral, but they are bound in that
482/// order, so in practice the RPC listener draws the higher number and probing
483/// high-to-low reaches it first — which keeps Go's `net/http.Server` from
484/// logging a TLS handshake error for every unencrypted request that lands on
485/// its HTTPS listener.
486///
487/// Sorting every discovered port as one descending set only gets that right
488/// for a single process, because the high/low tendency holds *within* a
489/// product and says nothing across two of them: with Antigravity 2.0 and an
490/// `agy` session both up, one product's TLS port can sort above the other's
491/// RPC port. Ports are therefore grouped per pid, sorted high-to-low inside
492/// each group, and taken rank by rank — every product's highest port, then
493/// every product's second-highest, and so on. Where each product shows both
494/// listeners that puts every RPC one ahead of every TLS one.
495///
496/// This stays a preference, not a guarantee, and the two-listener shape is the
497/// assumption it rests on: a product caught mid-startup, with only its TLS port
498/// bound so far, sits alone at rank 0 and is probed first. Every candidate is
499/// probed regardless, so a mis-ranked one costs an extra round-trip and a line
500/// on `agy`'s stderr, nothing more.
501///
502/// Order among products is arbitrary — all of them report the same
503/// account-wide quota, so whichever answers first is authoritative — and pid
504/// order is used only to keep the result reproducible, since `/proc`, `lsof`
505/// and the Windows TCP table each enumerate in their own order.
506#[cfg(any(test, target_os = "linux", target_os = "macos", target_os = "windows"))]
507fn probe_order(per_pid: std::collections::BTreeMap<u32, Vec<u16>>) -> Vec<u16> {
508    let groups: Vec<Vec<u16>> = per_pid
509        .into_values()
510        .map(|mut group| {
511            group.sort_unstable_by(|a, b| b.cmp(a));
512            // Within a product a port is one listener however many rows named
513            // it — a dual-stack bind reports the same port from both
514            // `/proc/net/tcp` and `tcp6`. Collapsing them here keeps a rank
515            // meaning "the Nth listener" rather than "the Nth row".
516            group.dedup();
517            group
518        })
519        .collect();
520
521    let mut ports: Vec<u16> = Vec::new();
522    for rank in 0..groups.iter().map(Vec::len).max().unwrap_or(0) {
523        for port in groups.iter().filter_map(|group| group.get(rank)) {
524            if !ports.contains(port) {
525                ports.push(*port);
526            }
527        }
528    }
529    ports
530}
531
532/// Loopback ports listened on by any running Antigravity product.
533///
534/// Reads `/proc` directly rather than shelling out to `ss`/`lsof`: find the
535/// candidate pids, collect their socket inodes, then keep the listening TCP
536/// entries owning one of those inodes. All three products report the *same*
537/// shared quota, so whichever answers first is authoritative.
538#[cfg(target_os = "linux")]
539fn discover_ls_ports() -> Vec<u16> {
540    use std::collections::{BTreeMap, HashMap};
541
542    // Socket inode -> owning pid, so the ports found in `/proc/net` can be
543    // grouped back per process for `probe_order`.
544    let mut owners: HashMap<u64, u32> = HashMap::new();
545    let Ok(entries) = std::fs::read_dir("/proc") else {
546        return Vec::new();
547    };
548    for entry in entries.flatten() {
549        let pid_dir = entry.path();
550        let Some(pid) = pid_dir
551            .file_name()
552            .and_then(|name| name.to_str())
553            .and_then(|name| name.parse::<u32>().ok())
554        else {
555            continue;
556        };
557        let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
558            continue;
559        };
560        let exe = std::fs::read_link(pid_dir.join("exe")).ok();
561        if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
562            continue;
563        }
564        let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
565            continue;
566        };
567        for fd in fds.flatten() {
568            let Ok(target) = std::fs::read_link(fd.path()) else {
569                continue;
570            };
571            if let Some(ino) = target
572                .to_str()
573                .and_then(|s| s.strip_prefix("socket:["))
574                .and_then(|s| s.strip_suffix(']'))
575                .and_then(|s| s.parse::<u64>().ok())
576            {
577                owners.insert(ino, pid);
578            }
579        }
580    }
581
582    if owners.is_empty() {
583        return Vec::new();
584    }
585
586    let mut per_pid: BTreeMap<u32, Vec<u16>> = BTreeMap::new();
587    for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
588        let Ok(contents) = std::fs::read_to_string(table) else {
589            continue;
590        };
591        for line in contents.lines().skip(1) {
592            if let Some((port, ino)) = parse_proc_net_line(line)
593                && let Some(&pid) = owners.get(&ino)
594            {
595                per_pid.entry(pid).or_default().push(port);
596            }
597        }
598    }
599    probe_order(per_pid)
600}
601
602/// macOS has no `/proc`, so fall back to `lsof` (present on every macOS
603/// install by default, unlike Linux where shelling out was deliberately
604/// avoided — see the doc comment above). `-F pcn` asks for machine-parsable
605/// output: one `p<pid>` line per process, one `c<command>` line for its name,
606/// then an `n<address>` line per matching socket already filtered down to
607/// listening TCP sockets by `-iTCP -sTCP:LISTEN`.
608#[cfg(target_os = "macos")]
609fn discover_ls_ports() -> Vec<u16> {
610    let Ok(output) = std::process::Command::new("lsof")
611        .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
612        .output()
613    else {
614        return Vec::new();
615    };
616    // A non-zero exit still emits usable output for the fds it *could* read,
617    // so parse regardless of status — an empty/garbled stdout just parses to
618    // an empty port list.
619    parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
620}
621
622/// Pure parser for `lsof -F pcn` output, kept separate from process spawning
623/// so the parsing logic is unit-testable without shelling out. Compiled under
624/// `test` on every platform, like [`matching_windows_ports`], so its tests are
625/// not macOS-only.
626#[cfg(any(test, target_os = "macos"))]
627fn parse_lsof_pcn(output: &str) -> Vec<u16> {
628    let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
629    // The pid arrives on the `p` line and the command name on the `c` line
630    // right after it, so hold the pid until the name confirms it is ours.
631    let mut pid = None;
632    let mut owner = None;
633    for line in output.lines() {
634        let Some(rest) = line.get(1..) else { continue };
635        match line.as_bytes().first() {
636            Some(b'p') => {
637                pid = rest.parse::<u32>().ok();
638                owner = None;
639            }
640            Some(b'c') => owner = pid.filter(|_| is_antigravity_process(rest, None)),
641            Some(b'n') => {
642                if let Some(pid) = owner
643                    && let Some(port) = rest.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
644                {
645                    per_pid.entry(pid).or_default().push(port);
646                }
647            }
648            _ => {}
649        }
650    }
651    probe_order(per_pid)
652}
653
654#[cfg(any(test, target_os = "windows"))]
655#[derive(Debug, Clone, Copy, PartialEq, Eq)]
656struct WindowsTcpRow {
657    local_addr: [u8; 4],
658    local_port: u32,
659    pid: u32,
660}
661
662#[cfg(any(test, target_os = "windows"))]
663fn decode_windows_process_name(raw: &[u16]) -> String {
664    let end = raw.iter().position(|&unit| unit == 0).unwrap_or(raw.len());
665    String::from_utf16_lossy(&raw[..end])
666}
667
668#[cfg(any(test, target_os = "windows"))]
669fn matching_windows_process_ids(processes: &[(u32, String)]) -> std::collections::HashSet<u32> {
670    processes
671        .iter()
672        .filter(|(_, name)| is_antigravity_process(name, None))
673        .map(|(pid, _)| *pid)
674        .collect()
675}
676
677/// Loopback ports owned by the matching processes, grouped per pid and handed
678/// to [`probe_order`], which explains why the grouping matters.
679#[cfg(any(test, target_os = "windows"))]
680fn matching_windows_ports(
681    pids: &std::collections::HashSet<u32>,
682    rows: &[WindowsTcpRow],
683) -> Vec<u16> {
684    let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
685    for row in rows {
686        if !pids.contains(&row.pid) || row.local_addr != [127, 0, 0, 1] {
687            continue;
688        }
689        let port = u16::from_be((row.local_port & u32::from(u16::MAX)) as u16);
690        if port != 0 {
691            per_pid.entry(row.pid).or_default().push(port);
692        }
693    }
694    probe_order(per_pid)
695}
696
697#[cfg(any(test, target_os = "windows"))]
698fn checked_windows_row_count(
699    buffer_len: usize,
700    rows_offset: usize,
701    row_size: usize,
702    declared: usize,
703) -> Option<usize> {
704    let rows_len = row_size.checked_mul(declared)?;
705    let end = rows_offset.checked_add(rows_len)?;
706    (row_size != 0 && end <= buffer_len).then_some(declared)
707}
708
709#[cfg(target_os = "windows")]
710struct WindowsHandle(windows_sys::Win32::Foundation::HANDLE);
711
712#[cfg(target_os = "windows")]
713impl Drop for WindowsHandle {
714    fn drop(&mut self) {
715        unsafe {
716            windows_sys::Win32::Foundation::CloseHandle(self.0);
717        }
718    }
719}
720
721#[cfg(target_os = "windows")]
722fn windows_processes() -> Vec<(u32, String)> {
723    use std::mem::size_of;
724    use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
725    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
726        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
727        TH32CS_SNAPPROCESS,
728    };
729
730    let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
731    if handle == INVALID_HANDLE_VALUE {
732        return Vec::new();
733    }
734    let snapshot = WindowsHandle(handle);
735    let mut entry = PROCESSENTRY32W::default();
736    let Ok(entry_size) = u32::try_from(size_of::<PROCESSENTRY32W>()) else {
737        return Vec::new();
738    };
739    entry.dwSize = entry_size;
740    if unsafe { Process32FirstW(snapshot.0, &mut entry) } == 0 {
741        return Vec::new();
742    }
743
744    let mut processes = Vec::new();
745    loop {
746        processes.push((
747            entry.th32ProcessID,
748            decode_windows_process_name(&entry.szExeFile),
749        ));
750        if unsafe { Process32NextW(snapshot.0, &mut entry) } == 0 {
751            break;
752        }
753    }
754    processes
755}
756
757#[cfg(target_os = "windows")]
758fn parse_windows_tcp_rows(buffer: &[u32], used_bytes: usize) -> Vec<WindowsTcpRow> {
759    use std::mem::{offset_of, size_of, size_of_val};
760    use windows_sys::Win32::NetworkManagement::IpHelper::{
761        MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
762    };
763
764    let available = used_bytes.min(size_of_val(buffer));
765    let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
766    if available < size_of::<u32>() || available < rows_offset {
767        return Vec::new();
768    }
769    let base = buffer.as_ptr().cast::<u8>();
770    let declared = unsafe { base.cast::<u32>().read_unaligned() } as usize;
771    if checked_windows_row_count(
772        available,
773        rows_offset,
774        size_of::<MIB_TCPROW_OWNER_PID>(),
775        declared,
776    )
777    .is_none()
778    {
779        return Vec::new();
780    }
781
782    let rows = unsafe { base.add(rows_offset).cast::<MIB_TCPROW_OWNER_PID>() };
783    (0..declared)
784        .map(|index| unsafe { rows.add(index).read_unaligned() })
785        .map(|row| WindowsTcpRow {
786            local_addr: row.dwLocalAddr.to_ne_bytes(),
787            local_port: row.dwLocalPort,
788            pid: row.dwOwningPid,
789        })
790        .collect()
791}
792
793#[cfg(target_os = "windows")]
794fn windows_tcp_rows() -> Vec<WindowsTcpRow> {
795    use std::mem::size_of;
796    use std::ptr::null_mut;
797    use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
798    use windows_sys::Win32::NetworkManagement::IpHelper::{
799        GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
800    };
801    use windows_sys::Win32::Networking::WinSock::AF_INET;
802
803    let mut size = 0u32;
804    let status = unsafe {
805        GetExtendedTcpTable(
806            null_mut(),
807            &mut size,
808            0,
809            u32::from(AF_INET),
810            TCP_TABLE_OWNER_PID_LISTENER,
811            0,
812        )
813    };
814    if status != ERROR_INSUFFICIENT_BUFFER {
815        return Vec::new();
816    }
817
818    for _ in 0..3 {
819        let Some(words) = (size as usize)
820            .checked_add(size_of::<u32>() - 1)
821            .map(|bytes| bytes / size_of::<u32>())
822        else {
823            return Vec::new();
824        };
825        if words == 0 {
826            return Vec::new();
827        }
828        let mut buffer = Vec::<u32>::new();
829        if buffer.try_reserve_exact(words).is_err() {
830            return Vec::new();
831        }
832        buffer.resize(words, 0);
833        let mut used = size;
834        let status = unsafe {
835            GetExtendedTcpTable(
836                buffer.as_mut_ptr().cast(),
837                &mut used,
838                0,
839                u32::from(AF_INET),
840                TCP_TABLE_OWNER_PID_LISTENER,
841                0,
842            )
843        };
844        if status == ERROR_INSUFFICIENT_BUFFER {
845            size = used;
846            continue;
847        }
848        if status != 0 {
849            return Vec::new();
850        }
851        return parse_windows_tcp_rows(&buffer, used as usize);
852    }
853    Vec::new()
854}
855
856#[cfg(target_os = "windows")]
857fn discover_ls_ports() -> Vec<u16> {
858    let pids = matching_windows_process_ids(&windows_processes());
859    if pids.is_empty() {
860        return Vec::new();
861    }
862    matching_windows_ports(&pids, &windows_tcp_rows())
863}
864
865#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
866fn discover_ls_ports() -> Vec<u16> {
867    Vec::new()
868}
869
870/// Pull `(local_port, inode)` out of a listening row of `/proc/net/tcp`.
871/// Columns: `sl local_address rem_address st ... uid timeout inode`.
872#[cfg(target_os = "linux")]
873fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
874    let cols: Vec<&str> = line.split_whitespace().collect();
875    if cols.len() < 10 {
876        return None;
877    }
878    // 0x0A == TCP_LISTEN. Anything else is an established/closing socket.
879    if cols[3] != "0A" {
880        return None;
881    }
882    let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
883    let inode = cols[9].parse::<u64>().ok()?;
884    Some((port, inode))
885}
886
887// ---------------------------------------------------------------------------
888// Cache
889// ---------------------------------------------------------------------------
890
891fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
892    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
893        return Err(original);
894    };
895    reuse_cache(bytes, cache, true, None, now).or(Err(original))
896}
897
898/// Serve the stale cache when there is one. With no cache to fall back on,
899/// surface `reason` — the actual diagnosis, e.g. "no local language server
900/// found" — rather than a generic cache-miss that tells the user nothing about
901/// what to do. This is the first-run path: no cache yet and Antigravity closed.
902fn fallback_with_error(
903    cache: &Cache,
904    last_error: Option<(u16, String)>,
905    reason: AppError,
906    now: DateTime<Utc>,
907) -> Result<FetchOutcome> {
908    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
909        return Err(reason);
910    };
911    let Ok(mut outcome) = reuse_cache(bytes, cache, true, None, now) else {
912        return Err(reason);
913    };
914    outcome.last_error = last_error;
915    Ok(outcome)
916}
917
918fn reuse_cache(
919    bytes: Vec<u8>,
920    cache: &Cache,
921    stale: bool,
922    account: Option<&str>,
923    now: DateTime<Utc>,
924) -> Result<FetchOutcome> {
925    let snap = parse_cache_at(&bytes, account, now)?;
926    Ok(FetchOutcome {
927        snapshot: snap,
928        stale,
929        last_error: cache.read_last_error(),
930        cache_age: cache.payload_age(),
931    })
932}
933
934/// `account` is the fingerprint of the currently signed-in account, or `None`
935/// when no local server answered. A payload belonging to a different account is
936/// rejected so a Google-account switch cannot show the previous account's
937/// quota. With `None` we cannot verify — but nothing is consuming quota while
938/// Antigravity is down, so the last known figures are the best available truth.
939pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
940    parse_cache_at(bytes, account, Utc::now())
941}
942
943/// A cached window whose reset has already passed describes a period that has
944/// since rolled over: the real figure is back near zero while the payload still
945/// carries the old one, and its countdown is pinned at "now". Serving that is
946/// presenting a known-obsolete number as current, so the payload is refused and
947/// the caller reports that Antigravity needs to be running.
948///
949/// This matters more here than for other vendors: "nothing running" is the
950/// normal state for Antigravity, and `MAX_STALE` is seven days — far past the
951/// five hours after which the session window is guaranteed wrong.
952fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
953    [
954        ("Gemini 5h", Some(&snap.session)),
955        ("Gemini weekly", Some(&snap.weekly)),
956        ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
957        ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
958    ]
959    .into_iter()
960    .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
961    .map(|(name, _)| name)
962}
963
964pub fn parse_cache_at(
965    bytes: &[u8],
966    account: Option<&str>,
967    now: DateTime<Utc>,
968) -> Result<AntigravitySnapshot> {
969    let v: serde_json::Value = serde_json::from_slice(bytes)?;
970
971    let cached_account = v.get("account").and_then(serde_json::Value::as_str);
972    if let Some(expected) = account
973        && cached_account != Some(expected)
974    {
975        return Err(AppError::Schema(
976            "antigravity cache belongs to a different account; refetching".into(),
977        ));
978    }
979
980    // The Gemini windows are required. Defaulting a missing or truncated field
981    // to 0 would render a confident "0% used" and keep serving it for the rest
982    // of the TTL; returning an error makes the caller fall through to a live
983    // fetch instead of displaying a fabricated snapshot.
984    let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
985        match v.get(pct_key) {
986            None | Some(serde_json::Value::Null) => Ok(None),
987            Some(value) => value
988                .as_i64()
989                .filter(|pct| (0..=100).contains(pct))
990                .map(|pct| Some(pct as i32))
991                .ok_or_else(|| {
992                    AppError::Schema(format!(
993                        "antigravity: cached {pct_key} must be an integer in 0..=100"
994                    ))
995                }),
996        }
997    };
998
999    let window = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1000        let pct = cached_pct(pct_key)?.ok_or_else(|| {
1001            AppError::Schema(format!("antigravity: cached payload missing {pct_key}"))
1002        })?;
1003        Ok::<_, AppError>(UsageWindow {
1004            utilization_pct: pct,
1005            resets_at: parse_reset(&v[reset_key], reset_key)?,
1006            window_duration: if weekly {
1007                chrono::Duration::days(7)
1008            } else {
1009                chrono::Duration::hours(5)
1010            },
1011        })
1012    };
1013
1014    let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1015        let Some(pct) = cached_pct(pct_key)? else {
1016            return Ok(None);
1017        };
1018        Ok::<_, AppError>(Some(UsageWindow {
1019            utilization_pct: pct,
1020            resets_at: parse_reset(&v[reset_key], reset_key)?,
1021            window_duration: if weekly {
1022                chrono::Duration::days(7)
1023            } else {
1024                chrono::Duration::hours(5)
1025            },
1026        }))
1027    };
1028
1029    let snap = AntigravitySnapshot {
1030        plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
1031        account: cached_account.unwrap_or_default().to_string(),
1032        session: window("session_pct", "session_reset", false)?,
1033        weekly: window("weekly_pct", "weekly_reset", true)?,
1034        third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
1035        third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
1036    };
1037
1038    if let Some(window) = expired_window(&snap, now) {
1039        return Err(AppError::Schema(format!(
1040            "antigravity cache is past its {window} reset; refetching"
1041        )));
1042    }
1043    Ok(snap)
1044}
1045
1046pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
1047    serde_json::json!({
1048        "plan": snap.plan,
1049        "account": snap.account,
1050        "session_pct": snap.session.utilization_pct,
1051        "session_reset": snap.session.resets_at.map(|dt| dt.to_rfc3339()),
1052        "weekly_pct": snap.weekly.utilization_pct,
1053        "weekly_reset": snap.weekly.resets_at.map(|dt| dt.to_rfc3339()),
1054        "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
1055        "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1056        "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
1057        "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1058    })
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063    use super::*;
1064
1065    /// Captured from a real `RetrieveUserQuotaSummary` response on 2026-07-22
1066    /// (Antigravity 2.0 build 2.3.1, `agy` 1.1.5), then trimmed. Percentages
1067    /// were edited to distinct non-zero values so a slot mix-up cannot pass.
1068    const QUOTA_JSON: &str = r#"{
1069      "response": {
1070        "groups": [
1071          {
1072            "displayName": "Gemini Models",
1073            "buckets": [
1074              {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
1075               "window": "weekly", "remainingFraction": 0.9191212,
1076               "resetTime": "2026-07-28T17:39:58Z"},
1077              {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
1078               "window": "5h", "remainingFraction": 0.5672253,
1079               "resetTime": "2026-07-22T17:47:00Z"}
1080            ]
1081          },
1082          {
1083            "displayName": "Claude and GPT models",
1084            "buckets": [
1085              {"bucketId": "3p-weekly", "window": "weekly",
1086               "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
1087              {"bucketId": "3p-5h", "window": "5h",
1088               "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
1089            ]
1090          }
1091        ]
1092      }
1093    }"#;
1094
1095    /// Fixed instant, earlier than every reset in the fixture. Using the wall
1096    /// clock here would make the suite start failing once those resets pass.
1097    fn now() -> DateTime<Utc> {
1098        DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
1099            .unwrap()
1100            .with_timezone(&Utc)
1101    }
1102
1103    fn parsed() -> AntigravitySnapshot {
1104        let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1105        parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
1106    }
1107
1108    #[test]
1109    fn quota_summary_maps_four_distinct_windows() {
1110        let snap = parsed();
1111        assert_eq!(snap.plan, "Google AI Pro");
1112        // remainingFraction is inverted into "used".
1113        assert_eq!(snap.session.utilization_pct, 43);
1114        assert_eq!(snap.weekly.utilization_pct, 8);
1115        assert_eq!(
1116            snap.third_party_session.as_ref().unwrap().utilization_pct,
1117            75
1118        );
1119        assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
1120    }
1121
1122    #[test]
1123    fn each_window_keeps_its_own_reset_time() {
1124        let snap = parsed();
1125        let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
1126        assert_eq!(snap.session.resets_at, at("2026-07-22T17:47:00Z"));
1127        assert_eq!(snap.weekly.resets_at, at("2026-07-28T17:39:58Z"));
1128        assert_eq!(
1129            snap.third_party_weekly.as_ref().unwrap().resets_at,
1130            at("2026-07-29T12:47:00Z")
1131        );
1132        // Regression: weekly must never be a copy of the 5h window.
1133        assert_ne!(snap.session.resets_at, snap.weekly.resets_at);
1134    }
1135
1136    #[test]
1137    fn window_durations_match_their_bucket() {
1138        let snap = parsed();
1139        assert_eq!(snap.session.window_duration, chrono::Duration::hours(5));
1140        assert_eq!(snap.weekly.window_duration, chrono::Duration::days(7));
1141        assert_eq!(
1142            snap.third_party_weekly.as_ref().unwrap().window_duration,
1143            chrono::Duration::days(7)
1144        );
1145    }
1146
1147    #[test]
1148    fn groups_are_matched_by_display_name_when_bucket_ids_change() {
1149        let v: serde_json::Value = serde_json::from_str(
1150            r#"{"response":{"groups":[
1151              {"displayName":"Gemini Models","buckets":[
1152                {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
1153                {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
1154              {"displayName":"Claude and GPT models","buckets":[
1155                {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
1156            ]}}"#,
1157        )
1158        .unwrap();
1159        let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
1160        assert_eq!(snap.session.utilization_pct, 50);
1161        assert_eq!(snap.weekly.utilization_pct, 10);
1162        assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
1163        assert!(snap.third_party_weekly.is_none());
1164    }
1165
1166    #[test]
1167    fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
1168        let duplicate: serde_json::Value = serde_json::from_str(
1169            r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
1170              {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1171              {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
1172              {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
1173            ]}]}}"#,
1174        )
1175        .unwrap();
1176        let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
1177        assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
1178
1179        // A future pool or cadence is ignored, not silently treated as the
1180        // Claude/GPT 5h slot.
1181        let unrelated: serde_json::Value = serde_json::from_str(
1182            r#"{"response":{"groups":[
1183              {"displayName":"Gemini Models","buckets":[
1184                {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1185                {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
1186                {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
1187              ]},
1188              {"displayName":"Future Models","buckets":[
1189                {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
1190              ]}
1191            ]}}"#,
1192        )
1193        .unwrap();
1194        let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
1195        assert!(snap.third_party_session.is_none());
1196        assert!(snap.third_party_weekly.is_none());
1197    }
1198
1199    /// A drifted bucket must fail the parse rather than report a reassuring
1200    /// "0% used" for a window whose real state is unknown.
1201    #[test]
1202    fn a_bucket_without_a_usable_fraction_is_rejected() {
1203        for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
1204            let v: serde_json::Value = serde_json::from_str(&format!(
1205                r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
1206                  {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
1207                  {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
1208            ))
1209            .unwrap();
1210            let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1211            assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
1212        }
1213    }
1214
1215    #[test]
1216    fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
1217        for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
1218            let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1219            v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
1220            let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1221            assert!(err.to_string().contains("resetTime"), "{err}");
1222        }
1223    }
1224
1225    #[test]
1226    fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
1227        let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
1228        assert!(parse_quota_summary(&v, "Pro".into()).is_err());
1229    }
1230
1231    #[test]
1232    fn cache_round_trip_preserves_every_window() {
1233        let snap = parsed();
1234        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1235        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1236    }
1237
1238    /// A truncated payload must fail so the caller refetches. Defaulting the
1239    /// missing field to 0 would serve a confident "0% used" for the rest of the
1240    /// TTL — the fabricated-placeholder defect corrected in PR #26.
1241    #[test]
1242    fn a_truncated_cached_payload_is_rejected_not_zeroed() {
1243        let full = snap_to_json(&parsed());
1244        for missing in ["session_pct", "weekly_pct"] {
1245            let mut v = full.clone();
1246            v.as_object_mut().unwrap().remove(missing);
1247            let bytes = serde_json::to_vec(&v).unwrap();
1248            let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1249            assert!(err.to_string().contains(missing), "{missing}: {err}");
1250        }
1251        // A wholly empty object is not a zero-usage snapshot either.
1252        assert!(parse_cache_at(b"{}", None, now()).is_err());
1253    }
1254
1255    #[test]
1256    fn cached_percentages_are_range_checked_before_narrowing() {
1257        let full = snap_to_json(&parsed());
1258        for (key, bad) in [
1259            ("session_pct", serde_json::json!(-1)),
1260            ("weekly_pct", serde_json::json!(101)),
1261            ("session_pct", serde_json::json!(i64::MAX)),
1262            ("tp_session_pct", serde_json::json!("75")),
1263        ] {
1264            let mut v = full.clone();
1265            v[key] = bad;
1266            let bytes = serde_json::to_vec(&v).unwrap();
1267            let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1268            assert!(err.to_string().contains(key), "{key}: {err}");
1269        }
1270    }
1271
1272    #[test]
1273    fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
1274        let mut v = snap_to_json(&parsed());
1275        v["session_reset"] = serde_json::json!("not-a-time");
1276        let bytes = serde_json::to_vec(&v).unwrap();
1277        let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1278        assert!(err.to_string().contains("session_reset"), "{err}");
1279    }
1280
1281    /// With Antigravity closed the cache is served for up to `MAX_STALE`, but a
1282    /// window whose reset has passed has since rolled over — the real figure is
1283    /// back near zero while the payload still carries the old one. Serving that
1284    /// would present a known-obsolete number as current.
1285    #[test]
1286    fn a_cache_past_its_reset_is_refused() {
1287        let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
1288        let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1289
1290        // Before every reset: served.
1291        assert!(parse_cache_at(&bytes, None, now()).is_ok());
1292        // One second before the earliest (the two 5h windows, 17:47:00Z).
1293        assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
1294
1295        // The reset instant itself already counts as rolled over.
1296        let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
1297        assert!(err.to_string().contains("5h"), "{err}");
1298
1299        // Well past it — this is the reboot-with-nothing-running case.
1300        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
1301    }
1302
1303    /// The weekly windows outlive the 5-hour ones, so expiry must be reported
1304    /// per window rather than assuming the shortest one speaks for all four.
1305    #[test]
1306    fn expiry_names_the_window_that_rolled_over() {
1307        let mut snap = parsed();
1308        // Drop the 5h windows so only the weeklies can expire.
1309        snap.session.resets_at = None;
1310        snap.third_party_session = None;
1311        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1312        let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1313
1314        // Past the 5h resets but before either weekly: still usable.
1315        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
1316
1317        // Past the Gemini weekly (28th) but not the third-party one (29th).
1318        let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
1319        assert!(err.to_string().contains("Gemini weekly"), "{err}");
1320    }
1321
1322    /// A window with no reset time is unknown, not expired.
1323    #[test]
1324    fn a_window_without_a_reset_never_expires() {
1325        let mut snap = parsed();
1326        for w in [&mut snap.session, &mut snap.weekly] {
1327            w.resets_at = None;
1328        }
1329        snap.third_party_session = None;
1330        snap.third_party_weekly = None;
1331        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1332        let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
1333            .unwrap()
1334            .with_timezone(&Utc);
1335        assert!(parse_cache_at(&bytes, None, far_future).is_ok());
1336    }
1337
1338    /// Switching Google accounts must not show the previous account's quota.
1339    #[test]
1340    fn a_cache_from_another_account_is_rejected() {
1341        let mut snap = parsed();
1342        snap.account = "acct:aaaa".into();
1343        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1344
1345        assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
1346        assert_eq!(
1347            parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
1348            snap
1349        );
1350
1351        // A payload written before the account was recorded is unattributable.
1352        let mut legacy = snap_to_json(&snap);
1353        legacy.as_object_mut().unwrap().remove("account");
1354        let legacy = serde_json::to_vec(&legacy).unwrap();
1355        assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
1356    }
1357
1358    /// With no local server there is nothing to compare against — and nothing
1359    /// is consuming quota either, so the last known figures still stand.
1360    #[test]
1361    fn an_unverifiable_cache_is_served_rather_than_discarded() {
1362        let mut snap = parsed();
1363        snap.account = "acct:aaaa".into();
1364        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1365        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1366    }
1367
1368    #[test]
1369    fn account_key_fingerprints_rather_than_storing_the_address() {
1370        let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
1371        let a = with("someone@example.com");
1372        assert!(!a.contains("someone"), "{a}");
1373        assert!(!a.contains('@'), "{a}");
1374        assert_eq!(a, with("someone@example.com"), "must be stable");
1375        assert_ne!(a, with("other@example.com"));
1376        // An unidentifiable response still compares equal to itself.
1377        let unknown = account_key(&serde_json::json!({}));
1378        assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
1379        assert_ne!(unknown, a);
1380    }
1381
1382    /// The third-party pool is genuinely optional — a plan without it caches a
1383    /// null and must still read back, unlike the required Gemini windows.
1384    #[test]
1385    fn absent_third_party_windows_are_not_treated_as_corruption() {
1386        let mut snap = parsed();
1387        snap.third_party_session = None;
1388        snap.third_party_weekly = None;
1389        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1390        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1391    }
1392
1393    #[test]
1394    fn cache_round_trip_preserves_absent_third_party_windows() {
1395        let mut snap = parsed();
1396        snap.third_party_session = None;
1397        snap.third_party_weekly = None;
1398        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1399        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1400    }
1401
1402    #[test]
1403    fn pct_used_inverts_valid_fractions() {
1404        assert_eq!(pct_used(1.0), 0);
1405        assert_eq!(pct_used(0.0), 100);
1406        assert_eq!(pct_used(0.5), 50);
1407    }
1408
1409    #[test]
1410    fn plan_falls_back_through_the_status_payload() {
1411        let tier: serde_json::Value =
1412            serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
1413                .unwrap();
1414        assert_eq!(plan_from_status(&tier), "Google AI Pro");
1415
1416        let plan_only: serde_json::Value = serde_json::from_str(
1417            r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
1418        )
1419        .unwrap();
1420        assert_eq!(plan_from_status(&plan_only), "Pro");
1421
1422        let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
1423        assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
1424    }
1425
1426    #[cfg(target_os = "linux")]
1427    #[test]
1428    fn proc_net_parser_keeps_only_listening_rows() {
1429        let listen = "   0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
1430                      00:00000000 00000000  1000        0 123456 1 0000 100 0";
1431        assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
1432
1433        let established = "   1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
1434                           00:00000000 00000000  1000        0 123457 1 0000 100 0";
1435        assert_eq!(parse_proc_net_line(established), None);
1436
1437        assert_eq!(parse_proc_net_line("garbage"), None);
1438    }
1439
1440    #[test]
1441    fn explicit_address_comes_first_and_gets_a_scheme() {
1442        assert_eq!(
1443            candidate_bases_with(Some("127.0.0.1:1234"), vec![5678]),
1444            vec![
1445                "http://127.0.0.1:1234".to_string(),
1446                "http://127.0.0.1:5678".to_string(),
1447            ]
1448        );
1449        // Trailing slashes are trimmed.
1450        assert_eq!(
1451            candidate_bases_with(Some("127.0.0.1:1234/"), vec![5678]),
1452            vec![
1453                "http://127.0.0.1:1234".to_string(),
1454                "http://127.0.0.1:5678".to_string(),
1455            ]
1456        );
1457        // Duplicate base URL is omitted.
1458        assert_eq!(
1459            candidate_bases_with(Some("127.0.0.1:5678"), vec![5678]),
1460            vec!["http://127.0.0.1:5678".to_string()]
1461        );
1462        // Duplicate discovered ports are omitted.
1463        assert_eq!(
1464            candidate_bases_with(None, vec![5678, 5678]),
1465            vec!["http://127.0.0.1:5678".to_string()]
1466        );
1467        // An address that already carries a scheme is left alone.
1468        assert_eq!(
1469            candidate_bases_with(Some("https://host:9"), vec![]),
1470            vec!["https://host:9".to_string()]
1471        );
1472    }
1473
1474    fn http(status: u16) -> AppError {
1475        AppError::Http {
1476            status,
1477            body: String::new(),
1478        }
1479    }
1480
1481    /// A signed-out server is worth reporting even when a later candidate only
1482    /// refused the connection — that is the whole point of probing on past the
1483    /// first failure.
1484    #[test]
1485    fn an_auth_failure_outranks_later_transport_noise() {
1486        let err = select_probe_error(vec![
1487            http(401),
1488            AppError::Transport("connection refused".into()),
1489        ]);
1490        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1491
1492        let err = select_probe_error(vec![
1493            AppError::Transport("connection refused".into()),
1494            http(403),
1495        ]);
1496        assert!(matches!(err, AppError::Http { status: 403, .. }), "{err}");
1497    }
1498
1499    /// The *first* actionable failure wins, so the explicit override's message
1500    /// survives a second signed-out product further down the list.
1501    #[test]
1502    fn the_first_auth_failure_wins() {
1503        let err = select_probe_error(vec![http(401), http(403)]);
1504        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1505    }
1506
1507    /// With nothing actionable, the last failure stands in for "nothing
1508    /// answered" — and stays transient, so the widget falls back silently
1509    /// instead of shouting about a product that simply is not running.
1510    #[test]
1511    fn without_an_auth_failure_the_last_error_stands() {
1512        let err = select_probe_error(vec![
1513            AppError::Transport("first".into()),
1514            http(500),
1515            AppError::Transport("last".into()),
1516        ]);
1517        assert!(
1518            matches!(&err, AppError::Transport(m) if m == "last"),
1519            "{err}"
1520        );
1521        assert!(err.is_transient());
1522    }
1523
1524    /// A 5xx is a server that answered but broke; the user cannot act on it, so
1525    /// it must not outrank a later real failure the way a 401 does.
1526    #[test]
1527    fn a_server_error_is_not_treated_as_actionable() {
1528        let err = select_probe_error(vec![http(500), http(401)]);
1529        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1530    }
1531
1532    #[test]
1533    fn no_candidates_at_all_yields_a_generic_error() {
1534        let err = select_probe_error(Vec::new());
1535        assert!(
1536            err.to_string().contains("no local server answered"),
1537            "{err}"
1538        );
1539    }
1540
1541    #[test]
1542    fn every_discovered_port_is_probed_in_order() {
1543        assert_eq!(
1544            candidate_bases_with(None, vec![33875, 37435]),
1545            vec![
1546                "http://127.0.0.1:33875".to_string(),
1547                "http://127.0.0.1:37435".to_string(),
1548            ]
1549        );
1550    }
1551
1552    /// The server's port is drawn from the ephemeral range, so there is nothing
1553    /// sensible to guess when discovery comes up empty. Probing a hardcoded
1554    /// port would contact an unrelated process; callers get the "start
1555    /// Antigravity or set ANTIGRAVITY_LS_ADDRESS" error instead.
1556    #[test]
1557    fn empty_discovery_yields_no_candidates() {
1558        assert!(candidate_bases_with(None, vec![]).is_empty());
1559        assert!(candidate_bases_with(Some(""), vec![]).is_empty());
1560    }
1561
1562    #[test]
1563    fn every_antigravity_product_is_recognised() {
1564        // Antigravity 2.0 / IDE: a separate language_server child.
1565        assert!(is_antigravity_process(
1566            "language_server\n",
1567            Some("/opt/antigravity/resources/bin/language_server")
1568        ));
1569        // agy CLI: embeds the RPC surface in its own process.
1570        assert!(is_antigravity_process(
1571            "agy\n",
1572            Some("/home/u/.local/bin/agy")
1573        ));
1574        // Recognised by path even when the process name says nothing.
1575        assert!(is_antigravity_process(
1576            "node",
1577            Some("/opt/antigravity/bin/helper")
1578        ));
1579        assert!(is_antigravity_process("antigravity", None));
1580        assert!(is_antigravity_process("agy.exe", None));
1581        assert!(is_antigravity_process("Antigravity.exe", None));
1582        assert!(is_antigravity_process("language_server.exe", None));
1583        assert!(is_antigravity_process(
1584            "language_server_windows_x64.exe",
1585            None
1586        ));
1587        assert!(is_antigravity_process(
1588            "node.exe",
1589            Some(r"C:\Users\u\AppData\Local\agy.exe")
1590        ));
1591    }
1592
1593    #[test]
1594    fn unrelated_processes_are_not_probed() {
1595        assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
1596        assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
1597        // "legacy" ends in a substring of "/agy" but is not the CLI.
1598        assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
1599        assert!(!is_antigravity_process("legacy.exe", None));
1600        assert!(!is_antigravity_process("not-agy.exe", None));
1601        assert!(!is_antigravity_process("", None));
1602    }
1603
1604    #[test]
1605    fn windows_process_names_decode_until_nul_and_tolerate_invalid_utf16() {
1606        let mut raw: Vec<u16> = "agy.exe".encode_utf16().collect();
1607        raw.extend([0, b'x' as u16]);
1608        assert_eq!(decode_windows_process_name(&raw), "agy.exe");
1609        assert_eq!(decode_windows_process_name(&[0xd800]), "�");
1610        assert_eq!(decode_windows_process_name(&[]), "");
1611    }
1612
1613    #[test]
1614    fn windows_process_filter_keeps_only_antigravity_pids() {
1615        let processes = vec![
1616            (10, "agy.exe".to_string()),
1617            (20, "language_server_windows_x64.exe".to_string()),
1618            (30, "sshd.exe".to_string()),
1619        ];
1620        let pids = matching_windows_process_ids(&processes);
1621        assert_eq!(pids, std::collections::HashSet::from([10, 20]));
1622    }
1623
1624    #[test]
1625    fn windows_listener_filter_joins_pid_loopback_and_port() {
1626        let pids = std::collections::HashSet::from([10]);
1627        let rows = [
1628            WindowsTcpRow {
1629                local_addr: [127, 0, 0, 1],
1630                local_port: u32::from(59870u16.to_be()),
1631                pid: 10,
1632            },
1633            WindowsTcpRow {
1634                local_addr: [127, 0, 0, 1],
1635                local_port: u32::from(59868u16.to_be()),
1636                pid: 10,
1637            },
1638            WindowsTcpRow {
1639                local_addr: [127, 0, 0, 1],
1640                local_port: u32::from(59870u16.to_be()),
1641                pid: 10,
1642            },
1643            WindowsTcpRow {
1644                local_addr: [0, 0, 0, 0],
1645                local_port: u32::from(50000u16.to_be()),
1646                pid: 10,
1647            },
1648            WindowsTcpRow {
1649                local_addr: [127, 0, 0, 1],
1650                local_port: u32::from(50001u16.to_be()),
1651                pid: 99,
1652            },
1653            WindowsTcpRow {
1654                local_addr: [127, 0, 0, 1],
1655                local_port: 0,
1656                pid: 10,
1657            },
1658        ];
1659        assert_eq!(matching_windows_ports(&pids, &rows), vec![59870, 59868]);
1660    }
1661
1662    /// Antigravity 2.0 and an interactive `agy` session at once. Their port
1663    /// pairs must not be flattened into one set: sorting all four descending
1664    /// would put pid 20's TLS listener ahead of pid 10's RPC listener.
1665    #[test]
1666    fn windows_ports_from_two_products_keep_tls_listeners_last() {
1667        let pids = std::collections::HashSet::from([10, 20]);
1668        let row = |port: u16, pid: u32| WindowsTcpRow {
1669            local_addr: [127, 0, 0, 1],
1670            local_port: u32::from(port.to_be()),
1671            pid,
1672        };
1673        let rows = [
1674            row(40000, 10),
1675            row(40001, 10),
1676            row(50000, 20),
1677            row(50001, 20),
1678        ];
1679        assert_eq!(
1680            matching_windows_ports(&pids, &rows),
1681            vec![40001, 50001, 40000, 50000]
1682        );
1683    }
1684
1685    /// The high-to-low preference only means something per product, so the
1686    /// grouping is what keeps a second product's TLS listener from being
1687    /// probed before the first product's RPC listener.
1688    #[test]
1689    fn probe_order_puts_every_rpc_listener_ahead_of_every_tls_listener() {
1690        use std::collections::BTreeMap;
1691
1692        // One process, the ordinary case: RPC (higher) before TLS (lower).
1693        assert_eq!(
1694            probe_order(BTreeMap::from([(10, vec![59868, 59870])])),
1695            vec![59870, 59868]
1696        );
1697        // Two products. A plain descending sort would yield 50001, 50000,
1698        // 40001, 40000 and reach pid 20's TLS listener second; taking the
1699        // ports rank by rank leaves both TLS listeners at the back, where they
1700        // are touched only if no RPC listener answered.
1701        assert_eq!(
1702            probe_order(BTreeMap::from([
1703                (10, vec![40000, 40001]),
1704                (20, vec![50000, 50001]),
1705            ])),
1706            vec![40001, 50001, 40000, 50000]
1707        );
1708        // Uneven groups: the extra port of the deeper group trails everything
1709        // it ranks below, and a port claimed by two pids is probed once.
1710        assert_eq!(
1711            probe_order(BTreeMap::from([
1712                (10, vec![6000, 5000, 4000]),
1713                (20, vec![6000, 7000]),
1714            ])),
1715            vec![6000, 7000, 5000, 4000]
1716        );
1717        assert!(probe_order(BTreeMap::new()).is_empty());
1718    }
1719
1720    /// A dual-stack bind names the same port from both `/proc/net/tcp` and
1721    /// `tcp6`. Those rows are one listener, so they must not consume two ranks
1722    /// and push the product's real second listener down past another
1723    /// product's.
1724    #[test]
1725    fn a_port_named_twice_by_one_product_still_occupies_one_rank() {
1726        use std::collections::BTreeMap;
1727
1728        assert_eq!(
1729            probe_order(BTreeMap::from([
1730                (10, vec![40001, 40001, 40000, 40000]),
1731                (20, vec![50001, 50000]),
1732            ])),
1733            vec![40001, 50001, 40000, 50000],
1734            "duplicate rows must not reorder the ranks below them"
1735        );
1736    }
1737
1738    /// The ordering rests on each product showing both listeners. A product
1739    /// caught mid-startup, with only its TLS port bound, sits alone at rank 0
1740    /// and is probed first — documented as the known cost, and harmless
1741    /// because every candidate is probed anyway.
1742    #[test]
1743    fn a_half_started_product_is_the_documented_exception() {
1744        use std::collections::BTreeMap;
1745
1746        assert_eq!(
1747            probe_order(BTreeMap::from([
1748                (10, vec![40000]),
1749                (20, vec![50001, 50000])
1750            ])),
1751            vec![40000, 50001, 50000]
1752        );
1753    }
1754
1755    /// `ANTIGRAVITY_LS_ADDRESS` is user input. An entry that leaves no
1756    /// authority to connect to is dropped instead of probed, so it can neither
1757    /// spend a round-trip nor add a failure that competes with the real one in
1758    /// [`select_probe_error`].
1759    #[test]
1760    fn an_override_with_no_authority_is_dropped_not_probed() {
1761        for junk in ["/", "///", "http://", "https://", "  /  "] {
1762            assert_eq!(
1763                candidate_bases_with(Some(junk), vec![4242]),
1764                vec!["http://127.0.0.1:4242".to_string()],
1765                "{junk:?} should not survive as a candidate"
1766            );
1767        }
1768        assert!(candidate_bases_with(Some("/"), vec![]).is_empty());
1769    }
1770
1771    #[test]
1772    fn windows_table_bounds_reject_truncation_and_overflow() {
1773        assert_eq!(checked_windows_row_count(52, 4, 24, 2), Some(2));
1774        assert_eq!(checked_windows_row_count(51, 4, 24, 2), None);
1775        assert_eq!(checked_windows_row_count(4, 4, 24, 0), Some(0));
1776        assert_eq!(checked_windows_row_count(52, 4, 0, 2), None);
1777        assert_eq!(
1778            checked_windows_row_count(usize::MAX, 4, 24, usize::MAX),
1779            None
1780        );
1781    }
1782
1783    #[cfg(target_os = "windows")]
1784    #[test]
1785    fn windows_tcp_table_parser_copies_complete_rows_only() {
1786        use std::mem::{offset_of, size_of};
1787        use windows_sys::Win32::NetworkManagement::IpHelper::{
1788            MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
1789        };
1790
1791        let offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
1792        let used = offset + 2 * size_of::<MIB_TCPROW_OWNER_PID>();
1793        let words = used.div_ceil(size_of::<u32>());
1794        let mut buffer = vec![0u32; words];
1795        let first = MIB_TCPROW_OWNER_PID {
1796            dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
1797            dwLocalPort: u32::from(59868u16.to_be()),
1798            dwOwningPid: 10,
1799            ..Default::default()
1800        };
1801        let second = MIB_TCPROW_OWNER_PID {
1802            dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
1803            dwLocalPort: u32::from(59870u16.to_be()),
1804            dwOwningPid: 10,
1805            ..Default::default()
1806        };
1807        unsafe {
1808            buffer.as_mut_ptr().write_unaligned(2);
1809            let rows = buffer
1810                .as_mut_ptr()
1811                .cast::<u8>()
1812                .add(offset)
1813                .cast::<MIB_TCPROW_OWNER_PID>();
1814            rows.write_unaligned(first);
1815            rows.add(1).write_unaligned(second);
1816        }
1817
1818        assert_eq!(
1819            parse_windows_tcp_rows(&buffer, used),
1820            vec![
1821                WindowsTcpRow {
1822                    local_addr: [127, 0, 0, 1],
1823                    local_port: u32::from(59868u16.to_be()),
1824                    pid: 10,
1825                },
1826                WindowsTcpRow {
1827                    local_addr: [127, 0, 0, 1],
1828                    local_port: u32::from(59870u16.to_be()),
1829                    pid: 10,
1830                },
1831            ]
1832        );
1833        assert!(parse_windows_tcp_rows(&buffer, used - 1).is_empty());
1834    }
1835
1836    #[test]
1837    fn lsof_parser_keeps_only_ports_owned_by_antigravity_processes() {
1838        // `agy` (pid 74101) has three listening sockets; `sshd` (pid 200) has
1839        // one that must be excluded even though it sorts right after `c`.
1840        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";
1841        assert_eq!(parse_lsof_pcn(output), vec![61290, 61289, 8829]);
1842    }
1843
1844    /// The pid on each `p` line has to survive to the `n` lines, or the ports
1845    /// of two running products collapse into one group and rank ordering can
1846    /// no longer keep the TLS listeners last.
1847    #[test]
1848    fn lsof_parser_keeps_each_products_ports_in_its_own_group() {
1849        let output = concat!(
1850            "p100\ncagy\nf3\nn127.0.0.1:40000\nf4\nn127.0.0.1:40001\n",
1851            "p200\nclanguage_server\nf5\nn127.0.0.1:50000\nf6\nn127.0.0.1:50001\n",
1852        );
1853        assert_eq!(
1854            parse_lsof_pcn(output),
1855            vec![40001, 50001, 40000, 50000],
1856            "both HTTP listeners must precede both TLS listeners"
1857        );
1858    }
1859
1860    #[test]
1861    fn lsof_parser_matches_the_capitalised_macos_app_name() {
1862        let output = "p900\ncAntigravity\nf7\nn127.0.0.1:54321\n";
1863        assert_eq!(parse_lsof_pcn(output), vec![54321]);
1864    }
1865
1866    #[test]
1867    fn lsof_parser_deduplicates_and_handles_empty_output() {
1868        let output = "p1\ncagy\nf3\nn127.0.0.1:9000\nf4\nn127.0.0.1:9000\n";
1869        assert_eq!(parse_lsof_pcn(output), vec![9000]);
1870        assert!(parse_lsof_pcn("").is_empty());
1871    }
1872
1873    /// First run with Antigravity closed: no cache to serve, so the user must
1874    /// be told what to start — not "no usable cache", which says nothing.
1875    #[test]
1876    fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
1877        let dir = tempfile::tempdir().unwrap();
1878        let cache = Cache::at(dir.path().join("usage.json"));
1879        let reason = AppError::Credentials("Antigravity: no local language server found".into());
1880
1881        let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1882        let msg = err.to_string();
1883        assert!(msg.contains("no local language server found"), "{msg}");
1884        assert!(!msg.contains("no usable cache"), "{msg}");
1885    }
1886
1887    #[test]
1888    fn unusable_cache_does_not_replace_the_live_diagnosis() {
1889        let dir = tempfile::tempdir().unwrap();
1890        let cache = Cache::at(dir.path().join("antigravity"));
1891        cache.write_payload(b"{}").unwrap();
1892
1893        let reason = AppError::Credentials("Antigravity must be running".into());
1894        let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1895        assert!(err.to_string().contains("must be running"), "{err}");
1896
1897        let original = AppError::Transport("original loopback failure".into());
1898        let err = fallback_silent(&cache, now(), original).unwrap_err();
1899        assert!(
1900            err.to_string().contains("original loopback failure"),
1901            "{err}"
1902        );
1903    }
1904
1905    #[tokio::test]
1906    async fn rpc_error_bodies_are_bounded_too() {
1907        let mut server = mockito::Server::new_async().await;
1908        let path = format!("/{STATUS_RPC}");
1909        server
1910            .mock("POST", path.as_str())
1911            .with_status(500)
1912            .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
1913            .create_async()
1914            .await;
1915
1916        let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
1917            .await
1918            .unwrap_err();
1919        assert!(err.to_string().contains("exceeds"), "{err}");
1920    }
1921
1922    #[test]
1923    fn blank_override_falls_through_to_discovery() {
1924        assert_eq!(
1925            candidate_bases_with(Some("   "), vec![4242]),
1926            vec!["http://127.0.0.1:4242".to_string()]
1927        );
1928    }
1929}