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