Skip to main content

ai_usagebar/antigravity/
fetch.rs

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