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 billing_brand: Option<String>,
26}
27
28#[derive(Debug, Clone, Deserialize, Default)]
34#[serde(default, rename_all = "camelCase")]
35struct OnDemandSettings {
36 visible: bool,
37 eligible: bool,
38 enabled: Option<bool>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43#[serde(untagged)]
44enum PercentOrString {
45 Int(i64),
46 Float(f64),
48 Text(String),
49}
50
51impl SandUsageStatus {
52 pub fn into_snapshot(self) -> Result<GrokbotSnapshot> {
53 let billed_by = billed_by(
56 self.billing_brand.as_deref(),
57 self.cursor_plan_name.as_deref(),
58 );
59 let plan = [self.grok_plan_label, self.cursor_plan_name]
60 .into_iter()
61 .flatten()
62 .map(|label| label.trim().to_string())
63 .find(|label| !label.is_empty())
64 .unwrap_or_else(|| "Grok Bot".to_string());
65
66 let period_start = parse_timestamp("currentPeriodStart", self.current_period_start)?;
67 let reset_at = parse_timestamp("nextResetTimestampUtc", self.next_reset_timestamp_utc)?;
68 let window = match (period_start, reset_at) {
72 (Some(start), Some(reset)) if reset > start => Some(reset - start),
73 _ => None,
74 };
75
76 let weekly_pct = if self.has_non_zero_included_limit {
80 let percent = self
81 .usage_percent
82 .ok_or_else(|| AppError::Schema("grokbot: missing usagePercent".into()))?;
83 parse_percent(percent)?
84 } else {
85 0
86 };
87
88 Ok(GrokbotSnapshot {
89 plan,
90 billed_by,
91 has_included_allowance: self.has_non_zero_included_limit,
92 weekly_pct,
93 has_available_usage: self.has_available_usage,
94 on_demand_enabled: self.on_demand_settings.enabled.unwrap_or(false),
95 period_start,
96 reset_at,
97 window,
98 })
99 }
100}
101
102fn billed_by(brand: Option<&str>, cursor_plan: Option<&str>) -> Option<String> {
107 let product = match brand?.trim() {
108 "SAND_BILLING_BRAND_CURSOR" => "Cursor",
109 _ => return None,
110 };
111 let plan = cursor_plan.map(str::trim).unwrap_or_default();
112 Some(
113 if plan.is_empty() || plan.to_lowercase().starts_with("cursor") {
114 if plan.is_empty() {
115 product.to_string()
116 } else {
117 plan.to_string()
118 }
119 } else {
120 format!("{product} {plan}")
121 },
122 )
123}
124
125fn parse_percent(value: PercentOrString) -> Result<i32> {
129 let raw = match value {
130 PercentOrString::Int(n) => n as f64,
131 PercentOrString::Float(n) => n,
132 PercentOrString::Text(s) => s.trim().parse::<f64>().map_err(|_| {
133 AppError::Schema(format!("grokbot: usagePercent is not numeric (got {s:?})"))
134 })?,
135 };
136 if !raw.is_finite() || !(0.0..=100.0).contains(&raw) {
137 return Err(AppError::Schema(format!(
138 "grokbot: usagePercent out of range: {raw}"
139 )));
140 }
141 Ok(raw.round() as i32)
142}
143
144fn parse_timestamp(field: &str, value: Option<String>) -> Result<Option<DateTime<Utc>>> {
145 match value {
146 None => Ok(None),
147 Some(s) if s.trim().is_empty() => Ok(None),
148 Some(s) => DateTime::parse_from_rfc3339(&s)
149 .map(|dt| Some(dt.with_timezone(&Utc)))
150 .map_err(|e| AppError::Schema(format!("grokbot: unparseable {field}: {e}"))),
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 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"}"#;
160
161 #[test]
162 fn the_verbatim_capture_parses() {
163 let snap = serde_json::from_str::<SandUsageStatus>(CAPTURE)
164 .unwrap()
165 .into_snapshot()
166 .unwrap();
167 assert_eq!(snap.plan, "Grok Bot Plan");
168 assert!(snap.has_included_allowance);
169 assert_eq!(snap.weekly_pct, 0);
170 assert!(snap.has_available_usage);
171 assert!(!snap.on_demand_enabled);
172 assert_eq!(
173 snap.period_start.map(|dt| dt.to_rfc3339()),
174 Some("2026-09-11T18:43:19.645+00:00".to_string())
175 );
176 assert_eq!(
177 snap.reset_at.map(|dt| dt.to_rfc3339()),
178 Some("2026-09-18T18:43:19.645+00:00".to_string())
179 );
180 assert_eq!(snap.window, Some(chrono::Duration::days(7)));
182 }
183
184 #[test]
185 fn the_dashboard_url_cannot_be_held_or_rendered() {
186 let status = serde_json::from_str::<SandUsageStatus>(CAPTURE).unwrap();
187 let rendered = format!("{status:?}");
190 assert!(!rendered.contains("dashboardUrl"), "{rendered}");
191 assert!(!rendered.contains("acct-123-secret"), "{rendered}");
192 let snap = status.into_snapshot().unwrap();
193 assert!(!format!("{snap:?}").contains("acct-123-secret"));
194 }
195
196 #[test]
197 fn a_live_macos_shape_parses_fractional_percent_and_null_on_demand() {
198 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"}"#;
201 let snap = serde_json::from_str::<SandUsageStatus>(json)
202 .unwrap()
203 .into_snapshot()
204 .unwrap();
205 assert_eq!(snap.plan, "Grok Bot Plan");
206 assert_eq!(snap.weekly_pct, 19);
207 assert!(!snap.on_demand_enabled);
208 assert_eq!(snap.window, Some(chrono::Duration::days(7)));
209 }
210
211 #[test]
212 fn usage_percent_accepts_an_int_or_a_numeric_string() {
213 for (raw, expected) in [
214 (r#""usagePercent": 42"#, 42),
215 (r#""usagePercent": "42""#, 42),
216 (r#""usagePercent": 19.150778"#, 19),
217 ] {
218 let json = format!(r#"{{"hasNonZeroIncludedLimit": true, {raw}}}"#);
219 let snap = serde_json::from_str::<SandUsageStatus>(&json)
220 .unwrap()
221 .into_snapshot()
222 .unwrap();
223 assert_eq!(snap.weekly_pct, expected, "{raw}");
224 }
225 }
226
227 #[test]
228 fn out_of_range_and_non_numeric_percents_are_schema_drift() {
229 for raw in [
230 r#""usagePercent": 150"#,
231 r#""usagePercent": -1"#,
232 r#""usagePercent": "NaN""#,
233 r#""usagePercent": "garbage""#,
234 r#""usagePercent": "101""#,
235 ] {
236 let json = format!(r#"{{"hasNonZeroIncludedLimit": true, {raw}}}"#);
237 let err = serde_json::from_str::<SandUsageStatus>(&json)
238 .unwrap()
239 .into_snapshot()
240 .unwrap_err();
241 assert!(matches!(err, AppError::Schema(_)), "{raw}: {err:?}");
242 }
243 }
244
245 #[test]
246 fn a_missing_reset_is_none_not_an_error() {
247 let json = r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 10}"#;
248 let snap = serde_json::from_str::<SandUsageStatus>(json)
249 .unwrap()
250 .into_snapshot()
251 .unwrap();
252 assert_eq!(snap.reset_at, None);
253 assert_eq!(snap.period_start, None);
254 assert_eq!(snap.window, None);
256 }
257
258 #[test]
259 fn an_unparseable_reset_is_schema_drift() {
260 let json = r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 10,
261 "nextResetTimestampUtc": "next tuesday"}"#;
262 let err = serde_json::from_str::<SandUsageStatus>(json)
263 .unwrap()
264 .into_snapshot()
265 .unwrap_err();
266 assert!(err.to_string().contains("nextResetTimestampUtc"), "{err}");
267 }
268
269 #[test]
272 fn no_included_limit_is_the_no_allowance_state_not_zero_percent() {
273 let json = r#"{"hasNonZeroIncludedLimit": false, "hasAvailableUsage": false,
274 "usagePercent": 0, "grokPlanLabel": "Grok Bot Plan"}"#;
275 let snap = serde_json::from_str::<SandUsageStatus>(json)
276 .unwrap()
277 .into_snapshot()
278 .unwrap();
279 assert!(!snap.has_included_allowance);
280 assert_eq!(snap.weekly_pct, 0);
283
284 let json = r#"{"hasNonZeroIncludedLimit": true}"#;
286 let err = serde_json::from_str::<SandUsageStatus>(json)
287 .unwrap()
288 .into_snapshot()
289 .unwrap_err();
290 assert!(err.to_string().contains("usagePercent"), "{err}");
291 }
292
293 #[test]
294 fn the_on_demand_note_needs_an_exhausted_pool_and_on_demand_enabled() {
295 let base = |pct: i64, available: bool, enabled: bool| {
296 let json = format!(
297 r#"{{"hasNonZeroIncludedLimit": true, "usagePercent": {pct},
298 "hasAvailableUsage": {available},
299 "onDemandSettings": {{"enabled": {enabled}}}}}"#
300 );
301 serde_json::from_str::<SandUsageStatus>(&json)
302 .unwrap()
303 .into_snapshot()
304 .unwrap()
305 };
306 assert!(base(100, true, true).on_demand_note().is_some());
308 assert!(base(100, true, false).on_demand_note().is_none());
310 assert!(base(100, false, true).on_demand_note().is_none());
311 assert!(base(99, true, true).on_demand_note().is_none());
312 }
313
314 #[test]
315 fn the_billing_brand_names_cursor_and_leaves_others_unnamed() {
316 let snap = |brand: &str| {
317 let json = format!(
318 r#"{{"hasNonZeroIncludedLimit": true, "usagePercent": 5, "grokPlanLabel": "Grok Bot Plan"{brand}}}"#
319 );
320 serde_json::from_str::<SandUsageStatus>(&json)
321 .unwrap()
322 .into_snapshot()
323 .unwrap()
324 };
325 let cursor =
326 snap(r#", "billingBrand": "SAND_BILLING_BRAND_CURSOR", "cursorPlanName": "Ultra""#);
327 assert_eq!(cursor.billed_by.as_deref(), Some("Cursor Ultra"));
328 let bare = snap(r#", "billingBrand": "SAND_BILLING_BRAND_CURSOR""#);
330 assert_eq!(bare.billed_by.as_deref(), Some("Cursor"));
331 let named = snap(
332 r#", "billingBrand": "SAND_BILLING_BRAND_CURSOR", "cursorPlanName": "Cursor Pro""#,
333 );
334 assert_eq!(named.billed_by.as_deref(), Some("Cursor Pro"));
335 assert_eq!(cursor.plan, "Grok Bot Plan");
337 assert_eq!(
338 snap(r#", "billingBrand": "SAND_BILLING_BRAND_SOMETHING_NEW""#).billed_by,
339 None
340 );
341 assert_eq!(snap("").billed_by, None);
342 }
343
344 #[test]
345 fn the_plan_label_prefers_grok_and_falls_back_to_cursor_plan() {
346 let json =
347 r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 5, "cursorPlanName": "Pro"}"#;
348 let snap = serde_json::from_str::<SandUsageStatus>(json)
349 .unwrap()
350 .into_snapshot()
351 .unwrap();
352 assert_eq!(snap.plan, "Pro");
353
354 let json = r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 5}"#;
355 let snap = serde_json::from_str::<SandUsageStatus>(json)
356 .unwrap()
357 .into_snapshot()
358 .unwrap();
359 assert_eq!(snap.plan, "Grok Bot");
360 }
361}