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//!
17//! When no usable product is running there is still a way to answer:
18//! Antigravity keeps the Google session it signed in with in the OS keyring,
19//! and the same quota summary is served by the Cloud Code API. That is the
20//! *fallback*, taken when no local server was found or when `agy` reports that
21//! its undiscoverable CSRF token is required. Every other local rejection — a
22//! server that is signed out or answering on the wrong protocol — keeps its
23//! own diagnosis.
24
25use std::time::Duration;
26
27use chrono::{DateTime, Utc};
28
29use super::cloud;
30use super::credential::{self, StoredToken};
31use crate::cache::{Cache, acquire_lock_async};
32use crate::error::{AppError, Result};
33use crate::usage::{AntigravitySnapshot, AntigravitySource, UsageWindow};
34
35const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
36const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
37
38const QUOTA_RPC: &str = "exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary";
39const STATUS_RPC: &str = "exa.language_server_pb.LanguageServerService/GetUserStatus";
40
41const DEFAULT_PLAN: &str = "Antigravity";
42
43const NO_LOCAL_SERVER: &str = "Antigravity: no local server found. Quota is only served while \
44                               Antigravity is running — open the Antigravity app, or an interactive \
45                               `agy` session, or point ANTIGRAVITY_LS_ADDRESS at a host:port.";
46
47const AGY_CSRF_UNAVAILABLE: &str = "Antigravity: the running `agy` server requires a CSRF token \
48                                   that it does not publish.";
49
50/// Appended to [`NO_LOCAL_SERVER`] once the remote fallback has also come up
51/// empty: the user has a second way out that the local-only message does not
52/// mention.
53const NO_SAVED_SESSION: &str = "Or sign in to Antigravity once, so its saved Google session can \
54                                be used while it is closed.";
55
56const SESSION_EXPIRED: &str =
57    "Antigravity's saved Google session expired; open Antigravity to sign in again";
58
59/// This vendor's [`Outcome`](crate::outcome::Outcome) — the shared shape,
60/// specialised to its snapshot.
61pub type FetchOutcome = crate::outcome::Outcome<AntigravitySnapshot>;
62
63impl From<FetchOutcome> for crate::vendor::VendorOutcome {
64    fn from(o: FetchOutcome) -> Self {
65        o.map(crate::usage::VendorSnapshot::Antigravity)
66    }
67}
68
69/// The keyring blob, or a stand-in for it. `Absent` exists so a test can
70/// exercise "nothing saved" without asking the real keyring, which is what
71/// `None` would have to mean otherwise.
72#[derive(Debug, Clone, Copy, Default)]
73pub enum SavedCredential<'a> {
74    /// Read the OS keyring, as production does.
75    #[default]
76    Keyring,
77    /// Use this raw blob.
78    Blob(&'a str),
79    /// Behave as if the keyring held nothing.
80    Absent,
81}
82
83/// Test seam for the remote fallback. Production passes
84/// [`RemoteOverride::default`], which reads the OS keyring, talks to Google,
85/// and probes the local ports discovery finds; a test supplies each of those
86/// instead so it never touches the real keyring, the network, or `/proc`.
87#[derive(Default)]
88pub struct RemoteOverride<'a> {
89    /// Where the saved Google session comes from.
90    pub credential: SavedCredential<'a>,
91    /// Cloud Code endpoints, in place of [`cloud::Endpoints::default`].
92    pub endpoints: Option<&'a cloud::Endpoints>,
93    /// Local base URLs to probe, in place of discovery. `Some(vec![])` means
94    /// "no local server", which is what sends the fetch down the remote path.
95    pub local_bases: Option<Vec<String>>,
96}
97
98pub async fn fetch_snapshot(
99    client: &reqwest::Client,
100    cache: &Cache,
101    cache_ttl: Duration,
102    oauth: Option<&cloud::OauthClient>,
103) -> Result<FetchOutcome> {
104    fetch_snapshot_at(
105        client,
106        cache,
107        cache_ttl,
108        oauth,
109        RemoteOverride::default(),
110        Utc::now(),
111    )
112    .await
113}
114
115/// Clock seam for [`fetch_snapshot`], so window expiry can be exercised at
116/// fixed instants instead of against the wall clock, and the seam for
117/// everything the remote fallback would otherwise read from the machine.
118pub async fn fetch_snapshot_at(
119    client: &reqwest::Client,
120    cache: &Cache,
121    cache_ttl: Duration,
122    oauth: Option<&cloud::OauthClient>,
123    remote: RemoteOverride<'_>,
124    now: DateTime<Utc>,
125) -> Result<FetchOutcome> {
126    cache.ensure_dir()?;
127    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
128
129    // Resolve the signed-in account first so a fresh cache can be attributed.
130    // Unlike Grok — where the same check would cost a remote round-trip on
131    // every poll — this is loopback, and it is the call that would supply the
132    // plan name anyway, so verification is effectively free.
133    //
134    // With no local server at all, the saved Google session identifies the
135    // account just as cheaply: reading the keyring is local, and only the
136    // quota call itself goes to the network — after the fresh-cache check,
137    // like the local RPC.
138    let origin = match open_session(client, remote.local_bases.as_deref()).await {
139        Ok(session) => Origin::Local(Ok(session)),
140        Err(error) => match remote_fallback_reason(&error) {
141            Some(reason) => Origin::Remote(saved_session(remote.credential, reason)),
142            None => Origin::Local(Err(error)),
143        },
144    };
145    let account = origin.account();
146
147    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
148        && let Ok(outcome) = reuse_cache(bytes, cache, false, account.as_deref(), now)
149    {
150        return Ok(outcome);
151    }
152
153    let default_endpoints = cloud::Endpoints::default();
154    let endpoints = remote.endpoints.unwrap_or(&default_endpoints);
155    let live = match origin {
156        Origin::Local(session) => fetch_live(client, session).await,
157        Origin::Remote(token) => fetch_remote(client, cache, oauth, endpoints, token, now).await,
158    };
159
160    match live {
161        Ok(snap) => {
162            let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
163            cache.write_payload(&bytes)?;
164            Ok(crate::outcome::Outcome::fresh(snap))
165        }
166        Err(e) if e.is_transient() => fallback_silent(cache, now, e),
167        Err(AppError::Http { status, body }) => {
168            cache.mark_stale();
169            let last_error = Some(cache.write_last_error(status, &body));
170            let reason = AppError::Http { status, body };
171            fallback_with_error(cache, last_error, reason, now)
172        }
173        Err(e) => {
174            cache.mark_stale();
175            let last_error = Some(cache.write_last_error(0, &e.to_string()));
176            fallback_with_error(cache, last_error, e, now)
177        }
178    }
179}
180
181/// A local server that answered `GetUserStatus`: where it lives, how to talk to
182/// it, and whose account it is signed in as.
183struct Session {
184    base: String,
185    csrf: Option<String>,
186    plan: String,
187    account: String,
188}
189
190/// Which source this fetch will draw on, decided before the cache is consulted
191/// so a fresh payload can be checked against the right account.
192enum Origin {
193    Local(Result<Session>),
194    Remote(Result<StoredToken>),
195}
196
197impl Origin {
198    /// The account fingerprint, when the source identified one.
199    fn account(&self) -> Option<String> {
200        match self {
201            Origin::Local(Ok(session)) => Some(session.account.clone()),
202            Origin::Remote(Ok(token)) => Some(remote_account(&token.fingerprint)),
203            Origin::Local(Err(_)) | Origin::Remote(Err(_)) => None,
204        }
205    }
206}
207
208fn no_local_server() -> AppError {
209    AppError::Credentials(NO_LOCAL_SERVER.into())
210}
211
212#[derive(Clone, Copy)]
213enum RemoteFallbackReason {
214    NoLocalServer,
215    AgyMissingCsrf,
216}
217
218impl RemoteFallbackReason {
219    fn message(self) -> &'static str {
220        match self {
221            Self::NoLocalServer => NO_LOCAL_SERVER,
222            Self::AgyMissingCsrf => AGY_CSRF_UNAVAILABLE,
223        }
224    }
225}
226
227/// Local failures the saved Google session is allowed to answer. The `agy`
228/// response is matched structurally and exactly; an arbitrary local `401`
229/// remains a signed-out diagnosis and never triggers remote traffic.
230fn remote_fallback_reason(error: &AppError) -> Option<RemoteFallbackReason> {
231    if matches!(error, AppError::Credentials(message) if message == NO_LOCAL_SERVER) {
232        Some(RemoteFallbackReason::NoLocalServer)
233    } else if is_missing_csrf(error) {
234        Some(RemoteFallbackReason::AgyMissingCsrf)
235    } else {
236        None
237    }
238}
239
240/// Walk every candidate language server until one identifies itself. A machine
241/// can host more than one — the desktop app, the IDE and an interactive `agy`
242/// session each run their own — and only some of them are signed in.
243///
244/// `bases` replaces discovery when given; see [`RemoteOverride::local_bases`].
245async fn open_session(client: &reqwest::Client, bases: Option<&[String]>) -> Result<Session> {
246    let bases = bases.map_or_else(candidate_bases, <[String]>::to_vec);
247    if bases.is_empty() {
248        return Err(no_local_server());
249    }
250
251    let mut errors = Vec::new();
252    for base in bases {
253        let csrf = fetch_csrf(client, &base).await;
254        match post_rpc(client, &base, csrf.as_deref(), STATUS_RPC).await {
255            Ok(v) => {
256                return Ok(Session {
257                    base,
258                    csrf,
259                    plan: plan_from_status(&v),
260                    account: account_key(&v),
261                });
262            }
263            Err(e) => errors.push(e),
264        }
265    }
266    Err(select_probe_error(errors))
267}
268
269/// Which failure to report when no candidate answered.
270///
271/// A server that replies `401`/`403` is normally running and reachable but
272/// signed out — the user can act on that, so it outranks the connection
273/// refusals from products that simply are not up. `agy`'s exact missing-CSRF
274/// response is ranked separately because it has no token discovery route.
275/// Without this, a stale
276/// `ANTIGRAVITY_LS_ADDRESS` (or a second product on another port) would mask
277/// the one message worth reading behind transport noise.
278///
279/// Note that this also decides *visibility*: transport errors are transient and
280/// fall back silently to cache, while the `401` surfaces in the widget. That is
281/// why a TLS listener's echo is ranked below everything else rather than merely
282/// tie-breaking — see [`is_tls_echo`]. Being an `Http`, it is not transient, so
283/// letting it stand as "the last failure" turns a product that simply is not
284/// serving RPC into a visible error about a protocol the user never chose.
285fn select_probe_error(errors: Vec<AppError>) -> AppError {
286    let mut actionable = None;
287    let mut missing_csrf = None;
288    let mut last = None;
289    let mut echo = None;
290    for e in errors {
291        if missing_csrf.is_none() && is_missing_csrf(&e) {
292            missing_csrf = Some(e);
293        } else if actionable.is_none() && is_actionable(&e) {
294            actionable = Some(e);
295        } else if is_tls_echo(&e) {
296            echo = Some(e);
297        } else {
298            last = Some(e);
299        }
300    }
301    actionable
302        .or(missing_csrf)
303        .or(last)
304        .or(echo)
305        .unwrap_or_else(|| {
306            AppError::Other("antigravity: no local server answered GetUserStatus".into())
307        })
308}
309
310/// `agy` currently serves no page containing its CSRF token, then returns this
311/// structured response from the status RPC. Matching the status, code, and
312/// message avoids treating an unrelated local service or a genuinely
313/// signed-out Antigravity product as permission to use the cloud fallback.
314fn is_missing_csrf(error: &AppError) -> bool {
315    let AppError::Http { status: 401, body } = error else {
316        return false;
317    };
318    let Ok(body) = serde_json::from_str::<serde_json::Value>(body) else {
319        return false;
320    };
321    matches!(
322        (body["code"].as_str(), body["message"].as_str()),
323        (Some(code), Some(message))
324            if code.eq_ignore_ascii_case("unauthenticated")
325                && message.trim().eq_ignore_ascii_case("missing CSRF token")
326    )
327}
328
329/// An error the user can do something about, as opposed to "that product is not
330/// running". `post_rpc` only ever yields `Http`/`Transport`/`Other`, so the
331/// authentication statuses are the whole set, apart from `agy`'s precise
332/// missing-CSRF response.
333fn is_actionable(e: &AppError) -> bool {
334    matches!(e, AppError::Http { status, .. } if *status == 401 || *status == 403)
335        && !is_missing_csrf(e)
336}
337
338/// A TLS listener answering the plaintext JSON-RPC probe.
339///
340/// Each Antigravity product binds two ports: JSON-RPC in the clear on one and
341/// HTTPS on another. `probe_order` deliberately tries the RPC listener first
342/// and leaves the TLS one behind it, so reaching the TLS port at all means the
343/// RPC port already had its say. Go's `net/http.Server` answers cleartext on a
344/// TLS listener with this fixed `400`, which describes our own probe rather
345/// than anything wrong with the product — as a diagnosis it is noise that
346/// always arrives last and therefore always wins.
347///
348/// Matched on the body, not the status alone: a real `400` from the language
349/// server is a genuine complaint about the request and must keep outranking it.
350fn is_tls_echo(e: &AppError) -> bool {
351    matches!(
352        e,
353        AppError::Http { status: 400, body } if body.contains("HTTP request to an HTTPS server")
354    )
355}
356
357async fn fetch_live(
358    client: &reqwest::Client,
359    session: Result<Session>,
360) -> Result<AntigravitySnapshot> {
361    let session = session?;
362    let quota = post_rpc(client, &session.base, session.csrf.as_deref(), QUOTA_RPC).await?;
363    let mut snap = parse_quota_summary(&quota, session.plan)?;
364    snap.account = session.account;
365    Ok(snap)
366}
367
368// ---------------------------------------------------------------------------
369// Remote fallback — the saved Google session against the Cloud Code API
370// ---------------------------------------------------------------------------
371
372/// The saved Google session, or why there is none. `credential` is the test
373/// seam for the keyring blob.
374///
375/// No saved session leaves the user exactly where the local probe left them,
376/// plus the one thing they can now do about it.
377fn saved_session(
378    credential: SavedCredential<'_>,
379    reason: RemoteFallbackReason,
380) -> Result<StoredToken> {
381    let raw = match credential {
382        SavedCredential::Keyring => credential::read()?,
383        SavedCredential::Blob(blob) => Some(blob.to_string()),
384        SavedCredential::Absent => None,
385    };
386    let Some(raw) = raw else {
387        return Err(AppError::Credentials(format!(
388            "{} {NO_SAVED_SESSION}",
389            reason.message()
390        )));
391    };
392    credential::parse_keyring_blob(&raw)
393}
394
395/// Cache attribution for a remote snapshot. Deliberately not the local
396/// fingerprint's format: the two are computed from different inputs and a
397/// collision would let one source's cache stand in for the other's.
398fn remote_account(fingerprint: &str) -> String {
399    format!("acct:{fingerprint}")
400}
401
402fn session_expired() -> AppError {
403    AppError::Credentials(SESSION_EXPIRED.into())
404}
405
406/// The saved session can only be renewed with Antigravity's OAuth client,
407/// which this program does not ship (a secret-shaped literal in source trips
408/// every secret scanner); the config names the two keys that provide it.
409fn refresh_unconfigured() -> AppError {
410    AppError::Credentials(
411        "Antigravity's saved Google session expired and ai-usagebar has no OAuth client to \
412         refresh it; open Antigravity to sign in again, or set [antigravity] oauth_client_id \
413         and oauth_client_secret in config.toml"
414            .into(),
415    )
416}
417
418fn is_auth_rejection(e: &AppError) -> bool {
419    matches!(
420        e,
421        AppError::Http {
422            status: 401 | 403,
423            ..
424        }
425    )
426}
427
428/// An access token to present, and whether it was minted just now — a `401`
429/// against a token this fresh is the session itself being gone, not a stale
430/// token worth refreshing again.
431struct AccessToken {
432    value: String,
433    just_refreshed: bool,
434}
435
436/// Pick the freshest usable access token without going to the network: the
437/// one this program persisted after its last refresh, if it outlives the
438/// keyring's and is not about to expire; else the keyring's own while it
439/// lasts. Only when both are spent does this refresh.
440async fn resolve_access_token(
441    client: &reqwest::Client,
442    oauth: Option<&cloud::OauthClient>,
443    endpoints: &cloud::Endpoints,
444    oauth_path: &std::path::Path,
445    token: &StoredToken,
446    now: DateTime<Utc>,
447) -> Result<AccessToken> {
448    if let Some(persisted) = cloud::read_persisted(oauth_path, &token.fingerprint)
449        && token
450            .expires_at
451            .is_none_or(|keyring| persisted.expires_at > keyring)
452        && !cloud::needs_refresh(Some(persisted.expires_at), now)
453    {
454        return Ok(AccessToken {
455            value: persisted.access_token,
456            just_refreshed: false,
457        });
458    }
459    if !cloud::needs_refresh(token.expires_at, now) {
460        return Ok(AccessToken {
461            value: token.access_token.clone(),
462            just_refreshed: false,
463        });
464    }
465    refresh_and_persist(client, oauth, endpoints, oauth_path, token).await
466}
467
468/// Mint a new access token off the saved refresh token and remember it, so
469/// the next poll does not spend another round-trip on the same refresh. A
470/// session with no refresh token cannot be renewed here at all.
471async fn refresh_and_persist(
472    client: &reqwest::Client,
473    oauth: Option<&cloud::OauthClient>,
474    endpoints: &cloud::Endpoints,
475    oauth_path: &std::path::Path,
476    token: &StoredToken,
477) -> Result<AccessToken> {
478    let Some(refresh_token) = token.refresh_token.as_deref() else {
479        return Err(session_expired());
480    };
481    let Some(oauth) = oauth else {
482        return Err(refresh_unconfigured());
483    };
484    let refreshed = cloud::refresh(client, &endpoints.token, oauth, refresh_token).await?;
485    cloud::write_persisted(
486        oauth_path,
487        &cloud::PersistedOAuth {
488            fingerprint: token.fingerprint.clone(),
489            access_token: refreshed.access_token.clone(),
490            expires_at: refreshed.expires_at,
491        },
492    )?;
493    Ok(AccessToken {
494        value: refreshed.access_token,
495        just_refreshed: true,
496    })
497}
498
499/// Quota through the Cloud Code API, as the signed-in Google account.
500///
501/// A rejected token gets one refresh and one retry, unless it was refreshed a
502/// moment ago — then the rejection is Google's verdict on the session, and
503/// the user has to sign in again. The plan name is best-effort: the summary is
504/// the figure, and a missing name must not cost it.
505async fn fetch_remote(
506    client: &reqwest::Client,
507    cache: &Cache,
508    oauth: Option<&cloud::OauthClient>,
509    endpoints: &cloud::Endpoints,
510    token: Result<StoredToken>,
511    now: DateTime<Utc>,
512) -> Result<AntigravitySnapshot> {
513    let token = token?;
514    let oauth_path = cloud::oauth_cache_path(cache);
515    let mut access =
516        resolve_access_token(client, oauth, endpoints, &oauth_path, &token, now).await?;
517
518    let quota = match cloud::fetch_quota(client, endpoints, &access.value).await {
519        Err(e) if is_auth_rejection(&e) && !access.just_refreshed => {
520            access = refresh_and_persist(client, oauth, endpoints, &oauth_path, &token).await?;
521            cloud::fetch_quota(client, endpoints, &access.value)
522                .await
523                .map_err(|e| {
524                    if is_auth_rejection(&e) {
525                        session_expired()
526                    } else {
527                        e
528                    }
529                })?
530        }
531        Err(e) if is_auth_rejection(&e) => return Err(session_expired()),
532        other => other?,
533    };
534
535    let plan = cloud::fetch_plan(client, endpoints, &access.value)
536        .await
537        .unwrap_or_else(|| DEFAULT_PLAN.to_string());
538    let mut snap = parse_quota_summary(&quota, plan)?;
539    snap.account = remote_account(&token.fingerprint);
540    snap.source = AntigravitySource::Remote;
541    Ok(snap)
542}
543
544/// Identity of the signed-in account, fingerprinted rather than stored in
545/// clear — the cache only needs a change detector, not the address itself.
546/// An unidentifiable response yields a stable "unknown" bucket so two such
547/// responses still compare equal.
548fn account_key(user_status: &serde_json::Value) -> String {
549    let email = user_status["userStatus"]["email"]
550        .as_str()
551        .filter(|s| !s.is_empty());
552    match email {
553        Some(e) => {
554            use std::hash::{Hash, Hasher};
555            let mut h = std::collections::hash_map::DefaultHasher::new();
556            e.hash(&mut h);
557            format!("acct:{:016x}", h.finish())
558        }
559        None => "acct:unknown".to_string(),
560    }
561}
562
563/// The desktop products embed a CSRF token in the HTML served at `/`. The
564/// `agy` CLI serves no such page, so a missing token is recorded as `None` and
565/// its precise rejection is classified after the RPC probe.
566async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
567    let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
568    // Bounded like every other response this crate reads: a local server is
569    // still an untrusted source of unbounded bytes.
570    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
571        .await
572        .ok()?;
573    let html = String::from_utf8_lossy(&bytes);
574    html.split("csrfToken\":\"")
575        .nth(1)
576        .and_then(|s| s.split('"').next())
577        .filter(|t| !t.is_empty())
578        .map(|t| t.to_string())
579}
580
581async fn post_rpc(
582    client: &reqwest::Client,
583    base: &str,
584    csrf: Option<&str>,
585    rpc: &str,
586) -> Result<serde_json::Value> {
587    let mut req = client
588        .post(format!("{base}/{rpc}"))
589        .header("Content-Type", "application/json")
590        .body("{}")
591        .timeout(HTTP_TIMEOUT);
592    if let Some(token) = csrf {
593        req = req.header("x-codeium-csrf-token", token);
594    }
595    let resp = req.send().await?;
596
597    let status = resp.status();
598    // Cap error bodies too. A local endpoint is still untrusted, and reading a
599    // non-2xx response with `text()` would bypass the invariant enforced for
600    // successful JSON responses.
601    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
602    if !status.is_success() {
603        let body = String::from_utf8_lossy(&bytes).into_owned();
604        return Err(AppError::Http {
605            status: status.as_u16(),
606            body,
607        });
608    }
609
610    Ok(serde_json::from_slice(&bytes)?)
611}
612
613pub fn plan_from_status(v: &serde_json::Value) -> String {
614    v["userStatus"]["userTier"]["name"]
615        .as_str()
616        .or_else(|| v["userStatus"]["userTier"]["description"].as_str())
617        .or_else(|| v["userStatus"]["planStatus"]["planInfo"]["planName"].as_str())
618        .filter(|s| !s.is_empty())
619        .unwrap_or(DEFAULT_PLAN)
620        .to_string()
621}
622
623// ---------------------------------------------------------------------------
624// Quota parsing
625// ---------------------------------------------------------------------------
626
627/// Map a `RetrieveUserQuotaSummary` payload onto the four usage windows.
628///
629/// Buckets are keyed by `bucketId` (`gemini-5h`, `gemini-weekly`, `3p-5h`,
630/// `3p-weekly`), falling back to the group display name plus the `window`
631/// discriminator so a renamed bucket id still lands in the right slot.
632/// One bucket, named the way the response named it.
633fn describe_bucket(group_name: &str, id: &str, window: Option<&str>) -> String {
634    let id = if id.is_empty() { "<unnamed>" } else { id };
635    let group = if group_name.is_empty() {
636        String::new()
637    } else {
638        format!(" in {group_name:?}")
639    };
640    match window {
641        Some(window) if !window.is_empty() => format!("{id} (window {window}){group}"),
642        _ => format!("{id}{group}"),
643    }
644}
645
646/// A quota summary with nothing we can render. Naming the buckets that *were*
647/// present turns a report of this into something actionable — the alternative
648/// says only what we wanted, which tells neither the user nor a maintainer
649/// whether the plan has no such pool, the product renamed one, or a new
650/// cadence appeared.
651fn no_usable_bucket(seen: &[String]) -> AppError {
652    if seen.is_empty() {
653        return AppError::Other(
654            "antigravity: quota summary has no buckets at all — the running product may \
655             not have a quota for this account yet"
656                .into(),
657        );
658    }
659    AppError::Other(format!(
660        "antigravity: quota summary has no bucket in a window we recognise (5h or \
661         weekly, Gemini or Claude/GPT); it offered: {}",
662        seen.join(", ")
663    ))
664}
665
666pub fn parse_quota_summary(v: &serde_json::Value, plan: String) -> Result<AntigravitySnapshot> {
667    let groups = v["response"]["groups"]
668        .as_array()
669        .or_else(|| v["groups"].as_array())
670        .ok_or_else(|| AppError::Other("antigravity: quota summary has no groups".into()))?;
671
672    let mut gemini_5h = None;
673    let mut gemini_weekly = None;
674    let mut tp_5h = None;
675    let mut tp_weekly = None;
676    // What the response actually offered, so a summary we cannot use says so
677    // instead of only naming what it wanted. Bucket ids and group names are
678    // quota vocabulary, not account data.
679    let mut seen: Vec<String> = Vec::new();
680
681    for group in groups {
682        let group_name = group["displayName"].as_str().unwrap_or_default();
683        let Some(buckets) = group["buckets"].as_array() else {
684            continue;
685        };
686        for bucket in buckets {
687            let id = bucket["bucketId"].as_str().unwrap_or_default();
688            seen.push(describe_bucket(group_name, id, bucket["window"].as_str()));
689            let window = bucket["window"].as_str().unwrap_or_default();
690            let is_weekly = if id.ends_with("weekly") || window == "weekly" {
691                true
692            } else if id.ends_with("5h") || window == "5h" {
693                false
694            } else {
695                // A new cadence is not a 5-hour bucket by default. Ignore it
696                // so it cannot overwrite a known slot.
697                continue;
698            };
699            let is_gemini = if id.starts_with("gemini") {
700                true
701            } else if id.starts_with("3p") {
702                false
703            } else if group_name.contains("Gemini") {
704                true
705            } else if group_name.contains("Claude") || group_name.contains("GPT") {
706                false
707            } else {
708                // Likewise, an unrelated future group is not implicitly the
709                // third-party pool.
710                continue;
711            };
712
713            let (slot, slot_name) = match (is_gemini, is_weekly) {
714                (true, false) => (&mut gemini_5h, "Gemini 5h"),
715                (true, true) => (&mut gemini_weekly, "Gemini weekly"),
716                (false, false) => (&mut tp_5h, "Claude/GPT 5h"),
717                (false, true) => (&mut tp_weekly, "Claude/GPT weekly"),
718            };
719            let parsed = usage_window(bucket, is_weekly)?;
720            if slot.replace(parsed).is_some() {
721                return Err(AppError::Schema(format!(
722                    "antigravity: duplicate {slot_name} bucket"
723                )));
724            }
725        }
726    }
727
728    // Not every product offers every window: Antigravity CLI 1.1.22 returns
729    // weekly buckets only. One recognised window is enough to render — what
730    // must never happen is showing a figure for a window that did not arrive.
731    if gemini_5h.is_none() && gemini_weekly.is_none() && tp_5h.is_none() && tp_weekly.is_none() {
732        return Err(no_usable_bucket(&seen));
733    }
734
735    Ok(AntigravitySnapshot {
736        plan,
737        // Stamped by the caller, which is what knows the session's identity
738        // and which path it came through.
739        account: String::new(),
740        source: AntigravitySource::Local,
741        session: gemini_5h,
742        weekly: gemini_weekly,
743        third_party_session: tp_5h,
744        third_party_weekly: tp_weekly,
745    })
746}
747
748/// `remainingFraction` is required and must be finite: defaulting a missing or
749/// drifted value to 1.0 would report a reassuring "0% used" for a window whose
750/// real state is unknown, and cache it.
751fn usage_window(bucket: &serde_json::Value, is_weekly: bool) -> Result<UsageWindow> {
752    let remaining = bucket["remainingFraction"]
753        .as_f64()
754        .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
755        .ok_or_else(|| {
756            AppError::Schema(format!(
757                "antigravity: bucket {} has no valid remainingFraction in 0..=1",
758                bucket["bucketId"].as_str().unwrap_or("<unnamed>")
759            ))
760        })?;
761    Ok(UsageWindow {
762        utilization_pct: pct_used(remaining),
763        resets_at: parse_reset(&bucket["resetTime"], "quota resetTime")?,
764        window_duration: if is_weekly {
765            chrono::Duration::days(7)
766        } else {
767            chrono::Duration::hours(5)
768        },
769    })
770}
771
772/// The API reports how much is *left*; every other vendor here reports how much
773/// is *spent*.
774fn pct_used(remaining_fraction: f64) -> i32 {
775    let used = (1.0 - remaining_fraction) * 100.0;
776    used.round() as i32
777}
778
779fn parse_reset(value: &serde_json::Value, field: &str) -> Result<Option<DateTime<Utc>>> {
780    match value {
781        serde_json::Value::Null => Ok(None),
782        serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
783            .map(|dt| Some(dt.with_timezone(&Utc)))
784            .map_err(|_| AppError::Schema(format!("antigravity: invalid {field}"))),
785        _ => Err(AppError::Schema(format!(
786            "antigravity: {field} must be a timestamp or null"
787        ))),
788    }
789}
790
791// ---------------------------------------------------------------------------
792// Language server discovery
793// ---------------------------------------------------------------------------
794
795/// Base URLs worth probing, most specific first.
796fn candidate_bases() -> Vec<String> {
797    candidate_bases_with(
798        std::env::var("ANTIGRAVITY_LS_ADDRESS").ok().as_deref(),
799        discover_ls_ports(),
800    )
801}
802
803/// Test seam for [`candidate_bases`] — takes the address override and the
804/// discovered ports instead of reading the environment and `/proc`.
805fn candidate_bases_with(override_addr: Option<&str>, discovered: Vec<u16>) -> Vec<String> {
806    let mut bases = Vec::new();
807    if let Some(base) = override_addr.and_then(normalize_base) {
808        bases.push(base);
809    }
810
811    // No hardcoded fallback port on purpose: the server always binds with
812    // `--https_server_port 0`, so its port is drawn from the ephemeral range
813    // and cannot be guessed. Probing a fixed one would just poke whatever
814    // unrelated process happens to own it. Discovered ports follow any
815    // explicit override as fallback, with duplicates omitted.
816    for p in discovered {
817        let candidate = format!("http://127.0.0.1:{p}");
818        if !bases.contains(&candidate) {
819            bases.push(candidate);
820        }
821    }
822
823    bases
824}
825
826/// Turn a configured address into a base URL: trim surrounding whitespace,
827/// supply the default scheme when it is missing, and drop trailing slashes so
828/// the RPC paths built on top do not come out with a double slash.
829///
830/// Returns `None` when nothing but a scheme survives. `ANTIGRAVITY_LS_ADDRESS`
831/// is user input, and a value like `"/"` carries no authority to connect to;
832/// admitting it as a candidate would spend a probe to learn what is already
833/// knowable here.
834fn normalize_base(addr: &str) -> Option<String> {
835    let trimmed = addr.trim();
836    let (scheme, authority) = match trimmed.split_once("://") {
837        Some((scheme @ ("http" | "https"), rest)) => (scheme, rest),
838        _ => ("http", trimmed),
839    };
840    let authority = authority.trim_end_matches('/');
841    (!authority.is_empty()).then(|| format!("{scheme}://{authority}"))
842}
843
844/// Does this process look like one of the three Antigravity products?
845///
846/// Antigravity 2.0 and the IDE spawn a separate `language_server` child, while
847/// the `agy` CLI embeds the same CSRF/RPC surface in its own process — so
848/// matching on the server binary alone would miss a CLI-only install. `comm` is
849/// truncated to 15 bytes by the kernel, which `language_server` exactly fills.
850fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
851    let comm = comm.trim().to_lowercase();
852    let comm = comm.strip_suffix(".exe").unwrap_or(&comm);
853    if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
854        return true;
855    }
856    exe.is_some_and(|p| {
857        let p = p.to_lowercase().replace('\\', "/");
858        let p = p.strip_suffix(".exe").unwrap_or(&p);
859        p.contains("antigravity") || p.ends_with("/agy")
860    })
861}
862
863/// Flatten per-process listener ports into the order they should be probed.
864///
865/// Each Antigravity product binds an HTTPS/TLS listener and the unencrypted
866/// HTTP JSON-RPC listener that serves `GetUserStatus` and
867/// `RetrieveUserQuotaSummary`. Both are ephemeral, but they are bound in that
868/// order, so in practice the RPC listener draws the higher number and probing
869/// high-to-low reaches it first — which keeps Go's `net/http.Server` from
870/// logging a TLS handshake error for every unencrypted request that lands on
871/// its HTTPS listener.
872///
873/// Sorting every discovered port as one descending set only gets that right
874/// for a single process, because the high/low tendency holds *within* a
875/// product and says nothing across two of them: with Antigravity 2.0 and an
876/// `agy` session both up, one product's TLS port can sort above the other's
877/// RPC port. Ports are therefore grouped per pid, sorted high-to-low inside
878/// each group, and taken rank by rank — every product's highest port, then
879/// every product's second-highest, and so on. Where each product shows both
880/// listeners that puts every RPC one ahead of every TLS one.
881///
882/// This stays a preference, not a guarantee, and the two-listener shape is the
883/// assumption it rests on: a product caught mid-startup, with only its TLS port
884/// bound so far, sits alone at rank 0 and is probed first. Every candidate is
885/// probed regardless, so a mis-ranked one costs an extra round-trip and a line
886/// on `agy`'s stderr, nothing more.
887///
888/// Order among products is arbitrary — all of them report the same
889/// account-wide quota, so whichever answers first is authoritative — and pid
890/// order is used only to keep the result reproducible, since `/proc`, `lsof`
891/// and the Windows TCP table each enumerate in their own order.
892#[cfg(any(test, target_os = "linux", target_os = "macos", target_os = "windows"))]
893fn probe_order(per_pid: std::collections::BTreeMap<u32, Vec<u16>>) -> Vec<u16> {
894    let groups: Vec<Vec<u16>> = per_pid
895        .into_values()
896        .map(|mut group| {
897            group.sort_unstable_by(|a, b| b.cmp(a));
898            // Within a product a port is one listener however many rows named
899            // it — a dual-stack bind reports the same port from both
900            // `/proc/net/tcp` and `tcp6`. Collapsing them here keeps a rank
901            // meaning "the Nth listener" rather than "the Nth row".
902            group.dedup();
903            group
904        })
905        .collect();
906
907    let mut ports: Vec<u16> = Vec::new();
908    for rank in 0..groups.iter().map(Vec::len).max().unwrap_or(0) {
909        for port in groups.iter().filter_map(|group| group.get(rank)) {
910            if !ports.contains(port) {
911                ports.push(*port);
912            }
913        }
914    }
915    ports
916}
917
918/// Loopback ports listened on by any running Antigravity product.
919///
920/// Reads `/proc` directly rather than shelling out to `ss`/`lsof`: find the
921/// candidate pids, collect their socket inodes, then keep the listening TCP
922/// entries owning one of those inodes. All three products report the *same*
923/// shared quota, so whichever answers first is authoritative.
924#[cfg(target_os = "linux")]
925pub(crate) fn discover_ls_ports() -> Vec<u16> {
926    use std::collections::{BTreeMap, HashMap};
927
928    // Socket inode -> owning pid, so the ports found in `/proc/net` can be
929    // grouped back per process for `probe_order`.
930    let mut owners: HashMap<u64, u32> = HashMap::new();
931    let Ok(entries) = std::fs::read_dir("/proc") else {
932        return Vec::new();
933    };
934    for entry in entries.flatten() {
935        let pid_dir = entry.path();
936        let Some(pid) = pid_dir
937            .file_name()
938            .and_then(|name| name.to_str())
939            .and_then(|name| name.parse::<u32>().ok())
940        else {
941            continue;
942        };
943        let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
944            continue;
945        };
946        let exe = std::fs::read_link(pid_dir.join("exe")).ok();
947        if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
948            continue;
949        }
950        let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
951            continue;
952        };
953        for fd in fds.flatten() {
954            let Ok(target) = std::fs::read_link(fd.path()) else {
955                continue;
956            };
957            if let Some(ino) = target
958                .to_str()
959                .and_then(|s| s.strip_prefix("socket:["))
960                .and_then(|s| s.strip_suffix(']'))
961                .and_then(|s| s.parse::<u64>().ok())
962            {
963                owners.insert(ino, pid);
964            }
965        }
966    }
967
968    if owners.is_empty() {
969        return Vec::new();
970    }
971
972    let mut per_pid: BTreeMap<u32, Vec<u16>> = BTreeMap::new();
973    for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
974        let Ok(contents) = std::fs::read_to_string(table) else {
975            continue;
976        };
977        for line in contents.lines().skip(1) {
978            if let Some((port, ino)) = parse_proc_net_line(line)
979                && let Some(&pid) = owners.get(&ino)
980            {
981                per_pid.entry(pid).or_default().push(port);
982            }
983        }
984    }
985    probe_order(per_pid)
986}
987
988/// macOS has no `/proc`, so fall back to `lsof` (present on every macOS
989/// install by default, unlike Linux where shelling out was deliberately
990/// avoided — see the doc comment above). `-F pcn` asks for machine-parsable
991/// output: one `p<pid>` line per process, one `c<command>` line for its name,
992/// then an `n<address>` line per matching socket already filtered down to
993/// listening TCP sockets by `-iTCP -sTCP:LISTEN`.
994#[cfg(target_os = "macos")]
995pub(crate) fn discover_ls_ports() -> Vec<u16> {
996    let Ok(output) = std::process::Command::new("lsof")
997        .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
998        .output()
999    else {
1000        return Vec::new();
1001    };
1002    // A non-zero exit still emits usable output for the fds it *could* read,
1003    // so parse regardless of status — an empty/garbled stdout just parses to
1004    // an empty port list.
1005    parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
1006}
1007
1008/// Pure parser for `lsof -F pcn` output, kept separate from process spawning
1009/// so the parsing logic is unit-testable without shelling out. Compiled under
1010/// `test` on every platform, like [`matching_windows_ports`], so its tests are
1011/// not macOS-only.
1012#[cfg(any(test, target_os = "macos"))]
1013fn parse_lsof_pcn(output: &str) -> Vec<u16> {
1014    let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
1015    // The pid arrives on the `p` line and the command name on the `c` line
1016    // right after it, so hold the pid until the name confirms it is ours.
1017    let mut pid = None;
1018    let mut owner = None;
1019    for line in output.lines() {
1020        let Some(rest) = line.get(1..) else { continue };
1021        match line.as_bytes().first() {
1022            Some(b'p') => {
1023                pid = rest.parse::<u32>().ok();
1024                owner = None;
1025            }
1026            Some(b'c') => owner = pid.filter(|_| is_antigravity_process(rest, None)),
1027            Some(b'n') => {
1028                if let Some(pid) = owner
1029                    && let Some(port) = rest.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
1030                {
1031                    per_pid.entry(pid).or_default().push(port);
1032                }
1033            }
1034            _ => {}
1035        }
1036    }
1037    probe_order(per_pid)
1038}
1039
1040#[cfg(any(test, target_os = "windows"))]
1041#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1042struct WindowsTcpRow {
1043    local_addr: [u8; 4],
1044    local_port: u32,
1045    pid: u32,
1046}
1047
1048#[cfg(any(test, target_os = "windows"))]
1049fn decode_windows_process_name(raw: &[u16]) -> String {
1050    let end = raw.iter().position(|&unit| unit == 0).unwrap_or(raw.len());
1051    String::from_utf16_lossy(&raw[..end])
1052}
1053
1054#[cfg(any(test, target_os = "windows"))]
1055fn matching_windows_process_ids(processes: &[(u32, String)]) -> std::collections::HashSet<u32> {
1056    processes
1057        .iter()
1058        .filter(|(_, name)| is_antigravity_process(name, None))
1059        .map(|(pid, _)| *pid)
1060        .collect()
1061}
1062
1063/// Loopback ports owned by the matching processes, grouped per pid and handed
1064/// to [`probe_order`], which explains why the grouping matters.
1065#[cfg(any(test, target_os = "windows"))]
1066fn matching_windows_ports(
1067    pids: &std::collections::HashSet<u32>,
1068    rows: &[WindowsTcpRow],
1069) -> Vec<u16> {
1070    let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
1071    for row in rows {
1072        if !pids.contains(&row.pid) || row.local_addr != [127, 0, 0, 1] {
1073            continue;
1074        }
1075        let port = u16::from_be((row.local_port & u32::from(u16::MAX)) as u16);
1076        if port != 0 {
1077            per_pid.entry(row.pid).or_default().push(port);
1078        }
1079    }
1080    probe_order(per_pid)
1081}
1082
1083#[cfg(any(test, target_os = "windows"))]
1084fn checked_windows_row_count(
1085    buffer_len: usize,
1086    rows_offset: usize,
1087    row_size: usize,
1088    declared: usize,
1089) -> Option<usize> {
1090    let rows_len = row_size.checked_mul(declared)?;
1091    let end = rows_offset.checked_add(rows_len)?;
1092    (row_size != 0 && end <= buffer_len).then_some(declared)
1093}
1094
1095#[cfg(target_os = "windows")]
1096struct WindowsHandle(windows_sys::Win32::Foundation::HANDLE);
1097
1098#[cfg(target_os = "windows")]
1099impl Drop for WindowsHandle {
1100    fn drop(&mut self) {
1101        unsafe {
1102            windows_sys::Win32::Foundation::CloseHandle(self.0);
1103        }
1104    }
1105}
1106
1107#[cfg(target_os = "windows")]
1108fn windows_processes() -> Vec<(u32, String)> {
1109    use std::mem::size_of;
1110    use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
1111    use windows_sys::Win32::System::Diagnostics::ToolHelp::{
1112        CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
1113        TH32CS_SNAPPROCESS,
1114    };
1115
1116    let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
1117    if handle == INVALID_HANDLE_VALUE {
1118        return Vec::new();
1119    }
1120    let snapshot = WindowsHandle(handle);
1121    let mut entry = PROCESSENTRY32W::default();
1122    let Ok(entry_size) = u32::try_from(size_of::<PROCESSENTRY32W>()) else {
1123        return Vec::new();
1124    };
1125    entry.dwSize = entry_size;
1126    if unsafe { Process32FirstW(snapshot.0, &mut entry) } == 0 {
1127        return Vec::new();
1128    }
1129
1130    let mut processes = Vec::new();
1131    loop {
1132        processes.push((
1133            entry.th32ProcessID,
1134            decode_windows_process_name(&entry.szExeFile),
1135        ));
1136        if unsafe { Process32NextW(snapshot.0, &mut entry) } == 0 {
1137            break;
1138        }
1139    }
1140    processes
1141}
1142
1143#[cfg(target_os = "windows")]
1144fn parse_windows_tcp_rows(buffer: &[u32], used_bytes: usize) -> Vec<WindowsTcpRow> {
1145    use std::mem::{offset_of, size_of, size_of_val};
1146    use windows_sys::Win32::NetworkManagement::IpHelper::{
1147        MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
1148    };
1149
1150    let available = used_bytes.min(size_of_val(buffer));
1151    let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
1152    if available < size_of::<u32>() || available < rows_offset {
1153        return Vec::new();
1154    }
1155    let base = buffer.as_ptr().cast::<u8>();
1156    let declared = unsafe { base.cast::<u32>().read_unaligned() } as usize;
1157    if checked_windows_row_count(
1158        available,
1159        rows_offset,
1160        size_of::<MIB_TCPROW_OWNER_PID>(),
1161        declared,
1162    )
1163    .is_none()
1164    {
1165        return Vec::new();
1166    }
1167
1168    let rows = unsafe { base.add(rows_offset).cast::<MIB_TCPROW_OWNER_PID>() };
1169    (0..declared)
1170        .map(|index| unsafe { rows.add(index).read_unaligned() })
1171        .map(|row| WindowsTcpRow {
1172            local_addr: row.dwLocalAddr.to_ne_bytes(),
1173            local_port: row.dwLocalPort,
1174            pid: row.dwOwningPid,
1175        })
1176        .collect()
1177}
1178
1179#[cfg(target_os = "windows")]
1180fn windows_tcp_rows() -> Vec<WindowsTcpRow> {
1181    use std::mem::size_of;
1182    use std::ptr::null_mut;
1183    use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
1184    use windows_sys::Win32::NetworkManagement::IpHelper::{
1185        GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
1186    };
1187    use windows_sys::Win32::Networking::WinSock::AF_INET;
1188
1189    let mut size = 0u32;
1190    let status = unsafe {
1191        GetExtendedTcpTable(
1192            null_mut(),
1193            &mut size,
1194            0,
1195            u32::from(AF_INET),
1196            TCP_TABLE_OWNER_PID_LISTENER,
1197            0,
1198        )
1199    };
1200    if status != ERROR_INSUFFICIENT_BUFFER {
1201        return Vec::new();
1202    }
1203
1204    for _ in 0..3 {
1205        let Some(words) = (size as usize)
1206            .checked_add(size_of::<u32>() - 1)
1207            .map(|bytes| bytes / size_of::<u32>())
1208        else {
1209            return Vec::new();
1210        };
1211        if words == 0 {
1212            return Vec::new();
1213        }
1214        let mut buffer = Vec::<u32>::new();
1215        if buffer.try_reserve_exact(words).is_err() {
1216            return Vec::new();
1217        }
1218        buffer.resize(words, 0);
1219        let mut used = size;
1220        let status = unsafe {
1221            GetExtendedTcpTable(
1222                buffer.as_mut_ptr().cast(),
1223                &mut used,
1224                0,
1225                u32::from(AF_INET),
1226                TCP_TABLE_OWNER_PID_LISTENER,
1227                0,
1228            )
1229        };
1230        if status == ERROR_INSUFFICIENT_BUFFER {
1231            size = used;
1232            continue;
1233        }
1234        if status != 0 {
1235            return Vec::new();
1236        }
1237        return parse_windows_tcp_rows(&buffer, used as usize);
1238    }
1239    Vec::new()
1240}
1241
1242#[cfg(target_os = "windows")]
1243pub(crate) fn discover_ls_ports() -> Vec<u16> {
1244    let pids = matching_windows_process_ids(&windows_processes());
1245    if pids.is_empty() {
1246        return Vec::new();
1247    }
1248    matching_windows_ports(&pids, &windows_tcp_rows())
1249}
1250
1251#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
1252pub(crate) fn discover_ls_ports() -> Vec<u16> {
1253    Vec::new()
1254}
1255
1256/// Pull `(local_port, inode)` out of a listening row of `/proc/net/tcp`.
1257/// Columns: `sl local_address rem_address st ... uid timeout inode`.
1258#[cfg(target_os = "linux")]
1259fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
1260    let cols: Vec<&str> = line.split_whitespace().collect();
1261    if cols.len() < 10 {
1262        return None;
1263    }
1264    // 0x0A == TCP_LISTEN. Anything else is an established/closing socket.
1265    if cols[3] != "0A" {
1266        return None;
1267    }
1268    let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
1269    let inode = cols[9].parse::<u64>().ok()?;
1270    Some((port, inode))
1271}
1272
1273// ---------------------------------------------------------------------------
1274// Cache
1275// ---------------------------------------------------------------------------
1276
1277fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
1278    crate::outcome::fallback(cache, None, original, |bytes| {
1279        parse_cache_at(bytes, None, now)
1280    })
1281}
1282
1283/// Serve the stale cache when there is one. With no cache to fall back on,
1284/// surface `reason` — the actual diagnosis, e.g. "no local language server
1285/// found" — rather than a generic cache-miss that tells the user nothing about
1286/// what to do. This is the first-run path: no cache yet and Antigravity closed.
1287fn fallback_with_error(
1288    cache: &Cache,
1289    last_error: Option<(u16, String)>,
1290    reason: AppError,
1291    now: DateTime<Utc>,
1292) -> Result<FetchOutcome> {
1293    crate::outcome::fallback(cache, last_error, reason, |bytes| {
1294        parse_cache_at(bytes, None, now)
1295    })
1296}
1297
1298fn reuse_cache(
1299    bytes: Vec<u8>,
1300    cache: &Cache,
1301    stale: bool,
1302    account: Option<&str>,
1303    now: DateTime<Utc>,
1304) -> Result<FetchOutcome> {
1305    let snap = parse_cache_at(&bytes, account, now)?;
1306    Ok(crate::outcome::Outcome::cached(snap, cache, stale))
1307}
1308
1309/// `account` is the fingerprint of the currently signed-in account, or `None`
1310/// when no local server answered. A payload belonging to a different account is
1311/// rejected so a Google-account switch cannot show the previous account's
1312/// quota. With `None` we cannot verify — but nothing is consuming quota while
1313/// Antigravity is down, so the last known figures are the best available truth.
1314pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
1315    parse_cache_at(bytes, account, Utc::now())
1316}
1317
1318/// A cached window whose reset has already passed describes a period that has
1319/// since rolled over: the real figure is back near zero while the payload still
1320/// carries the old one, and its countdown is pinned at "now". Serving that is
1321/// presenting a known-obsolete number as current, so the payload is refused and
1322/// the caller reports that Antigravity needs to be running.
1323///
1324/// This matters more here than for other vendors: "nothing running" is the
1325/// normal state for Antigravity, and `MAX_STALE` is seven days — far past the
1326/// five hours after which the session window is guaranteed wrong.
1327fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
1328    [
1329        ("Gemini 5h", snap.session.as_ref()),
1330        ("Gemini weekly", snap.weekly.as_ref()),
1331        ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
1332        ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
1333    ]
1334    .into_iter()
1335    .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
1336    .map(|(name, _)| name)
1337}
1338
1339pub fn parse_cache_at(
1340    bytes: &[u8],
1341    account: Option<&str>,
1342    now: DateTime<Utc>,
1343) -> Result<AntigravitySnapshot> {
1344    let v: serde_json::Value = serde_json::from_slice(bytes)?;
1345
1346    let cached_account = v.get("account").and_then(serde_json::Value::as_str);
1347    if let Some(expected) = account
1348        && cached_account != Some(expected)
1349    {
1350        return Err(AppError::Schema(
1351            "antigravity cache belongs to a different account; refetching".into(),
1352        ));
1353    }
1354
1355    // The Gemini windows are required. Defaulting a missing or truncated field
1356    // to 0 would render a confident "0% used" and keep serving it for the rest
1357    // of the TTL; returning an error makes the caller fall through to a live
1358    // fetch instead of displaying a fabricated snapshot.
1359    // An absent window and a truncated payload look alike unless we insist on
1360    // the difference: `snap_to_json` always writes every key, so an explicit
1361    // `null` means "this product reported no such window" while a *missing*
1362    // key means the document is not one we wrote whole. Only the first is a
1363    // snapshot; the second must refetch rather than render a window short.
1364    let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
1365        match v.get(pct_key) {
1366            None => Err(AppError::Schema(format!(
1367                "antigravity: cached payload is missing {pct_key}"
1368            ))),
1369            Some(serde_json::Value::Null) => Ok(None),
1370            Some(value) => value
1371                .as_i64()
1372                .filter(|pct| (0..=100).contains(pct))
1373                .map(|pct| Some(pct as i32))
1374                .ok_or_else(|| {
1375                    AppError::Schema(format!(
1376                        "antigravity: cached {pct_key} must be an integer in 0..=100"
1377                    ))
1378                }),
1379        }
1380    };
1381
1382    let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1383        let Some(pct) = cached_pct(pct_key)? else {
1384            return Ok(None);
1385        };
1386        Ok::<_, AppError>(Some(UsageWindow {
1387            utilization_pct: pct,
1388            resets_at: parse_reset(&v[reset_key], reset_key)?,
1389            window_duration: if weekly {
1390                chrono::Duration::days(7)
1391            } else {
1392                chrono::Duration::hours(5)
1393            },
1394        }))
1395    };
1396
1397    let snap = AntigravitySnapshot {
1398        plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
1399        account: cached_account.unwrap_or_default().to_string(),
1400        // Payloads written before the remote path existed were all local.
1401        source: v["source"]
1402            .as_str()
1403            .and_then(AntigravitySource::parse)
1404            .unwrap_or_default(),
1405        session: optional("session_pct", "session_reset", false)?,
1406        weekly: optional("weekly_pct", "weekly_reset", true)?,
1407        third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
1408        third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
1409    };
1410
1411    // A cache with no window left is not a snapshot; refetch rather than draw
1412    // an empty panel from it. Mirrors the live parse.
1413    if snap.session.is_none()
1414        && snap.weekly.is_none()
1415        && snap.third_party_session.is_none()
1416        && snap.third_party_weekly.is_none()
1417    {
1418        return Err(AppError::Schema(
1419            "antigravity cache holds no usable window; refetching".into(),
1420        ));
1421    }
1422
1423    if let Some(window) = expired_window(&snap, now) {
1424        return Err(AppError::Schema(format!(
1425            "antigravity cache is past its {window} reset; refetching"
1426        )));
1427    }
1428    Ok(snap)
1429}
1430
1431pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
1432    serde_json::json!({
1433        "plan": snap.plan,
1434        "account": snap.account,
1435        "source": snap.source.as_str(),
1436        "session_pct": snap.session.as_ref().map(|w| w.utilization_pct),
1437        "session_reset": snap.session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1438        "weekly_pct": snap.weekly.as_ref().map(|w| w.utilization_pct),
1439        "weekly_reset": snap.weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1440        "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
1441        "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1442        "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
1443        "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1444    })
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use super::*;
1450
1451    /// Captured from a real `RetrieveUserQuotaSummary` response on 2026-07-22
1452    /// (Antigravity 2.0 build 2.3.1, `agy` 1.1.5), then trimmed. Percentages
1453    /// were edited to distinct non-zero values so a slot mix-up cannot pass.
1454    const QUOTA_JSON: &str = r#"{
1455      "response": {
1456        "groups": [
1457          {
1458            "displayName": "Gemini Models",
1459            "buckets": [
1460              {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
1461               "window": "weekly", "remainingFraction": 0.9191212,
1462               "resetTime": "2026-07-28T17:39:58Z"},
1463              {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
1464               "window": "5h", "remainingFraction": 0.5672253,
1465               "resetTime": "2026-07-22T17:47:00Z"}
1466            ]
1467          },
1468          {
1469            "displayName": "Claude and GPT models",
1470            "buckets": [
1471              {"bucketId": "3p-weekly", "window": "weekly",
1472               "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
1473              {"bucketId": "3p-5h", "window": "5h",
1474               "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
1475            ]
1476          }
1477        ]
1478      }
1479    }"#;
1480
1481    /// Fixed instant, earlier than every reset in the fixture. Using the wall
1482    /// clock here would make the suite start failing once those resets pass.
1483    fn now() -> DateTime<Utc> {
1484        DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
1485            .unwrap()
1486            .with_timezone(&Utc)
1487    }
1488
1489    fn parsed() -> AntigravitySnapshot {
1490        let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1491        parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
1492    }
1493
1494    #[test]
1495    fn quota_summary_maps_four_distinct_windows() {
1496        let snap = parsed();
1497        assert_eq!(snap.plan, "Google AI Pro");
1498        // remainingFraction is inverted into "used".
1499        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 43);
1500        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 8);
1501        assert_eq!(
1502            snap.third_party_session.as_ref().unwrap().utilization_pct,
1503            75
1504        );
1505        assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
1506    }
1507
1508    #[test]
1509    fn each_window_keeps_its_own_reset_time() {
1510        let snap = parsed();
1511        let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
1512        assert_eq!(
1513            snap.session.as_ref().unwrap().resets_at,
1514            at("2026-07-22T17:47:00Z")
1515        );
1516        assert_eq!(
1517            snap.weekly.as_ref().unwrap().resets_at,
1518            at("2026-07-28T17:39:58Z")
1519        );
1520        assert_eq!(
1521            snap.third_party_weekly.as_ref().unwrap().resets_at,
1522            at("2026-07-29T12:47:00Z")
1523        );
1524        // Regression: weekly must never be a copy of the 5h window.
1525        assert_ne!(
1526            snap.session.as_ref().unwrap().resets_at,
1527            snap.weekly.as_ref().unwrap().resets_at
1528        );
1529    }
1530
1531    #[test]
1532    fn window_durations_match_their_bucket() {
1533        let snap = parsed();
1534        assert_eq!(
1535            snap.session.as_ref().unwrap().window_duration,
1536            chrono::Duration::hours(5)
1537        );
1538        assert_eq!(
1539            snap.weekly.as_ref().unwrap().window_duration,
1540            chrono::Duration::days(7)
1541        );
1542        assert_eq!(
1543            snap.third_party_weekly.as_ref().unwrap().window_duration,
1544            chrono::Duration::days(7)
1545        );
1546    }
1547
1548    #[test]
1549    fn groups_are_matched_by_display_name_when_bucket_ids_change() {
1550        let v: serde_json::Value = serde_json::from_str(
1551            r#"{"response":{"groups":[
1552              {"displayName":"Gemini Models","buckets":[
1553                {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
1554                {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
1555              {"displayName":"Claude and GPT models","buckets":[
1556                {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
1557            ]}}"#,
1558        )
1559        .unwrap();
1560        let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
1561        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 50);
1562        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 10);
1563        assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
1564        assert!(snap.third_party_weekly.is_none());
1565    }
1566
1567    #[test]
1568    fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
1569        let duplicate: serde_json::Value = serde_json::from_str(
1570            r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
1571              {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1572              {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
1573              {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
1574            ]}]}}"#,
1575        )
1576        .unwrap();
1577        let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
1578        assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
1579
1580        // A future pool or cadence is ignored, not silently treated as the
1581        // Claude/GPT 5h slot.
1582        let unrelated: serde_json::Value = serde_json::from_str(
1583            r#"{"response":{"groups":[
1584              {"displayName":"Gemini Models","buckets":[
1585                {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1586                {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
1587                {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
1588              ]},
1589              {"displayName":"Future Models","buckets":[
1590                {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
1591              ]}
1592            ]}}"#,
1593        )
1594        .unwrap();
1595        let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
1596        assert!(snap.third_party_session.is_none());
1597        assert!(snap.third_party_weekly.is_none());
1598    }
1599
1600    /// A drifted bucket must fail the parse rather than report a reassuring
1601    /// "0% used" for a window whose real state is unknown.
1602    #[test]
1603    fn a_bucket_without_a_usable_fraction_is_rejected() {
1604        for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
1605            let v: serde_json::Value = serde_json::from_str(&format!(
1606                r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
1607                  {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
1608                  {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
1609            ))
1610            .unwrap();
1611            let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1612            assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
1613        }
1614    }
1615
1616    #[test]
1617    fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
1618        for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
1619            let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1620            v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
1621            let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1622            assert!(err.to_string().contains("resetTime"), "{err}");
1623        }
1624    }
1625
1626    /// The cache must round-trip a product that has no 5h window, and must
1627    /// still reject a document it did not write whole — an explicit `null`
1628    /// means "no such window", a missing key means truncation.
1629    #[test]
1630    fn a_weekly_only_snapshot_round_trips_through_the_cache() {
1631        let mut snap = parsed();
1632        snap.session = None;
1633        snap.third_party_session = None;
1634
1635        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1636        let back = parse_cache_at(&bytes, None, now()).expect("weekly-only cache is usable");
1637
1638        assert!(back.session.is_none());
1639        assert_eq!(
1640            back.weekly.as_ref().unwrap().utilization_pct,
1641            snap.weekly.as_ref().unwrap().utilization_pct
1642        );
1643    }
1644
1645    /// Issue #139: Antigravity CLI 1.1.22 on a paid account returns weekly
1646    /// buckets and no 5-hour ones. Two usable windows arrived, so requiring a
1647    /// Gemini 5h bucket threw both away and failed the whole vendor. This is
1648    /// the reporter's payload.
1649    #[test]
1650    fn a_product_reporting_only_weekly_buckets_still_renders_them() {
1651        let summary = serde_json::json!({
1652            "groups": [
1653                {
1654                    "displayName": "Gemini Models",
1655                    "buckets": [{
1656                        "bucketId": "gemini-weekly", "window": "weekly",
1657                        "remainingFraction": 0.42,
1658                    }],
1659                },
1660                {
1661                    "displayName": "Claude and GPT models",
1662                    "buckets": [{
1663                        "bucketId": "3p-weekly", "window": "weekly",
1664                        "remainingFraction": 0.9,
1665                    }],
1666                },
1667            ],
1668        });
1669
1670        let snap = parse_quota_summary(&summary, "Pro".into()).expect("weekly-only is usable");
1671
1672        assert!(snap.session.is_none(), "no 5h bucket arrived");
1673        assert!(snap.third_party_session.is_none());
1674        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 58);
1675        assert_eq!(
1676            snap.third_party_weekly.as_ref().unwrap().utilization_pct,
1677            10
1678        );
1679    }
1680
1681    /// The opposite shape must work for the same reason — the fix is "at least
1682    /// one window", not "weekly is the required one now".
1683    #[test]
1684    fn a_product_reporting_only_five_hour_buckets_still_renders_them() {
1685        let summary = serde_json::json!({
1686            "groups": [{
1687                "displayName": "Gemini Models",
1688                "buckets": [{
1689                    "bucketId": "gemini-5h", "window": "5h", "remainingFraction": 0.25,
1690                }],
1691            }],
1692        });
1693
1694        let snap = parse_quota_summary(&summary, "Pro".into()).expect("5h-only is usable");
1695
1696        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 75);
1697        assert!(snap.weekly.is_none());
1698    }
1699
1700    /// Nothing recognisable is still an error, and still names what arrived so
1701    /// the next report is diagnosable.
1702    #[test]
1703    fn a_summary_with_no_recognisable_bucket_errors_and_names_what_it_had() {
1704        let summary = serde_json::json!({
1705            "groups": [{
1706                "displayName": "Gemini Models",
1707                "buckets": [{
1708                    "bucketId": "gemini-daily", "window": "daily", "remainingFraction": 0.9,
1709                }],
1710            }],
1711        });
1712
1713        let rendered = parse_quota_summary(&summary, "Pro".into())
1714            .expect_err("an unrecognised cadence alone is not a snapshot")
1715            .to_string();
1716
1717        assert!(
1718            rendered.contains("no bucket in a window we recognise"),
1719            "{rendered}"
1720        );
1721        assert!(rendered.contains("gemini-daily"), "{rendered}");
1722        assert!(rendered.contains("window daily"), "{rendered}");
1723    }
1724
1725    /// A summary with groups but no buckets at all is a different situation
1726    /// from a summary whose buckets we did not recognise, and says so.
1727    #[test]
1728    fn a_summary_with_no_buckets_at_all_says_that_rather_than_listing_nothing() {
1729        let summary = serde_json::json!({
1730            "groups": [{"displayName": "Gemini", "buckets": []}],
1731        });
1732
1733        let rendered = parse_quota_summary(&summary, "Pro".into())
1734            .expect_err("no buckets is an error")
1735            .to_string();
1736
1737        assert!(rendered.contains("no buckets at all"), "{rendered}");
1738        assert!(!rendered.contains("it offered:"), "{rendered}");
1739    }
1740
1741    /// An unnamed bucket must still be listed — a summary of nothing but
1742    /// unnamed buckets is itself the finding.
1743    #[test]
1744    fn buckets_without_an_id_are_still_named_in_the_error() {
1745        let summary = serde_json::json!({
1746            "groups": [{"displayName": "", "buckets": [{"remainingFraction": 0.5}]}],
1747        });
1748
1749        let rendered = parse_quota_summary(&summary, "Pro".into())
1750            .expect_err("an unusable summary is an error")
1751            .to_string();
1752
1753        assert!(rendered.contains("<unnamed>"), "{rendered}");
1754    }
1755
1756    #[test]
1757    fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
1758        let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
1759        assert!(parse_quota_summary(&v, "Pro".into()).is_err());
1760    }
1761
1762    #[test]
1763    fn cache_round_trip_preserves_every_window() {
1764        let snap = parsed();
1765        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1766        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1767    }
1768
1769    /// A truncated payload must fail so the caller refetches. Defaulting the
1770    /// missing field to 0 would serve a confident "0% used" for the rest of the
1771    /// TTL — the fabricated-placeholder defect corrected in PR #26.
1772    #[test]
1773    fn a_truncated_cached_payload_is_rejected_not_zeroed() {
1774        let full = snap_to_json(&parsed());
1775        for missing in ["session_pct", "weekly_pct"] {
1776            let mut v = full.clone();
1777            v.as_object_mut().unwrap().remove(missing);
1778            let bytes = serde_json::to_vec(&v).unwrap();
1779            let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1780            assert!(err.to_string().contains(missing), "{missing}: {err}");
1781        }
1782        // A wholly empty object is not a zero-usage snapshot either.
1783        assert!(parse_cache_at(b"{}", None, now()).is_err());
1784    }
1785
1786    #[test]
1787    fn cached_percentages_are_range_checked_before_narrowing() {
1788        let full = snap_to_json(&parsed());
1789        for (key, bad) in [
1790            ("session_pct", serde_json::json!(-1)),
1791            ("weekly_pct", serde_json::json!(101)),
1792            ("session_pct", serde_json::json!(i64::MAX)),
1793            ("tp_session_pct", serde_json::json!("75")),
1794        ] {
1795            let mut v = full.clone();
1796            v[key] = bad;
1797            let bytes = serde_json::to_vec(&v).unwrap();
1798            let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1799            assert!(err.to_string().contains(key), "{key}: {err}");
1800        }
1801    }
1802
1803    #[test]
1804    fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
1805        let mut v = snap_to_json(&parsed());
1806        v["session_reset"] = serde_json::json!("not-a-time");
1807        let bytes = serde_json::to_vec(&v).unwrap();
1808        let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1809        assert!(err.to_string().contains("session_reset"), "{err}");
1810    }
1811
1812    /// With Antigravity closed the cache is served for up to `MAX_STALE`, but a
1813    /// window whose reset has passed has since rolled over — the real figure is
1814    /// back near zero while the payload still carries the old one. Serving that
1815    /// would present a known-obsolete number as current.
1816    #[test]
1817    fn a_cache_past_its_reset_is_refused() {
1818        let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
1819        let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1820
1821        // Before every reset: served.
1822        assert!(parse_cache_at(&bytes, None, now()).is_ok());
1823        // One second before the earliest (the two 5h windows, 17:47:00Z).
1824        assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
1825
1826        // The reset instant itself already counts as rolled over.
1827        let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
1828        assert!(err.to_string().contains("5h"), "{err}");
1829
1830        // Well past it — this is the reboot-with-nothing-running case.
1831        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
1832    }
1833
1834    /// The weekly windows outlive the 5-hour ones, so expiry must be reported
1835    /// per window rather than assuming the shortest one speaks for all four.
1836    #[test]
1837    fn expiry_names_the_window_that_rolled_over() {
1838        let mut snap = parsed();
1839        // Drop the 5h windows so only the weeklies can expire.
1840        snap.session.as_mut().unwrap().resets_at = None;
1841        snap.third_party_session = None;
1842        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1843        let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1844
1845        // Past the 5h resets but before either weekly: still usable.
1846        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
1847
1848        // Past the Gemini weekly (28th) but not the third-party one (29th).
1849        let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
1850        assert!(err.to_string().contains("Gemini weekly"), "{err}");
1851    }
1852
1853    /// A window with no reset time is unknown, not expired.
1854    #[test]
1855    fn a_window_without_a_reset_never_expires() {
1856        let mut snap = parsed();
1857        for w in [&mut snap.session, &mut snap.weekly].into_iter().flatten() {
1858            w.resets_at = None;
1859        }
1860        snap.third_party_session = None;
1861        snap.third_party_weekly = None;
1862        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1863        let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
1864            .unwrap()
1865            .with_timezone(&Utc);
1866        assert!(parse_cache_at(&bytes, None, far_future).is_ok());
1867    }
1868
1869    /// Switching Google accounts must not show the previous account's quota.
1870    #[test]
1871    fn a_cache_from_another_account_is_rejected() {
1872        let mut snap = parsed();
1873        snap.account = "acct:aaaa".into();
1874        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1875
1876        assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
1877        assert_eq!(
1878            parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
1879            snap
1880        );
1881
1882        // A payload written before the account was recorded is unattributable.
1883        let mut legacy = snap_to_json(&snap);
1884        legacy.as_object_mut().unwrap().remove("account");
1885        let legacy = serde_json::to_vec(&legacy).unwrap();
1886        assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
1887    }
1888
1889    /// With no local server there is nothing to compare against — and nothing
1890    /// is consuming quota either, so the last known figures still stand.
1891    #[test]
1892    fn an_unverifiable_cache_is_served_rather_than_discarded() {
1893        let mut snap = parsed();
1894        snap.account = "acct:aaaa".into();
1895        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1896        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1897    }
1898
1899    #[test]
1900    fn account_key_fingerprints_rather_than_storing_the_address() {
1901        let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
1902        let a = with("someone@example.com");
1903        assert!(!a.contains("someone"), "{a}");
1904        assert!(!a.contains('@'), "{a}");
1905        assert_eq!(a, with("someone@example.com"), "must be stable");
1906        assert_ne!(a, with("other@example.com"));
1907        // An unidentifiable response still compares equal to itself.
1908        let unknown = account_key(&serde_json::json!({}));
1909        assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
1910        assert_ne!(unknown, a);
1911    }
1912
1913    /// The third-party pool is genuinely optional — a plan without it caches a
1914    /// null and must still read back, unlike the required Gemini windows.
1915    #[test]
1916    fn absent_third_party_windows_are_not_treated_as_corruption() {
1917        let mut snap = parsed();
1918        snap.third_party_session = None;
1919        snap.third_party_weekly = None;
1920        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1921        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1922    }
1923
1924    #[test]
1925    fn cache_round_trip_preserves_absent_third_party_windows() {
1926        let mut snap = parsed();
1927        snap.third_party_session = None;
1928        snap.third_party_weekly = None;
1929        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1930        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1931    }
1932
1933    #[test]
1934    fn pct_used_inverts_valid_fractions() {
1935        assert_eq!(pct_used(1.0), 0);
1936        assert_eq!(pct_used(0.0), 100);
1937        assert_eq!(pct_used(0.5), 50);
1938    }
1939
1940    #[test]
1941    fn plan_falls_back_through_the_status_payload() {
1942        let tier: serde_json::Value =
1943            serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
1944                .unwrap();
1945        assert_eq!(plan_from_status(&tier), "Google AI Pro");
1946
1947        let plan_only: serde_json::Value = serde_json::from_str(
1948            r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
1949        )
1950        .unwrap();
1951        assert_eq!(plan_from_status(&plan_only), "Pro");
1952
1953        let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
1954        assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
1955    }
1956
1957    #[cfg(target_os = "linux")]
1958    #[test]
1959    fn proc_net_parser_keeps_only_listening_rows() {
1960        let listen = "   0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
1961                      00:00000000 00000000  1000        0 123456 1 0000 100 0";
1962        assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
1963
1964        let established = "   1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
1965                           00:00000000 00000000  1000        0 123457 1 0000 100 0";
1966        assert_eq!(parse_proc_net_line(established), None);
1967
1968        assert_eq!(parse_proc_net_line("garbage"), None);
1969    }
1970
1971    #[test]
1972    fn explicit_address_comes_first_and_gets_a_scheme() {
1973        assert_eq!(
1974            candidate_bases_with(Some("127.0.0.1:1234"), vec![5678]),
1975            vec![
1976                "http://127.0.0.1:1234".to_string(),
1977                "http://127.0.0.1:5678".to_string(),
1978            ]
1979        );
1980        // Trailing slashes are trimmed.
1981        assert_eq!(
1982            candidate_bases_with(Some("127.0.0.1:1234/"), vec![5678]),
1983            vec![
1984                "http://127.0.0.1:1234".to_string(),
1985                "http://127.0.0.1:5678".to_string(),
1986            ]
1987        );
1988        // Duplicate base URL is omitted.
1989        assert_eq!(
1990            candidate_bases_with(Some("127.0.0.1:5678"), vec![5678]),
1991            vec!["http://127.0.0.1:5678".to_string()]
1992        );
1993        // Duplicate discovered ports are omitted.
1994        assert_eq!(
1995            candidate_bases_with(None, vec![5678, 5678]),
1996            vec!["http://127.0.0.1:5678".to_string()]
1997        );
1998        // An address that already carries a scheme is left alone.
1999        assert_eq!(
2000            candidate_bases_with(Some("https://host:9"), vec![]),
2001            vec!["https://host:9".to_string()]
2002        );
2003    }
2004
2005    fn http(status: u16) -> AppError {
2006        AppError::Http {
2007            status,
2008            body: String::new(),
2009        }
2010    }
2011
2012    fn missing_csrf() -> AppError {
2013        AppError::Http {
2014            status: 401,
2015            body: r#"{"code":"unauthenticated","message":"missing CSRF token"}"#.into(),
2016        }
2017    }
2018
2019    #[test]
2020    fn only_agys_exact_missing_csrf_response_enables_remote_fallback() {
2021        assert!(is_missing_csrf(&missing_csrf()));
2022        assert!(!is_missing_csrf(&AppError::Http {
2023            status: 403,
2024            body: r#"{"code":"unauthenticated","message":"missing CSRF token"}"#.into(),
2025        }));
2026        assert!(!is_missing_csrf(&AppError::Http {
2027            status: 401,
2028            body: r#"{"code":"other","message":"missing CSRF token"}"#.into(),
2029        }));
2030        assert!(!is_missing_csrf(&AppError::Http {
2031            status: 401,
2032            body: "prefix: missing CSRF token".into(),
2033        }));
2034    }
2035
2036    #[test]
2037    fn a_real_auth_failure_outranks_agys_missing_csrf_response() {
2038        for errors in [
2039            vec![missing_csrf(), http(403)],
2040            vec![http(401), missing_csrf()],
2041        ] {
2042            let err = select_probe_error(errors);
2043            assert!(is_actionable(&err), "{err}");
2044            assert!(!is_missing_csrf(&err), "{err}");
2045        }
2046    }
2047
2048    #[test]
2049    fn several_agy_sessions_still_select_the_remote_fallback_reason() {
2050        let err = select_probe_error(vec![missing_csrf(), missing_csrf()]);
2051        assert!(is_missing_csrf(&err), "{err}");
2052        assert!(remote_fallback_reason(&err).is_some(), "{err}");
2053    }
2054
2055    /// A signed-out server is worth reporting even when a later candidate only
2056    /// refused the connection — that is the whole point of probing on past the
2057    /// first failure.
2058    #[test]
2059    fn an_auth_failure_outranks_later_transport_noise() {
2060        let err = select_probe_error(vec![
2061            http(401),
2062            AppError::Transport("connection refused".into()),
2063        ]);
2064        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2065
2066        let err = select_probe_error(vec![
2067            AppError::Transport("connection refused".into()),
2068            http(403),
2069        ]);
2070        assert!(matches!(err, AppError::Http { status: 403, .. }), "{err}");
2071    }
2072
2073    /// The *first* actionable failure wins, so the explicit override's message
2074    /// survives a second signed-out product further down the list.
2075    #[test]
2076    fn the_first_auth_failure_wins() {
2077        let err = select_probe_error(vec![http(401), http(403)]);
2078        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2079    }
2080
2081    /// With nothing actionable, the last failure stands in for "nothing
2082    /// answered" — and stays transient, so the widget falls back silently
2083    /// instead of shouting about a product that simply is not running.
2084    #[test]
2085    fn without_an_auth_failure_the_last_error_stands() {
2086        let err = select_probe_error(vec![
2087            AppError::Transport("first".into()),
2088            http(500),
2089            AppError::Transport("last".into()),
2090        ]);
2091        assert!(
2092            matches!(&err, AppError::Transport(m) if m == "last"),
2093            "{err}"
2094        );
2095        assert!(err.is_transient());
2096    }
2097
2098    /// A 5xx is a server that answered but broke; the user cannot act on it, so
2099    /// it must not outrank a later real failure the way a 401 does.
2100    #[test]
2101    fn a_server_error_is_not_treated_as_actionable() {
2102        let err = select_probe_error(vec![http(500), http(401)]);
2103        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2104    }
2105
2106    #[test]
2107    fn no_candidates_at_all_yields_a_generic_error() {
2108        let err = select_probe_error(Vec::new());
2109        assert!(
2110            err.to_string().contains("no local server answered"),
2111            "{err}"
2112        );
2113    }
2114
2115    /// Verbatim from Go's `net/http`: this is what every Antigravity product's
2116    /// HTTPS listener replies to the cleartext probe, so it is the tail of a
2117    /// normal discovery run rather than a symptom.
2118    fn tls_echo() -> AppError {
2119        AppError::Http {
2120            status: 400,
2121            body: "Client sent an HTTP request to an HTTPS server.\n".into(),
2122        }
2123    }
2124
2125    /// The RPC listener is probed first, so whatever it said is the diagnosis.
2126    /// The TLS port is reached only afterwards and always "fails", so without
2127    /// demoting it, it overwrites the one error that came from the server the
2128    /// user actually cares about.
2129    #[test]
2130    fn a_tls_echo_does_not_mask_what_the_rpc_listener_said() {
2131        let err = select_probe_error(vec![
2132            AppError::Http {
2133                status: 500,
2134                body: "GetUserStatus: internal".into(),
2135            },
2136            tls_echo(),
2137        ]);
2138        assert!(
2139            matches!(&err, AppError::Http { status: 500, body } if body.contains("internal")),
2140            "{err}"
2141        );
2142    }
2143
2144    /// The regression that motivated this: an `Http` is not transient, so an
2145    /// echo standing in as "the last failure" costs the silent cache fallback
2146    /// that `without_an_auth_failure_the_last_error_stands` exists to protect.
2147    /// A product that is merely not serving RPC must stay quiet.
2148    #[test]
2149    fn a_tls_echo_does_not_cost_the_silent_fallback() {
2150        let err = select_probe_error(vec![
2151            AppError::Transport("connection refused".into()),
2152            tls_echo(),
2153        ]);
2154        assert!(
2155            matches!(&err, AppError::Transport(m) if m == "connection refused"),
2156            "{err}"
2157        );
2158        assert!(
2159            err.is_transient(),
2160            "the echo must not make the run non-transient: {err}"
2161        );
2162    }
2163
2164    /// Demoted, not discarded. When the TLS listener is genuinely all that
2165    /// answered, its reply is still better than a generic "nothing answered".
2166    #[test]
2167    fn a_tls_echo_still_stands_when_it_is_the_only_thing_that_answered() {
2168        let err = select_probe_error(vec![tls_echo()]);
2169        assert!(matches!(err, AppError::Http { status: 400, .. }), "{err}");
2170    }
2171
2172    /// The demotion keys on the body, so the language server's own `400` — a
2173    /// real complaint about a real request — keeps its normal rank.
2174    #[test]
2175    fn a_genuine_bad_request_is_not_mistaken_for_a_tls_echo() {
2176        let err = select_probe_error(vec![
2177            AppError::Http {
2178                status: 400,
2179                body: "unknown method GetUserStatus".into(),
2180            },
2181            tls_echo(),
2182        ]);
2183        assert!(
2184            matches!(&err, AppError::Http { status: 400, body } if body.contains("unknown method")),
2185            "{err}"
2186        );
2187    }
2188
2189    /// Ranking the echo last must not disturb the top of the order.
2190    #[test]
2191    fn an_auth_failure_still_outranks_a_tls_echo() {
2192        let err = select_probe_error(vec![tls_echo(), http(401)]);
2193        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
2194    }
2195
2196    #[test]
2197    fn every_discovered_port_is_probed_in_order() {
2198        assert_eq!(
2199            candidate_bases_with(None, vec![33875, 37435]),
2200            vec![
2201                "http://127.0.0.1:33875".to_string(),
2202                "http://127.0.0.1:37435".to_string(),
2203            ]
2204        );
2205    }
2206
2207    /// The server's port is drawn from the ephemeral range, so there is nothing
2208    /// sensible to guess when discovery comes up empty. Probing a hardcoded
2209    /// port would contact an unrelated process; callers get the "start
2210    /// Antigravity or set ANTIGRAVITY_LS_ADDRESS" error instead.
2211    #[test]
2212    fn empty_discovery_yields_no_candidates() {
2213        assert!(candidate_bases_with(None, vec![]).is_empty());
2214        assert!(candidate_bases_with(Some(""), vec![]).is_empty());
2215    }
2216
2217    #[test]
2218    fn every_antigravity_product_is_recognised() {
2219        // Antigravity 2.0 / IDE: a separate language_server child.
2220        assert!(is_antigravity_process(
2221            "language_server\n",
2222            Some("/opt/antigravity/resources/bin/language_server")
2223        ));
2224        // agy CLI: embeds the RPC surface in its own process.
2225        assert!(is_antigravity_process(
2226            "agy\n",
2227            Some("/home/u/.local/bin/agy")
2228        ));
2229        // Recognised by path even when the process name says nothing.
2230        assert!(is_antigravity_process(
2231            "node",
2232            Some("/opt/antigravity/bin/helper")
2233        ));
2234        assert!(is_antigravity_process("antigravity", None));
2235        assert!(is_antigravity_process("agy.exe", None));
2236        assert!(is_antigravity_process("Antigravity.exe", None));
2237        assert!(is_antigravity_process("language_server.exe", None));
2238        assert!(is_antigravity_process(
2239            "language_server_windows_x64.exe",
2240            None
2241        ));
2242        assert!(is_antigravity_process(
2243            "node.exe",
2244            Some(r"C:\Users\u\AppData\Local\agy.exe")
2245        ));
2246    }
2247
2248    #[test]
2249    fn unrelated_processes_are_not_probed() {
2250        assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
2251        assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
2252        // "legacy" ends in a substring of "/agy" but is not the CLI.
2253        assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
2254        assert!(!is_antigravity_process("legacy.exe", None));
2255        assert!(!is_antigravity_process("not-agy.exe", None));
2256        assert!(!is_antigravity_process("", None));
2257    }
2258
2259    #[test]
2260    fn windows_process_names_decode_until_nul_and_tolerate_invalid_utf16() {
2261        let mut raw: Vec<u16> = "agy.exe".encode_utf16().collect();
2262        raw.extend([0, b'x' as u16]);
2263        assert_eq!(decode_windows_process_name(&raw), "agy.exe");
2264        assert_eq!(decode_windows_process_name(&[0xd800]), "�");
2265        assert_eq!(decode_windows_process_name(&[]), "");
2266    }
2267
2268    #[test]
2269    fn windows_process_filter_keeps_only_antigravity_pids() {
2270        let processes = vec![
2271            (10, "agy.exe".to_string()),
2272            (20, "language_server_windows_x64.exe".to_string()),
2273            (30, "sshd.exe".to_string()),
2274        ];
2275        let pids = matching_windows_process_ids(&processes);
2276        assert_eq!(pids, std::collections::HashSet::from([10, 20]));
2277    }
2278
2279    #[test]
2280    fn windows_listener_filter_joins_pid_loopback_and_port() {
2281        let pids = std::collections::HashSet::from([10]);
2282        let rows = [
2283            WindowsTcpRow {
2284                local_addr: [127, 0, 0, 1],
2285                local_port: u32::from(59870u16.to_be()),
2286                pid: 10,
2287            },
2288            WindowsTcpRow {
2289                local_addr: [127, 0, 0, 1],
2290                local_port: u32::from(59868u16.to_be()),
2291                pid: 10,
2292            },
2293            WindowsTcpRow {
2294                local_addr: [127, 0, 0, 1],
2295                local_port: u32::from(59870u16.to_be()),
2296                pid: 10,
2297            },
2298            WindowsTcpRow {
2299                local_addr: [0, 0, 0, 0],
2300                local_port: u32::from(50000u16.to_be()),
2301                pid: 10,
2302            },
2303            WindowsTcpRow {
2304                local_addr: [127, 0, 0, 1],
2305                local_port: u32::from(50001u16.to_be()),
2306                pid: 99,
2307            },
2308            WindowsTcpRow {
2309                local_addr: [127, 0, 0, 1],
2310                local_port: 0,
2311                pid: 10,
2312            },
2313        ];
2314        assert_eq!(matching_windows_ports(&pids, &rows), vec![59870, 59868]);
2315    }
2316
2317    /// Antigravity 2.0 and an interactive `agy` session at once. Their port
2318    /// pairs must not be flattened into one set: sorting all four descending
2319    /// would put pid 20's TLS listener ahead of pid 10's RPC listener.
2320    #[test]
2321    fn windows_ports_from_two_products_keep_tls_listeners_last() {
2322        let pids = std::collections::HashSet::from([10, 20]);
2323        let row = |port: u16, pid: u32| WindowsTcpRow {
2324            local_addr: [127, 0, 0, 1],
2325            local_port: u32::from(port.to_be()),
2326            pid,
2327        };
2328        let rows = [
2329            row(40000, 10),
2330            row(40001, 10),
2331            row(50000, 20),
2332            row(50001, 20),
2333        ];
2334        assert_eq!(
2335            matching_windows_ports(&pids, &rows),
2336            vec![40001, 50001, 40000, 50000]
2337        );
2338    }
2339
2340    /// The high-to-low preference only means something per product, so the
2341    /// grouping is what keeps a second product's TLS listener from being
2342    /// probed before the first product's RPC listener.
2343    #[test]
2344    fn probe_order_puts_every_rpc_listener_ahead_of_every_tls_listener() {
2345        use std::collections::BTreeMap;
2346
2347        // One process, the ordinary case: RPC (higher) before TLS (lower).
2348        assert_eq!(
2349            probe_order(BTreeMap::from([(10, vec![59868, 59870])])),
2350            vec![59870, 59868]
2351        );
2352        // Two products. A plain descending sort would yield 50001, 50000,
2353        // 40001, 40000 and reach pid 20's TLS listener second; taking the
2354        // ports rank by rank leaves both TLS listeners at the back, where they
2355        // are touched only if no RPC listener answered.
2356        assert_eq!(
2357            probe_order(BTreeMap::from([
2358                (10, vec![40000, 40001]),
2359                (20, vec![50000, 50001]),
2360            ])),
2361            vec![40001, 50001, 40000, 50000]
2362        );
2363        // Uneven groups: the extra port of the deeper group trails everything
2364        // it ranks below, and a port claimed by two pids is probed once.
2365        assert_eq!(
2366            probe_order(BTreeMap::from([
2367                (10, vec![6000, 5000, 4000]),
2368                (20, vec![6000, 7000]),
2369            ])),
2370            vec![6000, 7000, 5000, 4000]
2371        );
2372        assert!(probe_order(BTreeMap::new()).is_empty());
2373    }
2374
2375    /// A dual-stack bind names the same port from both `/proc/net/tcp` and
2376    /// `tcp6`. Those rows are one listener, so they must not consume two ranks
2377    /// and push the product's real second listener down past another
2378    /// product's.
2379    #[test]
2380    fn a_port_named_twice_by_one_product_still_occupies_one_rank() {
2381        use std::collections::BTreeMap;
2382
2383        assert_eq!(
2384            probe_order(BTreeMap::from([
2385                (10, vec![40001, 40001, 40000, 40000]),
2386                (20, vec![50001, 50000]),
2387            ])),
2388            vec![40001, 50001, 40000, 50000],
2389            "duplicate rows must not reorder the ranks below them"
2390        );
2391    }
2392
2393    /// The ordering rests on each product showing both listeners. A product
2394    /// caught mid-startup, with only its TLS port bound, sits alone at rank 0
2395    /// and is probed first — documented as the known cost, and harmless
2396    /// because every candidate is probed anyway.
2397    #[test]
2398    fn a_half_started_product_is_the_documented_exception() {
2399        use std::collections::BTreeMap;
2400
2401        assert_eq!(
2402            probe_order(BTreeMap::from([
2403                (10, vec![40000]),
2404                (20, vec![50001, 50000])
2405            ])),
2406            vec![40000, 50001, 50000]
2407        );
2408    }
2409
2410    /// `ANTIGRAVITY_LS_ADDRESS` is user input. An entry that leaves no
2411    /// authority to connect to is dropped instead of probed, so it can neither
2412    /// spend a round-trip nor add a failure that competes with the real one in
2413    /// [`select_probe_error`].
2414    #[test]
2415    fn an_override_with_no_authority_is_dropped_not_probed() {
2416        for junk in ["/", "///", "http://", "https://", "  /  "] {
2417            assert_eq!(
2418                candidate_bases_with(Some(junk), vec![4242]),
2419                vec!["http://127.0.0.1:4242".to_string()],
2420                "{junk:?} should not survive as a candidate"
2421            );
2422        }
2423        assert!(candidate_bases_with(Some("/"), vec![]).is_empty());
2424    }
2425
2426    #[test]
2427    fn windows_table_bounds_reject_truncation_and_overflow() {
2428        assert_eq!(checked_windows_row_count(52, 4, 24, 2), Some(2));
2429        assert_eq!(checked_windows_row_count(51, 4, 24, 2), None);
2430        assert_eq!(checked_windows_row_count(4, 4, 24, 0), Some(0));
2431        assert_eq!(checked_windows_row_count(52, 4, 0, 2), None);
2432        assert_eq!(
2433            checked_windows_row_count(usize::MAX, 4, 24, usize::MAX),
2434            None
2435        );
2436    }
2437
2438    #[cfg(target_os = "windows")]
2439    #[test]
2440    fn windows_tcp_table_parser_copies_complete_rows_only() {
2441        use std::mem::{offset_of, size_of};
2442        use windows_sys::Win32::NetworkManagement::IpHelper::{
2443            MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
2444        };
2445
2446        let offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
2447        let used = offset + 2 * size_of::<MIB_TCPROW_OWNER_PID>();
2448        let words = used.div_ceil(size_of::<u32>());
2449        let mut buffer = vec![0u32; words];
2450        let first = MIB_TCPROW_OWNER_PID {
2451            dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
2452            dwLocalPort: u32::from(59868u16.to_be()),
2453            dwOwningPid: 10,
2454            ..Default::default()
2455        };
2456        let second = MIB_TCPROW_OWNER_PID {
2457            dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
2458            dwLocalPort: u32::from(59870u16.to_be()),
2459            dwOwningPid: 10,
2460            ..Default::default()
2461        };
2462        unsafe {
2463            buffer.as_mut_ptr().write_unaligned(2);
2464            let rows = buffer
2465                .as_mut_ptr()
2466                .cast::<u8>()
2467                .add(offset)
2468                .cast::<MIB_TCPROW_OWNER_PID>();
2469            rows.write_unaligned(first);
2470            rows.add(1).write_unaligned(second);
2471        }
2472
2473        assert_eq!(
2474            parse_windows_tcp_rows(&buffer, used),
2475            vec![
2476                WindowsTcpRow {
2477                    local_addr: [127, 0, 0, 1],
2478                    local_port: u32::from(59868u16.to_be()),
2479                    pid: 10,
2480                },
2481                WindowsTcpRow {
2482                    local_addr: [127, 0, 0, 1],
2483                    local_port: u32::from(59870u16.to_be()),
2484                    pid: 10,
2485                },
2486            ]
2487        );
2488        assert!(parse_windows_tcp_rows(&buffer, used - 1).is_empty());
2489    }
2490
2491    #[test]
2492    fn lsof_parser_keeps_only_ports_owned_by_antigravity_processes() {
2493        // `agy` (pid 74101) has three listening sockets; `sshd` (pid 200) has
2494        // one that must be excluded even though it sorts right after `c`.
2495        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";
2496        assert_eq!(parse_lsof_pcn(output), vec![61290, 61289, 8829]);
2497    }
2498
2499    /// The pid on each `p` line has to survive to the `n` lines, or the ports
2500    /// of two running products collapse into one group and rank ordering can
2501    /// no longer keep the TLS listeners last.
2502    #[test]
2503    fn lsof_parser_keeps_each_products_ports_in_its_own_group() {
2504        let output = concat!(
2505            "p100\ncagy\nf3\nn127.0.0.1:40000\nf4\nn127.0.0.1:40001\n",
2506            "p200\nclanguage_server\nf5\nn127.0.0.1:50000\nf6\nn127.0.0.1:50001\n",
2507        );
2508        assert_eq!(
2509            parse_lsof_pcn(output),
2510            vec![40001, 50001, 40000, 50000],
2511            "both HTTP listeners must precede both TLS listeners"
2512        );
2513    }
2514
2515    #[test]
2516    fn lsof_parser_matches_the_capitalised_macos_app_name() {
2517        let output = "p900\ncAntigravity\nf7\nn127.0.0.1:54321\n";
2518        assert_eq!(parse_lsof_pcn(output), vec![54321]);
2519    }
2520
2521    #[test]
2522    fn lsof_parser_deduplicates_and_handles_empty_output() {
2523        let output = "p1\ncagy\nf3\nn127.0.0.1:9000\nf4\nn127.0.0.1:9000\n";
2524        assert_eq!(parse_lsof_pcn(output), vec![9000]);
2525        assert!(parse_lsof_pcn("").is_empty());
2526    }
2527
2528    /// First run with Antigravity closed: no cache to serve, so the user must
2529    /// be told what to start — not "no usable cache", which says nothing.
2530    #[test]
2531    fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
2532        let dir = tempfile::tempdir().unwrap();
2533        let cache = Cache::at(dir.path().join("usage.json"));
2534        let reason = AppError::Credentials("Antigravity: no local language server found".into());
2535
2536        let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
2537        let msg = err.to_string();
2538        assert!(msg.contains("no local language server found"), "{msg}");
2539        assert!(!msg.contains("no usable cache"), "{msg}");
2540    }
2541
2542    #[test]
2543    fn unusable_cache_does_not_replace_the_live_diagnosis() {
2544        let dir = tempfile::tempdir().unwrap();
2545        let cache = Cache::at(dir.path().join("antigravity"));
2546        cache.write_payload(b"{}").unwrap();
2547
2548        let reason = AppError::Credentials("Antigravity must be running".into());
2549        let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
2550        assert!(err.to_string().contains("must be running"), "{err}");
2551
2552        let original = AppError::Transport("original loopback failure".into());
2553        let err = fallback_silent(&cache, now(), original).unwrap_err();
2554        assert!(
2555            err.to_string().contains("original loopback failure"),
2556            "{err}"
2557        );
2558    }
2559
2560    #[tokio::test]
2561    async fn rpc_error_bodies_are_bounded_too() {
2562        let mut server = mockito::Server::new_async().await;
2563        let path = format!("/{STATUS_RPC}");
2564        server
2565            .mock("POST", path.as_str())
2566            .with_status(500)
2567            .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
2568            .create_async()
2569            .await;
2570
2571        let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
2572            .await
2573            .unwrap_err();
2574        assert!(err.to_string().contains("exceeds"), "{err}");
2575    }
2576
2577    #[test]
2578    fn blank_override_falls_through_to_discovery() {
2579        assert_eq!(
2580            candidate_bases_with(Some("   "), vec![4242]),
2581            vec!["http://127.0.0.1:4242".to_string()]
2582        );
2583    }
2584
2585    // -----------------------------------------------------------------------
2586    // Remote fallback
2587    // -----------------------------------------------------------------------
2588
2589    fn fixture() -> (tempfile::TempDir, Cache) {
2590        let td = tempfile::TempDir::new().unwrap();
2591        let cache = Cache::at(td.path().join("antigravity"));
2592        (td, cache)
2593    }
2594
2595    fn endpoints(server: &mockito::Server) -> cloud::Endpoints {
2596        let base = server.url();
2597        cloud::Endpoints {
2598            quota: vec![format!("{base}/daily/quota"), format!("{base}/prod/quota")],
2599            load_code_assist: vec![format!("{base}/daily/plan"), format!("{base}/prod/plan")],
2600            token: format!("{base}/token"),
2601        }
2602    }
2603
2604    /// A keyring blob as Antigravity writes it, expiring at `expiry`.
2605    fn keyring_blob(expiry: &str, with_refresh: bool) -> String {
2606        let mut token = serde_json::json!({
2607            "access_token": "KEYRING-AT",
2608            "expiry": expiry,
2609        });
2610        if with_refresh {
2611            token["refresh_token"] = serde_json::json!("KEYRING-RT");
2612        }
2613        serde_json::json!({ "token": token }).to_string()
2614    }
2615
2616    /// Well before the fixture's `now()`.
2617    const EXPIRED: &str = "2026-07-22T11:00:00Z";
2618    /// Comfortably after it.
2619    const VALID: &str = "2026-07-22T13:00:00Z";
2620
2621    /// No local server, this blob, these endpoints.
2622    fn remote<'a>(blob: &'a str, eps: &'a cloud::Endpoints) -> RemoteOverride<'a> {
2623        RemoteOverride {
2624            credential: SavedCredential::Blob(blob),
2625            endpoints: Some(eps),
2626            local_bases: Some(vec![]),
2627        }
2628    }
2629
2630    /// The bare summary the API returns: the RPC's payload without its
2631    /// `{"response": …}` envelope.
2632    fn bare_summary() -> String {
2633        let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
2634        v["response"].to_string()
2635    }
2636
2637    fn quota_mock(server: &mut mockito::Server, bearer: &str) -> mockito::Mock {
2638        server
2639            .mock("POST", "/daily/quota")
2640            .match_header("authorization", format!("Bearer {bearer}").as_str())
2641            .with_status(200)
2642            .with_body(bare_summary())
2643    }
2644
2645    fn token_mock(server: &mut mockito::Server) -> mockito::Mock {
2646        server
2647            .mock("POST", "/token")
2648            .with_status(200)
2649            .with_body(r#"{"access_token":"NEW-AT","expires_in":3600}"#)
2650    }
2651
2652    fn test_oauth() -> cloud::OauthClient {
2653        cloud::OauthClient {
2654            id: "test-client".into(),
2655            secret: "test-client-secret".into(),
2656        }
2657    }
2658
2659    async fn run(cache: &Cache, remote: RemoteOverride<'_>, ttl: Duration) -> Result<FetchOutcome> {
2660        fetch_snapshot_at(
2661            &reqwest::Client::new(),
2662            cache,
2663            ttl,
2664            Some(&test_oauth()),
2665            remote,
2666            now(),
2667        )
2668        .await
2669    }
2670
2671    #[tokio::test]
2672    async fn with_no_local_server_the_saved_session_answers_from_the_api() {
2673        let mut server = mockito::Server::new_async().await;
2674        let eps = endpoints(&server);
2675        let quota = quota_mock(&mut server, "KEYRING-AT")
2676            .expect(1)
2677            .create_async()
2678            .await;
2679        let plan = server
2680            .mock("POST", "/daily/plan")
2681            .match_header("authorization", "Bearer KEYRING-AT")
2682            .with_status(200)
2683            .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
2684            .expect(1)
2685            .create_async()
2686            .await;
2687        // A token this fresh is used as it is.
2688        let token = token_mock(&mut server).expect(0).create_async().await;
2689        let blob = keyring_blob(VALID, true);
2690        let (_td, cache) = fixture();
2691
2692        let outcome = run(&cache, remote(&blob, &eps), Duration::from_secs(60))
2693            .await
2694            .expect("remote path yields a snapshot");
2695
2696        quota.assert_async().await;
2697        plan.assert_async().await;
2698        token.assert_async().await;
2699        let snap = outcome.snapshot;
2700        assert!(!outcome.stale);
2701        assert_eq!(snap.source, AntigravitySource::Remote);
2702        assert_eq!(snap.plan, "Pro");
2703        assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 43);
2704        assert_eq!(snap.weekly.as_ref().unwrap().utilization_pct, 8);
2705        assert_eq!(snap.third_party_session.unwrap().utilization_pct, 75);
2706        assert_eq!(snap.third_party_weekly.unwrap().utilization_pct, 0);
2707        let fingerprint = credential::parse_keyring_blob(&blob).unwrap().fingerprint;
2708        assert_eq!(snap.account, format!("acct:{fingerprint}"));
2709        assert!(!snap.account.contains("KEYRING"), "{}", snap.account);
2710
2711        // What was cached is attributed to the remote path too.
2712        let cached = parse_cache_at(
2713            &cache
2714                .fresh_payload(Duration::from_secs(60))
2715                .unwrap()
2716                .unwrap(),
2717            None,
2718            now(),
2719        )
2720        .unwrap();
2721        assert_eq!(cached.source, AntigravitySource::Remote);
2722        assert_eq!(cached.account, snap.account);
2723    }
2724
2725    /// `agy` exposes the local RPC port but, unlike the desktop products, does
2726    /// not expose the CSRF token needed to use it. That one precise response is
2727    /// equivalent to having no usable local source, so the saved session may
2728    /// answer without weakening the normal signed-out-server rule below.
2729    #[tokio::test]
2730    async fn agys_missing_csrf_response_uses_the_saved_session() {
2731        let mut server = mockito::Server::new_async().await;
2732        let eps = endpoints(&server);
2733        let root = server
2734            .mock("GET", "/")
2735            .with_status(404)
2736            .expect(1)
2737            .create_async()
2738            .await;
2739        let status_path = format!("/{STATUS_RPC}");
2740        let status = server
2741            .mock("POST", status_path.as_str())
2742            .with_status(401)
2743            .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
2744            .expect(1)
2745            .create_async()
2746            .await;
2747        let quota = quota_mock(&mut server, "KEYRING-AT")
2748            .expect(1)
2749            .create_async()
2750            .await;
2751        let plan = server
2752            .mock("POST", "/daily/plan")
2753            .match_header("authorization", "Bearer KEYRING-AT")
2754            .with_status(200)
2755            .with_body(r#"{"currentTier":{"name":"google_ai_pro"}}"#)
2756            .expect(1)
2757            .create_async()
2758            .await;
2759        let blob = keyring_blob(VALID, true);
2760        let (_td, cache) = fixture();
2761
2762        let outcome = run(
2763            &cache,
2764            RemoteOverride {
2765                credential: SavedCredential::Blob(&blob),
2766                endpoints: Some(&eps),
2767                local_bases: Some(vec![server.url()]),
2768            },
2769            Duration::ZERO,
2770        )
2771        .await
2772        .expect("saved session bypasses agy's unusable local RPC");
2773
2774        root.assert_async().await;
2775        status.assert_async().await;
2776        quota.assert_async().await;
2777        plan.assert_async().await;
2778        assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
2779    }
2780
2781    #[tokio::test]
2782    async fn agys_missing_csrf_without_a_saved_session_explains_both_options() {
2783        let mut server = mockito::Server::new_async().await;
2784        let eps = endpoints(&server);
2785        let root = server
2786            .mock("GET", "/")
2787            .with_status(404)
2788            .expect(1)
2789            .create_async()
2790            .await;
2791        let status_path = format!("/{STATUS_RPC}");
2792        let status = server
2793            .mock("POST", status_path.as_str())
2794            .with_status(401)
2795            .with_body(r#"{"code":"unauthenticated","message":"missing CSRF token"}"#)
2796            .expect(1)
2797            .create_async()
2798            .await;
2799        let quota = quota_mock(&mut server, "KEYRING-AT")
2800            .expect(0)
2801            .create_async()
2802            .await;
2803        let (_td, cache) = fixture();
2804
2805        let error = run(
2806            &cache,
2807            RemoteOverride {
2808                credential: SavedCredential::Absent,
2809                endpoints: Some(&eps),
2810                local_bases: Some(vec![server.url()]),
2811            },
2812            Duration::ZERO,
2813        )
2814        .await
2815        .expect_err("neither the local RPC nor a saved session is usable");
2816
2817        root.assert_async().await;
2818        status.assert_async().await;
2819        quota.assert_async().await;
2820        let message = error.to_string();
2821        assert!(message.contains("requires a CSRF token"), "{message}");
2822        assert!(message.contains("saved Google session"), "{message}");
2823    }
2824
2825    /// The refreshed token is persisted under the session's fingerprint, so
2826    /// the next poll spends no round-trip on the same refresh.
2827    #[tokio::test]
2828    async fn an_expired_keyring_token_is_refreshed_once_and_the_refresh_is_reused() {
2829        let mut server = mockito::Server::new_async().await;
2830        let eps = endpoints(&server);
2831        let _quota = quota_mock(&mut server, "NEW-AT")
2832            .expect(2)
2833            .create_async()
2834            .await;
2835        let token = token_mock(&mut server).expect(1).create_async().await;
2836        let blob = keyring_blob(EXPIRED, true);
2837        let (_td, cache) = fixture();
2838
2839        let first = run(&cache, remote(&blob, &eps), Duration::ZERO)
2840            .await
2841            .expect("refresh, then quota");
2842        assert_eq!(first.snapshot.source, AntigravitySource::Remote);
2843        token.assert_async().await;
2844
2845        let fingerprint = credential::parse_keyring_blob(&blob).unwrap().fingerprint;
2846        let persisted = cloud::read_persisted(&cloud::oauth_cache_path(&cache), &fingerprint)
2847            .expect("refreshed token persisted under the keyring session's fingerprint");
2848        assert_eq!(persisted.access_token, "NEW-AT");
2849
2850        // A zero TTL forces the network again; the token endpoint stays at one hit.
2851        run(&cache, remote(&blob, &eps), Duration::ZERO)
2852            .await
2853            .expect("persisted token reused");
2854        token.assert_async().await;
2855    }
2856
2857    /// The API rejecting a token that was not just minted is the token being
2858    /// stale, not the session: one refresh, one retry.
2859    #[tokio::test]
2860    async fn a_stale_token_the_api_rejects_gets_one_refresh_and_one_retry() {
2861        let mut server = mockito::Server::new_async().await;
2862        let eps = endpoints(&server);
2863        let rejected = server
2864            .mock("POST", "/daily/quota")
2865            .match_header("authorization", "Bearer KEYRING-AT")
2866            .with_status(401)
2867            .expect(1)
2868            .create_async()
2869            .await;
2870        let accepted = quota_mock(&mut server, "NEW-AT")
2871            .expect(1)
2872            .create_async()
2873            .await;
2874        let token = token_mock(&mut server).expect(1).create_async().await;
2875        let blob = keyring_blob(VALID, true);
2876        let (_td, cache) = fixture();
2877
2878        let outcome = run(&cache, remote(&blob, &eps), Duration::ZERO)
2879            .await
2880            .expect("retry with the refreshed token succeeds");
2881
2882        rejected.assert_async().await;
2883        accepted.assert_async().await;
2884        token.assert_async().await;
2885        assert_eq!(outcome.snapshot.source, AntigravitySource::Remote);
2886    }
2887
2888    /// A refresh Google refuses is the session being gone. The error is
2889    /// actionable and carries no token material.
2890    #[tokio::test]
2891    async fn a_refused_refresh_is_a_credentials_error_without_the_token() {
2892        let mut server = mockito::Server::new_async().await;
2893        let eps = endpoints(&server);
2894        let _token = server
2895            .mock("POST", "/token")
2896            .with_status(400)
2897            .with_body(r#"{"error":"invalid_grant"}"#)
2898            .create_async()
2899            .await;
2900        let blob = keyring_blob(EXPIRED, true);
2901        let (_td, cache) = fixture();
2902
2903        let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
2904            .await
2905            .expect_err("no cache to fall back on");
2906
2907        assert!(matches!(err, AppError::Credentials(_)), "{err}");
2908        let rendered = err.to_string();
2909        assert!(!rendered.contains("KEYRING-RT"), "{rendered}");
2910        assert!(!rendered.contains("KEYRING-AT"), "{rendered}");
2911    }
2912
2913    /// A `401` against a token minted a moment ago is Google's verdict on the
2914    /// session; refreshing again would only repeat it.
2915    #[tokio::test]
2916    async fn a_rejection_right_after_a_refresh_asks_to_sign_in_again() {
2917        let mut server = mockito::Server::new_async().await;
2918        let eps = endpoints(&server);
2919        let _quota = server
2920            .mock("POST", "/daily/quota")
2921            .with_status(401)
2922            .create_async()
2923            .await;
2924        let token = token_mock(&mut server).expect(1).create_async().await;
2925        let blob = keyring_blob(EXPIRED, true);
2926        let (_td, cache) = fixture();
2927
2928        let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
2929            .await
2930            .expect_err("rejected after refresh");
2931
2932        token.assert_async().await;
2933        assert!(matches!(err, AppError::Credentials(_)), "{err}");
2934        assert!(err.to_string().contains("sign in again"), "{err}");
2935    }
2936
2937    /// A session saved without a refresh token cannot be renewed here.
2938    #[tokio::test]
2939    async fn an_expired_session_without_a_refresh_token_asks_to_sign_in_again() {
2940        let mut server = mockito::Server::new_async().await;
2941        let eps = endpoints(&server);
2942        let token = token_mock(&mut server).expect(0).create_async().await;
2943        let blob = keyring_blob(EXPIRED, false);
2944        let (_td, cache) = fixture();
2945
2946        let err = run(&cache, remote(&blob, &eps), Duration::ZERO)
2947            .await
2948            .expect_err("nothing to refresh with");
2949
2950        token.assert_async().await;
2951        assert!(matches!(err, AppError::Credentials(_)), "{err}");
2952        assert!(err.to_string().contains("sign in again"), "{err}");
2953    }
2954
2955    /// Nothing running and nothing saved: the local diagnosis stands, plus
2956    /// the one thing the user can now do about it.
2957    #[tokio::test]
2958    async fn no_saved_session_extends_the_no_local_server_error() {
2959        let mut server = mockito::Server::new_async().await;
2960        let eps = endpoints(&server);
2961        let quota = quota_mock(&mut server, "KEYRING-AT")
2962            .expect(0)
2963            .create_async()
2964            .await;
2965        let (_td, cache) = fixture();
2966
2967        let err = run(
2968            &cache,
2969            RemoteOverride {
2970                credential: SavedCredential::Absent,
2971                endpoints: Some(&eps),
2972                local_bases: Some(vec![]),
2973            },
2974            Duration::ZERO,
2975        )
2976        .await
2977        .expect_err("no source at all");
2978
2979        quota.assert_async().await;
2980        assert!(matches!(err, AppError::Credentials(_)), "{err}");
2981        let rendered = err.to_string();
2982        assert!(rendered.contains("no local server found"), "{rendered}");
2983        assert!(rendered.contains("saved Google session"), "{rendered}");
2984    }
2985
2986    /// A local server that is up but signed out is its own diagnosis; the
2987    /// saved session must not paper over it.
2988    #[tokio::test]
2989    async fn a_signed_out_local_server_is_reported_rather_than_bypassed() {
2990        let mut server = mockito::Server::new_async().await;
2991        let eps = endpoints(&server);
2992        let status_path = format!("/{STATUS_RPC}");
2993        let _status = server
2994            .mock("POST", status_path.as_str())
2995            .with_status(401)
2996            .create_async()
2997            .await;
2998        let quota = quota_mock(&mut server, "KEYRING-AT")
2999            .expect(0)
3000            .create_async()
3001            .await;
3002        let blob = keyring_blob(VALID, true);
3003        let (_td, cache) = fixture();
3004
3005        let err = run(
3006            &cache,
3007            RemoteOverride {
3008                credential: SavedCredential::Blob(&blob),
3009                endpoints: Some(&eps),
3010                local_bases: Some(vec![server.url()]),
3011            },
3012            Duration::ZERO,
3013        )
3014        .await
3015        .expect_err("signed-out local server");
3016
3017        quota.assert_async().await;
3018        assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
3019    }
3020
3021    /// The remote path falls back exactly like the local one: the last good
3022    /// payload is served stale, with the failure attached.
3023    #[tokio::test]
3024    async fn a_cached_remote_snapshot_is_served_when_the_api_fails() {
3025        let mut server = mockito::Server::new_async().await;
3026        let eps = endpoints(&server);
3027        for path in ["/daily/quota", "/prod/quota"] {
3028            server
3029                .mock("POST", path)
3030                .with_status(500)
3031                .with_body("boom")
3032                .create_async()
3033                .await;
3034        }
3035        let blob = keyring_blob(VALID, true);
3036        let (_td, cache) = fixture();
3037        cache.ensure_dir().unwrap();
3038        let mut earlier = parsed();
3039        earlier.source = AntigravitySource::Remote;
3040        earlier.account = "acct:earlier".into();
3041        cache
3042            .write_payload(&serde_json::to_vec(&snap_to_json(&earlier)).unwrap())
3043            .unwrap();
3044
3045        let outcome = run(&cache, remote(&blob, &eps), Duration::ZERO)
3046            .await
3047            .expect("stale cache stands in");
3048
3049        assert!(outcome.stale);
3050        assert_eq!(outcome.snapshot, earlier);
3051        assert!(
3052            matches!(outcome.last_error, Some((500, _))),
3053            "{:?}",
3054            outcome.last_error
3055        );
3056    }
3057
3058    #[test]
3059    fn source_round_trips_through_the_cache_and_defaults_to_local() {
3060        let mut snap = parsed();
3061        snap.source = AntigravitySource::Remote;
3062        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
3063        assert_eq!(
3064            parse_cache_at(&bytes, None, now()).unwrap().source,
3065            AntigravitySource::Remote
3066        );
3067
3068        // A payload from before the field existed is a local one.
3069        let mut legacy = snap_to_json(&snap);
3070        legacy.as_object_mut().unwrap().remove("source");
3071        let legacy = serde_json::to_vec(&legacy).unwrap();
3072        assert_eq!(
3073            parse_cache_at(&legacy, None, now()).unwrap().source,
3074            AntigravitySource::Local
3075        );
3076    }
3077}