Skip to main content

ai_usagebar/antigravity/
fetch.rs

1//! Fetch a Google Antigravity usage snapshot from a local language server.
2//!
3//! Google ships three separate Antigravity products — Antigravity 2.0, the
4//! `agy` CLI, and the Antigravity IDE — and they all draw on the **same**
5//! account-wide quota. A machine may have any combination of them installed and
6//! running, so this module probes every local server it can find and trusts the
7//! first that answers; there is no need to prefer one product over another.
8//!
9//! Each exposes a CSRF-guarded JSON-RPC surface on a **dynamically assigned**
10//! loopback port (`--https_server_port 0`), so the port cannot be hardcoded.
11//! Quota lives behind `RetrieveUserQuotaSummary`, which reports two model groups
12//! — Gemini, and Claude/GPT — each holding a 5-hour and a weekly bucket.
13//! `GetUserStatus` carries only the plan name; its per-model `quotaInfo` mirrors
14//! whichever bucket is scarcest and must not be read as a window in its own
15//! right.
16
17use std::time::Duration;
18
19use chrono::{DateTime, Utc};
20
21use crate::cache::{Cache, MAX_STALE, acquire_lock_async};
22use crate::error::{AppError, Result};
23use crate::usage::{AntigravitySnapshot, UsageWindow};
24
25const HTTP_TIMEOUT: Duration = Duration::from_secs(5);
26const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
27
28const QUOTA_RPC: &str = "exa.language_server_pb.LanguageServerService/RetrieveUserQuotaSummary";
29const STATUS_RPC: &str = "exa.language_server_pb.LanguageServerService/GetUserStatus";
30
31const DEFAULT_PLAN: &str = "Antigravity";
32
33#[derive(Debug, Clone)]
34pub struct FetchOutcome {
35    pub snapshot: AntigravitySnapshot,
36    pub stale: bool,
37    pub last_error: Option<(u16, String)>,
38    pub cache_age: Option<Duration>,
39}
40
41impl From<FetchOutcome> for crate::vendor::VendorOutcome {
42    fn from(o: FetchOutcome) -> Self {
43        Self {
44            snapshot: crate::usage::VendorSnapshot::Antigravity(o.snapshot),
45            stale: o.stale,
46            last_error: o.last_error,
47            cache_age: o.cache_age,
48        }
49    }
50}
51
52pub async fn fetch_snapshot(
53    client: &reqwest::Client,
54    cache: &Cache,
55    cache_ttl: Duration,
56) -> Result<FetchOutcome> {
57    fetch_snapshot_at(client, cache, cache_ttl, Utc::now()).await
58}
59
60/// Clock seam for [`fetch_snapshot`], so window expiry can be exercised at
61/// fixed instants instead of against the wall clock.
62pub async fn fetch_snapshot_at(
63    client: &reqwest::Client,
64    cache: &Cache,
65    cache_ttl: Duration,
66    now: DateTime<Utc>,
67) -> Result<FetchOutcome> {
68    cache.ensure_dir()?;
69    let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
70
71    // Resolve the signed-in account first so a fresh cache can be attributed.
72    // Unlike Grok — where the same check would cost a remote round-trip on
73    // every poll — this is loopback, and it is the call that would supply the
74    // plan name anyway, so verification is effectively free.
75    let session = open_session(client).await;
76    let account = session.as_ref().ok().map(|s| s.account.as_str());
77
78    if let Some(bytes) = cache.fresh_payload(cache_ttl)?
79        && let Ok(outcome) = reuse_cache(bytes, cache, false, account, now)
80    {
81        return Ok(outcome);
82    }
83
84    match fetch_live(client, session).await {
85        Ok(snap) => {
86            let bytes = serde_json::to_vec(&snap_to_json(&snap))?;
87            cache.write_payload(&bytes)?;
88            Ok(FetchOutcome {
89                snapshot: snap,
90                stale: false,
91                last_error: None,
92                cache_age: Some(Duration::ZERO),
93            })
94        }
95        Err(e) if e.is_transient() => fallback_silent(cache, now, e),
96        Err(AppError::Http { status, body }) => {
97            cache.mark_stale();
98            cache.write_last_error(status, &body);
99            let reason = AppError::Http {
100                status,
101                body: body.clone(),
102            };
103            fallback_with_error(cache, Some((status, body)), reason, now)
104        }
105        Err(e) => {
106            cache.mark_stale();
107            cache.write_last_error(0, &e.to_string());
108            let last_error = Some((0, e.to_string()));
109            fallback_with_error(cache, last_error, e, now)
110        }
111    }
112}
113
114/// A local server that answered `GetUserStatus`: where it lives, how to talk to
115/// it, and whose account it is signed in as.
116struct Session {
117    base: String,
118    csrf: Option<String>,
119    plan: String,
120    account: String,
121}
122
123/// Walk every candidate language server until one identifies itself. A machine
124/// can host more than one — the desktop app, the IDE and an interactive `agy`
125/// session each run their own — and only some of them are signed in.
126async fn open_session(client: &reqwest::Client) -> Result<Session> {
127    let bases = candidate_bases();
128    if bases.is_empty() {
129        return Err(AppError::Credentials(
130            "Antigravity: no local server found. Quota is only served while Antigravity is \
131             running — open the Antigravity app, or an interactive `agy` session, or point \
132             ANTIGRAVITY_LS_ADDRESS at a host:port."
133                .into(),
134        ));
135    }
136
137    let mut last_err = None;
138    for base in bases {
139        let csrf = fetch_csrf(client, &base).await;
140        match post_rpc(client, &base, csrf.as_deref(), STATUS_RPC).await {
141            Ok(v) => {
142                return Ok(Session {
143                    base,
144                    csrf,
145                    plan: plan_from_status(&v),
146                    account: account_key(&v),
147                });
148            }
149            Err(e) => last_err = Some(e),
150        }
151    }
152    Err(last_err.unwrap_or_else(|| {
153        AppError::Other("antigravity: no local server answered GetUserStatus".into())
154    }))
155}
156
157async fn fetch_live(
158    client: &reqwest::Client,
159    session: Result<Session>,
160) -> Result<AntigravitySnapshot> {
161    let session = session?;
162    let quota = post_rpc(client, &session.base, session.csrf.as_deref(), QUOTA_RPC).await?;
163    let mut snap = parse_quota_summary(&quota, session.plan)?;
164    snap.account = session.account;
165    Ok(snap)
166}
167
168/// Identity of the signed-in account, fingerprinted rather than stored in
169/// clear — the cache only needs a change detector, not the address itself.
170/// An unidentifiable response yields a stable "unknown" bucket so two such
171/// responses still compare equal.
172fn account_key(user_status: &serde_json::Value) -> String {
173    let email = user_status["userStatus"]["email"]
174        .as_str()
175        .filter(|s| !s.is_empty());
176    match email {
177        Some(e) => {
178            use std::hash::{Hash, Hasher};
179            let mut h = std::collections::hash_map::DefaultHasher::new();
180            e.hash(&mut h);
181            format!("acct:{:016x}", h.finish())
182        }
183        None => "acct:unknown".to_string(),
184    }
185}
186
187/// The Antigravity 2.0 server embeds a CSRF token in the HTML it serves at `/`
188/// and rejects the RPC without it. The `agy` CLI serves no such page — it 404s
189/// at `/` and answers the RPC unauthenticated — so a missing token is not an
190/// error here, just a server that does not use one.
191async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
192    let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
193    // Bounded like every other response this crate reads: a local server is
194    // still an untrusted source of unbounded bytes.
195    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
196        .await
197        .ok()?;
198    let html = String::from_utf8_lossy(&bytes);
199    html.split("csrfToken\":\"")
200        .nth(1)
201        .and_then(|s| s.split('"').next())
202        .filter(|t| !t.is_empty())
203        .map(|t| t.to_string())
204}
205
206async fn post_rpc(
207    client: &reqwest::Client,
208    base: &str,
209    csrf: Option<&str>,
210    rpc: &str,
211) -> Result<serde_json::Value> {
212    let mut req = client
213        .post(format!("{base}/{rpc}"))
214        .header("Content-Type", "application/json")
215        .body("{}")
216        .timeout(HTTP_TIMEOUT);
217    if let Some(token) = csrf {
218        req = req.header("x-codeium-csrf-token", token);
219    }
220    let resp = req.send().await?;
221
222    let status = resp.status();
223    // Cap error bodies too. A local endpoint is still untrusted, and reading a
224    // non-2xx response with `text()` would bypass the invariant enforced for
225    // successful JSON responses.
226    let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
227    if !status.is_success() {
228        let body = String::from_utf8_lossy(&bytes).into_owned();
229        return Err(AppError::Http {
230            status: status.as_u16(),
231            body,
232        });
233    }
234
235    Ok(serde_json::from_slice(&bytes)?)
236}
237
238pub fn plan_from_status(v: &serde_json::Value) -> String {
239    v["userStatus"]["userTier"]["name"]
240        .as_str()
241        .or_else(|| v["userStatus"]["userTier"]["description"].as_str())
242        .or_else(|| v["userStatus"]["planStatus"]["planInfo"]["planName"].as_str())
243        .filter(|s| !s.is_empty())
244        .unwrap_or(DEFAULT_PLAN)
245        .to_string()
246}
247
248// ---------------------------------------------------------------------------
249// Quota parsing
250// ---------------------------------------------------------------------------
251
252/// Map a `RetrieveUserQuotaSummary` payload onto the four usage windows.
253///
254/// Buckets are keyed by `bucketId` (`gemini-5h`, `gemini-weekly`, `3p-5h`,
255/// `3p-weekly`), falling back to the group display name plus the `window`
256/// discriminator so a renamed bucket id still lands in the right slot.
257pub fn parse_quota_summary(v: &serde_json::Value, plan: String) -> Result<AntigravitySnapshot> {
258    let groups = v["response"]["groups"]
259        .as_array()
260        .or_else(|| v["groups"].as_array())
261        .ok_or_else(|| AppError::Other("antigravity: quota summary has no groups".into()))?;
262
263    let mut gemini_5h = None;
264    let mut gemini_weekly = None;
265    let mut tp_5h = None;
266    let mut tp_weekly = None;
267
268    for group in groups {
269        let group_name = group["displayName"].as_str().unwrap_or_default();
270        let Some(buckets) = group["buckets"].as_array() else {
271            continue;
272        };
273        for bucket in buckets {
274            let id = bucket["bucketId"].as_str().unwrap_or_default();
275            let window = bucket["window"].as_str().unwrap_or_default();
276            let is_weekly = if id.ends_with("weekly") || window == "weekly" {
277                true
278            } else if id.ends_with("5h") || window == "5h" {
279                false
280            } else {
281                // A new cadence is not a 5-hour bucket by default. Ignore it
282                // so it cannot overwrite a known slot.
283                continue;
284            };
285            let is_gemini = if id.starts_with("gemini") {
286                true
287            } else if id.starts_with("3p") {
288                false
289            } else if group_name.contains("Gemini") {
290                true
291            } else if group_name.contains("Claude") || group_name.contains("GPT") {
292                false
293            } else {
294                // Likewise, an unrelated future group is not implicitly the
295                // third-party pool.
296                continue;
297            };
298
299            let (slot, slot_name) = match (is_gemini, is_weekly) {
300                (true, false) => (&mut gemini_5h, "Gemini 5h"),
301                (true, true) => (&mut gemini_weekly, "Gemini weekly"),
302                (false, false) => (&mut tp_5h, "Claude/GPT 5h"),
303                (false, true) => (&mut tp_weekly, "Claude/GPT weekly"),
304            };
305            let parsed = usage_window(bucket, is_weekly)?;
306            if slot.replace(parsed).is_some() {
307                return Err(AppError::Schema(format!(
308                    "antigravity: duplicate {slot_name} bucket"
309                )));
310            }
311        }
312    }
313
314    let session = gemini_5h.ok_or_else(|| {
315        AppError::Other("antigravity: quota summary has no Gemini 5h bucket".into())
316    })?;
317    let weekly = gemini_weekly.ok_or_else(|| {
318        AppError::Other("antigravity: quota summary has no Gemini weekly bucket".into())
319    })?;
320
321    Ok(AntigravitySnapshot {
322        plan,
323        // Stamped by the caller, which is what knows the session's identity.
324        account: String::new(),
325        session,
326        weekly,
327        third_party_session: tp_5h,
328        third_party_weekly: tp_weekly,
329    })
330}
331
332/// `remainingFraction` is required and must be finite: defaulting a missing or
333/// drifted value to 1.0 would report a reassuring "0% used" for a window whose
334/// real state is unknown, and cache it.
335fn usage_window(bucket: &serde_json::Value, is_weekly: bool) -> Result<UsageWindow> {
336    let remaining = bucket["remainingFraction"]
337        .as_f64()
338        .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
339        .ok_or_else(|| {
340            AppError::Schema(format!(
341                "antigravity: bucket {} has no valid remainingFraction in 0..=1",
342                bucket["bucketId"].as_str().unwrap_or("<unnamed>")
343            ))
344        })?;
345    Ok(UsageWindow {
346        utilization_pct: pct_used(remaining),
347        resets_at: parse_reset(&bucket["resetTime"], "quota resetTime")?,
348        window_duration: if is_weekly {
349            chrono::Duration::days(7)
350        } else {
351            chrono::Duration::hours(5)
352        },
353    })
354}
355
356/// The API reports how much is *left*; every other vendor here reports how much
357/// is *spent*.
358fn pct_used(remaining_fraction: f64) -> i32 {
359    let used = (1.0 - remaining_fraction) * 100.0;
360    used.round() as i32
361}
362
363fn parse_reset(value: &serde_json::Value, field: &str) -> Result<Option<DateTime<Utc>>> {
364    match value {
365        serde_json::Value::Null => Ok(None),
366        serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
367            .map(|dt| Some(dt.with_timezone(&Utc)))
368            .map_err(|_| AppError::Schema(format!("antigravity: invalid {field}"))),
369        _ => Err(AppError::Schema(format!(
370            "antigravity: {field} must be a timestamp or null"
371        ))),
372    }
373}
374
375// ---------------------------------------------------------------------------
376// Language server discovery
377// ---------------------------------------------------------------------------
378
379/// Base URLs worth probing, most specific first.
380fn candidate_bases() -> Vec<String> {
381    candidate_bases_with(
382        std::env::var("ANTIGRAVITY_LS_ADDRESS").ok().as_deref(),
383        discover_ls_ports(),
384    )
385}
386
387/// Test seam for [`candidate_bases`] — takes the address override and the
388/// discovered ports instead of reading the environment and `/proc`.
389fn candidate_bases_with(override_addr: Option<&str>, discovered: Vec<u16>) -> Vec<String> {
390    if let Some(addr) = override_addr {
391        let addr = addr.trim();
392        if !addr.is_empty() {
393            return vec![normalize_base(addr)];
394        }
395    }
396
397    // No hardcoded fallback port on purpose: the server always binds with
398    // `--https_server_port 0`, so its port is drawn from the ephemeral range
399    // and cannot be guessed. Probing a fixed one would just poke whatever
400    // unrelated process happens to own it. Discovery or the explicit override.
401    discovered
402        .into_iter()
403        .map(|p| format!("http://127.0.0.1:{p}"))
404        .collect()
405}
406
407fn normalize_base(addr: &str) -> String {
408    if addr.starts_with("http://") || addr.starts_with("https://") {
409        addr.to_string()
410    } else {
411        format!("http://{addr}")
412    }
413}
414
415/// Does this process look like one of the three Antigravity products?
416///
417/// Antigravity 2.0 and the IDE spawn a separate `language_server` child, while
418/// the `agy` CLI embeds the same CSRF/RPC surface in its own process — so
419/// matching on the server binary alone would miss a CLI-only install. `comm` is
420/// truncated to 15 bytes by the kernel, which `language_server` exactly fills.
421fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
422    let comm = comm.trim();
423    if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
424        return true;
425    }
426    exe.is_some_and(|p| p.contains("antigravity") || p.ends_with("/agy"))
427}
428
429/// Loopback ports listened on by any running Antigravity product.
430///
431/// Reads `/proc` directly rather than shelling out to `ss`/`lsof`: find the
432/// candidate pids, collect their socket inodes, then keep the listening TCP
433/// entries owning one of those inodes. All three products report the *same*
434/// shared quota, so whichever answers first is authoritative.
435#[cfg(target_os = "linux")]
436fn discover_ls_ports() -> Vec<u16> {
437    use std::collections::HashSet;
438
439    let mut inodes: HashSet<u64> = HashSet::new();
440    let Ok(entries) = std::fs::read_dir("/proc") else {
441        return Vec::new();
442    };
443    for entry in entries.flatten() {
444        let pid_dir = entry.path();
445        let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
446            continue;
447        };
448        let exe = std::fs::read_link(pid_dir.join("exe")).ok();
449        if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
450            continue;
451        }
452        let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
453            continue;
454        };
455        for fd in fds.flatten() {
456            let Ok(target) = std::fs::read_link(fd.path()) else {
457                continue;
458            };
459            if let Some(ino) = target
460                .to_str()
461                .and_then(|s| s.strip_prefix("socket:["))
462                .and_then(|s| s.strip_suffix(']'))
463                .and_then(|s| s.parse::<u64>().ok())
464            {
465                inodes.insert(ino);
466            }
467        }
468    }
469
470    if inodes.is_empty() {
471        return Vec::new();
472    }
473
474    let mut ports = Vec::new();
475    for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
476        let Ok(contents) = std::fs::read_to_string(table) else {
477            continue;
478        };
479        for line in contents.lines().skip(1) {
480            if let Some((port, ino)) = parse_proc_net_line(line)
481                && inodes.contains(&ino)
482                && !ports.contains(&port)
483            {
484                ports.push(port);
485            }
486        }
487    }
488    ports
489}
490
491#[cfg(not(target_os = "linux"))]
492fn discover_ls_ports() -> Vec<u16> {
493    Vec::new()
494}
495
496/// Pull `(local_port, inode)` out of a listening row of `/proc/net/tcp`.
497/// Columns: `sl local_address rem_address st ... uid timeout inode`.
498#[cfg(target_os = "linux")]
499fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
500    let cols: Vec<&str> = line.split_whitespace().collect();
501    if cols.len() < 10 {
502        return None;
503    }
504    // 0x0A == TCP_LISTEN. Anything else is an established/closing socket.
505    if cols[3] != "0A" {
506        return None;
507    }
508    let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
509    let inode = cols[9].parse::<u64>().ok()?;
510    Some((port, inode))
511}
512
513// ---------------------------------------------------------------------------
514// Cache
515// ---------------------------------------------------------------------------
516
517fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
518    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
519        return Err(original);
520    };
521    reuse_cache(bytes, cache, true, None, now).or(Err(original))
522}
523
524/// Serve the stale cache when there is one. With no cache to fall back on,
525/// surface `reason` — the actual diagnosis, e.g. "no local language server
526/// found" — rather than a generic cache-miss that tells the user nothing about
527/// what to do. This is the first-run path: no cache yet and Antigravity closed.
528fn fallback_with_error(
529    cache: &Cache,
530    last_error: Option<(u16, String)>,
531    reason: AppError,
532    now: DateTime<Utc>,
533) -> Result<FetchOutcome> {
534    let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
535        return Err(reason);
536    };
537    let Ok(mut outcome) = reuse_cache(bytes, cache, true, None, now) else {
538        return Err(reason);
539    };
540    outcome.last_error = last_error;
541    Ok(outcome)
542}
543
544fn reuse_cache(
545    bytes: Vec<u8>,
546    cache: &Cache,
547    stale: bool,
548    account: Option<&str>,
549    now: DateTime<Utc>,
550) -> Result<FetchOutcome> {
551    let snap = parse_cache_at(&bytes, account, now)?;
552    Ok(FetchOutcome {
553        snapshot: snap,
554        stale,
555        last_error: cache.read_last_error(),
556        cache_age: cache.payload_age(),
557    })
558}
559
560/// `account` is the fingerprint of the currently signed-in account, or `None`
561/// when no local server answered. A payload belonging to a different account is
562/// rejected so a Google-account switch cannot show the previous account's
563/// quota. With `None` we cannot verify — but nothing is consuming quota while
564/// Antigravity is down, so the last known figures are the best available truth.
565pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
566    parse_cache_at(bytes, account, Utc::now())
567}
568
569/// A cached window whose reset has already passed describes a period that has
570/// since rolled over: the real figure is back near zero while the payload still
571/// carries the old one, and its countdown is pinned at "now". Serving that is
572/// presenting a known-obsolete number as current, so the payload is refused and
573/// the caller reports that Antigravity needs to be running.
574///
575/// This matters more here than for other vendors: "nothing running" is the
576/// normal state for Antigravity, and `MAX_STALE` is seven days — far past the
577/// five hours after which the session window is guaranteed wrong.
578fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
579    [
580        ("Gemini 5h", Some(&snap.session)),
581        ("Gemini weekly", Some(&snap.weekly)),
582        ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
583        ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
584    ]
585    .into_iter()
586    .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
587    .map(|(name, _)| name)
588}
589
590pub fn parse_cache_at(
591    bytes: &[u8],
592    account: Option<&str>,
593    now: DateTime<Utc>,
594) -> Result<AntigravitySnapshot> {
595    let v: serde_json::Value = serde_json::from_slice(bytes)?;
596
597    let cached_account = v.get("account").and_then(serde_json::Value::as_str);
598    if let Some(expected) = account
599        && cached_account != Some(expected)
600    {
601        return Err(AppError::Schema(
602            "antigravity cache belongs to a different account; refetching".into(),
603        ));
604    }
605
606    // The Gemini windows are required. Defaulting a missing or truncated field
607    // to 0 would render a confident "0% used" and keep serving it for the rest
608    // of the TTL; returning an error makes the caller fall through to a live
609    // fetch instead of displaying a fabricated snapshot.
610    let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
611        match v.get(pct_key) {
612            None | Some(serde_json::Value::Null) => Ok(None),
613            Some(value) => value
614                .as_i64()
615                .filter(|pct| (0..=100).contains(pct))
616                .map(|pct| Some(pct as i32))
617                .ok_or_else(|| {
618                    AppError::Schema(format!(
619                        "antigravity: cached {pct_key} must be an integer in 0..=100"
620                    ))
621                }),
622        }
623    };
624
625    let window = |pct_key: &'static str, reset_key: &str, weekly: bool| {
626        let pct = cached_pct(pct_key)?.ok_or_else(|| {
627            AppError::Schema(format!("antigravity: cached payload missing {pct_key}"))
628        })?;
629        Ok::<_, AppError>(UsageWindow {
630            utilization_pct: pct,
631            resets_at: parse_reset(&v[reset_key], reset_key)?,
632            window_duration: if weekly {
633                chrono::Duration::days(7)
634            } else {
635                chrono::Duration::hours(5)
636            },
637        })
638    };
639
640    let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
641        let Some(pct) = cached_pct(pct_key)? else {
642            return Ok(None);
643        };
644        Ok::<_, AppError>(Some(UsageWindow {
645            utilization_pct: pct,
646            resets_at: parse_reset(&v[reset_key], reset_key)?,
647            window_duration: if weekly {
648                chrono::Duration::days(7)
649            } else {
650                chrono::Duration::hours(5)
651            },
652        }))
653    };
654
655    let snap = AntigravitySnapshot {
656        plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
657        account: cached_account.unwrap_or_default().to_string(),
658        session: window("session_pct", "session_reset", false)?,
659        weekly: window("weekly_pct", "weekly_reset", true)?,
660        third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
661        third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
662    };
663
664    if let Some(window) = expired_window(&snap, now) {
665        return Err(AppError::Schema(format!(
666            "antigravity cache is past its {window} reset; refetching"
667        )));
668    }
669    Ok(snap)
670}
671
672pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
673    serde_json::json!({
674        "plan": snap.plan,
675        "account": snap.account,
676        "session_pct": snap.session.utilization_pct,
677        "session_reset": snap.session.resets_at.map(|dt| dt.to_rfc3339()),
678        "weekly_pct": snap.weekly.utilization_pct,
679        "weekly_reset": snap.weekly.resets_at.map(|dt| dt.to_rfc3339()),
680        "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
681        "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
682        "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
683        "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
684    })
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    /// Captured from a real `RetrieveUserQuotaSummary` response on 2026-07-22
692    /// (Antigravity 2.0 build 2.3.1, `agy` 1.1.5), then trimmed. Percentages
693    /// were edited to distinct non-zero values so a slot mix-up cannot pass.
694    const QUOTA_JSON: &str = r#"{
695      "response": {
696        "groups": [
697          {
698            "displayName": "Gemini Models",
699            "buckets": [
700              {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
701               "window": "weekly", "remainingFraction": 0.9191212,
702               "resetTime": "2026-07-28T17:39:58Z"},
703              {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
704               "window": "5h", "remainingFraction": 0.5672253,
705               "resetTime": "2026-07-22T17:47:00Z"}
706            ]
707          },
708          {
709            "displayName": "Claude and GPT models",
710            "buckets": [
711              {"bucketId": "3p-weekly", "window": "weekly",
712               "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
713              {"bucketId": "3p-5h", "window": "5h",
714               "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
715            ]
716          }
717        ]
718      }
719    }"#;
720
721    /// Fixed instant, earlier than every reset in the fixture. Using the wall
722    /// clock here would make the suite start failing once those resets pass.
723    fn now() -> DateTime<Utc> {
724        DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
725            .unwrap()
726            .with_timezone(&Utc)
727    }
728
729    fn parsed() -> AntigravitySnapshot {
730        let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
731        parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
732    }
733
734    #[test]
735    fn quota_summary_maps_four_distinct_windows() {
736        let snap = parsed();
737        assert_eq!(snap.plan, "Google AI Pro");
738        // remainingFraction is inverted into "used".
739        assert_eq!(snap.session.utilization_pct, 43);
740        assert_eq!(snap.weekly.utilization_pct, 8);
741        assert_eq!(
742            snap.third_party_session.as_ref().unwrap().utilization_pct,
743            75
744        );
745        assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
746    }
747
748    #[test]
749    fn each_window_keeps_its_own_reset_time() {
750        let snap = parsed();
751        let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
752        assert_eq!(snap.session.resets_at, at("2026-07-22T17:47:00Z"));
753        assert_eq!(snap.weekly.resets_at, at("2026-07-28T17:39:58Z"));
754        assert_eq!(
755            snap.third_party_weekly.as_ref().unwrap().resets_at,
756            at("2026-07-29T12:47:00Z")
757        );
758        // Regression: weekly must never be a copy of the 5h window.
759        assert_ne!(snap.session.resets_at, snap.weekly.resets_at);
760    }
761
762    #[test]
763    fn window_durations_match_their_bucket() {
764        let snap = parsed();
765        assert_eq!(snap.session.window_duration, chrono::Duration::hours(5));
766        assert_eq!(snap.weekly.window_duration, chrono::Duration::days(7));
767        assert_eq!(
768            snap.third_party_weekly.as_ref().unwrap().window_duration,
769            chrono::Duration::days(7)
770        );
771    }
772
773    #[test]
774    fn groups_are_matched_by_display_name_when_bucket_ids_change() {
775        let v: serde_json::Value = serde_json::from_str(
776            r#"{"response":{"groups":[
777              {"displayName":"Gemini Models","buckets":[
778                {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
779                {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
780              {"displayName":"Claude and GPT models","buckets":[
781                {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
782            ]}}"#,
783        )
784        .unwrap();
785        let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
786        assert_eq!(snap.session.utilization_pct, 50);
787        assert_eq!(snap.weekly.utilization_pct, 10);
788        assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
789        assert!(snap.third_party_weekly.is_none());
790    }
791
792    #[test]
793    fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
794        let duplicate: serde_json::Value = serde_json::from_str(
795            r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
796              {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
797              {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
798              {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
799            ]}]}}"#,
800        )
801        .unwrap();
802        let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
803        assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
804
805        // A future pool or cadence is ignored, not silently treated as the
806        // Claude/GPT 5h slot.
807        let unrelated: serde_json::Value = serde_json::from_str(
808            r#"{"response":{"groups":[
809              {"displayName":"Gemini Models","buckets":[
810                {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
811                {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
812                {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
813              ]},
814              {"displayName":"Future Models","buckets":[
815                {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
816              ]}
817            ]}}"#,
818        )
819        .unwrap();
820        let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
821        assert!(snap.third_party_session.is_none());
822        assert!(snap.third_party_weekly.is_none());
823    }
824
825    /// A drifted bucket must fail the parse rather than report a reassuring
826    /// "0% used" for a window whose real state is unknown.
827    #[test]
828    fn a_bucket_without_a_usable_fraction_is_rejected() {
829        for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
830            let v: serde_json::Value = serde_json::from_str(&format!(
831                r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
832                  {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
833                  {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
834            ))
835            .unwrap();
836            let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
837            assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
838        }
839    }
840
841    #[test]
842    fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
843        for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
844            let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
845            v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
846            let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
847            assert!(err.to_string().contains("resetTime"), "{err}");
848        }
849    }
850
851    #[test]
852    fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
853        let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
854        assert!(parse_quota_summary(&v, "Pro".into()).is_err());
855    }
856
857    #[test]
858    fn cache_round_trip_preserves_every_window() {
859        let snap = parsed();
860        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
861        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
862    }
863
864    /// A truncated payload must fail so the caller refetches. Defaulting the
865    /// missing field to 0 would serve a confident "0% used" for the rest of the
866    /// TTL — the fabricated-placeholder defect corrected in PR #26.
867    #[test]
868    fn a_truncated_cached_payload_is_rejected_not_zeroed() {
869        let full = snap_to_json(&parsed());
870        for missing in ["session_pct", "weekly_pct"] {
871            let mut v = full.clone();
872            v.as_object_mut().unwrap().remove(missing);
873            let bytes = serde_json::to_vec(&v).unwrap();
874            let err = parse_cache_at(&bytes, None, now()).unwrap_err();
875            assert!(err.to_string().contains(missing), "{missing}: {err}");
876        }
877        // A wholly empty object is not a zero-usage snapshot either.
878        assert!(parse_cache_at(b"{}", None, now()).is_err());
879    }
880
881    #[test]
882    fn cached_percentages_are_range_checked_before_narrowing() {
883        let full = snap_to_json(&parsed());
884        for (key, bad) in [
885            ("session_pct", serde_json::json!(-1)),
886            ("weekly_pct", serde_json::json!(101)),
887            ("session_pct", serde_json::json!(i64::MAX)),
888            ("tp_session_pct", serde_json::json!("75")),
889        ] {
890            let mut v = full.clone();
891            v[key] = bad;
892            let bytes = serde_json::to_vec(&v).unwrap();
893            let err = parse_cache_at(&bytes, None, now()).unwrap_err();
894            assert!(err.to_string().contains(key), "{key}: {err}");
895        }
896    }
897
898    #[test]
899    fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
900        let mut v = snap_to_json(&parsed());
901        v["session_reset"] = serde_json::json!("not-a-time");
902        let bytes = serde_json::to_vec(&v).unwrap();
903        let err = parse_cache_at(&bytes, None, now()).unwrap_err();
904        assert!(err.to_string().contains("session_reset"), "{err}");
905    }
906
907    /// With Antigravity closed the cache is served for up to `MAX_STALE`, but a
908    /// window whose reset has passed has since rolled over — the real figure is
909    /// back near zero while the payload still carries the old one. Serving that
910    /// would present a known-obsolete number as current.
911    #[test]
912    fn a_cache_past_its_reset_is_refused() {
913        let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
914        let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
915
916        // Before every reset: served.
917        assert!(parse_cache_at(&bytes, None, now()).is_ok());
918        // One second before the earliest (the two 5h windows, 17:47:00Z).
919        assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
920
921        // The reset instant itself already counts as rolled over.
922        let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
923        assert!(err.to_string().contains("5h"), "{err}");
924
925        // Well past it — this is the reboot-with-nothing-running case.
926        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
927    }
928
929    /// The weekly windows outlive the 5-hour ones, so expiry must be reported
930    /// per window rather than assuming the shortest one speaks for all four.
931    #[test]
932    fn expiry_names_the_window_that_rolled_over() {
933        let mut snap = parsed();
934        // Drop the 5h windows so only the weeklies can expire.
935        snap.session.resets_at = None;
936        snap.third_party_session = None;
937        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
938        let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
939
940        // Past the 5h resets but before either weekly: still usable.
941        assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
942
943        // Past the Gemini weekly (28th) but not the third-party one (29th).
944        let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
945        assert!(err.to_string().contains("Gemini weekly"), "{err}");
946    }
947
948    /// A window with no reset time is unknown, not expired.
949    #[test]
950    fn a_window_without_a_reset_never_expires() {
951        let mut snap = parsed();
952        for w in [&mut snap.session, &mut snap.weekly] {
953            w.resets_at = None;
954        }
955        snap.third_party_session = None;
956        snap.third_party_weekly = None;
957        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
958        let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
959            .unwrap()
960            .with_timezone(&Utc);
961        assert!(parse_cache_at(&bytes, None, far_future).is_ok());
962    }
963
964    /// Switching Google accounts must not show the previous account's quota.
965    #[test]
966    fn a_cache_from_another_account_is_rejected() {
967        let mut snap = parsed();
968        snap.account = "acct:aaaa".into();
969        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
970
971        assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
972        assert_eq!(
973            parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
974            snap
975        );
976
977        // A payload written before the account was recorded is unattributable.
978        let mut legacy = snap_to_json(&snap);
979        legacy.as_object_mut().unwrap().remove("account");
980        let legacy = serde_json::to_vec(&legacy).unwrap();
981        assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
982    }
983
984    /// With no local server there is nothing to compare against — and nothing
985    /// is consuming quota either, so the last known figures still stand.
986    #[test]
987    fn an_unverifiable_cache_is_served_rather_than_discarded() {
988        let mut snap = parsed();
989        snap.account = "acct:aaaa".into();
990        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
991        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
992    }
993
994    #[test]
995    fn account_key_fingerprints_rather_than_storing_the_address() {
996        let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
997        let a = with("someone@example.com");
998        assert!(!a.contains("someone"), "{a}");
999        assert!(!a.contains('@'), "{a}");
1000        assert_eq!(a, with("someone@example.com"), "must be stable");
1001        assert_ne!(a, with("other@example.com"));
1002        // An unidentifiable response still compares equal to itself.
1003        let unknown = account_key(&serde_json::json!({}));
1004        assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
1005        assert_ne!(unknown, a);
1006    }
1007
1008    /// The third-party pool is genuinely optional — a plan without it caches a
1009    /// null and must still read back, unlike the required Gemini windows.
1010    #[test]
1011    fn absent_third_party_windows_are_not_treated_as_corruption() {
1012        let mut snap = parsed();
1013        snap.third_party_session = None;
1014        snap.third_party_weekly = None;
1015        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1016        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1017    }
1018
1019    #[test]
1020    fn cache_round_trip_preserves_absent_third_party_windows() {
1021        let mut snap = parsed();
1022        snap.third_party_session = None;
1023        snap.third_party_weekly = None;
1024        let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1025        assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1026    }
1027
1028    #[test]
1029    fn pct_used_inverts_valid_fractions() {
1030        assert_eq!(pct_used(1.0), 0);
1031        assert_eq!(pct_used(0.0), 100);
1032        assert_eq!(pct_used(0.5), 50);
1033    }
1034
1035    #[test]
1036    fn plan_falls_back_through_the_status_payload() {
1037        let tier: serde_json::Value =
1038            serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
1039                .unwrap();
1040        assert_eq!(plan_from_status(&tier), "Google AI Pro");
1041
1042        let plan_only: serde_json::Value = serde_json::from_str(
1043            r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
1044        )
1045        .unwrap();
1046        assert_eq!(plan_from_status(&plan_only), "Pro");
1047
1048        let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
1049        assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
1050    }
1051
1052    #[cfg(target_os = "linux")]
1053    #[test]
1054    fn proc_net_parser_keeps_only_listening_rows() {
1055        let listen = "   0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
1056                      00:00000000 00000000  1000        0 123456 1 0000 100 0";
1057        assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
1058
1059        let established = "   1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
1060                           00:00000000 00000000  1000        0 123457 1 0000 100 0";
1061        assert_eq!(parse_proc_net_line(established), None);
1062
1063        assert_eq!(parse_proc_net_line("garbage"), None);
1064    }
1065
1066    #[test]
1067    fn explicit_address_wins_and_gets_a_scheme() {
1068        assert_eq!(
1069            candidate_bases_with(Some("127.0.0.1:1234"), vec![5678]),
1070            vec!["http://127.0.0.1:1234".to_string()]
1071        );
1072        // An address that already carries a scheme is left alone.
1073        assert_eq!(
1074            candidate_bases_with(Some("https://host:9"), vec![]),
1075            vec!["https://host:9".to_string()]
1076        );
1077    }
1078
1079    #[test]
1080    fn every_discovered_port_is_probed_in_order() {
1081        assert_eq!(
1082            candidate_bases_with(None, vec![33875, 37435]),
1083            vec![
1084                "http://127.0.0.1:33875".to_string(),
1085                "http://127.0.0.1:37435".to_string(),
1086            ]
1087        );
1088    }
1089
1090    /// The server's port is drawn from the ephemeral range, so there is nothing
1091    /// sensible to guess when discovery comes up empty. Probing a hardcoded
1092    /// port would contact an unrelated process; callers get the "start
1093    /// Antigravity or set ANTIGRAVITY_LS_ADDRESS" error instead.
1094    #[test]
1095    fn empty_discovery_yields_no_candidates() {
1096        assert!(candidate_bases_with(None, vec![]).is_empty());
1097        assert!(candidate_bases_with(Some(""), vec![]).is_empty());
1098    }
1099
1100    #[test]
1101    fn every_antigravity_product_is_recognised() {
1102        // Antigravity 2.0 / IDE: a separate language_server child.
1103        assert!(is_antigravity_process(
1104            "language_server\n",
1105            Some("/opt/antigravity/resources/bin/language_server")
1106        ));
1107        // agy CLI: embeds the RPC surface in its own process.
1108        assert!(is_antigravity_process(
1109            "agy\n",
1110            Some("/home/u/.local/bin/agy")
1111        ));
1112        // Recognised by path even when the process name says nothing.
1113        assert!(is_antigravity_process(
1114            "node",
1115            Some("/opt/antigravity/bin/helper")
1116        ));
1117        assert!(is_antigravity_process("antigravity", None));
1118    }
1119
1120    #[test]
1121    fn unrelated_processes_are_not_probed() {
1122        assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
1123        assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
1124        // "legacy" ends in a substring of "/agy" but is not the CLI.
1125        assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
1126        assert!(!is_antigravity_process("", None));
1127    }
1128
1129    /// First run with Antigravity closed: no cache to serve, so the user must
1130    /// be told what to start — not "no usable cache", which says nothing.
1131    #[test]
1132    fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
1133        let dir = tempfile::tempdir().unwrap();
1134        let cache = Cache::at(dir.path().join("usage.json"));
1135        let reason = AppError::Credentials("Antigravity: no local language server found".into());
1136
1137        let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1138        let msg = err.to_string();
1139        assert!(msg.contains("no local language server found"), "{msg}");
1140        assert!(!msg.contains("no usable cache"), "{msg}");
1141    }
1142
1143    #[test]
1144    fn unusable_cache_does_not_replace_the_live_diagnosis() {
1145        let dir = tempfile::tempdir().unwrap();
1146        let cache = Cache::at(dir.path().join("antigravity"));
1147        cache.write_payload(b"{}").unwrap();
1148
1149        let reason = AppError::Credentials("Antigravity must be running".into());
1150        let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1151        assert!(err.to_string().contains("must be running"), "{err}");
1152
1153        let original = AppError::Transport("original loopback failure".into());
1154        let err = fallback_silent(&cache, now(), original).unwrap_err();
1155        assert!(
1156            err.to_string().contains("original loopback failure"),
1157            "{err}"
1158        );
1159    }
1160
1161    #[tokio::test]
1162    async fn rpc_error_bodies_are_bounded_too() {
1163        let mut server = mockito::Server::new_async().await;
1164        let path = format!("/{STATUS_RPC}");
1165        server
1166            .mock("POST", path.as_str())
1167            .with_status(500)
1168            .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
1169            .create_async()
1170            .await;
1171
1172        let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
1173            .await
1174            .unwrap_err();
1175        assert!(err.to_string().contains("exceeds"), "{err}");
1176    }
1177
1178    #[test]
1179    fn blank_override_falls_through_to_discovery() {
1180        assert_eq!(
1181            candidate_bases_with(Some("   "), vec![4242]),
1182            vec!["http://127.0.0.1:4242".to_string()]
1183        );
1184    }
1185}