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