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();
423 if comm.contains("language_server") || comm == "agy" || comm == "antigravity" {
424 return true;
425 }
426 exe.is_some_and(|p| p.contains("antigravity") || p.ends_with("/agy"))
427}
428
429#[cfg(target_os = "linux")]
436fn discover_ls_ports() -> Vec<u16> {
437 use std::collections::HashSet;
438
439 let mut inodes: HashSet<u64> = HashSet::new();
440 let Ok(entries) = std::fs::read_dir("/proc") else {
441 return Vec::new();
442 };
443 for entry in entries.flatten() {
444 let pid_dir = entry.path();
445 let Ok(comm) = std::fs::read_to_string(pid_dir.join("comm")) else {
446 continue;
447 };
448 let exe = std::fs::read_link(pid_dir.join("exe")).ok();
449 if !is_antigravity_process(&comm, exe.as_deref().and_then(|p| p.to_str())) {
450 continue;
451 }
452 let Ok(fds) = std::fs::read_dir(pid_dir.join("fd")) else {
453 continue;
454 };
455 for fd in fds.flatten() {
456 let Ok(target) = std::fs::read_link(fd.path()) else {
457 continue;
458 };
459 if let Some(ino) = target
460 .to_str()
461 .and_then(|s| s.strip_prefix("socket:["))
462 .and_then(|s| s.strip_suffix(']'))
463 .and_then(|s| s.parse::<u64>().ok())
464 {
465 inodes.insert(ino);
466 }
467 }
468 }
469
470 if inodes.is_empty() {
471 return Vec::new();
472 }
473
474 let mut ports = Vec::new();
475 for table in ["/proc/net/tcp", "/proc/net/tcp6"] {
476 let Ok(contents) = std::fs::read_to_string(table) else {
477 continue;
478 };
479 for line in contents.lines().skip(1) {
480 if let Some((port, ino)) = parse_proc_net_line(line)
481 && inodes.contains(&ino)
482 && !ports.contains(&port)
483 {
484 ports.push(port);
485 }
486 }
487 }
488 ports
489}
490
491#[cfg(not(target_os = "linux"))]
492fn discover_ls_ports() -> Vec<u16> {
493 Vec::new()
494}
495
496#[cfg(target_os = "linux")]
499fn parse_proc_net_line(line: &str) -> Option<(u16, u64)> {
500 let cols: Vec<&str> = line.split_whitespace().collect();
501 if cols.len() < 10 {
502 return None;
503 }
504 if cols[3] != "0A" {
506 return None;
507 }
508 let port = u16::from_str_radix(cols[1].split(':').nth(1)?, 16).ok()?;
509 let inode = cols[9].parse::<u64>().ok()?;
510 Some((port, inode))
511}
512
513fn fallback_silent(cache: &Cache, now: DateTime<Utc>, original: AppError) -> Result<FetchOutcome> {
518 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
519 return Err(original);
520 };
521 reuse_cache(bytes, cache, true, None, now).or(Err(original))
522}
523
524fn fallback_with_error(
529 cache: &Cache,
530 last_error: Option<(u16, String)>,
531 reason: AppError,
532 now: DateTime<Utc>,
533) -> Result<FetchOutcome> {
534 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
535 return Err(reason);
536 };
537 let Ok(mut outcome) = reuse_cache(bytes, cache, true, None, now) else {
538 return Err(reason);
539 };
540 outcome.last_error = last_error;
541 Ok(outcome)
542}
543
544fn reuse_cache(
545 bytes: Vec<u8>,
546 cache: &Cache,
547 stale: bool,
548 account: Option<&str>,
549 now: DateTime<Utc>,
550) -> Result<FetchOutcome> {
551 let snap = parse_cache_at(&bytes, account, now)?;
552 Ok(FetchOutcome {
553 snapshot: snap,
554 stale,
555 last_error: cache.read_last_error(),
556 cache_age: cache.payload_age(),
557 })
558}
559
560pub fn parse_cache(bytes: &[u8], account: Option<&str>) -> Result<AntigravitySnapshot> {
566 parse_cache_at(bytes, account, Utc::now())
567}
568
569fn expired_window(snap: &AntigravitySnapshot, now: DateTime<Utc>) -> Option<&'static str> {
579 [
580 ("Gemini 5h", Some(&snap.session)),
581 ("Gemini weekly", Some(&snap.weekly)),
582 ("Claude & GPT OSS 5h", snap.third_party_session.as_ref()),
583 ("Claude & GPT OSS weekly", snap.third_party_weekly.as_ref()),
584 ]
585 .into_iter()
586 .find(|(_, w)| w.and_then(|w| w.resets_at).is_some_and(|r| r <= now))
587 .map(|(name, _)| name)
588}
589
590pub fn parse_cache_at(
591 bytes: &[u8],
592 account: Option<&str>,
593 now: DateTime<Utc>,
594) -> Result<AntigravitySnapshot> {
595 let v: serde_json::Value = serde_json::from_slice(bytes)?;
596
597 let cached_account = v.get("account").and_then(serde_json::Value::as_str);
598 if let Some(expected) = account
599 && cached_account != Some(expected)
600 {
601 return Err(AppError::Schema(
602 "antigravity cache belongs to a different account; refetching".into(),
603 ));
604 }
605
606 let cached_pct = |pct_key: &'static str| -> Result<Option<i32>> {
611 match v.get(pct_key) {
612 None | Some(serde_json::Value::Null) => Ok(None),
613 Some(value) => value
614 .as_i64()
615 .filter(|pct| (0..=100).contains(pct))
616 .map(|pct| Some(pct as i32))
617 .ok_or_else(|| {
618 AppError::Schema(format!(
619 "antigravity: cached {pct_key} must be an integer in 0..=100"
620 ))
621 }),
622 }
623 };
624
625 let window = |pct_key: &'static str, reset_key: &str, weekly: bool| {
626 let pct = cached_pct(pct_key)?.ok_or_else(|| {
627 AppError::Schema(format!("antigravity: cached payload missing {pct_key}"))
628 })?;
629 Ok::<_, AppError>(UsageWindow {
630 utilization_pct: pct,
631 resets_at: parse_reset(&v[reset_key], reset_key)?,
632 window_duration: if weekly {
633 chrono::Duration::days(7)
634 } else {
635 chrono::Duration::hours(5)
636 },
637 })
638 };
639
640 let optional = |pct_key: &'static str, reset_key: &str, weekly: bool| {
641 let Some(pct) = cached_pct(pct_key)? else {
642 return Ok(None);
643 };
644 Ok::<_, AppError>(Some(UsageWindow {
645 utilization_pct: pct,
646 resets_at: parse_reset(&v[reset_key], reset_key)?,
647 window_duration: if weekly {
648 chrono::Duration::days(7)
649 } else {
650 chrono::Duration::hours(5)
651 },
652 }))
653 };
654
655 let snap = AntigravitySnapshot {
656 plan: v["plan"].as_str().unwrap_or(DEFAULT_PLAN).to_string(),
657 account: cached_account.unwrap_or_default().to_string(),
658 session: window("session_pct", "session_reset", false)?,
659 weekly: window("weekly_pct", "weekly_reset", true)?,
660 third_party_session: optional("tp_session_pct", "tp_session_reset", false)?,
661 third_party_weekly: optional("tp_weekly_pct", "tp_weekly_reset", true)?,
662 };
663
664 if let Some(window) = expired_window(&snap, now) {
665 return Err(AppError::Schema(format!(
666 "antigravity cache is past its {window} reset; refetching"
667 )));
668 }
669 Ok(snap)
670}
671
672pub fn snap_to_json(snap: &AntigravitySnapshot) -> serde_json::Value {
673 serde_json::json!({
674 "plan": snap.plan,
675 "account": snap.account,
676 "session_pct": snap.session.utilization_pct,
677 "session_reset": snap.session.resets_at.map(|dt| dt.to_rfc3339()),
678 "weekly_pct": snap.weekly.utilization_pct,
679 "weekly_reset": snap.weekly.resets_at.map(|dt| dt.to_rfc3339()),
680 "tp_session_pct": snap.third_party_session.as_ref().map(|w| w.utilization_pct),
681 "tp_session_reset": snap.third_party_session.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
682 "tp_weekly_pct": snap.third_party_weekly.as_ref().map(|w| w.utilization_pct),
683 "tp_weekly_reset": snap.third_party_weekly.as_ref().and_then(|w| w.resets_at.map(|dt| dt.to_rfc3339())),
684 })
685}
686
687#[cfg(test)]
688mod tests {
689 use super::*;
690
691 const QUOTA_JSON: &str = r#"{
695 "response": {
696 "groups": [
697 {
698 "displayName": "Gemini Models",
699 "buckets": [
700 {"bucketId": "gemini-weekly", "displayName": "Weekly Limit",
701 "window": "weekly", "remainingFraction": 0.9191212,
702 "resetTime": "2026-07-28T17:39:58Z"},
703 {"bucketId": "gemini-5h", "displayName": "Five Hour Limit",
704 "window": "5h", "remainingFraction": 0.5672253,
705 "resetTime": "2026-07-22T17:47:00Z"}
706 ]
707 },
708 {
709 "displayName": "Claude and GPT models",
710 "buckets": [
711 {"bucketId": "3p-weekly", "window": "weekly",
712 "remainingFraction": 1, "resetTime": "2026-07-29T12:47:00Z"},
713 {"bucketId": "3p-5h", "window": "5h",
714 "remainingFraction": 0.25, "resetTime": "2026-07-22T17:47:00Z"}
715 ]
716 }
717 ]
718 }
719 }"#;
720
721 fn now() -> DateTime<Utc> {
724 DateTime::parse_from_rfc3339("2026-07-22T12:00:00Z")
725 .unwrap()
726 .with_timezone(&Utc)
727 }
728
729 fn parsed() -> AntigravitySnapshot {
730 let v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
731 parse_quota_summary(&v, "Google AI Pro".into()).unwrap()
732 }
733
734 #[test]
735 fn quota_summary_maps_four_distinct_windows() {
736 let snap = parsed();
737 assert_eq!(snap.plan, "Google AI Pro");
738 assert_eq!(snap.session.utilization_pct, 43);
740 assert_eq!(snap.weekly.utilization_pct, 8);
741 assert_eq!(
742 snap.third_party_session.as_ref().unwrap().utilization_pct,
743 75
744 );
745 assert_eq!(snap.third_party_weekly.as_ref().unwrap().utilization_pct, 0);
746 }
747
748 #[test]
749 fn each_window_keeps_its_own_reset_time() {
750 let snap = parsed();
751 let at = |s: &str| Some(DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc));
752 assert_eq!(snap.session.resets_at, at("2026-07-22T17:47:00Z"));
753 assert_eq!(snap.weekly.resets_at, at("2026-07-28T17:39:58Z"));
754 assert_eq!(
755 snap.third_party_weekly.as_ref().unwrap().resets_at,
756 at("2026-07-29T12:47:00Z")
757 );
758 assert_ne!(snap.session.resets_at, snap.weekly.resets_at);
760 }
761
762 #[test]
763 fn window_durations_match_their_bucket() {
764 let snap = parsed();
765 assert_eq!(snap.session.window_duration, chrono::Duration::hours(5));
766 assert_eq!(snap.weekly.window_duration, chrono::Duration::days(7));
767 assert_eq!(
768 snap.third_party_weekly.as_ref().unwrap().window_duration,
769 chrono::Duration::days(7)
770 );
771 }
772
773 #[test]
774 fn groups_are_matched_by_display_name_when_bucket_ids_change() {
775 let v: serde_json::Value = serde_json::from_str(
776 r#"{"response":{"groups":[
777 {"displayName":"Gemini Models","buckets":[
778 {"bucketId":"x1","window":"5h","remainingFraction":0.5,"resetTime":"2026-07-22T17:47:00Z"},
779 {"bucketId":"x2","window":"weekly","remainingFraction":0.9,"resetTime":"2026-07-28T17:39:58Z"}]},
780 {"displayName":"Claude and GPT models","buckets":[
781 {"bucketId":"y1","window":"5h","remainingFraction":0.0,"resetTime":"2026-07-22T17:47:00Z"}]}
782 ]}}"#,
783 )
784 .unwrap();
785 let snap = parse_quota_summary(&v, "Pro".into()).unwrap();
786 assert_eq!(snap.session.utilization_pct, 50);
787 assert_eq!(snap.weekly.utilization_pct, 10);
788 assert_eq!(snap.third_party_session.unwrap().utilization_pct, 100);
789 assert!(snap.third_party_weekly.is_none());
790 }
791
792 #[test]
793 fn duplicate_or_unclassified_buckets_cannot_overwrite_a_slot() {
794 let duplicate: serde_json::Value = serde_json::from_str(
795 r#"{"response":{"groups":[{"displayName":"Gemini Models","buckets":[
796 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
797 {"bucketId":"gemini-5h-copy","window":"5h","remainingFraction":0.1},
798 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8}
799 ]}]}}"#,
800 )
801 .unwrap();
802 let err = parse_quota_summary(&duplicate, "Pro".into()).unwrap_err();
803 assert!(err.to_string().contains("duplicate Gemini 5h"), "{err}");
804
805 let unrelated: serde_json::Value = serde_json::from_str(
808 r#"{"response":{"groups":[
809 {"displayName":"Gemini Models","buckets":[
810 {"bucketId":"gemini-5h","window":"5h","remainingFraction":0.9},
811 {"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.8},
812 {"bucketId":"gemini-monthly","window":"monthly","remainingFraction":0.7}
813 ]},
814 {"displayName":"Future Models","buckets":[
815 {"bucketId":"future-5h","window":"5h","remainingFraction":0.1}
816 ]}
817 ]}}"#,
818 )
819 .unwrap();
820 let snap = parse_quota_summary(&unrelated, "Pro".into()).unwrap();
821 assert!(snap.third_party_session.is_none());
822 assert!(snap.third_party_weekly.is_none());
823 }
824
825 #[test]
828 fn a_bucket_without_a_usable_fraction_is_rejected() {
829 for bad in [r#""oops""#, "null", "-0.01", "1.01"] {
830 let v: serde_json::Value = serde_json::from_str(&format!(
831 r#"{{"response":{{"groups":[{{"displayName":"Gemini Models","buckets":[
832 {{"bucketId":"gemini-5h","window":"5h","remainingFraction":{bad}}},
833 {{"bucketId":"gemini-weekly","window":"weekly","remainingFraction":0.9}}]}}]}}}}"#
834 ))
835 .unwrap();
836 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
837 assert!(err.to_string().contains("gemini-5h"), "{bad}: {err}");
838 }
839 }
840
841 #[test]
842 fn malformed_present_reset_is_rejected_instead_of_disabling_expiry() {
843 for bad in [serde_json::json!("not-a-time"), serde_json::json!(42)] {
844 let mut v: serde_json::Value = serde_json::from_str(QUOTA_JSON).unwrap();
845 v["response"]["groups"][0]["buckets"][0]["resetTime"] = bad;
846 let err = parse_quota_summary(&v, "Pro".into()).unwrap_err();
847 assert!(err.to_string().contains("resetTime"), "{err}");
848 }
849 }
850
851 #[test]
852 fn missing_gemini_buckets_is_an_error_not_a_zero_bar() {
853 let v: serde_json::Value = serde_json::from_str(r#"{"response":{"groups":[]}}"#).unwrap();
854 assert!(parse_quota_summary(&v, "Pro".into()).is_err());
855 }
856
857 #[test]
858 fn cache_round_trip_preserves_every_window() {
859 let snap = parsed();
860 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
861 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
862 }
863
864 #[test]
868 fn a_truncated_cached_payload_is_rejected_not_zeroed() {
869 let full = snap_to_json(&parsed());
870 for missing in ["session_pct", "weekly_pct"] {
871 let mut v = full.clone();
872 v.as_object_mut().unwrap().remove(missing);
873 let bytes = serde_json::to_vec(&v).unwrap();
874 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
875 assert!(err.to_string().contains(missing), "{missing}: {err}");
876 }
877 assert!(parse_cache_at(b"{}", None, now()).is_err());
879 }
880
881 #[test]
882 fn cached_percentages_are_range_checked_before_narrowing() {
883 let full = snap_to_json(&parsed());
884 for (key, bad) in [
885 ("session_pct", serde_json::json!(-1)),
886 ("weekly_pct", serde_json::json!(101)),
887 ("session_pct", serde_json::json!(i64::MAX)),
888 ("tp_session_pct", serde_json::json!("75")),
889 ] {
890 let mut v = full.clone();
891 v[key] = bad;
892 let bytes = serde_json::to_vec(&v).unwrap();
893 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
894 assert!(err.to_string().contains(key), "{key}: {err}");
895 }
896 }
897
898 #[test]
899 fn malformed_cached_reset_is_rejected_instead_of_served_for_a_week() {
900 let mut v = snap_to_json(&parsed());
901 v["session_reset"] = serde_json::json!("not-a-time");
902 let bytes = serde_json::to_vec(&v).unwrap();
903 let err = parse_cache_at(&bytes, None, now()).unwrap_err();
904 assert!(err.to_string().contains("session_reset"), "{err}");
905 }
906
907 #[test]
912 fn a_cache_past_its_reset_is_refused() {
913 let bytes = serde_json::to_vec(&snap_to_json(&parsed())).unwrap();
914 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
915
916 assert!(parse_cache_at(&bytes, None, now()).is_ok());
918 assert!(parse_cache_at(&bytes, None, at("2026-07-22T17:46:59Z")).is_ok());
920
921 let err = parse_cache_at(&bytes, None, at("2026-07-22T17:47:00Z")).unwrap_err();
923 assert!(err.to_string().contains("5h"), "{err}");
924
925 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_err());
927 }
928
929 #[test]
932 fn expiry_names_the_window_that_rolled_over() {
933 let mut snap = parsed();
934 snap.session.resets_at = None;
936 snap.third_party_session = None;
937 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
938 let at = |s: &str| DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc);
939
940 assert!(parse_cache_at(&bytes, None, at("2026-07-23T09:00:00Z")).is_ok());
942
943 let err = parse_cache_at(&bytes, None, at("2026-07-28T18:00:00Z")).unwrap_err();
945 assert!(err.to_string().contains("Gemini weekly"), "{err}");
946 }
947
948 #[test]
950 fn a_window_without_a_reset_never_expires() {
951 let mut snap = parsed();
952 for w in [&mut snap.session, &mut snap.weekly] {
953 w.resets_at = None;
954 }
955 snap.third_party_session = None;
956 snap.third_party_weekly = None;
957 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
958 let far_future = DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
959 .unwrap()
960 .with_timezone(&Utc);
961 assert!(parse_cache_at(&bytes, None, far_future).is_ok());
962 }
963
964 #[test]
966 fn a_cache_from_another_account_is_rejected() {
967 let mut snap = parsed();
968 snap.account = "acct:aaaa".into();
969 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
970
971 assert!(parse_cache_at(&bytes, Some("acct:bbbb"), now()).is_err());
972 assert_eq!(
973 parse_cache_at(&bytes, Some("acct:aaaa"), now()).unwrap(),
974 snap
975 );
976
977 let mut legacy = snap_to_json(&snap);
979 legacy.as_object_mut().unwrap().remove("account");
980 let legacy = serde_json::to_vec(&legacy).unwrap();
981 assert!(parse_cache_at(&legacy, Some("acct:aaaa"), now()).is_err());
982 }
983
984 #[test]
987 fn an_unverifiable_cache_is_served_rather_than_discarded() {
988 let mut snap = parsed();
989 snap.account = "acct:aaaa".into();
990 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
991 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
992 }
993
994 #[test]
995 fn account_key_fingerprints_rather_than_storing_the_address() {
996 let with = |email: &str| account_key(&serde_json::json!({"userStatus": {"email": email}}));
997 let a = with("someone@example.com");
998 assert!(!a.contains("someone"), "{a}");
999 assert!(!a.contains('@'), "{a}");
1000 assert_eq!(a, with("someone@example.com"), "must be stable");
1001 assert_ne!(a, with("other@example.com"));
1002 let unknown = account_key(&serde_json::json!({}));
1004 assert_eq!(unknown, account_key(&serde_json::json!({"userStatus": {}})));
1005 assert_ne!(unknown, a);
1006 }
1007
1008 #[test]
1011 fn absent_third_party_windows_are_not_treated_as_corruption() {
1012 let mut snap = parsed();
1013 snap.third_party_session = None;
1014 snap.third_party_weekly = None;
1015 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1016 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1017 }
1018
1019 #[test]
1020 fn cache_round_trip_preserves_absent_third_party_windows() {
1021 let mut snap = parsed();
1022 snap.third_party_session = None;
1023 snap.third_party_weekly = None;
1024 let bytes = serde_json::to_vec(&snap_to_json(&snap)).unwrap();
1025 assert_eq!(parse_cache_at(&bytes, None, now()).unwrap(), snap);
1026 }
1027
1028 #[test]
1029 fn pct_used_inverts_valid_fractions() {
1030 assert_eq!(pct_used(1.0), 0);
1031 assert_eq!(pct_used(0.0), 100);
1032 assert_eq!(pct_used(0.5), 50);
1033 }
1034
1035 #[test]
1036 fn plan_falls_back_through_the_status_payload() {
1037 let tier: serde_json::Value =
1038 serde_json::from_str(r#"{"userStatus":{"userTier":{"name":"Google AI Pro"}}}"#)
1039 .unwrap();
1040 assert_eq!(plan_from_status(&tier), "Google AI Pro");
1041
1042 let plan_only: serde_json::Value = serde_json::from_str(
1043 r#"{"userStatus":{"planStatus":{"planInfo":{"planName":"Pro"}}}}"#,
1044 )
1045 .unwrap();
1046 assert_eq!(plan_from_status(&plan_only), "Pro");
1047
1048 let empty: serde_json::Value = serde_json::from_str("{}").unwrap();
1049 assert_eq!(plan_from_status(&empty), DEFAULT_PLAN);
1050 }
1051
1052 #[cfg(target_os = "linux")]
1053 #[test]
1054 fn proc_net_parser_keeps_only_listening_rows() {
1055 let listen = " 0: 0100007F:975B 00000000:0000 0A 00000000:00000000 \
1056 00:00000000 00000000 1000 0 123456 1 0000 100 0";
1057 assert_eq!(parse_proc_net_line(listen), Some((38747, 123456)));
1058
1059 let established = " 1: 0100007F:975B 0100007F:A1B2 01 00000000:00000000 \
1060 00:00000000 00000000 1000 0 123457 1 0000 100 0";
1061 assert_eq!(parse_proc_net_line(established), None);
1062
1063 assert_eq!(parse_proc_net_line("garbage"), None);
1064 }
1065
1066 #[test]
1067 fn explicit_address_wins_and_gets_a_scheme() {
1068 assert_eq!(
1069 candidate_bases_with(Some("127.0.0.1:1234"), vec![5678]),
1070 vec!["http://127.0.0.1:1234".to_string()]
1071 );
1072 assert_eq!(
1074 candidate_bases_with(Some("https://host:9"), vec![]),
1075 vec!["https://host:9".to_string()]
1076 );
1077 }
1078
1079 #[test]
1080 fn every_discovered_port_is_probed_in_order() {
1081 assert_eq!(
1082 candidate_bases_with(None, vec![33875, 37435]),
1083 vec![
1084 "http://127.0.0.1:33875".to_string(),
1085 "http://127.0.0.1:37435".to_string(),
1086 ]
1087 );
1088 }
1089
1090 #[test]
1095 fn empty_discovery_yields_no_candidates() {
1096 assert!(candidate_bases_with(None, vec![]).is_empty());
1097 assert!(candidate_bases_with(Some(""), vec![]).is_empty());
1098 }
1099
1100 #[test]
1101 fn every_antigravity_product_is_recognised() {
1102 assert!(is_antigravity_process(
1104 "language_server\n",
1105 Some("/opt/antigravity/resources/bin/language_server")
1106 ));
1107 assert!(is_antigravity_process(
1109 "agy\n",
1110 Some("/home/u/.local/bin/agy")
1111 ));
1112 assert!(is_antigravity_process(
1114 "node",
1115 Some("/opt/antigravity/bin/helper")
1116 ));
1117 assert!(is_antigravity_process("antigravity", None));
1118 }
1119
1120 #[test]
1121 fn unrelated_processes_are_not_probed() {
1122 assert!(!is_antigravity_process("sshd", Some("/usr/sbin/sshd")));
1123 assert!(!is_antigravity_process("node", Some("/usr/bin/node")));
1124 assert!(!is_antigravity_process("legacy", Some("/usr/bin/legacy")));
1126 assert!(!is_antigravity_process("", None));
1127 }
1128
1129 #[test]
1132 fn missing_cache_surfaces_the_diagnosis_not_a_cache_miss() {
1133 let dir = tempfile::tempdir().unwrap();
1134 let cache = Cache::at(dir.path().join("usage.json"));
1135 let reason = AppError::Credentials("Antigravity: no local language server found".into());
1136
1137 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1138 let msg = err.to_string();
1139 assert!(msg.contains("no local language server found"), "{msg}");
1140 assert!(!msg.contains("no usable cache"), "{msg}");
1141 }
1142
1143 #[test]
1144 fn unusable_cache_does_not_replace_the_live_diagnosis() {
1145 let dir = tempfile::tempdir().unwrap();
1146 let cache = Cache::at(dir.path().join("antigravity"));
1147 cache.write_payload(b"{}").unwrap();
1148
1149 let reason = AppError::Credentials("Antigravity must be running".into());
1150 let err = fallback_with_error(&cache, None, reason, now()).unwrap_err();
1151 assert!(err.to_string().contains("must be running"), "{err}");
1152
1153 let original = AppError::Transport("original loopback failure".into());
1154 let err = fallback_silent(&cache, now(), original).unwrap_err();
1155 assert!(
1156 err.to_string().contains("original loopback failure"),
1157 "{err}"
1158 );
1159 }
1160
1161 #[tokio::test]
1162 async fn rpc_error_bodies_are_bounded_too() {
1163 let mut server = mockito::Server::new_async().await;
1164 let path = format!("/{STATUS_RPC}");
1165 server
1166 .mock("POST", path.as_str())
1167 .with_status(500)
1168 .with_body("x".repeat(crate::vendor::MAX_BODY_BYTES + 1))
1169 .create_async()
1170 .await;
1171
1172 let err = post_rpc(&reqwest::Client::new(), &server.url(), None, STATUS_RPC)
1173 .await
1174 .unwrap_err();
1175 assert!(err.to_string().contains("exceeds"), "{err}");
1176 }
1177
1178 #[test]
1179 fn blank_override_falls_through_to_discovery() {
1180 assert_eq!(
1181 candidate_bases_with(Some(" "), vec![4242]),
1182 vec!["http://127.0.0.1:4242".to_string()]
1183 );
1184 }
1185}