Skip to main content

ai_usagebar/commandcode/
types.rs

1//! Command Code usage response types and schema validation.
2//!
3//! Two documents make up a Command Code snapshot. `/alpha/billing/credits`
4//! carries the credit ledger and the rolling spend windows; the subscription
5//! names the plan. Both are parsed defensively — Command Code publishes no
6//! schema for either, so an additive field must never break a running widget,
7//! and a missing section degrades that section alone.
8
9use chrono::{DateTime, Utc};
10use serde_json::Value;
11
12/// One rolling spend window: dollars drawn against a dollar cap.
13///
14/// Unlike most vendors' percentage windows, Command Code meters spend, so the
15/// absolute figures are kept and the percentage is derived. That keeps
16/// "$5.24 of $35" available to the tooltip without a second round trip.
17#[derive(Debug, Clone, PartialEq)]
18pub struct SpendWindow {
19    pub used: f64,
20    pub cap: f64,
21    pub resets_at: Option<DateTime<Utc>>,
22}
23
24impl Eq for SpendWindow {}
25
26impl SpendWindow {
27    /// Percentage of the cap consumed, rounded and clamped to `0..=100`.
28    pub fn pct(&self) -> i32 {
29        // Guards a zero cap and a non-finite one alike: either way there is
30        // no denominator to divide by.
31        if !self.cap.is_finite() || self.cap <= 0.0 {
32            return 0;
33        }
34        ((self.used / self.cap) * 100.0).round().clamp(0.0, 100.0) as i32
35    }
36}
37
38/// The monthly credit ledger. Command Code reports three separate pools and
39/// expects a client to sum them, which is what the official CLI does.
40#[derive(Debug, Clone, PartialEq, Default)]
41pub struct Credits {
42    pub monthly: f64,
43    pub purchased: f64,
44    pub free: f64,
45}
46
47impl Eq for Credits {}
48
49impl Credits {
50    /// Total credit still spendable across every pool.
51    pub fn remaining(&self) -> f64 {
52        self.monthly + self.purchased + self.free
53    }
54}
55
56/// Everything the widget shows for Command Code.
57#[derive(Debug, Clone, PartialEq, Default)]
58pub struct Snapshot {
59    /// Plan label, e.g. "GOAT". `None` until the subscription is read.
60    pub plan: Option<String>,
61    pub five_hour: Option<SpendWindow>,
62    pub weekly: Option<SpendWindow>,
63    pub credits: Option<Credits>,
64    /// Monthly credit allowance for the plan, when the plan is recognised.
65    pub credit_pool: Option<f64>,
66}
67
68impl Eq for Snapshot {}
69
70impl Snapshot {
71    /// The window closest to its cap, which is what the bar text leads with.
72    pub fn worst_pct(&self) -> i32 {
73        self.five_hour
74            .iter()
75            .chain(self.weekly.iter())
76            .map(SpendWindow::pct)
77            .max()
78            .unwrap_or(0)
79    }
80
81    /// Fraction of the monthly allowance already spent, when both are known.
82    pub fn credits_spent(&self) -> Option<f64> {
83        let pool = self.credit_pool?;
84        let remaining = self.credits.as_ref()?.remaining();
85        Some((pool - remaining).max(0.0))
86    }
87}
88
89/// Monthly credit allowance per plan, in USD.
90///
91/// The API reports remaining credit but never the plan's ceiling, so the
92/// "spent of allowance" line needs this table. An unknown plan simply omits
93/// that line rather than guessing a denominator.
94const PLAN_CREDITS: &[(&str, f64)] = &[
95    ("individual-go", 10.0),
96    ("individual-goat", 70.0),
97    ("individual-pro", 30.0),
98    ("individual-pro-v1", 80.0),
99    ("individual-provider", 15.0),
100    ("individual-max", 150.0),
101    ("individual-ultra", 300.0),
102    ("teams-pro", 40.0),
103];
104
105/// Display label per plan id, matching what Command Code's own `/usage` shows.
106const PLAN_LABELS: &[(&str, &str)] = &[
107    ("individual-go", "Go"),
108    ("individual-goat", "GOAT"),
109    ("individual-pro", "Pro"),
110    ("individual-pro-v1", "Pro"),
111    ("individual-provider", "Provider"),
112    ("individual-max", "Max"),
113    ("individual-ultra", "Ultra"),
114    ("teams-pro", "Teams Pro"),
115];
116
117pub fn plan_label(plan_id: &str) -> String {
118    PLAN_LABELS
119        .iter()
120        .find(|(id, _)| *id == plan_id)
121        .map(|(_, label)| (*label).to_string())
122        .unwrap_or_else(|| plan_id.to_string())
123}
124
125pub fn plan_credits(plan_id: &str) -> Option<f64> {
126    PLAN_CREDITS
127        .iter()
128        .find(|(id, _)| *id == plan_id)
129        .map(|(_, credits)| *credits)
130}
131
132/// Parse `/alpha/billing/credits`.
133pub fn parse_credits(value: &Value) -> Result<Snapshot, String> {
134    let root = value
135        .as_object()
136        .ok_or_else(|| "Command Code credits response must be a JSON object".to_string())?;
137    if root.contains_key("error") {
138        return Err("Command Code credits response is an error envelope".to_string());
139    }
140
141    // `windowLimits` has been observed both at the top level and beside the
142    // ledger; accept either rather than betting on one.
143    let ledger = root.get("credits").and_then(Value::as_object);
144    let windows = root
145        .get("windowLimits")
146        .and_then(Value::as_object)
147        .or_else(|| {
148            ledger
149                .and_then(|l| l.get("windowLimits"))
150                .and_then(Value::as_object)
151        });
152
153    let credits = ledger.map(|ledger| Credits {
154        monthly: finite(ledger.get("monthlyCredits")).unwrap_or(0.0),
155        purchased: finite(ledger.get("purchasedCredits")).unwrap_or(0.0),
156        free: finite(ledger.get("freeCredits")).unwrap_or(0.0),
157    });
158
159    let snapshot = Snapshot {
160        plan: None,
161        five_hour: windows
162            .and_then(|w| parse_window(w.get("fiveHour"), "fiveHour"))
163            .transpose()?,
164        weekly: windows
165            .and_then(|w| parse_window(w.get("weekly"), "weekly"))
166            .transpose()?,
167        credits,
168        credit_pool: None,
169    };
170
171    if snapshot.five_hour.is_none() && snapshot.weekly.is_none() && snapshot.credits.is_none() {
172        return Err("Command Code credits response has no windows or ledger".to_string());
173    }
174    Ok(snapshot)
175}
176
177/// Fold `/alpha/billing/subscriptions` into a snapshot: plan label and the
178/// allowance its tier includes.
179pub fn apply_subscription(snapshot: &mut Snapshot, value: &Value) {
180    let Some(plan_id) = value
181        .get("data")
182        .and_then(|data| data.get("planId"))
183        .and_then(Value::as_str)
184        .map(str::trim)
185        .filter(|id| !id.is_empty())
186    else {
187        return;
188    };
189    snapshot.plan = Some(plan_label(plan_id));
190    snapshot.credit_pool = plan_credits(plan_id);
191}
192
193fn parse_window(value: Option<&Value>, name: &str) -> Option<Result<SpendWindow, String>> {
194    let value = value?;
195    if value.is_null() {
196        return None;
197    }
198    let Some(object) = value.as_object() else {
199        return Some(Err(format!("windowLimits.{name} must be an object")));
200    };
201    let (Some(used), Some(cap)) = (finite(object.get("used")), finite(object.get("cap"))) else {
202        return Some(Err(format!(
203            "windowLimits.{name} must carry finite used and cap"
204        )));
205    };
206    if used < 0.0 || cap < 0.0 {
207        return Some(Err(format!("windowLimits.{name} must not be negative")));
208    }
209    Some(Ok(SpendWindow {
210        used,
211        cap,
212        resets_at: object.get("resetAt").and_then(parse_reset),
213    }))
214}
215
216/// Resets arrive as millisecond epochs, which is why this cannot lean on the
217/// RFC3339 parsing every other vendor uses. A string is accepted too, in case
218/// the field ever changes shape.
219fn parse_reset(value: &Value) -> Option<DateTime<Utc>> {
220    if let Some(millis) = value.as_i64() {
221        return DateTime::from_timestamp_millis(millis);
222    }
223    if let Some(text) = value.as_str() {
224        if let Ok(parsed) = text.parse::<DateTime<chrono::FixedOffset>>() {
225            return Some(parsed.with_timezone(&Utc));
226        }
227        if let Ok(millis) = text.parse::<i64>() {
228            return DateTime::from_timestamp_millis(millis);
229        }
230    }
231    None
232}
233
234fn finite(value: Option<&Value>) -> Option<f64> {
235    value.and_then(Value::as_f64).filter(|n| n.is_finite())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn credits_value() -> Value {
243        serde_json::from_str(include_str!(
244            "../../tests/fixtures/commandcode/credits.json"
245        ))
246        .expect("fixture JSON must be valid")
247    }
248
249    #[test]
250    fn parses_the_live_credits_fixture() {
251        let snapshot = parse_credits(&credits_value()).expect("fixture must parse");
252
253        let five_hour = snapshot.five_hour.expect("fiveHour window");
254        assert_eq!(five_hour.cap, 14.0);
255        assert_eq!(five_hour.pct(), 25);
256        assert_eq!(
257            five_hour.resets_at.expect("reset").to_rfc3339(),
258            "2026-08-27T20:00:00+00:00"
259        );
260
261        let weekly = snapshot.weekly.expect("weekly window");
262        assert_eq!(weekly.cap, 35.0);
263        assert_eq!(weekly.pct(), 30);
264
265        let credits = snapshot.credits.expect("ledger");
266        assert_eq!(credits.remaining(), 42.0);
267    }
268
269    #[test]
270    fn millisecond_epoch_resets_become_utc_timestamps() {
271        // The API reports resets as ms epochs, not the RFC3339 every other
272        // vendor sends; a naive parser silently drops the countdown.
273        let value = serde_json::json!({
274            "windowLimits": {"weekly": {"used": 1, "cap": 4, "resetAt": 1788374172830_i64}}
275        });
276
277        let weekly = parse_credits(&value).unwrap().weekly.expect("weekly");
278
279        assert_eq!(
280            weekly.resets_at.expect("reset").to_rfc3339(),
281            "2026-09-02T18:36:12.830+00:00"
282        );
283    }
284
285    #[test]
286    fn accepts_windows_nested_beside_the_ledger() {
287        let value = serde_json::json!({
288            "credits": {
289                "monthlyCredits": 5.0,
290                "windowLimits": {"weekly": {"used": 1, "cap": 4, "resetAt": null}}
291            }
292        });
293
294        let snapshot = parse_credits(&value).expect("nested windows must parse");
295
296        assert_eq!(snapshot.weekly.expect("weekly").pct(), 25);
297        assert_eq!(snapshot.credits.expect("ledger").remaining(), 5.0);
298    }
299
300    #[test]
301    fn sums_every_credit_pool() {
302        let value = serde_json::json!({
303            "credits": {"monthlyCredits": 10.5, "purchasedCredits": 4.0, "freeCredits": 0.5},
304            "windowLimits": {"weekly": {"used": 1, "cap": 4}}
305        });
306
307        let credits = parse_credits(&value).unwrap().credits.expect("ledger");
308
309        assert_eq!(credits.remaining(), 15.0);
310    }
311
312    #[test]
313    fn rejects_error_envelopes_and_empty_documents() {
314        for value in [
315            serde_json::json!({}),
316            serde_json::json!({"error": "unauthorized"}),
317            serde_json::json!({"windowLimits": {}}),
318        ] {
319            assert!(parse_credits(&value).is_err(), "accepted {value}");
320        }
321        assert!(parse_credits(&serde_json::json!("nope")).is_err());
322    }
323
324    #[test]
325    fn rejects_malformed_or_negative_windows() {
326        for window in [
327            serde_json::json!("not-an-object"),
328            serde_json::json!({"used": -1, "cap": 4}),
329            serde_json::json!({"used": 1}),
330            serde_json::json!({"used": "1", "cap": 4}),
331        ] {
332            let value = serde_json::json!({"windowLimits": {"weekly": window}});
333            assert!(parse_credits(&value).is_err(), "accepted {window}");
334        }
335    }
336
337    #[test]
338    fn additive_fields_and_null_windows_are_tolerated() {
339        // `exceeded` and `limited` already ship alongside the windows, and a
340        // plan without a five-hour cap sends null rather than omitting it.
341        let value = serde_json::json!({
342            "credits": {"monthlyCredits": 1.0, "unexpected": true},
343            "windowLimits": {
344                "limited": true,
345                "exceeded": null,
346                "fiveHour": null,
347                "weekly": {"used": 1, "cap": 4, "exceeded": false, "future": 1}
348            }
349        });
350
351        let snapshot = parse_credits(&value).expect("must tolerate additive fields");
352
353        assert!(snapshot.five_hour.is_none());
354        assert_eq!(snapshot.weekly.expect("weekly").pct(), 25);
355    }
356
357    #[test]
358    fn percentage_is_clamped_and_safe_at_a_zero_cap() {
359        assert_eq!(
360            SpendWindow {
361                used: 9.0,
362                cap: 0.0,
363                resets_at: None
364            }
365            .pct(),
366            0
367        );
368        assert_eq!(
369            SpendWindow {
370                used: 9.0,
371                cap: 4.0,
372                resets_at: None
373            }
374            .pct(),
375            100
376        );
377    }
378
379    #[test]
380    fn subscription_supplies_the_plan_label_and_its_allowance() {
381        let subscription: Value = serde_json::from_str(include_str!(
382            "../../tests/fixtures/commandcode/subscriptions.json"
383        ))
384        .expect("fixture JSON must be valid");
385        let mut snapshot = parse_credits(&credits_value()).unwrap();
386
387        apply_subscription(&mut snapshot, &subscription);
388
389        assert_eq!(snapshot.plan.as_deref(), Some("GOAT"));
390        assert_eq!(snapshot.credit_pool, Some(70.0));
391        // The $70 allowance less the $42 still on the ledger.
392        assert_eq!(snapshot.credits_spent(), Some(28.0));
393    }
394
395    #[test]
396    fn unknown_plan_keeps_its_id_and_claims_no_allowance() {
397        let mut snapshot = parse_credits(&credits_value()).unwrap();
398
399        apply_subscription(
400            &mut snapshot,
401            &serde_json::json!({"data": {"planId": "individual-future"}}),
402        );
403
404        assert_eq!(snapshot.plan.as_deref(), Some("individual-future"));
405        assert_eq!(snapshot.credit_pool, None);
406        assert_eq!(snapshot.credits_spent(), None);
407    }
408
409    #[test]
410    fn missing_subscription_leaves_the_snapshot_untouched() {
411        let mut snapshot = parse_credits(&credits_value()).unwrap();
412
413        apply_subscription(&mut snapshot, &serde_json::json!({"success": false}));
414
415        assert!(snapshot.plan.is_none());
416        assert!(snapshot.weekly.is_some());
417    }
418
419    #[test]
420    fn worst_window_leads_the_bar_text() {
421        let snapshot = Snapshot {
422            five_hour: Some(SpendWindow {
423                used: 1.0,
424                cap: 10.0,
425                resets_at: None,
426            }),
427            weekly: Some(SpendWindow {
428                used: 8.0,
429                cap: 10.0,
430                resets_at: None,
431            }),
432            ..Snapshot::default()
433        };
434
435        assert_eq!(snapshot.worst_pct(), 80);
436        assert_eq!(Snapshot::default().worst_pct(), 0);
437    }
438}