1use chrono::{DateTime, Duration, Utc};
8use serde_json::Value;
9
10use crate::error::{AppError, Result};
11use crate::usage::{DroidSnapshot, UsageWindow};
12
13pub const SESSION_WINDOW: Duration = Duration::hours(5);
14pub const WEEKLY_WINDOW: Duration = Duration::days(7);
15pub const MONTHLY_WINDOW: Duration = Duration::days(30);
16
17pub fn parse_response(bytes: &[u8]) -> Result<DroidSnapshot> {
18 let root: Value = serde_json::from_slice(bytes)
19 .map_err(|_| AppError::Schema("Droid limits response was not valid JSON".into()))?;
20 let limits = root
21 .get("limits")
22 .and_then(Value::as_object)
23 .ok_or_else(|| AppError::Schema("Droid limits response omitted `limits`".into()))?;
24 if !limits.contains_key("standard") && !limits.contains_key("core") {
25 return Err(AppError::Schema(
26 "Droid limits response contained no recognized quota buckets".into(),
27 ));
28 }
29 let bucket = |name: &str, duration: (Duration, Duration, Duration)| -> Result<_> {
30 Ok(match limits.get(name) {
31 None | Some(Value::Null) => (None, None, None),
32 Some(bucket) => (
33 window(bucket.get("fiveHour"), duration.0)?,
34 window(bucket.get("weekly"), duration.1)?,
35 window(bucket.get("monthly"), duration.2)?,
36 ),
37 })
38 };
39 let (session, weekly, monthly) =
40 bucket("standard", (SESSION_WINDOW, WEEKLY_WINDOW, MONTHLY_WINDOW))?;
41 let (core_session, core_weekly, core_monthly) =
42 bucket("core", (SESSION_WINDOW, WEEKLY_WINDOW, MONTHLY_WINDOW))?;
43 let extra_balance_cents = match root.get("extraUsageBalanceCents") {
44 None | Some(Value::Null) => None,
45 Some(value) => Some(
46 value
47 .as_i64()
48 .ok_or_else(|| drift("extraUsageBalanceCents"))?,
49 ),
50 };
51 Ok(DroidSnapshot {
52 session,
53 weekly,
54 monthly,
55 core_session,
56 core_weekly,
57 core_monthly,
58 extra_balance_cents,
59 })
60}
61
62fn window(value: Option<&Value>, duration: Duration) -> Result<Option<UsageWindow>> {
66 let Some(value) = value else { return Ok(None) };
67 if value.is_null() {
68 return Ok(None);
69 }
70 let used = value
71 .get("usedPercent")
72 .and_then(Value::as_i64)
73 .filter(|pct| (0..=100).contains(pct))
74 .ok_or_else(|| drift("usedPercent"))? as i32;
75 let resets_at = match value.get("windowEnd") {
76 None | Some(Value::Null) => None,
77 Some(Value::String(text)) => Some(
78 DateTime::parse_from_rfc3339(text)
79 .map(|at| at.with_timezone(&Utc))
80 .map_err(|_| drift("windowEnd"))?,
81 ),
82 Some(_) => return Err(drift("windowEnd")),
83 };
84 Ok(Some(UsageWindow {
85 utilization_pct: used,
86 resets_at,
87 window_duration: duration,
88 }))
89}
90
91fn drift(field: &str) -> AppError {
92 AppError::Schema(format!(
93 "Droid limits field {field} had an unsupported value"
94 ))
95}
96
97#[cfg(test)]
98mod tests {
99 use super::*;
100
101 const LIVE: &str = r#"{
103 "usesTokenRateLimitsBilling": true,
104 "limits": {
105 "standard": {
106 "fiveHour": {"usedPercent": 2, "windowEnd": "2026-10-22T01:38:45.109Z", "secondsRemaining": 16146},
107 "weekly": {"usedPercent": 1, "windowEnd": "2026-10-28T20:38:45.109Z", "secondsRemaining": 602946},
108 "monthly": {"usedPercent": 1, "windowEnd": "2026-11-20T20:38:45.109Z", "secondsRemaining": 2590146}
109 },
110 "core": {
111 "fiveHour": {"usedPercent": 0, "windowEnd": null, "secondsRemaining": null},
112 "weekly": {"usedPercent": 0, "windowEnd": null, "secondsRemaining": null},
113 "monthly": {"usedPercent": 0, "windowEnd": null, "secondsRemaining": null}
114 }
115 },
116 "overagePreference": "droidCore",
117 "canManageOverage": true,
118 "extraUsageBalanceCents": 0,
119 "extraUsageAllowed": true
120 }"#;
121
122 #[test]
123 fn the_observed_response_projects_both_buckets_and_the_balance() {
124 let snap = parse_response(LIVE.as_bytes()).unwrap();
125 let session = snap.session.as_ref().unwrap();
126 assert_eq!(session.utilization_pct, 2);
127 assert_eq!(
128 session.resets_at.unwrap().timestamp(),
129 DateTime::parse_from_rfc3339("2026-10-22T01:38:45.109Z")
130 .unwrap()
131 .timestamp()
132 );
133 assert_eq!(session.window_duration, SESSION_WINDOW);
134 let weekly = snap.weekly.as_ref().unwrap();
135 assert_eq!(weekly.utilization_pct, 1);
136 assert_eq!(weekly.window_duration, WEEKLY_WINDOW);
137 assert_eq!(
138 snap.monthly.as_ref().unwrap().window_duration,
139 MONTHLY_WINDOW
140 );
141 let core = snap.core_session.as_ref().unwrap();
143 assert_eq!(core.utilization_pct, 0);
144 assert_eq!(core.resets_at, None);
145 assert_eq!(snap.extra_balance_cents, Some(0));
146 }
147
148 #[test]
149 fn an_absent_window_or_bucket_stays_absent() {
150 let snap = parse_response(
151 br#"{"limits": {"standard": {"fiveHour": {"usedPercent": 40, "windowEnd": "2026-10-22T01:38:45.109Z"}}}, "extraUsageBalanceCents": 250}"#,
152 )
153 .unwrap();
154 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 40);
155 assert!(snap.weekly.is_none());
156 assert!(snap.monthly.is_none());
157 assert!(snap.core_session.is_none());
158 assert_eq!(snap.extra_balance_cents, Some(250));
159 }
160
161 #[test]
162 fn invalid_values_and_envelopes_are_schema_errors() {
163 for body in [
164 br#"{"limits":{"standard":{"fiveHour":{"usedPercent":-1}}}}"#.as_slice(),
165 br#"{"limits":{"standard":{"fiveHour":{"usedPercent":101}}}}"#.as_slice(),
166 br#"{"limits":{"standard":{"fiveHour":{"usedPercent":"5"}}}}"#.as_slice(),
167 br#"{"limits":{"standard":{"fiveHour":{"usedPercent":5,"windowEnd":12}}}}"#.as_slice(),
168 br#"{"limits":{"standard":{"fiveHour":{"usedPercent":5,"windowEnd":"soon"}}}}"#
169 .as_slice(),
170 br#"{"limits":{"standard":{"fiveHour":{}}}}"#.as_slice(),
171 br#"{"limits":{"extraUsageBalanceCents":"5"}}"#.as_slice(),
172 br#"{"limits":{}}"#.as_slice(),
173 br#"{"other":1}"#.as_slice(),
174 b"<html>secret</html>".as_slice(),
175 ] {
176 assert!(
177 matches!(parse_response(body), Err(AppError::Schema(_))),
178 "{body:?}"
179 );
180 }
181 }
182}