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(base) = override_addr.and_then(normalize_base) {
422 bases.push(base);
423 }
424
425 for p in discovered {
431 let candidate = format!("http://127.0.0.1:{p}");
432 if !bases.contains(&candidate) {
433 bases.push(candidate);
434 }
435 }
436
437 bases
438}
439
440fn normalize_base(addr: &str) -> Option<String> {
449 let trimmed = addr.trim();
450 let (scheme, authority) = match trimmed.split_once("://") {
451 Some((scheme @ ("http" | "https"), rest)) => (scheme, rest),
452 _ => ("http", trimmed),
453 };
454 let authority = authority.trim_end_matches('/');
455 (!authority.is_empty()).then(|| format!("{scheme}://{authority}"))
456}
457
458fn is_antigravity_process(comm: &str, exe: Option<&str>) -> bool {
465 let comm = comm.trim().to_lowercase();
466 let comm = comm.strip_suffix(".exe").unwrap_or(&comm);
467 if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
468 return true;
469 }
470 exe.is_some_and(|p| {
471 let p = p.to_lowercase().replace('\\', "/");
472 let p = p.strip_suffix(".exe").unwrap_or(&p);
473 p.contains("antigravity") || p.ends_with("/agy")
474 })
475}
476
477#[cfg(any(test, target_os = "linux", target_os = "macos", target_os = "windows"))]
507fn probe_order(per_pid: std::collections::BTreeMap<u32, Vec<u16>>) -> Vec<u16> {
508 let groups: Vec<Vec<u16>> = per_pid
509 .into_values()
510 .map(|mut group| {
511 group.sort_unstable_by(|a, b| b.cmp(a));
512 group.dedup();
517 group
518 })
519 .collect();
520
521 let mut ports: Vec<u16> = Vec::new();
522 for rank in 0..groups.iter().map(Vec::len).max().unwrap_or(0) {
523 for port in groups.iter().filter_map(|group| group.get(rank)) {
524 if !ports.contains(port) {
525 ports.push(*port);
526 }
527 }
528 }
529 ports
530}
531
532#[cfg(target_os = "linux")]
539fn discover_ls_ports() -> Vec<u16> {
540 use std::collections::{BTreeMap, HashMap};
541
542 let mut owners: HashMap<u64, u32> = HashMap::new();
545 let Ok(entries) = std::fs::read_dir("/proc") else {
546 return Vec::new();
547 };
548 for entry in entries.flatten() {
549 let pid_dir = entry.path();
550 let Some(pid) = pid_dir
551 .file_name()
552 .and_then(|name| name.to_str())
553 .and_then(|name| name.parse::<u32>().ok())
554 else {
555 continue;
556 };
557 let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
558 continue;
559 };
560 let exe = std::fs::read_link(pid_dir.join("exe")).ok();
561 if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
562 continue;
563 }
564 let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
565 continue;
566 };
567 for fd in fds.flatten() {
568 let Ok(target) = std::fs::read_link(fd.path()) else {
569 continue;
570 };
571 if let Some(ino) = target
572 .to_str()
573 .and_then(|s| s.strip_prefix("socket:["))
574 .and_then(|s| s.strip_suffix(']'))
575 .and_then(|s| s.parse::<u64>().ok())
576 {
577 owners.insert(ino, pid);
578 }
579 }
580 }
581
582 if owners.is_empty() {
583 return Vec::new();
584 }
585
586 let mut per_pid: BTreeMap<u32, Vec<u16>> = BTreeMap::new();
587 for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
588 let Ok(contents) = std::fs::read_to_string(table) else {
589 continue;
590 };
591 for line in contents.lines().skip(1) {
592 if let Some((port, ino)) = parse_proc_net_line(line)
593 && let Some(&pid) = owners.get(&ino)
594 {
595 per_pid.entry(pid).or_default().push(port);
596 }
597 }
598 }
599 probe_order(per_pid)
600}
601
602#[cfg(target_os = "macos")]
609fn discover_ls_ports() -> Vec<u16> {
610 let Ok(output) = std::process::Command::new("lsof")
611 .args(["-nP", "-iTCP", "-sTCP:LISTEN", "-F", "pcn"])
612 .output()
613 else {
614 return Vec::new();
615 };
616 parse_lsof_pcn(&String::from_utf8_lossy(&output.stdout))
620}
621
622#[cfg(any(test, target_os = "macos"))]
627fn parse_lsof_pcn(output: &str) -> Vec<u16> {
628 let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
629 let mut pid = None;
632 let mut owner = None;
633 for line in output.lines() {
634 let Some(rest) = line.get(1..) else { continue };
635 match line.as_bytes().first() {
636 Some(b'p') => {
637 pid = rest.parse::<u32>().ok();
638 owner = None;
639 }
640 Some(b'c') => owner = pid.filter(|_| is_antigravity_process(rest, None)),
641 Some(b'n') => {
642 if let Some(pid) = owner
643 && let Some(port) = rest.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
644 {
645 per_pid.entry(pid).or_default().push(port);
646 }
647 }
648 _ => {}
649 }
650 }
651 probe_order(per_pid)
652}
653
654#[cfg(any(test, target_os = "windows"))]
655#[derive(Debug, Clone, Copy, PartialEq, Eq)]
656struct WindowsTcpRow {
657 local_addr: [u8; 4],
658 local_port: u32,
659 pid: u32,
660}
661
662#[cfg(any(test, target_os = "windows"))]
663fn decode_windows_process_name(raw: &[u16]) -> String {
664 let end = raw.iter().position(|&unit| unit == 0).unwrap_or(raw.len());
665 String::from_utf16_lossy(&raw[..end])
666}
667
668#[cfg(any(test, target_os = "windows"))]
669fn matching_windows_process_ids(processes: &[(u32, String)]) -> std::collections::HashSet<u32> {
670 processes
671 .iter()
672 .filter(|(_, name)| is_antigravity_process(name, None))
673 .map(|(pid, _)| *pid)
674 .collect()
675}
676
677#[cfg(any(test, target_os = "windows"))]
680fn matching_windows_ports(
681 pids: &std::collections::HashSet<u32>,
682 rows: &[WindowsTcpRow],
683) -> Vec<u16> {
684 let mut per_pid: std::collections::BTreeMap<u32, Vec<u16>> = std::collections::BTreeMap::new();
685 for row in rows {
686 if !pids.contains(&row.pid) || row.local_addr != [127, 0, 0, 1] {
687 continue;
688 }
689 let port = u16::from_be((row.local_port & u32::from(u16::MAX)) as u16);
690 if port != 0 {
691 per_pid.entry(row.pid).or_default().push(port);
692 }
693 }
694 probe_order(per_pid)
695}
696
697#[cfg(any(test, target_os = "windows"))]
698fn checked_windows_row_count(
699 buffer_len: usize,
700 rows_offset: usize,
701 row_size: usize,
702 declared: usize,
703) -> Option<usize> {
704 let rows_len = row_size.checked_mul(declared)?;
705 let end = rows_offset.checked_add(rows_len)?;
706 (row_size != 0 && end <= buffer_len).then_some(declared)
707}
708
709#[cfg(target_os = "windows")]
710struct WindowsHandle(windows_sys::Win32::Foundation::HANDLE);
711
712#[cfg(target_os = "windows")]
713impl Drop for WindowsHandle {
714 fn drop(&mut self) {
715 unsafe {
716 windows_sys::Win32::Foundation::CloseHandle(self.0);
717 }
718 }
719}
720
721#[cfg(target_os = "windows")]
722fn windows_processes() -> Vec<(u32, String)> {
723 use std::mem::size_of;
724 use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
725 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
726 CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
727 TH32CS_SNAPPROCESS,
728 };
729
730 let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
731 if handle == INVALID_HANDLE_VALUE {
732 return Vec::new();
733 }
734 let snapshot = WindowsHandle(handle);
735 let mut entry = PROCESSENTRY32W::default();
736 let Ok(entry_size) = u32::try_from(size_of::<PROCESSENTRY32W>()) else {
737 return Vec::new();
738 };
739 entry.dwSize = entry_size;
740 if unsafe { Process32FirstW(snapshot.0, &mut entry) } == 0 {
741 return Vec::new();
742 }
743
744 let mut processes = Vec::new();
745 loop {
746 processes.push((
747 entry.th32ProcessID,
748 decode_windows_process_name(&entry.szExeFile),
749 ));
750 if unsafe { Process32NextW(snapshot.0, &mut entry) } == 0 {
751 break;
752 }
753 }
754 processes
755}
756
757#[cfg(target_os = "windows")]
758fn parse_windows_tcp_rows(buffer: &[u32], used_bytes: usize) -> Vec<WindowsTcpRow> {
759 use std::mem::{offset_of, size_of, size_of_val};
760 use windows_sys::Win32::NetworkManagement::IpHelper::{
761 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
762 };
763
764 let available = used_bytes.min(size_of_val(buffer));
765 let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
766 if available < size_of::<u32>() || available < rows_offset {
767 return Vec::new();
768 }
769 let base = buffer.as_ptr().cast::<u8>();
770 let declared = unsafe { base.cast::<u32>().read_unaligned() } as usize;
771 if checked_windows_row_count(
772 available,
773 rows_offset,
774 size_of::<MIB_TCPROW_OWNER_PID>(),
775 declared,
776 )
777 .is_none()
778 {
779 return Vec::new();
780 }
781
782 let rows = unsafe { base.add(rows_offset).cast::<MIB_TCPROW_OWNER_PID>() };
783 (0..declared)
784 .map(|index| unsafe { rows.add(index).read_unaligned() })
785 .map(|row| WindowsTcpRow {
786 local_addr: row.dwLocalAddr.to_ne_bytes(),
787 local_port: row.dwLocalPort,
788 pid: row.dwOwningPid,
789 })
790 .collect()
791}
792
793#[cfg(target_os = "windows")]
794fn windows_tcp_rows() -> Vec<WindowsTcpRow> {
795 use std::mem::size_of;
796 use std::ptr::null_mut;
797 use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
798 use windows_sys::Win32::NetworkManagement::IpHelper::{
799 GetExtendedTcpTable, TCP_TABLE_OWNER_PID_LISTENER,
800 };
801 use windows_sys::Win32::Networking::WinSock::AF_INET;
802
803 let mut size = 0u32;
804 let status = unsafe {
805 GetExtendedTcpTable(
806 null_mut(),
807 &mut size,
808 0,
809 u32::from(AF_INET),
810 TCP_TABLE_OWNER_PID_LISTENER,
811 0,
812 )
813 };
814 if status != ERROR_INSUFFICIENT_BUFFER {
815 return Vec::new();
816 }
817
818 for _ in 0..3 {
819 let Some(words) = (size as usize)
820 .checked_add(size_of::<u32>() - 1)
821 .map(|bytes| bytes / size_of::<u32>())
822 else {
823 return Vec::new();
824 };
825 if words == 0 {
826 return Vec::new();
827 }
828 let mut buffer = Vec::<u32>::new();
829 if buffer.try_reserve_exact(words).is_err() {
830 return Vec::new();
831 }
832 buffer.resize(words, 0);
833 let mut used = size;
834 let status = unsafe {
835 GetExtendedTcpTable(
836 buffer.as_mut_ptr().cast(),
837 &mut used,
838 0,
839 u32::from(AF_INET),
840 TCP_TABLE_OWNER_PID_LISTENER,
841 0,
842 )
843 };
844 if status == ERROR_INSUFFICIENT_BUFFER {
845 size = used;
846 continue;
847 }
848 if status != 0 {
849 return Vec::new();
850 }
851 return parse_windows_tcp_rows(&buffer, used as usize);
852 }
853 Vec::new()
854}
855
856#[cfg(target_os = "windows")]
857fn discover_ls_ports() -> Vec<u16> {
858 let pids = matching_windows_process_ids(&windows_processes());
859 if pids.is_empty() {
860 return Vec::new();
861 }
862 matching_windows_ports(&pids, &windows_tcp_rows())
863}
864
865#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
866fn discover_ls_ports() -> Vec<u16> {
867 Vec::new()
868}
869
870#[cfg(target_os = "linux")]
873fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
874 let cols: Vec<&str> = line.split_whitespace().collect();
875 if cols.len() < 10 {
876 return None;
877 }
878 if cols[3] != "0A" {
880 return None;
881 }
882 let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
883 let inode = cols[9].parse::<u64>().ok()?;
884 Some((port, inode))
885}
886
887fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
892 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
893 return Err(original);
894 };
895 reuse_cache(bytes, cache, true, None, now).or(Err(original))
896}
897
898fn fallback_with_error(
903 cache: &Cache,
904 last_error: Option<(u16, String)>,
905 reason: AppError,
906 now: DateTime<Utc>,
907) -> Result<FetchOutcome> {
908 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
909 return Err(reason);
910 };
911 let Ok(mut outcome) = reuse_cache(bytes, cache, true, None, now) else {
912 return Err(reason);
913 };
914 outcome.last_error = last_error;
915 Ok(outcome)
916}
917
918fn reuse_cache(
919 bytes: Vec<u8>,
920 cache: &Cache,
921 stale: bool,
922 account: Option<&str>,
923 now: DateTime<Utc>,
924) -> Result<FetchOutcome> {
925 let snap = parse_cache_at(&bytes, account, now)?;
926 Ok(FetchOutcome {
927 snapshot: snap,
928 stale,
929 last_error: cache.read_last_error(),
930 cache_age: cache.payload_age(),
931 })
932}
933
934pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
940 parse_cache_at(bytes, account, Utc::now())
941}
942
943fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
953 [
954 ("Gemini 5h", Some(&snap.session)),
955 ("Gemini weekly", Some(&snap.weekly)),
956 ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
957 ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
958 ]
959 .into_iter()
960 .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
961 .map(|(name, _)| name)
962}
963
964pub fn parse_cache_at(
965 bytes: &[u8],
966 account: Option<&str>,
967 now: DateTime<Utc>,
968) -> Result<AntigravitySnapshot> {
969 let v: serde_json::Value = serde_json::from_slice(bytes)?;
970
971 let cached_account = v.get("account").and_then(serde_json::Value::as_str);
972 if let Some(expected) = account
973 && cached_account != Some(expected)
974 {
975 return Err(AppError::Schema(
976 "antigravity cache belongs to a different account; refetching".into(),
977 ));
978 }
979
980 let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
985 match v.get(pct_key) {
986 None | Some(serde_json::Value::Null) => Ok(None),
987 Some(value) => value
988 .as_i64()
989 .filter(|pct| (0..=100).contains(pct))
990 .map(|pct| Some(pct as i32))
991 .ok_or_else(|| {
992 AppError::Schema(format!(
993 "antigravity: cached {pct_key} must be an integer in 0..=100"
994 ))
995 }),
996 }
997 };
998
999 let window = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1000 let pct = cached_pct(pct_key)?.ok_or_else(|| {
1001 AppError::Schema(format!("antigravity: cached payload missing {pct_key}"))
1002 })?;
1003 Ok::<_, AppError>(UsageWindow {
1004 utilization_pct: pct,
1005 resets_at: parse_reset(&v[reset_key], reset_key)?,
1006 window_duration: if weekly {
1007 chrono::Duration::days(7)
1008 } else {
1009 chrono::Duration::hours(5)
1010 },
1011 })
1012 };
1013
1014 let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
1015 let Some(pct) = cached_pct(pct_key)? else {
1016 return Ok(None);
1017 };
1018 Ok::<_, AppError>(Some(UsageWindow {
1019 utilization_pct: pct,
1020 resets_at: parse_reset(&v[reset_key], reset_key)?,
1021 window_duration: if weekly {
1022 chrono::Duration::days(7)
1023 } else {
1024 chrono::Duration::hours(5)
1025 },
1026 }))
1027 };
1028
1029 let snap = AntigravitySnapshot {
1030 plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
1031 account: cached_account.unwrap_or_default().to_string(),
1032 session: window("session_pct", "session_reset", false)?,
1033 weekly: window("weekly_pct", "weekly_reset", true)?,
1034 third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
1035 third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
1036 };
1037
1038 if let Some(window) = expired_window(&snap, now) {
1039 return Err(AppError::Schema(format!(
1040 "antigravity cache is past its {window} reset; refetching"
1041 )));
1042 }
1043 Ok(snap)
1044}
1045
1046pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
1047 serde_json::json!({
1048 "plan": snap.plan,
1049 "account": snap.account,
1050 "session_pct": snap.session.utilization_pct,
1051 "session_reset": snap.session.resets_at.map(|dt| dt.to_rfc3339()),
1052 "weekly_pct": snap.weekly.utilization_pct,
1053 "weekly_reset": snap.weekly.resets_at.map(|dt| dt.to_rfc3339()),
1054 "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
1055 "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1056 "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
1057 "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
1058 })
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 use super::*;
1064
1065 const QUOTA_JSON: &str = r#"{
1069 "response": {
1070 "groups": [
1071 {
1072 "displayName": "Gemini Models",
1073 "buckets": [
1074 {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
1075 "window": "weekly", "remainingFraction": 0.9191212,
1076 "resetTime": "2026-07-28T17:39:58Z"},
1077 {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
1078 "window": "5h", "remainingFraction": 0.5672253,
1079 "resetTime": "2026-07-22T17:47:00Z"}
1080 ]
1081 },
1082 {
1083 "displayName": "Claude and GPT models",
1084 "buckets": [
1085 {"bucketId": "3p-weekly", "window": "weekly",
1086 "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
1087 {"bucketId": "3p-5h", "window": "5h",
1088 "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
1089 ]
1090 }
1091 ]
1092 }
1093 }"#;
1094
1095 fn now() -> DateTime<Utc> {
1098 DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
1099 .unwrap()
1100 .with_timezone(&Utc)
1101 }
1102
1103 fn parsed() -> AntigravitySnapshot {
1104 let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1105 parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
1106 }
1107
1108 #[test]
1109 fn quota_summary_maps_four_distinct_windows() {
1110 let snap = parsed();
1111 assert_eq!(snap.plan, "Google AI Pro");
1112 assert_eq!(snap.session.utilization_pct, 43);
1114 assert_eq!(snap.weekly.utilization_pct, 8);
1115 assert_eq!(
1116 snap.third_party_session.as_ref().unwrap().utilization_pct,
1117 75
1118 );
1119 assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
1120 }
1121
1122 #[test]
1123 fn each_window_keeps_its_own_reset_time() {
1124 let snap = parsed();
1125 let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
1126 assert_eq!(snap.session.resets_at, at("2026-07-22T17:47:00Z"));
1127 assert_eq!(snap.weekly.resets_at, at("2026-07-28T17:39:58Z"));
1128 assert_eq!(
1129 snap.third_party_weekly.as_ref().unwrap().resets_at,
1130 at("2026-07-29T12:47:00Z")
1131 );
1132 assert_ne!(snap.session.resets_at, snap.weekly.resets_at);
1134 }
1135
1136 #[test]
1137 fn window_durations_match_their_bucket() {
1138 let snap = parsed();
1139 assert_eq!(snap.session.window_duration, chrono::Duration::hours(5));
1140 assert_eq!(snap.weekly.window_duration, chrono::Duration::days(7));
1141 assert_eq!(
1142 snap.third_party_weekly.as_ref().unwrap().window_duration,
1143 chrono::Duration::days(7)
1144 );
1145 }
1146
1147 #[test]
1148 fn groups_are_matched_by_display_name_when_bucket_ids_change() {
1149 let v: serde_json::Value = serde_json::from_str(
1150 r#"{"response":{"groups":[
1151 {"displayName":"Gemini Models","buckets":[
1152 {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
1153 {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
1154 {"displayName":"Claude and GPT models","buckets":[
1155 {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
1156 ]}}"#,
1157 )
1158 .unwrap();
1159 let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
1160 assert_eq!(snap.session.utilization_pct, 50);
1161 assert_eq!(snap.weekly.utilization_pct, 10);
1162 assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
1163 assert!(snap.third_party_weekly.is_none());
1164 }
1165
1166 #[test]
1167 fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
1168 let duplicate: serde_json::Value = serde_json::from_str(
1169 r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
1170 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1171 {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
1172 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
1173 ]}]}}"#,
1174 )
1175 .unwrap();
1176 let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
1177 assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
1178
1179 let unrelated: serde_json::Value = serde_json::from_str(
1182 r#"{"response":{"groups":[
1183 {"displayName":"Gemini Models","buckets":[
1184 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
1185 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
1186 {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
1187 ]},
1188 {"displayName":"Future Models","buckets":[
1189 {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
1190 ]}
1191 ]}}"#,
1192 )
1193 .unwrap();
1194 let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
1195 assert!(snap.third_party_session.is_none());
1196 assert!(snap.third_party_weekly.is_none());
1197 }
1198
1199 #[test]
1202 fn a_bucket_without_a_usable_fraction_is_rejected() {
1203 for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
1204 let v: serde_json::Value = serde_json::from_str(&format!(
1205 r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
1206 {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
1207 {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
1208 ))
1209 .unwrap();
1210 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1211 assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
1212 }
1213 }
1214
1215 #[test]
1216 fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
1217 for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
1218 let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
1219 v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
1220 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
1221 assert!(err.to_string().contains("resetTime"), "{err}");
1222 }
1223 }
1224
1225 #[test]
1226 fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
1227 let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
1228 assert!(parse_quota_summary(&v, "Pro".into()).is_err());
1229 }
1230
1231 #[test]
1232 fn cache_round_trip_preserves_every_window() {
1233 let snap = parsed();
1234 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1235 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1236 }
1237
1238 #[test]
1242 fn a_truncated_cached_payload_is_rejected_not_zeroed() {
1243 let full = snap_to_json(&parsed());
1244 for missing in ["session_pct", "weekly_pct"] {
1245 let mut v = full.clone();
1246 v.as_object_mut().unwrap().remove(missing);
1247 let bytes = serde_json::to_vec(&v).unwrap();
1248 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1249 assert!(err.to_string().contains(missing), "{missing}: {err}");
1250 }
1251 assert!(parse_cache_at(b"{}", None, now()).is_err());
1253 }
1254
1255 #[test]
1256 fn cached_percentages_are_range_checked_before_narrowing() {
1257 let full = snap_to_json(&parsed());
1258 for (key, bad) in [
1259 ("session_pct", serde_json::json!(-1)),
1260 ("weekly_pct", serde_json::json!(101)),
1261 ("session_pct", serde_json::json!(i64::MAX)),
1262 ("tp_session_pct", serde_json::json!("75")),
1263 ] {
1264 let mut v = full.clone();
1265 v[key] = bad;
1266 let bytes = serde_json::to_vec(&v).unwrap();
1267 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1268 assert!(err.to_string().contains(key), "{key}: {err}");
1269 }
1270 }
1271
1272 #[test]
1273 fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
1274 let mut v = snap_to_json(&parsed());
1275 v["session_reset"] = serde_json::json!("not-a-time");
1276 let bytes = serde_json::to_vec(&v).unwrap();
1277 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
1278 assert!(err.to_string().contains("session_reset"), "{err}");
1279 }
1280
1281 #[test]
1286 fn a_cache_past_its_reset_is_refused() {
1287 let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
1288 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1289
1290 assert!(parse_cache_at(&bytes, None, now()).is_ok());
1292 assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
1294
1295 let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
1297 assert!(err.to_string().contains("5h"), "{err}");
1298
1299 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
1301 }
1302
1303 #[test]
1306 fn expiry_names_the_window_that_rolled_over() {
1307 let mut snap = parsed();
1308 snap.session.resets_at = None;
1310 snap.third_party_session = None;
1311 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1312 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
1313
1314 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
1316
1317 let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
1319 assert!(err.to_string().contains("Gemini weekly"), "{err}");
1320 }
1321
1322 #[test]
1324 fn a_window_without_a_reset_never_expires() {
1325 let mut snap = parsed();
1326 for w in [&mut snap.session, &mut snap.weekly] {
1327 w.resets_at = None;
1328 }
1329 snap.third_party_session = None;
1330 snap.third_party_weekly = None;
1331 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1332 let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
1333 .unwrap()
1334 .with_timezone(&Utc);
1335 assert!(parse_cache_at(&bytes, None, far_future).is_ok());
1336 }
1337
1338 #[test]
1340 fn a_cache_from_another_account_is_rejected() {
1341 let mut snap = parsed();
1342 snap.account = "acct:aaaa".into();
1343 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1344
1345 assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
1346 assert_eq!(
1347 parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
1348 snap
1349 );
1350
1351 let mut legacy = snap_to_json(&snap);
1353 legacy.as_object_mut().unwrap().remove("account");
1354 let legacy = serde_json::to_vec(&legacy).unwrap();
1355 assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
1356 }
1357
1358 #[test]
1361 fn an_unverifiable_cache_is_served_rather_than_discarded() {
1362 let mut snap = parsed();
1363 snap.account = "acct:aaaa".into();
1364 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1365 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1366 }
1367
1368 #[test]
1369 fn account_key_fingerprints_rather_than_storing_the_address() {
1370 let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
1371 let a = with("someone@example.com");
1372 assert!(!a.contains("someone"), "{a}");
1373 assert!(!a.contains('@'), "{a}");
1374 assert_eq!(a, with("someone@example.com"), "must be stable");
1375 assert_ne!(a, with("other@example.com"));
1376 let unknown = account_key(&serde_json::json!({}));
1378 assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
1379 assert_ne!(unknown, a);
1380 }
1381
1382 #[test]
1385 fn absent_third_party_windows_are_not_treated_as_corruption() {
1386 let mut snap = parsed();
1387 snap.third_party_session = None;
1388 snap.third_party_weekly = None;
1389 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1390 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1391 }
1392
1393 #[test]
1394 fn cache_round_trip_preserves_absent_third_party_windows() {
1395 let mut snap = parsed();
1396 snap.third_party_session = None;
1397 snap.third_party_weekly = None;
1398 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1399 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1400 }
1401
1402 #[test]
1403 fn pct_used_inverts_valid_fractions() {
1404 assert_eq!(pct_used(1.0), 0);
1405 assert_eq!(pct_used(0.0), 100);
1406 assert_eq!(pct_used(0.5), 50);
1407 }
1408
1409 #[test]
1410 fn plan_falls_back_through_the_status_payload() {
1411 let tier: serde_json::Value =
1412 serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
1413 .unwrap();
1414 assert_eq!(plan_from_status(&tier), "Google AI Pro");
1415
1416 let plan_only: serde_json::Value = serde_json::from_str(
1417 r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
1418 )
1419 .unwrap();
1420 assert_eq!(plan_from_status(&plan_only), "Pro");
1421
1422 let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
1423 assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
1424 }
1425
1426 #[cfg(target_os = "linux")]
1427 #[test]
1428 fn proc_net_parser_keeps_only_listening_rows() {
1429 let listen = " 0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
1430 00:00000000 00000000 1000 0 123456 1 0000 100 0";
1431 assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
1432
1433 let established = " 1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
1434 00:00000000 00000000 1000 0 123457 1 0000 100 0";
1435 assert_eq!(parse_proc_net_line(established), None);
1436
1437 assert_eq!(parse_proc_net_line("garbage"), None);
1438 }
1439
1440 #[test]
1441 fn explicit_address_comes_first_and_gets_a_scheme() {
1442 assert_eq!(
1443 candidate_bases_with(Some("127.0.0.1:1234"), vec![5678]),
1444 vec![
1445 "http://127.0.0.1:1234".to_string(),
1446 "http://127.0.0.1:5678".to_string(),
1447 ]
1448 );
1449 assert_eq!(
1451 candidate_bases_with(Some("127.0.0.1:1234/"), vec![5678]),
1452 vec![
1453 "http://127.0.0.1:1234".to_string(),
1454 "http://127.0.0.1:5678".to_string(),
1455 ]
1456 );
1457 assert_eq!(
1459 candidate_bases_with(Some("127.0.0.1:5678"), vec![5678]),
1460 vec!["http://127.0.0.1:5678".to_string()]
1461 );
1462 assert_eq!(
1464 candidate_bases_with(None, vec![5678, 5678]),
1465 vec!["http://127.0.0.1:5678".to_string()]
1466 );
1467 assert_eq!(
1469 candidate_bases_with(Some("https://host:9"), vec![]),
1470 vec!["https://host:9".to_string()]
1471 );
1472 }
1473
1474 fn http(status: u16) -> AppError {
1475 AppError::Http {
1476 status,
1477 body: String::new(),
1478 }
1479 }
1480
1481 #[test]
1485 fn an_auth_failure_outranks_later_transport_noise() {
1486 let err = select_probe_error(vec![
1487 http(401),
1488 AppError::Transport("connection refused".into()),
1489 ]);
1490 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1491
1492 let err = select_probe_error(vec![
1493 AppError::Transport("connection refused".into()),
1494 http(403),
1495 ]);
1496 assert!(matches!(err, AppError::Http { status: 403, .. }), "{err}");
1497 }
1498
1499 #[test]
1502 fn the_first_auth_failure_wins() {
1503 let err = select_probe_error(vec![http(401), http(403)]);
1504 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1505 }
1506
1507 #[test]
1511 fn without_an_auth_failure_the_last_error_stands() {
1512 let err = select_probe_error(vec![
1513 AppError::Transport("first".into()),
1514 http(500),
1515 AppError::Transport("last".into()),
1516 ]);
1517 assert!(
1518 matches!(&err, AppError::Transport(m) if m == "last"),
1519 "{err}"
1520 );
1521 assert!(err.is_transient());
1522 }
1523
1524 #[test]
1527 fn a_server_error_is_not_treated_as_actionable() {
1528 let err = select_probe_error(vec![http(500), http(401)]);
1529 assert!(matches!(err, AppError::Http { status: 401, .. }), "{err}");
1530 }
1531
1532 #[test]
1533 fn no_candidates_at_all_yields_a_generic_error() {
1534 let err = select_probe_error(Vec::new());
1535 assert!(
1536 err.to_string().contains("no local server answered"),
1537 "{err}"
1538 );
1539 }
1540
1541 #[test]
1542 fn every_discovered_port_is_probed_in_order() {
1543 assert_eq!(
1544 candidate_bases_with(None, vec![33875, 37435]),
1545 vec![
1546 "http://127.0.0.1:33875".to_string(),
1547 "http://127.0.0.1:37435".to_string(),
1548 ]
1549 );
1550 }
1551
1552 #[test]
1557 fn empty_discovery_yields_no_candidates() {
1558 assert!(candidate_bases_with(None, vec![]).is_empty());
1559 assert!(candidate_bases_with(Some(""), vec![]).is_empty());
1560 }
1561
1562 #[test]
1563 fn every_antigravity_product_is_recognised() {
1564 assert!(is_antigravity_process(
1566 "language_server\n",
1567 Some("/opt/antigravity/resources/bin/language_server")
1568 ));
1569 assert!(is_antigravity_process(
1571 "agy\n",
1572 Some("/home/u/.local/bin/agy")
1573 ));
1574 assert!(is_antigravity_process(
1576 "node",
1577 Some("/opt/antigravity/bin/helper")
1578 ));
1579 assert!(is_antigravity_process("antigravity", None));
1580 assert!(is_antigravity_process("agy.exe", None));
1581 assert!(is_antigravity_process("Antigravity.exe", None));
1582 assert!(is_antigravity_process("language_server.exe", None));
1583 assert!(is_antigravity_process(
1584 "language_server_windows_x64.exe",
1585 None
1586 ));
1587 assert!(is_antigravity_process(
1588 "node.exe",
1589 Some(r"C:\Users\u\AppData\Local\agy.exe")
1590 ));
1591 }
1592
1593 #[test]
1594 fn unrelated_processes_are_not_probed() {
1595 assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
1596 assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
1597 assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
1599 assert!(!is_antigravity_process("legacy.exe", None));
1600 assert!(!is_antigravity_process("not-agy.exe", None));
1601 assert!(!is_antigravity_process("", None));
1602 }
1603
1604 #[test]
1605 fn windows_process_names_decode_until_nul_and_tolerate_invalid_utf16() {
1606 let mut raw: Vec<u16> = "agy.exe".encode_utf16().collect();
1607 raw.extend([0, b'x' as u16]);
1608 assert_eq!(decode_windows_process_name(&raw), "agy.exe");
1609 assert_eq!(decode_windows_process_name(&[0xd800]), "�");
1610 assert_eq!(decode_windows_process_name(&[]), "");
1611 }
1612
1613 #[test]
1614 fn windows_process_filter_keeps_only_antigravity_pids() {
1615 let processes = vec![
1616 (10, "agy.exe".to_string()),
1617 (20, "language_server_windows_x64.exe".to_string()),
1618 (30, "sshd.exe".to_string()),
1619 ];
1620 let pids = matching_windows_process_ids(&processes);
1621 assert_eq!(pids, std::collections::HashSet::from([10, 20]));
1622 }
1623
1624 #[test]
1625 fn windows_listener_filter_joins_pid_loopback_and_port() {
1626 let pids = std::collections::HashSet::from([10]);
1627 let rows = [
1628 WindowsTcpRow {
1629 local_addr: [127, 0, 0, 1],
1630 local_port: u32::from(59870u16.to_be()),
1631 pid: 10,
1632 },
1633 WindowsTcpRow {
1634 local_addr: [127, 0, 0, 1],
1635 local_port: u32::from(59868u16.to_be()),
1636 pid: 10,
1637 },
1638 WindowsTcpRow {
1639 local_addr: [127, 0, 0, 1],
1640 local_port: u32::from(59870u16.to_be()),
1641 pid: 10,
1642 },
1643 WindowsTcpRow {
1644 local_addr: [0, 0, 0, 0],
1645 local_port: u32::from(50000u16.to_be()),
1646 pid: 10,
1647 },
1648 WindowsTcpRow {
1649 local_addr: [127, 0, 0, 1],
1650 local_port: u32::from(50001u16.to_be()),
1651 pid: 99,
1652 },
1653 WindowsTcpRow {
1654 local_addr: [127, 0, 0, 1],
1655 local_port: 0,
1656 pid: 10,
1657 },
1658 ];
1659 assert_eq!(matching_windows_ports(&pids, &rows), vec![59870, 59868]);
1660 }
1661
1662 #[test]
1666 fn windows_ports_from_two_products_keep_tls_listeners_last() {
1667 let pids = std::collections::HashSet::from([10, 20]);
1668 let row = |port: u16, pid: u32| WindowsTcpRow {
1669 local_addr: [127, 0, 0, 1],
1670 local_port: u32::from(port.to_be()),
1671 pid,
1672 };
1673 let rows = [
1674 row(40000, 10),
1675 row(40001, 10),
1676 row(50000, 20),
1677 row(50001, 20),
1678 ];
1679 assert_eq!(
1680 matching_windows_ports(&pids, &rows),
1681 vec![40001, 50001, 40000, 50000]
1682 );
1683 }
1684
1685 #[test]
1689 fn probe_order_puts_every_rpc_listener_ahead_of_every_tls_listener() {
1690 use std::collections::BTreeMap;
1691
1692 assert_eq!(
1694 probe_order(BTreeMap::from([(10, vec![59868, 59870])])),
1695 vec![59870, 59868]
1696 );
1697 assert_eq!(
1702 probe_order(BTreeMap::from([
1703 (10, vec![40000, 40001]),
1704 (20, vec![50000, 50001]),
1705 ])),
1706 vec![40001, 50001, 40000, 50000]
1707 );
1708 assert_eq!(
1711 probe_order(BTreeMap::from([
1712 (10, vec![6000, 5000, 4000]),
1713 (20, vec![6000, 7000]),
1714 ])),
1715 vec![6000, 7000, 5000, 4000]
1716 );
1717 assert!(probe_order(BTreeMap::new()).is_empty());
1718 }
1719
1720 #[test]
1725 fn a_port_named_twice_by_one_product_still_occupies_one_rank() {
1726 use std::collections::BTreeMap;
1727
1728 assert_eq!(
1729 probe_order(BTreeMap::from([
1730 (10, vec![40001, 40001, 40000, 40000]),
1731 (20, vec![50001, 50000]),
1732 ])),
1733 vec![40001, 50001, 40000, 50000],
1734 "duplicate rows must not reorder the ranks below them"
1735 );
1736 }
1737
1738 #[test]
1743 fn a_half_started_product_is_the_documented_exception() {
1744 use std::collections::BTreeMap;
1745
1746 assert_eq!(
1747 probe_order(BTreeMap::from([
1748 (10, vec![40000]),
1749 (20, vec![50001, 50000])
1750 ])),
1751 vec![40000, 50001, 50000]
1752 );
1753 }
1754
1755 #[test]
1760 fn an_override_with_no_authority_is_dropped_not_probed() {
1761 for junk in ["/", "///", "http://", "https://", " / "] {
1762 assert_eq!(
1763 candidate_bases_with(Some(junk), vec![4242]),
1764 vec!["http://127.0.0.1:4242".to_string()],
1765 "{junk:?} should not survive as a candidate"
1766 );
1767 }
1768 assert!(candidate_bases_with(Some("/"), vec![]).is_empty());
1769 }
1770
1771 #[test]
1772 fn windows_table_bounds_reject_truncation_and_overflow() {
1773 assert_eq!(checked_windows_row_count(52, 4, 24, 2), Some(2));
1774 assert_eq!(checked_windows_row_count(51, 4, 24, 2), None);
1775 assert_eq!(checked_windows_row_count(4, 4, 24, 0), Some(0));
1776 assert_eq!(checked_windows_row_count(52, 4, 0, 2), None);
1777 assert_eq!(
1778 checked_windows_row_count(usize::MAX, 4, 24, usize::MAX),
1779 None
1780 );
1781 }
1782
1783 #[cfg(target_os = "windows")]
1784 #[test]
1785 fn windows_tcp_table_parser_copies_complete_rows_only() {
1786 use std::mem::{offset_of, size_of};
1787 use windows_sys::Win32::NetworkManagement::IpHelper::{
1788 MIB_TCPROW_OWNER_PID, MIB_TCPTABLE_OWNER_PID,
1789 };
1790
1791 let offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table);
1792 let used = offset + 2 * size_of::<MIB_TCPROW_OWNER_PID>();
1793 let words = used.div_ceil(size_of::<u32>());
1794 let mut buffer = vec![0u32; words];
1795 let first = MIB_TCPROW_OWNER_PID {
1796 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
1797 dwLocalPort: u32::from(59868u16.to_be()),
1798 dwOwningPid: 10,
1799 ..Default::default()
1800 };
1801 let second = MIB_TCPROW_OWNER_PID {
1802 dwLocalAddr: u32::from_ne_bytes([127, 0, 0, 1]),
1803 dwLocalPort: u32::from(59870u16.to_be()),
1804 dwOwningPid: 10,
1805 ..Default::default()
1806 };
1807 unsafe {
1808 buffer.as_mut_ptr().write_unaligned(2);
1809 let rows = buffer
1810 .as_mut_ptr()
1811 .cast::<u8>()
1812 .add(offset)
1813 .cast::<MIB_TCPROW_OWNER_PID>();
1814 rows.write_unaligned(first);
1815 rows.add(1).write_unaligned(second);
1816 }
1817
1818 assert_eq!(
1819 parse_windows_tcp_rows(&buffer, used),
1820 vec![
1821 WindowsTcpRow {
1822 local_addr: [127, 0, 0, 1],
1823 local_port: u32::from(59868u16.to_be()),
1824 pid: 10,
1825 },
1826 WindowsTcpRow {
1827 local_addr: [127, 0, 0, 1],
1828 local_port: u32::from(59870u16.to_be()),
1829 pid: 10,
1830 },
1831 ]
1832 );
1833 assert!(parse_windows_tcp_rows(&buffer, used - 1).is_empty());
1834 }
1835
1836 #[test]
1837 fn lsof_parser_keeps_only_ports_owned_by_antigravity_processes() {
1838 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";
1841 assert_eq!(parse_lsof_pcn(output), vec![61290, 61289, 8829]);
1842 }
1843
1844 #[test]
1848 fn lsof_parser_keeps_each_products_ports_in_its_own_group() {
1849 let output = concat!(
1850 "p100\ncagy\nf3\nn127.0.0.1:40000\nf4\nn127.0.0.1:40001\n",
1851 "p200\nclanguage_server\nf5\nn127.0.0.1:50000\nf6\nn127.0.0.1:50001\n",
1852 );
1853 assert_eq!(
1854 parse_lsof_pcn(output),
1855 vec![40001, 50001, 40000, 50000],
1856 "both HTTP listeners must precede both TLS listeners"
1857 );
1858 }
1859
1860 #[test]
1861 fn lsof_parser_matches_the_capitalised_macos_app_name() {
1862 let output = "p900\ncAntigravity\nf7\nn127.0.0.1:54321\n";
1863 assert_eq!(parse_lsof_pcn(output), vec![54321]);
1864 }
1865
1866 #[test]
1867 fn lsof_parser_deduplicates_and_handles_empty_output() {
1868 let output = "p1\ncagy\nf3\nn127.0.0.1:9000\nf4\nn127.0.0.1:9000\n";
1869 assert_eq!(parse_lsof_pcn(output), vec![9000]);
1870 assert!(parse_lsof_pcn("").is_empty());
1871 }
1872
1873 #[test]
1876 fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
1877 let dir = tempfile::tempdir().unwrap();
1878 let cache = Cache::at(dir.path().join("usage.json"));
1879 let reason = AppError::Credentials("Antigravity: no local language server found".into());
1880
1881 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1882 let msg = err.to_string();
1883 assert!(msg.contains("no local language server found"), "{msg}");
1884 assert!(!msg.contains("no usable cache"), "{msg}");
1885 }
1886
1887 #[test]
1888 fn unusable_cache_does_not_replace_the_live_diagnosis() {
1889 let dir = tempfile::tempdir().unwrap();
1890 let cache = Cache::at(dir.path().join("antigravity"));
1891 cache.write_payload(b"{}").unwrap();
1892
1893 let reason = AppError::Credentials("Antigravity must be running".into());
1894 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1895 assert!(err.to_string().contains("must be running"), "{err}");
1896
1897 let original = AppError::Transport("original loopback failure".into());
1898 let err = fallback_silent(&cache, now(), original).unwrap_err();
1899 assert!(
1900 err.to_string().contains("original loopback failure"),
1901 "{err}"
1902 );
1903 }
1904
1905 #[tokio::test]
1906 async fn rpc_error_bodies_are_bounded_too() {
1907 let mut server = mockito::Server::new_async().await;
1908 let path = format!("/{STATUS_RPC}");
1909 server
1910 .mock("POST", path.as_str())
1911 .with_status(500)
1912 .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
1913 .create_async()
1914 .await;
1915
1916 let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
1917 .await
1918 .unwrap_err();
1919 assert!(err.to_string().contains("exceeds"), "{err}");
1920 }
1921
1922 #[test]
1923 fn blank_override_falls_through_to_discovery() {
1924 assert_eq!(
1925 candidate_bases_with(Some(" "), vec![4242]),
1926 vec!["http://127.0.0.1:4242".to_string()]
1927 );
1928 }
1929}