Skip to main content

ai_usagebar/grokbot/
types.rs

1//! Wire types for `aiserver.v1.DashboardService/GetSandUsageStatus` — the
2//! Grok Bot desktop app's Connect-RPC usage call, captured live by the
3//! reporter of #206.
4
5use 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    /// Integer percent, or the same as a numeric string.
17    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/// The on-demand (pay-as-you-go) block. `visible`/`eligible`/`enabled` are
26/// the only fields read; the response also carries a **`dashboardUrl`, which
27/// is account-identifying and is deliberately not deserialized** — serde
28/// drops unknown fields, so the URL never enters a snapshot, the cache, or a
29/// Debug line. Keep it that way.
30#[derive(Debug, Clone, Deserialize, Default)]
31#[serde(default, rename_all = "camelCase")]
32struct OnDemandSettings {
33    visible: bool,
34    eligible: bool,
35    /// Live captures have sent `null` here; treat that as off.
36    enabled: Option<bool>,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40#[serde(untagged)]
41enum PercentOrString {
42    Int(i64),
43    /// Live captures have sent a fractional JSON number (`19.150778`).
44    Float(f64),
45    Text(String),
46}
47
48impl SandUsageStatus {
49    pub fn into_snapshot(self) -> Result<GrokbotSnapshot> {
50        // The app's own label first ("Grok Bot Plan"); the underlying Cursor
51        // plan name is the fallback, not a peer.
52        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        // The window's length is the two reported instants apart — honest,
62        // not an assumed 7 days. A reset at or before the period start is not
63        // a window at all.
64        let window = match (period_start, reset_at) {
65            (Some(start), Some(reset)) if reset > start => Some(reset - start),
66            _ => None,
67        };
68
69        // `usagePercent` means something only against a non-zero included
70        // limit; an account without one is a distinct "no included allowance"
71        // state, not 0%.
72        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
94/// `usagePercent` is an integer percent in 0..=100. A numeric string goes
95/// through f64 so `"42"` and `"42.0"` both parse; anything non-finite
96/// (`"NaN"`) or out of range is schema drift, not a quota.
97fn 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    /// The reporter's verbatim typed capture (#206).
128    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        // The captured window is exactly seven days — computed, not assumed.
150        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        // The struct has no field to hold the account-identifying URL, so it
157        // can leak into neither a snapshot nor a Debug line.
158        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        // Redacted 2026-09-19 capture from GetSandUsageStatus against a macOS
168        // Grok Bot 0.57.1 session: fractional usagePercent, enabled: null.
169        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        // No window can be derived from one missing endpoint.
224        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    /// `hasNonZeroIncludedLimit: false` is a distinct state — the account has
239    /// no included allowance at all — never a 0% meter.
240    #[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        // The percent is not meaningful here, and the renderers must key off
250        // the flag, not the number.
251        assert_eq!(snap.weekly_pct, 0);
252
253        // Without the flag, a missing usagePercent is schema drift.
254        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        // The footnote fires at exactly the documented combination.
276        assert!(base(100, true, true).on_demand_note().is_some());
277        // 100% with on-demand off really is exhausted.
278        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}