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    /// Which product bills the pool: `SAND_BILLING_BRAND_CURSOR` on the accounts
24    /// seen so far, one with both Cursor Ultra and SuperGrok Heavy included.
25    billing_brand: Option<String>,
26}
27
28/// The on-demand (pay-as-you-go) block. `visible`/`eligible`/`enabled` are
29/// the only fields read; the response also carries a **`dashboardUrl`, which
30/// is account-identifying and is deliberately not deserialized** — serde
31/// drops unknown fields, so the URL never enters a snapshot, the cache, or a
32/// Debug line. Keep it that way.
33#[derive(Debug, Clone, Deserialize, Default)]
34#[serde(default, rename_all = "camelCase")]
35struct OnDemandSettings {
36    visible: bool,
37    eligible: bool,
38    /// Live captures have sent `null` here; treat that as off.
39    enabled: Option<bool>,
40}
41
42#[derive(Debug, Clone, Deserialize)]
43#[serde(untagged)]
44enum PercentOrString {
45    Int(i64),
46    /// Live captures have sent a fractional JSON number (`19.150778`).
47    Float(f64),
48    Text(String),
49}
50
51impl SandUsageStatus {
52    pub fn into_snapshot(self) -> Result<GrokbotSnapshot> {
53        // The app's own label first ("Grok Bot Plan"); the underlying Cursor
54        // plan name is the fallback, not a peer.
55        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        // The window's length is the two reported instants apart — honest,
69        // not an assumed 7 days. A reset at or before the period start is not
70        // a window at all.
71        let window = match (period_start, reset_at) {
72            (Some(start), Some(reset)) if reset > start => Some(reset - start),
73            _ => None,
74        };
75
76        // `usagePercent` means something only against a non-zero included
77        // limit; an account without one is a distinct "no included allowance"
78        // state, not 0%.
79        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
102/// The subscription that bills the pool: the product `billingBrand` names,
103/// with the plan it reports for that product ("Cursor Ultra"). Only brands
104/// seen live are named; any other stays unnamed, since a guessed brand would
105/// be worse than none.
106fn 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
125/// `usagePercent` is an integer percent in 0..=100. A numeric string goes
126/// through f64 so `"42"` and `"42.0"` both parse; anything non-finite
127/// (`"NaN"`) or out of range is schema drift, not a quota.
128fn 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    /// The reporter's verbatim typed capture (#206).
159    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        // The captured window is exactly seven days — computed, not assumed.
181        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        // The struct has no field to hold the account-identifying URL, so it
188        // can leak into neither a snapshot nor a Debug line.
189        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        // Redacted 2026-09-19 capture from GetSandUsageStatus against a macOS
199        // Grok Bot 0.57.1 session: fractional usagePercent, enabled: null.
200        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        // No window can be derived from one missing endpoint.
255        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    /// `hasNonZeroIncludedLimit: false` is a distinct state — the account has
270    /// no included allowance at all — never a 0% meter.
271    #[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        // The percent is not meaningful here, and the renderers must key off
281        // the flag, not the number.
282        assert_eq!(snap.weekly_pct, 0);
283
284        // Without the flag, a missing usagePercent is schema drift.
285        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        // The footnote fires at exactly the documented combination.
307        assert!(base(100, true, true).on_demand_note().is_some());
308        // 100% with on-demand off really is exhausted.
309        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        // No plan name: the product alone; a plan already naming it is not doubled.
329        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        // The plan label is untouched: `{gbt_plan}` and the tooltip keep it.
336        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}