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    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        // The app's own label first ("Grok Bot Plan"); the underlying Cursor
48        // plan name is the fallback, not a peer.
49        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        // The window's length is the two reported instants apart — honest,
59        // not an assumed 7 days. A reset at or before the period start is not
60        // a window at all.
61        let window = match (period_start, reset_at) {
62            (Some(start), Some(reset)) if reset > start => Some(reset - start),
63            _ => None,
64        };
65
66        // `usagePercent` means something only against a non-zero included
67        // limit; an account without one is a distinct "no included allowance"
68        // state, not 0%.
69        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
91/// `usagePercent` is an integer percent in 0..=100. A numeric string goes
92/// through f64 so `"42"` and `"42.0"` both parse; anything non-finite
93/// (`"NaN"`) or out of range is schema drift, not a quota.
94fn 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    /// The reporter's verbatim typed capture (#206).
124    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        // The captured window is exactly seven days — computed, not assumed.
146        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        // The struct has no field to hold the account-identifying URL, so it
153        // can leak into neither a snapshot nor a Debug line.
154        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        // No window can be derived from one missing endpoint.
204        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    /// `hasNonZeroIncludedLimit: false` is a distinct state — the account has
219    /// no included allowance at all — never a 0% meter.
220    #[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        // The percent is not meaningful here, and the renderers must key off
230        // the flag, not the number.
231        assert_eq!(snap.weekly_pct, 0);
232
233        // Without the flag, a missing usagePercent is schema drift.
234        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        // The footnote fires at exactly the documented combination.
256        assert!(base(100, true, true).on_demand_note().is_some());
257        // 100% with on-demand off really is exhausted.
258        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}