1use 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
50const 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
59pub 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#[derive(Debug, Clone, Copy, Default)]
73pub enum SavedCredential<'a> {
74 #[default]
76 Keyring,
77 Blob(&'a str),
79 Absent,
81}
82
83#[derive(Default)]
88pub struct RemoteOverride<'a> {
89 pub credential: SavedCredential<'a>,
91 pub endpoints: Option<&'a cloud::Endpoints>,
93 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
115pub 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 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
181struct Session {
184 base: String,
185 csrf: Option<String>,
186 plan: String,
187 account: String,
188}
189
190enum Origin {
193 Local(Result<Session>),
194 Remote(Result<StoredToken>),
195}
196
197impl Origin {
198 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
227fn 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
240async 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
269fn 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
310fn 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
329fn is_actionable(e: &AppError) -> bool {
334 matches!(e, AppError::Http { status, .. } if *status == 401 || *status == 403)
335 && !is_missing_csrf(e)
336}
337
338fn 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("a, session.plan)?;
364 snap.account = session.account;
365 Ok(snap)
366}
367
368fn 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
395fn remote_account(fingerprint: &str) -> String {
399 format!("acct:{fingerprint}")
400}
401
402fn session_expired() -> AppError {
403 AppError::Credentials(SESSION_EXPIRED.into())
404}
405
406fn 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
428struct AccessToken {
432 value: String,
433 just_refreshed: bool,
434}
435
436async 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
468async 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
499async 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("a, plan)?;
539 snap.account = remote_account(&token.fingerprint);
540 snap.source = AntigravitySource::Remote;
541 Ok(snap)
542}
543
544fn 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
563async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
567 let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
568 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 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
623fn 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
646fn 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 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 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 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 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 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
748fn 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
772fn 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
791fn 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
803fn 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 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
826fn 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
844fn 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#[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 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#[cfg(target_os = "linux")]
925pub(crate) fn discover_ls_ports() -> Vec<u16> {
926 use std::collections::{BTreeMap, HashMap};
927
928 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#[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 parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
1006}
1007
1008#[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 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#[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#[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 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
1273fn 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
1283fn 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
1309pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
1315 parse_cache_at(bytes, account, Utc::now())
1316}
1317
1318fn 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 assert!(parse_cache_at(&bytes, None, now()).is_ok());
1823 assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
1825
1826 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 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
1832 }
1833
1834 #[test]
1837 fn expiry_names_the_window_that_rolled_over() {
1838 let mut snap = parsed();
1839 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 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
1847
1848 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 #[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 #[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 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 #[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 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 #[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 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 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 assert_eq!(
1995 candidate_bases_with(None, vec![5678, 5678]),
1996 vec!["http://127.0.0.1:5678".to_string()]
1997 );
1998 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 assert!(is_antigravity_process(
2221 "language_server\n",
2222 Some("/opt/antigravity/resources/bin/language_server")
2223 ));
2224 assert!(is_antigravity_process(
2226 "agy\n",
2227 Some("/home/u/.local/bin/agy")
2228 ));
2229 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 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 #[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 #[test]
2344 fn probe_order_puts_every_rpc_listener_ahead_of_every_tls_listener() {
2345 use std::collections::BTreeMap;
2346
2347 assert_eq!(
2349 probe_order(BTreeMap::from([(10, vec![59868, 59870])])),
2350 vec![59870, 59868]
2351 );
2352 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 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 #[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 #[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 #[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 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 #[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 #[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 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 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 const EXPIRED: &str = "2026-07-22T11:00:00Z";
2618 const VALID: &str = "2026-07-22T13:00:00Z";
2620
2621 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 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 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 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 #[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 #[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 run(&cache, remote(&blob, &eps), Duration::ZERO)
2852 .await
2853 .expect("persisted token reused");
2854 token.assert_async().await;
2855 }
2856
2857 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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}