Skip to main content

ai_usagebar/antigravity/
fetch.rs

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