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: bool,
36}
37
38#[derive(Debug, Clone, Deserialize)]
39#[serde(untagged)]
40enum PercentOrString {
41 Int(i64),
42 Text(String),
43}
44
45impl SandUsageStatus {
46 pub fn into_snapshot(self) -> Result<GrokbotSnapshot> {
47 let plan = [self.grok_plan_label, self.cursor_plan_name]
50 .into_iter()
51 .flatten()
52 .map(|label| label.trim().to_string())
53 .find(|label| !label.is_empty())
54 .unwrap_or_else(|| "Grok Bot".to_string());
55
56 let period_start = parse_timestamp("currentPeriodStart", self.current_period_start)?;
57 let reset_at = parse_timestamp("nextResetTimestampUtc", self.next_reset_timestamp_utc)?;
58 let window = match (period_start, reset_at) {
62 (Some(start), Some(reset)) if reset > start => Some(reset - start),
63 _ => None,
64 };
65
66 let weekly_pct = if self.has_non_zero_included_limit {
70 let percent = self
71 .usage_percent
72 .ok_or_else(|| AppError::Schema("grokbot: missing usagePercent".into()))?;
73 parse_percent(percent)?
74 } else {
75 0
76 };
77
78 Ok(GrokbotSnapshot {
79 plan,
80 has_included_allowance: self.has_non_zero_included_limit,
81 weekly_pct,
82 has_available_usage: self.has_available_usage,
83 on_demand_enabled: self.on_demand_settings.enabled,
84 period_start,
85 reset_at,
86 window,
87 })
88 }
89}
90
91fn parse_percent(value: PercentOrString) -> Result<i32> {
95 let raw = match value {
96 PercentOrString::Int(n) => n as f64,
97 PercentOrString::Text(s) => s.trim().parse::<f64>().map_err(|_| {
98 AppError::Schema(format!("grokbot: usagePercent is not numeric (got {s:?})"))
99 })?,
100 };
101 if !raw.is_finite() || !(0.0..=100.0).contains(&raw) {
102 return Err(AppError::Schema(format!(
103 "grokbot: usagePercent out of range: {raw}"
104 )));
105 }
106 Ok(raw.round() as i32)
107}
108
109fn parse_timestamp(field: &str, value: Option<String>) -> Result<Option<DateTime<Utc>>> {
110 match value {
111 None => Ok(None),
112 Some(s) if s.trim().is_empty() => Ok(None),
113 Some(s) => DateTime::parse_from_rfc3339(&s)
114 .map(|dt| Some(dt.with_timezone(&Utc)))
115 .map_err(|e| AppError::Schema(format!("grokbot: unparseable {field}: {e}"))),
116 }
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 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"}"#;
125
126 #[test]
127 fn the_verbatim_capture_parses() {
128 let snap = serde_json::from_str::<SandUsageStatus>(CAPTURE)
129 .unwrap()
130 .into_snapshot()
131 .unwrap();
132 assert_eq!(snap.plan, "Grok Bot Plan");
133 assert!(snap.has_included_allowance);
134 assert_eq!(snap.weekly_pct, 0);
135 assert!(snap.has_available_usage);
136 assert!(!snap.on_demand_enabled);
137 assert_eq!(
138 snap.period_start.map(|dt| dt.to_rfc3339()),
139 Some("2026-09-11T18:43:19.645+00:00".to_string())
140 );
141 assert_eq!(
142 snap.reset_at.map(|dt| dt.to_rfc3339()),
143 Some("2026-09-18T18:43:19.645+00:00".to_string())
144 );
145 assert_eq!(snap.window, Some(chrono::Duration::days(7)));
147 }
148
149 #[test]
150 fn the_dashboard_url_cannot_be_held_or_rendered() {
151 let status = serde_json::from_str::<SandUsageStatus>(CAPTURE).unwrap();
152 let rendered = format!("{status:?}");
155 assert!(!rendered.contains("dashboardUrl"), "{rendered}");
156 assert!(!rendered.contains("acct-123-secret"), "{rendered}");
157 let snap = status.into_snapshot().unwrap();
158 assert!(!format!("{snap:?}").contains("acct-123-secret"));
159 }
160
161 #[test]
162 fn usage_percent_accepts_an_int_or_a_numeric_string() {
163 for (raw, expected) in [
164 (r#""usagePercent": 42"#, 42),
165 (r#""usagePercent": "42""#, 42),
166 ] {
167 let json = format!(r#"{{"hasNonZeroIncludedLimit": true, {raw}}}"#);
168 let snap = serde_json::from_str::<SandUsageStatus>(&json)
169 .unwrap()
170 .into_snapshot()
171 .unwrap();
172 assert_eq!(snap.weekly_pct, expected, "{raw}");
173 }
174 }
175
176 #[test]
177 fn out_of_range_and_non_numeric_percents_are_schema_drift() {
178 for raw in [
179 r#""usagePercent": 150"#,
180 r#""usagePercent": -1"#,
181 r#""usagePercent": "NaN""#,
182 r#""usagePercent": "garbage""#,
183 r#""usagePercent": "101""#,
184 ] {
185 let json = format!(r#"{{"hasNonZeroIncludedLimit": true, {raw}}}"#);
186 let err = serde_json::from_str::<SandUsageStatus>(&json)
187 .unwrap()
188 .into_snapshot()
189 .unwrap_err();
190 assert!(matches!(err, AppError::Schema(_)), "{raw}: {err:?}");
191 }
192 }
193
194 #[test]
195 fn a_missing_reset_is_none_not_an_error() {
196 let json = r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 10}"#;
197 let snap = serde_json::from_str::<SandUsageStatus>(json)
198 .unwrap()
199 .into_snapshot()
200 .unwrap();
201 assert_eq!(snap.reset_at, None);
202 assert_eq!(snap.period_start, None);
203 assert_eq!(snap.window, None);
205 }
206
207 #[test]
208 fn an_unparseable_reset_is_schema_drift() {
209 let json = r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 10,
210 "nextResetTimestampUtc": "next tuesday"}"#;
211 let err = serde_json::from_str::<SandUsageStatus>(json)
212 .unwrap()
213 .into_snapshot()
214 .unwrap_err();
215 assert!(err.to_string().contains("nextResetTimestampUtc"), "{err}");
216 }
217
218 #[test]
221 fn no_included_limit_is_the_no_allowance_state_not_zero_percent() {
222 let json = r#"{"hasNonZeroIncludedLimit": false, "hasAvailableUsage": false,
223 "usagePercent": 0, "grokPlanLabel": "Grok Bot Plan"}"#;
224 let snap = serde_json::from_str::<SandUsageStatus>(json)
225 .unwrap()
226 .into_snapshot()
227 .unwrap();
228 assert!(!snap.has_included_allowance);
229 assert_eq!(snap.weekly_pct, 0);
232
233 let json = r#"{"hasNonZeroIncludedLimit": true}"#;
235 let err = serde_json::from_str::<SandUsageStatus>(json)
236 .unwrap()
237 .into_snapshot()
238 .unwrap_err();
239 assert!(err.to_string().contains("usagePercent"), "{err}");
240 }
241
242 #[test]
243 fn the_on_demand_note_needs_an_exhausted_pool_and_on_demand_enabled() {
244 let base = |pct: i64, available: bool, enabled: bool| {
245 let json = format!(
246 r#"{{"hasNonZeroIncludedLimit": true, "usagePercent": {pct},
247 "hasAvailableUsage": {available},
248 "onDemandSettings": {{"enabled": {enabled}}}}}"#
249 );
250 serde_json::from_str::<SandUsageStatus>(&json)
251 .unwrap()
252 .into_snapshot()
253 .unwrap()
254 };
255 assert!(base(100, true, true).on_demand_note().is_some());
257 assert!(base(100, true, false).on_demand_note().is_none());
259 assert!(base(100, false, true).on_demand_note().is_none());
260 assert!(base(99, true, true).on_demand_note().is_none());
261 }
262
263 #[test]
264 fn the_plan_label_prefers_grok_and_falls_back_to_cursor_plan() {
265 let json =
266 r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 5, "cursorPlanName": "Pro"}"#;
267 let snap = serde_json::from_str::<SandUsageStatus>(json)
268 .unwrap()
269 .into_snapshot()
270 .unwrap();
271 assert_eq!(snap.plan, "Pro");
272
273 let json = r#"{"hasNonZeroIncludedLimit": true, "usagePercent": 5}"#;
274 let snap = serde_json::from_str::<SandUsageStatus>(json)
275 .unwrap()
276 .into_snapshot()
277 .unwrap();
278 assert_eq!(snap.plan, "Grok Bot");
279 }
280}