1use 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
60pub 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 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
114struct Session {
117 base: String,
118 csrf: Option<String>,
119 plan: String,
120 account: String,
121}
122
123async fn open_session(client: &reqwest::Client) -> Result<Session> {
127 let bases = candidate_bases();
128 if bases.is_empty() {
129 return Err(AppError::Credentials(
130 "Antigravity: no local server found. Quota is only served while Antigravity is \
131 running — open the Antigravity app, or an interactive `agy` session, or point \
132 ANTIGRAVITY_LS_ADDRESS at a host:port."
133 .into(),
134 ));
135 }
136
137 let mut errors = Vec::new();
138 for base in bases {
139 let csrf = fetch_csrf(client, &base).await;
140 match post_rpc(client, &base, csrf.as_deref(), STATUS_RPC).await {
141 Ok(v) => {
142 return Ok(Session {
143 base,
144 csrf,
145 plan: plan_from_status(&v),
146 account: account_key(&v),
147 });
148 }
149 Err(e) => errors.push(e),
150 }
151 }
152 Err(select_probe_error(errors))
153}
154
155fn select_probe_error(errors: Vec<AppError>) -> AppError {
166 let mut actionable = None;
167 let mut last = None;
168 for e in errors {
169 if actionable.is_none() && is_actionable(&e) {
170 actionable = Some(e);
171 } else {
172 last = Some(e);
173 }
174 }
175 actionable.or(last).unwrap_or_else(|| {
176 AppError::Other("antigravity: no local server answered GetUserStatus".into())
177 })
178}
179
180fn is_actionable(e: &AppError) -> bool {
184 matches!(e, AppError::Http { status, .. } if *status == 401 || *status == 403)
185}
186
187async fn fetch_live(
188 client: &reqwest::Client,
189 session: Result<Session>,
190) -> Result<AntigravitySnapshot> {
191 let session = session?;
192 let quota = post_rpc(client, &session.base, session.csrf.as_deref(), QUOTA_RPC).await?;
193 let mut snap = parse_quota_summary("a, session.plan)?;
194 snap.account = session.account;
195 Ok(snap)
196}
197
198fn account_key(user_status: &serde_json::Value) -> String {
203 let email = user_status["userStatus"]["email"]
204 .as_str()
205 .filter(|s| !s.is_empty());
206 match email {
207 Some(e) => {
208 use std::hash::{Hash, Hasher};
209 let mut h = std::collections::hash_map::DefaultHasher::new();
210 e.hash(&mut h);
211 format!("acct:{:016x}", h.finish())
212 }
213 None => "acct:unknown".to_string(),
214 }
215}
216
217async fn fetch_csrf(client: &reqwest::Client, base: &str) -> Option<String> {
222 let resp = client.get(base).timeout(HTTP_TIMEOUT).send().await.ok()?;
223 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES)
226 .await
227 .ok()?;
228 let html = String::from_utf8_lossy(&bytes);
229 html.split("csrfToken\":\"")
230 .nth(1)
231 .and_then(|s| s.split('"').next())
232 .filter(|t| !t.is_empty())
233 .map(|t| t.to_string())
234}
235
236async fn post_rpc(
237 client: &reqwest::Client,
238 base: &str,
239 csrf: Option<&str>,
240 rpc: &str,
241) -> Result<serde_json::Value> {
242 let mut req = client
243 .post(format!("{base}/{rpc}"))
244 .header("Content-Type", "application/json")
245 .body("{}")
246 .timeout(HTTP_TIMEOUT);
247 if let Some(token) = csrf {
248 req = req.header("x-codeium-csrf-token", token);
249 }
250 let resp = req.send().await?;
251
252 let status = resp.status();
253 let bytes = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
257 if !status.is_success() {
258 let body = String::from_utf8_lossy(&bytes).into_owned();
259 return Err(AppError::Http {
260 status: status.as_u16(),
261 body,
262 });
263 }
264
265 Ok(serde_json::from_slice(&bytes)?)
266}
267
268pub fn plan_from_status(v: &serde_json::Value) -> String {
269 v["userStatus"]["userTier"]["name"]
270 .as_str()
271 .or_else(|| v["userStatus"]["userTier"]["description"].as_str())
272 .or_else(|| v["userStatus"]["planStatus"]["planInfo"]["planName"].as_str())
273 .filter(|s| !s.is_empty())
274 .unwrap_or(DEFAULT_PLAN)
275 .to_string()
276}
277
278pub fn parse_quota_summary(v: &serde_json::Value, plan: String) -> Result<AntigravitySnapshot> {
288 let groups = v["response"]["groups"]
289 .as_array()
290 .or_else(|| v["groups"].as_array())
291 .ok_or_else(|| AppError::Other("antigravity: quota summary has no groups".into()))?;
292
293 let mut gemini_5h = None;
294 let mut gemini_weekly = None;
295 let mut tp_5h = None;
296 let mut tp_weekly = None;
297
298 for group in groups {
299 let group_name = group["displayName"].as_str().unwrap_or_default();
300 let Some(buckets) = group["buckets"].as_array() else {
301 continue;
302 };
303 for bucket in buckets {
304 let id = bucket["bucketId"].as_str().unwrap_or_default();
305 let window = bucket["window"].as_str().unwrap_or_default();
306 let is_weekly = if id.ends_with("weekly") || window == "weekly" {
307 true
308 } else if id.ends_with("5h") || window == "5h" {
309 false
310 } else {
311 continue;
314 };
315 let is_gemini = if id.starts_with("gemini") {
316 true
317 } else if id.starts_with("3p") {
318 false
319 } else if group_name.contains("Gemini") {
320 true
321 } else if group_name.contains("Claude") || group_name.contains("GPT") {
322 false
323 } else {
324 continue;
327 };
328
329 let (slot, slot_name) = match (is_gemini, is_weekly) {
330 (true, false) => (&mut gemini_5h, "Gemini 5h"),
331 (true, true) => (&mut gemini_weekly, "Gemini weekly"),
332 (false, false) => (&mut tp_5h, "Claude/GPT 5h"),
333 (false, true) => (&mut tp_weekly, "Claude/GPT weekly"),
334 };
335 let parsed = usage_window(bucket, is_weekly)?;
336 if slot.replace(parsed).is_some() {
337 return Err(AppError::Schema(format!(
338 "antigravity: duplicate {slot_name} bucket"
339 )));
340 }
341 }
342 }
343
344 let session = gemini_5h.ok_or_else(|| {
345 AppError::Other("antigravity: quota summary has no Gemini 5h bucket".into())
346 })?;
347 let weekly = gemini_weekly.ok_or_else(|| {
348 AppError::Other("antigravity: quota summary has no Gemini weekly bucket".into())
349 })?;
350
351 Ok(AntigravitySnapshot {
352 plan,
353 account: String::new(),
355 session,
356 weekly,
357 third_party_session: tp_5h,
358 third_party_weekly: tp_weekly,
359 })
360}
361
362fn usage_window(bucket: &serde_json::Value, is_weekly: bool) -> Result<UsageWindow> {
366 let remaining = bucket["remainingFraction"]
367 .as_f64()
368 .filter(|f| f.is_finite() && (0.0..=1.0).contains(f))
369 .ok_or_else(|| {
370 AppError::Schema(format!(
371 "antigravity: bucket {} has no valid remainingFraction in 0..=1",
372 bucket["bucketId"].as_str().unwrap_or("<unnamed>")
373 ))
374 })?;
375 Ok(UsageWindow {
376 utilization_pct: pct_used(remaining),
377 resets_at: parse_reset(&bucket["resetTime"], "quota resetTime")?,
378 window_duration: if is_weekly {
379 chrono::Duration::days(7)
380 } else {
381 chrono::Duration::hours(5)
382 },
383 })
384}
385
386fn pct_used(remaining_fraction: f64) -> i32 {
389 let used = (1.0 - remaining_fraction) * 100.0;
390 used.round() as i32
391}
392
393fn parse_reset(value: &serde_json::Value, field: &str) -> Result<Option<DateTime<Utc>>> {
394 match value {
395 serde_json::Value::Null => Ok(None),
396 serde_json::Value::String(s) => DateTime::parse_from_rfc3339(s)
397 .map(|dt| Some(dt.with_timezone(&Utc)))
398 .map_err(|_| AppError::Schema(format!("antigravity: invalid {field}"))),
399 _ => Err(AppError::Schema(format!(
400 "antigravity: {field} must be a timestamp or null"
401 ))),
402 }
403}
404
405fn candidate_bases() -> Vec<String> {
411 candidate_bases_with(
412 std::env::var("ANTIGRAVITY_LS_ADDRESS").ok().as_deref(),
413 discover_ls_ports(),
414 )
415}
416
417fn candidate_bases_with(override_addr: Option<&str>, discovered: Vec<u16>) -> Vec<String> {
420 let mut bases = Vec::new();
421 if let Some(addr) = override_addr {
422 let addr = addr.trim();
423 if !addr.is_empty() {
424 bases.push(normalize_base(addr));
425 }
426 }
427
428 for p in discovered {
434 let candidate = format!("http://127.0.0.1:{p}");
435 if !bases.contains(&candidate) {
436 bases.push(candidate);
437 }
438 }
439
440 bases
441}
442
443fn normalize_base(addr: &str) -> String {
444 let trimmed = addr.trim().trim_end_matches('/');
445 if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
446 trimmed.to_string()
447 } else {
448 format!("http://{trimmed}")
449 }
450}
451
452fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
459 let comm = comm.trim().to_lowercase();
460 let comm = comm.strip_suffix(".exe").unwrap_or(&comm);
461 if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
462 return true;
463 }
464 exe.is_some_and(|p| {
465 let p = p.to_lowercase().replace('\\', "/");
466 let p = p.strip_suffix(".exe").unwrap_or(&p);
467 p.contains("antigravity") || p.ends_with("/agy")
468 })
469}
470
471#[cfg(target_os = "linux")]
478fn discover_ls_ports() -> Vec<u16> {
479 use std::collections::HashSet;
480
481 let mut inodes: HashSet<u64> = HashSet::new();
482 let Ok(entries) = std::fs::read_dir("/proc") else {
483 return Vec::new();
484 };
485 for entry in entries.flatten() {
486 let pid_dir = entry.path();
487 let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
488 continue;
489 };
490 let exe = std::fs::read_link(pid_dir.join("exe")).ok();
491 if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
492 continue;
493 }
494 let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
495 continue;
496 };
497 for fd in fds.flatten() {
498 let Ok(target) = std::fs::read_link(fd.path()) else {
499 continue;
500 };
501 if let Some(ino) = target
502 .to_str()
503 .and_then(|s| s.strip_prefix("socket:["))
504 .and_then(|s| s.strip_suffix(']'))
505 .and_then(|s| s.parse::<u64>().ok())
506 {
507 inodes.insert(ino);
508 }
509 }
510 }
511
512 if inodes.is_empty() {
513 return Vec::new();
514 }
515
516 let mut ports = Vec::new();
517 for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
518 let Ok(contents) = std::fs::read_to_string(table) else {
519 continue;
520 };
521 for line in contents.lines().skip(1) {
522 if let Some((port, ino)) = parse_proc_net_line(line)
523 && inodes.contains(&ino)
524 && !ports.contains(&port)
525 {
526 ports.push(port);
527 }
528 }
529 }
530 ports
531}
532
533#[cfg(target_os = "macos")]
540fn discover_ls_ports() -> Vec<u16> {
541 let Ok(output) = std::process::Command::new("lsof")
542 .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
543 .output()
544 else {
545 return Vec::new();
546 };
547 parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
551}
552
553#[cfg(target_os = "macos")]
556fn parse_lsof_pcn(output: &str) -> Vec<u16> {
557 let mut ports = Vec::new();
558 let mut current_matches = false;
559 for line in output.lines() {
560 let Some(rest) = line.get(1..) else { continue };
561 match line.as_bytes().first() {
562 Some(b'p') => current_matches = false,
563 Some(b'c') => current_matches = is_antigravity_process(rest, None),
564 Some(b'n') if current_matches => {
565 if let Some(port) = rest.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
566 && !ports.contains(&port)
567 {
568 ports.push(port);
569 }
570 }
571 _ => {}
572 }
573 }
574 ports
575}
576
577#[cfg(any(test, target_os = "windows"))]
578#[derive(Debug, Clone, Copy, PartialEq, Eq)]
579struct WindowsTcpRow {
580 local_addr: [u8; 4],
581 local_port: u32,
582 pid: u32,
583}
584
585#[cfg(any(test, target_os = "windows"))]
586fn decode_windows_process_name(raw: &[u16]) -> String {
587 let end = raw.iter().position(|&unit| unit == 0).unwrap_or(raw.len());
588 String::from_utf16_lossy(&raw[..end])
589}
590
591#[cfg(any(test, target_os = "windows"))]
592fn matching_windows_process_ids(processes: &[(u32, String)]) -> std::collections::HashSet<u32> {
593 processes
594 .iter()
595 .filter(|(_, name)| is_antigravity_process(name, None))
596 .map(|(pid, _)| *pid)
597 .collect()
598}
599
600#[cfg(any(test, target_os = "windows"))]
612fn matching_windows_ports(
613 pids: &std::collections::HashSet<u32>,
614 rows: &[WindowsTcpRow],
615) -> Vec<u16> {
616 rows.iter()
617 .filter(|row| pids.contains(&row.pid) && row.local_addr == [127, 0, 0, 1])
618 .filter_map(|row| {
619 let port = u16::from_be((row.local_port & u32::from(u16::MAX)) as u16);
620 (port != 0).then_some(port)
621 })
622 .collect::<std::collections::BTreeSet<_>>()
623 .into_iter()
624 .rev()
625 .collect()
626}
627
628#[cfg(any(test, target_os = "windows"))]
629fn checked_windows_row_count(
630 buffer_len: usize,
631 rows_offset: usize,
632 row_size: usize,
633 declared: usize,
634) -> Option<usize> {
635 let rows_len = row_size.checked_mul(declared)?;
636 let end = rows_offset.checked_add(rows_len)?;
637 (row_size != 0 && end <= buffer_len).then_some(declared)
638}
639
640#[cfg(target_os = "windows")]
641struct WindowsHandle(windows_sys::Win32::Foundation::HANDLE);
642
643#[cfg(target_os = "windows")]
644impl Drop for WindowsHandle {
645 fn drop(&mut self) {
646 unsafe {
647 windows_sys::Win32::Foundation::CloseHandle(self.0);
648 }
649 }
650}
651
652#[cfg(target_os = "windows")]
653fn windows_processes() -> Vec<(u32, String)> {
654 use std::mem::size_of;
655 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
656 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
657 CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
658 TH32CS_SNAPPROCESS,
659 };
660
661 let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
662 if handle == INVALID_HANDLE_VALUE {
663 return Vec::new();
664 }
665 let snapshot = WindowsHandle(handle);
666 let mut entry = PROCESSENTRY32W::default();
667 let Ok(entry_size) = u32::try_from(size_of::<PROCESSENTRY32W>()) else {
668 return Vec::new();
669 };
670 entry.dwSize = entry_size;
671 if unsafe { Process32FirstW(snapshot.0, &mut entry) } == 0 {
672 return Vec::new();
673 }
674
675 let mut processes = Vec::new();
676 loop {
677 processes.push((
678 entry.th32ProcessID,
679 decode_windows_process_name(&entry.szExeFile),
680 ));
681 if unsafe { Process32NextW(snapshot.0, &mut entry) } == 0 {
682 break;
683 }
684 }
685 processes
686}
687
688#[cfg(target_os = "windows")]
689fn parse_windows_tcp_rows(buffer: &[u32], used_bytes: usize) -> Vec<WindowsTcpRow> {
690 use std::mem::{offset_of, size_of, size_of_val};
691 use windows_sys::Win32::NetworkManagement::IpHelper::{
692 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
693 };
694
695 let available = used_bytes.min(size_of_val(buffer));
696 let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
697 if available < size_of::<u32>() || available < rows_offset {
698 return Vec::new();
699 }
700 let base = buffer.as_ptr().cast::<u8>();
701 let declared = unsafe { base.cast::<u32>().read_unaligned() } as usize;
702 if checked_windows_row_count(
703 available,
704 rows_offset,
705 size_of::<MIB_TCPROW_OWNER_PID>(),
706 declared,
707 )
708 .is_none()
709 {
710 return Vec::new();
711 }
712
713 let rows = unsafe { base.add(rows_offset).cast::<MIB_TCPROW_OWNER_PID>() };
714 (0..declared)
715 .map(|index| unsafe { rows.add(index).read_unaligned() })
716 .map(|row| WindowsTcpRow {
717 local_addr: row.dwLocalAddr.to_ne_bytes(),
718 local_port: row.dwLocalPort,
719 pid: row.dwOwningPid,
720 })
721 .collect()
722}
723
724#[cfg(target_os = "windows")]
725fn windows_tcp_rows() -> Vec<WindowsTcpRow> {
726 use std::mem::size_of;
727 use std::ptr::null_mut;
728 use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
729 use windows_sys::Win32::NetworkManagement::IpHelper::{
730 GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
731 };
732 use windows_sys::Win32::Networking::WinSock::AF_INET;
733
734 let mut size = 0u32;
735 let status = unsafe {
736 GetExtendedTcpTable(
737 null_mut(),
738 &mut size,
739 0,
740 u32::from(AF_INET),
741 TCP_TABLE_OWNER_PID_LISTENER,
742 0,
743 )
744 };
745 if status != ERROR_INSUFFICIENT_BUFFER {
746 return Vec::new();
747 }
748
749 for _ in 0..3 {
750 let Some(words) = (size as usize)
751 .checked_add(size_of::<u32>() - 1)
752 .map(|bytes| bytes / size_of::<u32>())
753 else {
754 return Vec::new();
755 };
756 if words == 0 {
757 return Vec::new();
758 }
759 let mut buffer = Vec::<u32>::new();
760 if buffer.try_reserve_exact(words).is_err() {
761 return Vec::new();
762 }
763 buffer.resize(words, 0);
764 let mut used = size;
765 let status = unsafe {
766 GetExtendedTcpTable(
767 buffer.as_mut_ptr().cast(),
768 &mut used,
769 0,
770 u32::from(AF_INET),
771 TCP_TABLE_OWNER_PID_LISTENER,
772 0,
773 )
774 };
775 if status == ERROR_INSUFFICIENT_BUFFER {
776 size = used;
777 continue;
778 }
779 if status != 0 {
780 return Vec::new();
781 }
782 return parse_windows_tcp_rows(&buffer, used as usize);
783 }
784 Vec::new()
785}
786
787#[cfg(target_os = "windows")]
788fn discover_ls_ports() -> Vec<u16> {
789 let pids = matching_windows_process_ids(&windows_processes());
790 if pids.is_empty() {
791 return Vec::new();
792 }
793 matching_windows_ports(&pids, &windows_tcp_rows())
794}
795
796#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
797fn discover_ls_ports() -> Vec<u16> {
798 Vec::new()
799}
800
801#[cfg(target_os = "linux")]
804fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
805 let cols: Vec<&str> = line.split_whitespace().collect();
806 if cols.len() < 10 {
807 return None;
808 }
809 if cols[3] != "0A" {
811 return None;
812 }
813 let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
814 let inode = cols[9].parse::<u64>().ok()?;
815 Some((port, inode))
816}
817
818fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
823 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
824 return Err(original);
825 };
826 reuse_cache(bytes, cache, true, None, now).or(Err(original))
827}
828
829fn fallback_with_error(
834 cache: &Cache,
835 last_error: Option<(u16, String)>,
836 reason: AppError,
837 now: DateTime<Utc>,
838) -> Result<FetchOutcome> {
839 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
840 return Err(reason);
841 };
842 let Ok(mut outcome) = reuse_cache(bytes, cache, true, None, now) else {
843 return Err(reason);
844 };
845 outcome.last_error = last_error;
846 Ok(outcome)
847}
848
849fn reuse_cache(
850 bytes: Vec<u8>,
851 cache: &Cache,
852 stale: bool,
853 account: Option<&str>,
854 now: DateTime<Utc>,
855) -> Result<FetchOutcome> {
856 let snap = parse_cache_at(&bytes, account, now)?;
857 Ok(FetchOutcome {
858 snapshot: snap,
859 stale,
860 last_error: cache.read_last_error(),
861 cache_age: cache.payload_age(),
862 })
863}
864
865pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
871 parse_cache_at(bytes, account, Utc::now())
872}
873
874fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
884 [
885 ("Gemini 5h", Some(&snap.session)),
886 ("Gemini weekly", Some(&snap.weekly)),
887 ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
888 ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
889 ]
890 .into_iter()
891 .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
892 .map(|(name, _)| name)
893}
894
895pub fn parse_cache_at(
896 bytes: &[u8],
897 account: Option<&str>,
898 now: DateTime<Utc>,
899) -> Result<AntigravitySnapshot> {
900 let v: serde_json::Value = serde_json::from_slice(bytes)?;
901
902 let cached_account = v.get("account").and_then(serde_json::Value::as_str);
903 if let Some(expected) = account
904 && cached_account != Some(expected)
905 {
906 return Err(AppError::Schema(
907 "antigravity cache belongs to a different account; refetching".into(),
908 ));
909 }
910
911 let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
916 match v.get(pct_key) {
917 None | Some(serde_json::Value::Null) => Ok(None),
918 Some(value) => value
919 .as_i64()
920 .filter(|pct| (0..=100).contains(pct))
921 .map(|pct| Some(pct as i32))
922 .ok_or_else(|| {
923 AppError::Schema(format!(
924 "antigravity: cached {pct_key} must be an integer in 0..=100"
925 ))
926 }),
927 }
928 };
929
930 let window = |pct_key: &'static str, reset_key: &str, weekly: bool| {
931 let pct = cached_pct(pct_key)?.ok_or_else(|| {
932 AppError::Schema(format!("antigravity: cached payload missing {pct_key}"))
933 })?;
934 Ok::<_, AppError>(UsageWindow {
935 utilization_pct: pct,
936 resets_at: parse_reset(&v[reset_key], reset_key)?,
937 window_duration: if weekly {
938 chrono::Duration::days(7)
939 } else {
940 chrono::Duration::hours(5)
941 },
942 })
943 };
944
945 let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
946 let Some(pct) = cached_pct(pct_key)? else {
947 return Ok(None);
948 };
949 Ok::<_, AppError>(Some(UsageWindow {
950 utilization_pct: pct,
951 resets_at: parse_reset(&v[reset_key], reset_key)?,
952 window_duration: if weekly {
953 chrono::Duration::days(7)
954 } else {
955 chrono::Duration::hours(5)
956 },
957 }))
958 };
959
960 let snap = AntigravitySnapshot {
961 plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
962 account: cached_account.unwrap_or_default().to_string(),
963 session: window("session_pct", "session_reset", false)?,
964 weekly: window("weekly_pct", "weekly_reset", true)?,
965 third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
966 third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
967 };
968
969 if let Some(window) = expired_window(&snap, now) {
970 return Err(AppError::Schema(format!(
971 "antigravity cache is past its {window} reset; refetching"
972 )));
973 }
974 Ok(snap)
975}
976
977pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
978 serde_json::json!({
979 "plan": snap.plan,
980 "account": snap.account,
981 "session_pct": snap.session.utilization_pct,
982 "session_reset": snap.session.resets_at.map(|dt| dt.to_rfc3339()),
983 "weekly_pct": snap.weekly.utilization_pct,
984 "weekly_reset": snap.weekly.resets_at.map(|dt| dt.to_rfc3339()),
985 "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
986 "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
987 "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
988 "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
989 })
990}
991
992#[cfg(test)]
993mod tests {
994 use super::*;
995
996 const QUOTA_JSON: &str = r#"{
1000 "response": {
1001 "groups": [
1002 {
1003 "displayName": "Gemini Models",
1004 "buckets": [
1005 {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
1006 "window": "weekly", "remainingFraction": 0.9191212,
1007 "resetTime": "2026-07-28T17:39:58Z"},
1008 {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
1009 "window": "5h", "remainingFraction": 0.5672253,
1010 "resetTime": "2026-07-22T17:47:00Z"}
1011 ]
1012 },
1013 {
1014 "displayName": "Claude and GPT models",
1015 "buckets": [
1016 {"bucketId": "3p-weekly", "window": "weekly",
1017 "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
1018 {"bucketId": "3p-5h", "window": "5h",
1019 "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
1020 ]
1021 }
1022 ]
1023 }
1024 }"#;
1025
1026 fn now() -> DateTime<Utc> {
1029 DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
1030 .unwrap()
1031 .with_timezone(&Utc)
1032 }
1033
1034 fn parsed() -> AntigravitySnapshot {
1035 let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1036 parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
1037 }
1038
1039 #[test]
1040 fn quota_summary_maps_four_distinct_windows() {
1041 let snap = parsed();
1042 assert_eq!(snap.plan, "Google AI Pro");
1043 assert_eq!(snap.session.utilization_pct, 43);
1045 assert_eq!(snap.weekly.utilization_pct, 8);
1046 assert_eq!(
1047 snap.third_party_session.as_ref().unwrap().utilization_pct,
1048 75
1049 );
1050 assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
1051 }
1052
1053 #[test]
1054 fn each_window_keeps_its_own_reset_time() {
1055 let snap = parsed();
1056 let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
1057 assert_eq!(snap.session.resets_at, at("2026-07-22T17:47:00Z"));
1058 assert_eq!(snap.weekly.resets_at, at("2026-07-28T17:39:58Z"));
1059 assert_eq!(
1060 snap.third_party_weekly.as_ref().unwrap().resets_at,
1061 at("2026-07-29T12:47:00Z")
1062 );
1063 assert_ne!(snap.session.resets_at, snap.weekly.resets_at);
1065 }
1066
1067 #[test]
1068 fn window_durations_match_their_bucket() {
1069 let snap = parsed();
1070 assert_eq!(snap.session.window_duration, chrono::Duration::hours(5));
1071 assert_eq!(snap.weekly.window_duration, chrono::Duration::days(7));
1072 assert_eq!(
1073 snap.third_party_weekly.as_ref().unwrap().window_duration,
1074 chrono::Duration::days(7)
1075 );
1076 }
1077
1078 #[test]
1079 fn groups_are_matched_by_display_name_when_bucket_ids_change() {
1080 let v: serde_json::Value = serde_json::from_str(
1081 r#"{"response":{"groups":[
1082 {"displayName":"Gemini Models","buckets":[
1083 {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
1084 {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
1085 {"displayName":"Claude and GPT models","buckets":[
1086 {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
1087 ]}}"#,
1088 )
1089 .unwrap();
1090 let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
1091 assert_eq!(snap.session.utilization_pct, 50);
1092 assert_eq!(snap.weekly.utilization_pct, 10);
1093 assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
1094 assert!(snap.third_party_weekly.is_none());
1095 }
1096
1097 #[test]
1098 fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
1099 let duplicate: serde_json::Value = serde_json::from_str(
1100 r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
1101 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1102 {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
1103 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
1104 ]}]}}"#,
1105 )
1106 .unwrap();
1107 let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
1108 assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
1109
1110 let unrelated: serde_json::Value = serde_json::from_str(
1113 r#"{"response":{"groups":[
1114 {"displayName":"Gemini Models","buckets":[
1115 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1116 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
1117 {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
1118 ]},
1119 {"displayName":"Future Models","buckets":[
1120 {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
1121 ]}
1122 ]}}"#,
1123 )
1124 .unwrap();
1125 let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
1126 assert!(snap.third_party_session.is_none());
1127 assert!(snap.third_party_weekly.is_none());
1128 }
1129
1130 #[test]
1133 fn a_bucket_without_a_usable_fraction_is_rejected() {
1134 for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
1135 let v: serde_json::Value = serde_json::from_str(&format!(
1136 r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
1137 {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
1138 {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
1139 ))
1140 .unwrap();
1141 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1142 assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
1143 }
1144 }
1145
1146 #[test]
1147 fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
1148 for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
1149 let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1150 v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
1151 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1152 assert!(err.to_string().contains("resetTime"), "{err}");
1153 }
1154 }
1155
1156 #[test]
1157 fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
1158 let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
1159 assert!(parse_quota_summary(&v, "Pro".into()).is_err());
1160 }
1161
1162 #[test]
1163 fn cache_round_trip_preserves_every_window() {
1164 let snap = parsed();
1165 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1166 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1167 }
1168
1169 #[test]
1173 fn a_truncated_cached_payload_is_rejected_not_zeroed() {
1174 let full = snap_to_json(&parsed());
1175 for missing in ["session_pct", "weekly_pct"] {
1176 let mut v = full.clone();
1177 v.as_object_mut().unwrap().remove(missing);
1178 let bytes = serde_json::to_vec(&v).unwrap();
1179 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1180 assert!(err.to_string().contains(missing), "{missing}: {err}");
1181 }
1182 assert!(parse_cache_at(b"{}", None, now()).is_err());
1184 }
1185
1186 #[test]
1187 fn cached_percentages_are_range_checked_before_narrowing() {
1188 let full = snap_to_json(&parsed());
1189 for (key, bad) in [
1190 ("session_pct", serde_json::json!(-1)),
1191 ("weekly_pct", serde_json::json!(101)),
1192 ("session_pct", serde_json::json!(i64::MAX)),
1193 ("tp_session_pct", serde_json::json!("75")),
1194 ] {
1195 let mut v = full.clone();
1196 v[key] = bad;
1197 let bytes = serde_json::to_vec(&v).unwrap();
1198 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1199 assert!(err.to_string().contains(key), "{key}: {err}");
1200 }
1201 }
1202
1203 #[test]
1204 fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
1205 let mut v = snap_to_json(&parsed());
1206 v["session_reset"] = serde_json::json!("not-a-time");
1207 let bytes = serde_json::to_vec(&v).unwrap();
1208 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1209 assert!(err.to_string().contains("session_reset"), "{err}");
1210 }
1211
1212 #[test]
1217 fn a_cache_past_its_reset_is_refused() {
1218 let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
1219 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1220
1221 assert!(parse_cache_at(&bytes, None, now()).is_ok());
1223 assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
1225
1226 let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
1228 assert!(err.to_string().contains("5h"), "{err}");
1229
1230 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
1232 }
1233
1234 #[test]
1237 fn expiry_names_the_window_that_rolled_over() {
1238 let mut snap = parsed();
1239 snap.session.resets_at = None;
1241 snap.third_party_session = None;
1242 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1243 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1244
1245 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
1247
1248 let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
1250 assert!(err.to_string().contains("Gemini weekly"), "{err}");
1251 }
1252
1253 #[test]
1255 fn a_window_without_a_reset_never_expires() {
1256 let mut snap = parsed();
1257 for w in [&mut snap.session, &mut snap.weekly] {
1258 w.resets_at = None;
1259 }
1260 snap.third_party_session = None;
1261 snap.third_party_weekly = None;
1262 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1263 let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
1264 .unwrap()
1265 .with_timezone(&Utc);
1266 assert!(parse_cache_at(&bytes, None, far_future).is_ok());
1267 }
1268
1269 #[test]
1271 fn a_cache_from_another_account_is_rejected() {
1272 let mut snap = parsed();
1273 snap.account = "acct:aaaa".into();
1274 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1275
1276 assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
1277 assert_eq!(
1278 parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
1279 snap
1280 );
1281
1282 let mut legacy = snap_to_json(&snap);
1284 legacy.as_object_mut().unwrap().remove("account");
1285 let legacy = serde_json::to_vec(&legacy).unwrap();
1286 assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
1287 }
1288
1289 #[test]
1292 fn an_unverifiable_cache_is_served_rather_than_discarded() {
1293 let mut snap = parsed();
1294 snap.account = "acct:aaaa".into();
1295 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1296 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1297 }
1298
1299 #[test]
1300 fn account_key_fingerprints_rather_than_storing_the_address() {
1301 let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
1302 let a = with("someone@example.com");
1303 assert!(!a.contains("someone"), "{a}");
1304 assert!(!a.contains('@'), "{a}");
1305 assert_eq!(a, with("someone@example.com"), "must be stable");
1306 assert_ne!(a, with("other@example.com"));
1307 let unknown = account_key(&serde_json::json!({}));
1309 assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
1310 assert_ne!(unknown, a);
1311 }
1312
1313 #[test]
1316 fn absent_third_party_windows_are_not_treated_as_corruption() {
1317 let mut snap = parsed();
1318 snap.third_party_session = None;
1319 snap.third_party_weekly = None;
1320 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1321 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1322 }
1323
1324 #[test]
1325 fn cache_round_trip_preserves_absent_third_party_windows() {
1326 let mut snap = parsed();
1327 snap.third_party_session = None;
1328 snap.third_party_weekly = None;
1329 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1330 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1331 }
1332
1333 #[test]
1334 fn pct_used_inverts_valid_fractions() {
1335 assert_eq!(pct_used(1.0), 0);
1336 assert_eq!(pct_used(0.0), 100);
1337 assert_eq!(pct_used(0.5), 50);
1338 }
1339
1340 #[test]
1341 fn plan_falls_back_through_the_status_payload() {
1342 let tier: serde_json::Value =
1343 serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
1344 .unwrap();
1345 assert_eq!(plan_from_status(&tier), "Google AI Pro");
1346
1347 let plan_only: serde_json::Value = serde_json::from_str(
1348 r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
1349 )
1350 .unwrap();
1351 assert_eq!(plan_from_status(&plan_only), "Pro");
1352
1353 let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
1354 assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
1355 }
1356
1357 #[cfg(target_os = "linux")]
1358 #[test]
1359 fn proc_net_parser_keeps_only_listening_rows() {
1360 let listen = " 0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
1361 00:00000000 00000000 1000 0 123456 1 0000 100 0";
1362 assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
1363
1364 let established = " 1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
1365 00:00000000 00000000 1000 0 123457 1 0000 100 0";
1366 assert_eq!(parse_proc_net_line(established), None);
1367
1368 assert_eq!(parse_proc_net_line("garbage"), None);
1369 }
1370
1371 #[test]
1372 fn explicit_address_comes_first_and_gets_a_scheme() {
1373 assert_eq!(
1374 candidate_bases_with(Some("127.0.0.1:1234"), vec![5678]),
1375 vec![
1376 "http://127.0.0.1:1234".to_string(),
1377 "http://127.0.0.1:5678".to_string(),
1378 ]
1379 );
1380 assert_eq!(
1382 candidate_bases_with(Some("127.0.0.1:1234/"), vec![5678]),
1383 vec![
1384 "http://127.0.0.1:1234".to_string(),
1385 "http://127.0.0.1:5678".to_string(),
1386 ]
1387 );
1388 assert_eq!(
1390 candidate_bases_with(Some("127.0.0.1:5678"), vec![5678]),
1391 vec!["http://127.0.0.1:5678".to_string()]
1392 );
1393 assert_eq!(
1395 candidate_bases_with(None, vec![5678, 5678]),
1396 vec!["http://127.0.0.1:5678".to_string()]
1397 );
1398 assert_eq!(
1400 candidate_bases_with(Some("https://host:9"), vec![]),
1401 vec!["https://host:9".to_string()]
1402 );
1403 }
1404
1405 fn http(status: u16) -> AppError {
1406 AppError::Http {
1407 status,
1408 body: String::new(),
1409 }
1410 }
1411
1412 #[test]
1416 fn an_auth_failure_outranks_later_transport_noise() {
1417 let err = select_probe_error(vec![
1418 http(401),
1419 AppError::Transport("connection refused".into()),
1420 ]);
1421 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1422
1423 let err = select_probe_error(vec![
1424 AppError::Transport("connection refused".into()),
1425 http(403),
1426 ]);
1427 assert!(matches!(err, AppError::Http { status: 403, .. }), "{err}");
1428 }
1429
1430 #[test]
1433 fn the_first_auth_failure_wins() {
1434 let err = select_probe_error(vec![http(401), http(403)]);
1435 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1436 }
1437
1438 #[test]
1442 fn without_an_auth_failure_the_last_error_stands() {
1443 let err = select_probe_error(vec![
1444 AppError::Transport("first".into()),
1445 http(500),
1446 AppError::Transport("last".into()),
1447 ]);
1448 assert!(
1449 matches!(&err, AppError::Transport(m) if m == "last"),
1450 "{err}"
1451 );
1452 assert!(err.is_transient());
1453 }
1454
1455 #[test]
1458 fn a_server_error_is_not_treated_as_actionable() {
1459 let err = select_probe_error(vec![http(500), http(401)]);
1460 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1461 }
1462
1463 #[test]
1464 fn no_candidates_at_all_yields_a_generic_error() {
1465 let err = select_probe_error(Vec::new());
1466 assert!(
1467 err.to_string().contains("no local server answered"),
1468 "{err}"
1469 );
1470 }
1471
1472 #[test]
1473 fn every_discovered_port_is_probed_in_order() {
1474 assert_eq!(
1475 candidate_bases_with(None, vec![33875, 37435]),
1476 vec![
1477 "http://127.0.0.1:33875".to_string(),
1478 "http://127.0.0.1:37435".to_string(),
1479 ]
1480 );
1481 }
1482
1483 #[test]
1488 fn empty_discovery_yields_no_candidates() {
1489 assert!(candidate_bases_with(None, vec![]).is_empty());
1490 assert!(candidate_bases_with(Some(""), vec![]).is_empty());
1491 }
1492
1493 #[test]
1494 fn every_antigravity_product_is_recognised() {
1495 assert!(is_antigravity_process(
1497 "language_server\n",
1498 Some("/opt/antigravity/resources/bin/language_server")
1499 ));
1500 assert!(is_antigravity_process(
1502 "agy\n",
1503 Some("/home/u/.local/bin/agy")
1504 ));
1505 assert!(is_antigravity_process(
1507 "node",
1508 Some("/opt/antigravity/bin/helper")
1509 ));
1510 assert!(is_antigravity_process("antigravity", None));
1511 assert!(is_antigravity_process("agy.exe", None));
1512 assert!(is_antigravity_process("Antigravity.exe", None));
1513 assert!(is_antigravity_process("language_server.exe", None));
1514 assert!(is_antigravity_process(
1515 "language_server_windows_x64.exe",
1516 None
1517 ));
1518 assert!(is_antigravity_process(
1519 "node.exe",
1520 Some(r"C:\Users\u\AppData\Local\agy.exe")
1521 ));
1522 }
1523
1524 #[test]
1525 fn unrelated_processes_are_not_probed() {
1526 assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
1527 assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
1528 assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
1530 assert!(!is_antigravity_process("legacy.exe", None));
1531 assert!(!is_antigravity_process("not-agy.exe", None));
1532 assert!(!is_antigravity_process("", None));
1533 }
1534
1535 #[test]
1536 fn windows_process_names_decode_until_nul_and_tolerate_invalid_utf16() {
1537 let mut raw: Vec<u16> = "agy.exe".encode_utf16().collect();
1538 raw.extend([0, b'x' as u16]);
1539 assert_eq!(decode_windows_process_name(&raw), "agy.exe");
1540 assert_eq!(decode_windows_process_name(&[0xd800]), "�");
1541 assert_eq!(decode_windows_process_name(&[]), "");
1542 }
1543
1544 #[test]
1545 fn windows_process_filter_keeps_only_antigravity_pids() {
1546 let processes = vec![
1547 (10, "agy.exe".to_string()),
1548 (20, "language_server_windows_x64.exe".to_string()),
1549 (30, "sshd.exe".to_string()),
1550 ];
1551 let pids = matching_windows_process_ids(&processes);
1552 assert_eq!(pids, std::collections::HashSet::from([10, 20]));
1553 }
1554
1555 #[test]
1556 fn windows_listener_filter_joins_pid_loopback_and_port() {
1557 let pids = std::collections::HashSet::from([10]);
1558 let rows = [
1559 WindowsTcpRow {
1560 local_addr: [127, 0, 0, 1],
1561 local_port: u32::from(59870u16.to_be()),
1562 pid: 10,
1563 },
1564 WindowsTcpRow {
1565 local_addr: [127, 0, 0, 1],
1566 local_port: u32::from(59868u16.to_be()),
1567 pid: 10,
1568 },
1569 WindowsTcpRow {
1570 local_addr: [127, 0, 0, 1],
1571 local_port: u32::from(59870u16.to_be()),
1572 pid: 10,
1573 },
1574 WindowsTcpRow {
1575 local_addr: [0, 0, 0, 0],
1576 local_port: u32::from(50000u16.to_be()),
1577 pid: 10,
1578 },
1579 WindowsTcpRow {
1580 local_addr: [127, 0, 0, 1],
1581 local_port: u32::from(50001u16.to_be()),
1582 pid: 99,
1583 },
1584 WindowsTcpRow {
1585 local_addr: [127, 0, 0, 1],
1586 local_port: 0,
1587 pid: 10,
1588 },
1589 ];
1590 assert_eq!(matching_windows_ports(&pids, &rows), vec![59870, 59868]);
1591 }
1592
1593 #[test]
1594 fn windows_table_bounds_reject_truncation_and_overflow() {
1595 assert_eq!(checked_windows_row_count(52, 4, 24, 2), Some(2));
1596 assert_eq!(checked_windows_row_count(51, 4, 24, 2), None);
1597 assert_eq!(checked_windows_row_count(4, 4, 24, 0), Some(0));
1598 assert_eq!(checked_windows_row_count(52, 4, 0, 2), None);
1599 assert_eq!(
1600 checked_windows_row_count(usize::MAX, 4, 24, usize::MAX),
1601 None
1602 );
1603 }
1604
1605 #[cfg(target_os = "windows")]
1606 #[test]
1607 fn windows_tcp_table_parser_copies_complete_rows_only() {
1608 use std::mem::{offset_of, size_of};
1609 use windows_sys::Win32::NetworkManagement::IpHelper::{
1610 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
1611 };
1612
1613 let offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
1614 let used = offset + 2 * size_of::<MIB_TCPROW_OWNER_PID>();
1615 let words = used.div_ceil(size_of::<u32>());
1616 let mut buffer = vec![0u32; words];
1617 let first = MIB_TCPROW_OWNER_PID {
1618 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
1619 dwLocalPort: u32::from(59868u16.to_be()),
1620 dwOwningPid: 10,
1621 ..Default::default()
1622 };
1623 let second = MIB_TCPROW_OWNER_PID {
1624 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
1625 dwLocalPort: u32::from(59870u16.to_be()),
1626 dwOwningPid: 10,
1627 ..Default::default()
1628 };
1629 unsafe {
1630 buffer.as_mut_ptr().write_unaligned(2);
1631 let rows = buffer
1632 .as_mut_ptr()
1633 .cast::<u8>()
1634 .add(offset)
1635 .cast::<MIB_TCPROW_OWNER_PID>();
1636 rows.write_unaligned(first);
1637 rows.add(1).write_unaligned(second);
1638 }
1639
1640 assert_eq!(
1641 parse_windows_tcp_rows(&buffer, used),
1642 vec![
1643 WindowsTcpRow {
1644 local_addr: [127, 0, 0, 1],
1645 local_port: u32::from(59868u16.to_be()),
1646 pid: 10,
1647 },
1648 WindowsTcpRow {
1649 local_addr: [127, 0, 0, 1],
1650 local_port: u32::from(59870u16.to_be()),
1651 pid: 10,
1652 },
1653 ]
1654 );
1655 assert!(parse_windows_tcp_rows(&buffer, used - 1).is_empty());
1656 }
1657
1658 #[cfg(target_os = "macos")]
1659 #[test]
1660 fn lsof_parser_keeps_only_ports_owned_by_antigravity_processes() {
1661 let output = "p74101\ncagy\nf10\nn127.0.0.1:8829\nf11\nn127.0.0.1:61289\nf12\nn127.0.0.1:61290\np200\ncsshd\nf5\nn*:22\n";
1664 assert_eq!(parse_lsof_pcn(output), vec![8829, 61289, 61290]);
1665 }
1666
1667 #[cfg(target_os = "macos")]
1668 #[test]
1669 fn lsof_parser_matches_the_capitalised_macos_app_name() {
1670 let output = "p900\ncAntigravity\nf7\nn127.0.0.1:54321\n";
1671 assert_eq!(parse_lsof_pcn(output), vec![54321]);
1672 }
1673
1674 #[cfg(target_os = "macos")]
1675 #[test]
1676 fn lsof_parser_deduplicates_and_handles_empty_output() {
1677 let output = "p1\ncagy\nf3\nn127.0.0.1:9000\nf4\nn127.0.0.1:9000\n";
1678 assert_eq!(parse_lsof_pcn(output), vec![9000]);
1679 assert!(parse_lsof_pcn("").is_empty());
1680 }
1681
1682 #[test]
1685 fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
1686 let dir = tempfile::tempdir().unwrap();
1687 let cache = Cache::at(dir.path().join("usage.json"));
1688 let reason = AppError::Credentials("Antigravity: no local language server found".into());
1689
1690 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1691 let msg = err.to_string();
1692 assert!(msg.contains("no local language server found"), "{msg}");
1693 assert!(!msg.contains("no usable cache"), "{msg}");
1694 }
1695
1696 #[test]
1697 fn unusable_cache_does_not_replace_the_live_diagnosis() {
1698 let dir = tempfile::tempdir().unwrap();
1699 let cache = Cache::at(dir.path().join("antigravity"));
1700 cache.write_payload(b"{}").unwrap();
1701
1702 let reason = AppError::Credentials("Antigravity must be running".into());
1703 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1704 assert!(err.to_string().contains("must be running"), "{err}");
1705
1706 let original = AppError::Transport("original loopback failure".into());
1707 let err = fallback_silent(&cache, now(), original).unwrap_err();
1708 assert!(
1709 err.to_string().contains("original loopback failure"),
1710 "{err}"
1711 );
1712 }
1713
1714 #[tokio::test]
1715 async fn rpc_error_bodies_are_bounded_too() {
1716 let mut server = mockito::Server::new_async().await;
1717 let path = format!("/{STATUS_RPC}");
1718 server
1719 .mock("POST", path.as_str())
1720 .with_status(500)
1721 .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
1722 .create_async()
1723 .await;
1724
1725 let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
1726 .await
1727 .unwrap_err();
1728 assert!(err.to_string().contains("exceeds"), "{err}");
1729 }
1730
1731 #[test]
1732 fn blank_override_falls_through_to_discovery() {
1733 assert_eq!(
1734 candidate_bases_with(Some(" "), vec![4242]),
1735 vec!["http://127.0.0.1:4242".to_string()]
1736 );
1737 }
1738}