1use chrono::{DateTime, Utc};
6use serde::Deserialize;
7
8use crate::error::{AppError, Result};
9use crate::usage::GrokbotSnapshot;
10
11#[derive(Debug, Clone, Deserialize, Default)]
12#[serde(default, rename_all = "camelCase")]
13pub struct SandUsageStatus {
14 current_period_start: Option<String>,
15 next_reset_timestamp_utc: Option<String>,
16 usage_percent: Option<PercentOrString>,
18 has_available_usage: bool,
19 has_non_zero_included_limit: bool,
20 on_demand_settings: OnDemandSettings,
21 grok_plan_label: Option<String>,
22 cursor_plan_name: Option<String>,
23}
24
25#[derive(Debug, Clone, Deserialize, Default)]
31#[serde(default, rename_all = "camelCase")]
32struct OnDemandSettings {
33 visible: bool,
34 eligible: bool,
35 enabled: Option<bool>,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40#[serde(untagged)]
41enum PercentOrString {
42 Int(i64),
43 Float(f64),
45 Text(String),
46}
47
48impl SandUsageStatus {
49 pub fn into_snapshot(self) -> Result<GrokbotSnapshot> {
50 let plan = [self.grok_plan_label, self.cursor_plan_name]
53 .into_iter()
54 .flatten()
55 .map(|label| label.trim().to_string())
56 .find(|label| !label.is_empty())
57 .unwrap_or_else(|| "Grok Bot".to_string());
58
59 let period_start = parse_timestamp("currentPeriodStart", self.current_period_start)?;
60 let reset_at = parse_timestamp("nextResetTimestampUtc", self.next_reset_timestamp_utc)?;
61 let window = match (period_start, reset_at) {
65 (Some(start), Some(reset)) if reset > start => Some(reset - start),
66 _ => None,
67 };
68
69 let weekly_pct = if self.has_non_zero_included_limit {
73 let percent = self
74 .usage_percent
75 .ok_or_else(|| AppError::Schema("grokbot: missing usagePercent".into()))?;
76 parse_percent(percent)?
77 } else {
78 0
79 };
80
81 Ok(GrokbotSnapshot {
82 plan,
83 has_included_allowance: self.has_non_zero_included_limit,
84 weekly_pct,
85 has_available_usage: self.has_available_usage,
86 on_demand_enabled: self.on_demand_settings.enabled.unwrap_or(false),
87 period_start,
88 reset_at,
89 window,
90 })
91 }
92}
93
94fn parse_percent(value: PercentOrString) -> Result<i32> {
98 let raw = match value {
99 PercentOrString::Int(n) => n as f64,
100 PercentOrString::Float(n) => n,
101 PercentOrString::Text(s) => s.trim().parse::<f64>().map_err(|_| {
102 AppError::Schema(format!("grokbot: usagePercent is not numeric (got {s:?})"))
103 })?,
104 };
105 if !raw.is_finite() || !(0.0..=100.0).contains(&raw) {
106 return Err(AppError::Schema(format!(
107 "grokbot: usagePercent out of range: {raw}"
108 )));
109 }
110 Ok(raw.round() as i32)
111}
112
113fn parse_timestamp(field: &str, value: Option<String>) -> Result<Option<DateTime<Utc>>> {
114 match value {
115 None => Ok(None),
116 Some(s) if s.trim().is_empty() => Ok(None),
117 Some(s) => DateTime::parse_from_rfc3339(&s)
118 .map(|dt| Some(dt.with_timezone(&Utc)))
119 .map_err(|e| AppError::Schema(format!("grokbot: unparseable {field}: {e}"))),
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 const CAPTURE: &str = r#"{"currentPeriodStart":"2026-09-11T18:43:19.645Z","nextResetTimestampUtc":"2026-09-18T18:43:19.645Z","usagePercent":0,"hasAvailableUsage":true,"hasNonZeroIncludedLimit":true,"onDemandSettings":{"visible":true,"eligible":true,"enabled":false,"dashboardUrl":"https://cursor.com/dashboard?team=acct-123-secret"},"grokPlanLabel":"Grok Bot Plan","cursorPlanName":"Pro"}"#;
129
130 #[test]
131 fn the_verbatim_capture_parses() {
132 let snap = serde_json::from_str::<SandUsageStatus>(CAPTURE)
133 .unwrap()
134 .into_snapshot()
135 .unwrap();
136 assert_eq!(snap.plan, "Grok Bot Plan");
137 assert!(snap.has_included_allowance);
138 assert_eq!(snap.weekly_pct, 0);
139 assert!(snap.has_available_usage);
140 assert!(!snap.on_demand_enabled);
141 assert_eq!(
142 snap.period_start.map(|dt| dt.to_rfc3339()),
143 Some("2026-09-11T18:43:19.645+00:00".to_string())
144 );
145 assert_eq!(
146 snap.reset_at.map(|dt| dt.to_rfc3339()),
147 Some("2026-09-18T18:43:19.645+00:00".to_string())
148 );
149 assert_eq!(snap.window, Some(chrono::Duration::days(7)));
151 }
152
153 #[test]
154 fn the_dashboard_url_cannot_be_held_or_rendered() {
155 let status = serde_json::from_str::<SandUsageStatus>(CAPTURE).unwrap();
156 let rendered = format!("{status:?}");
159 assert!(!rendered.contains("dashboardUrl"), "{rendered}");
160 assert!(!rendered.contains("acct-123-secret"), "{rendered}");
161 let snap = status.into_snapshot().unwrap();
162 assert!(!format!("{snap:?}").contains("acct-123-secret"));
163 }
164
165 #[test]
166 fn a_live_macos_shape_parses_fractional_percent_and_null_on_demand() {
167 let json = r#"{"currentPeriodStart":"2026-09-16T15:56:23.315Z","nextResetTimestampUtc":"2026-09-23T15:56:23.315Z","usagePercent":19.150778,"hasAvailableUsage":true,"hasNonZeroIncludedLimit":true,"onDemandSettings":{"visible":true,"eligible":true,"enabled":null},"grokPlanLabel":"Grok Bot Plan","cursorPlanName":"Ultra"}"#;
170 let snap = serde_json::from_str::<SandUsageStatus>(json)
171 .unwrap()
172 .into_snapshot()
173 .unwrap();
174 assert_eq!(snap.plan, "Grok Bot Plan");
175 assert_eq!(snap.weekly_pct, 19);
176 assert!(!snap.on_demand_enabled);
177 assert_eq!(snap.window, Some(chrono::Duration::days(7)));
178 }
179
180 #[test]
181 fn usage_percent_accepts_an_int_or_a_numeric_string() {
182 for (raw, expected) in [
183 (r#""usagePercent": 42"#, 42),
184 (r#""usagePercent": "42""#, 42),
185 (r#""usagePercent": 19.150778"#, 19),
186 ] {
187 let json = format!(r#"{{"hasNonZeroIncludedLimit": true, {raw}}}"#);
188 let snap = serde_json::from_str::<SandUsageStatus>(&json)
189 .unwrap()
190 .into_snapshot()
191 .unwrap();
192 assert_eq!(snap.weekly_pct, expected, "{raw}");
193 }
194 }
195
196 #[test]
197 fn out_of_range_and_non_numeric_percents_are_schema_drift() {
198 for raw in [
199 r#""usagePercent": 150"#,
200 r#""usagePercent": -1"#,
201 r#""usagePercent": "NaN""#,
202 r#""usagePercent": "garbage""#,
203 r#""usagePercent": "101""#,
204 ] {
205 let json = format!(r#"{{"hasNonZeroIncludedLimit": true, {raw}}}"#);
206 let err = serde_json::from_str::<SandUsageStatus>(&json)
207 .unwrap()
208 .into_snapshot()
209 .unwrap_err();
210 assert!(matches!(err, AppError::Schema(_)), "{raw}: {err:?}");
211 }
212 }
213
214 #[test]
215 fn a_missing_reset_is_none_not_an_error() {
216 let json = r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 10}"#;
217 let snap = serde_json::from_str::<SandUsageStatus>(json)
218 .unwrap()
219 .into_snapshot()
220 .unwrap();
221 assert_eq!(snap.reset_at, None);
222 assert_eq!(snap.period_start, None);
223 assert_eq!(snap.window, None);
225 }
226
227 #[test]
228 fn an_unparseable_reset_is_schema_drift() {
229 let json = r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 10,
230 "nextResetTimestampUtc": "next tuesday"}"#;
231 let err = serde_json::from_str::<SandUsageStatus>(json)
232 .unwrap()
233 .into_snapshot()
234 .unwrap_err();
235 assert!(err.to_string().contains("nextResetTimestampUtc"), "{err}");
236 }
237
238 #[test]
241 fn no_included_limit_is_the_no_allowance_state_not_zero_percent() {
242 let json = r#"{"hasNonZeroIncludedLimit": false, "hasAvailableUsage": false,
243 "usagePercent": 0, "grokPlanLabel": "Grok Bot Plan"}"#;
244 let snap = serde_json::from_str::<SandUsageStatus>(json)
245 .unwrap()
246 .into_snapshot()
247 .unwrap();
248 assert!(!snap.has_included_allowance);
249 assert_eq!(snap.weekly_pct, 0);
252
253 let json = r#"{"hasNonZeroIncludedLimit": true}"#;
255 let err = serde_json::from_str::<SandUsageStatus>(json)
256 .unwrap()
257 .into_snapshot()
258 .unwrap_err();
259 assert!(err.to_string().contains("usagePercent"), "{err}");
260 }
261
262 #[test]
263 fn the_on_demand_note_needs_an_exhausted_pool_and_on_demand_enabled() {
264 let base = |pct: i64, available: bool, enabled: bool| {
265 let json = format!(
266 r#"{{"hasNonZeroIncludedLimit": true, "usagePercent": {pct},
267 "hasAvailableUsage": {available},
268 "onDemandSettings": {{"enabled": {enabled}}}}}"#
269 );
270 serde_json::from_str::<SandUsageStatus>(&json)
271 .unwrap()
272 .into_snapshot()
273 .unwrap()
274 };
275 assert!(base(100, true, true).on_demand_note().is_some());
277 assert!(base(100, true, false).on_demand_note().is_none());
279 assert!(base(100, false, true).on_demand_note().is_none());
280 assert!(base(99, true, true).on_demand_note().is_none());
281 }
282
283 #[test]
284 fn the_plan_label_prefers_grok_and_falls_back_to_cursor_plan() {
285 let json =
286 r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 5, "cursorPlanName": "Pro"}"#;
287 let snap = serde_json::from_str::<SandUsageStatus>(json)
288 .unwrap()
289 .into_snapshot()
290 .unwrap();
291 assert_eq!(snap.plan, "Pro");
292
293 let json = r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 5}"#;
294 let snap = serde_json::from_str::<SandUsageStatus>(json)
295 .unwrap()
296 .into_snapshot()
297 .unwrap();
298 assert_eq!(snap.plan, "Grok Bot");
299 }
300}