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