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    /// End of the current billing period — when the monthly credit ledger
67    /// refills. The windows reset on their own clocks; only the subscription
68    /// carries this instant.
69    pub period_end: Option<DateTime<Utc>>,
70}
71
72impl Eq for Snapshot {}
73
74impl Snapshot {
75    /// The window closest to its cap, which is what the bar text leads with.
76    pub fn worst_pct(&self) -> i32 {
77        self.five_hour
78            .iter()
79            .chain(self.weekly.iter())
80            .chain(self.monthly_window().iter())
81            .map(SpendWindow::pct)
82            .max()
83            .unwrap_or(0)
84    }
85
86    /// Fraction of the monthly allowance already spent, when both are known.
87    pub fn credits_spent(&self) -> Option<f64> {
88        let pool = self.credit_pool?;
89        let remaining = self.credits.as_ref()?.remaining();
90        Some((pool - remaining).max(0.0))
91    }
92
93    /// The monthly credit allowance as a spend window: dollars drawn from the
94    /// plan's pool, refilling at the billing period end. Needs the ledger and
95    /// a recognised plan; the subscription supplies the refill instant.
96    pub fn monthly_window(&self) -> Option<SpendWindow> {
97        let spent = self.credits_spent()?;
98        Some(SpendWindow {
99            used: spent,
100            cap: self.credit_pool.unwrap_or_default(),
101            resets_at: self.period_end,
102        })
103    }
104}
105
106/// Monthly credit allowance per plan, in USD.
107///
108/// The API reports remaining credit but never the plan's ceiling, so the
109/// "spent of allowance" line needs this table. An unknown plan simply omits
110/// that line rather than guessing a denominator.
111const PLAN_CREDITS: &[(&str, f64)] = &[
112    ("individual-go", 10.0),
113    ("individual-goat", 70.0),
114    ("individual-pro", 30.0),
115    ("individual-pro-v1", 80.0),
116    ("individual-provider", 15.0),
117    ("individual-max", 150.0),
118    ("individual-ultra", 300.0),
119    ("teams-pro", 40.0),
120];
121
122/// Display label per plan id, matching what Command Code's own `/usage` shows.
123const PLAN_LABELS: &[(&str, &str)] = &[
124    ("individual-go", "Go"),
125    ("individual-goat", "GOAT"),
126    ("individual-pro", "Pro"),
127    ("individual-pro-v1", "Pro"),
128    ("individual-provider", "Provider"),
129    ("individual-max", "Max"),
130    ("individual-ultra", "Ultra"),
131    ("teams-pro", "Teams Pro"),
132];
133
134pub fn plan_label(plan_id: &str) -> String {
135    PLAN_LABELS
136        .iter()
137        .find(|(id, _)| *id == plan_id)
138        .map(|(_, label)| (*label).to_string())
139        .unwrap_or_else(|| plan_id.to_string())
140}
141
142pub fn plan_credits(plan_id: &str) -> Option<f64> {
143    PLAN_CREDITS
144        .iter()
145        .find(|(id, _)| *id == plan_id)
146        .map(|(_, credits)| *credits)
147}
148
149/// Parse `/alpha/billing/credits`.
150pub fn parse_credits(value: &Value) -> Result<Snapshot, String> {
151    let root = value
152        .as_object()
153        .ok_or_else(|| "Command Code credits response must be a JSON object".to_string())?;
154    if root.contains_key("error") {
155        return Err("Command Code credits response is an error envelope".to_string());
156    }
157
158    // `windowLimits` has been observed both at the top level and beside the
159    // ledger; accept either rather than betting on one.
160    let ledger = root.get("credits").and_then(Value::as_object);
161    let windows = root
162        .get("windowLimits")
163        .and_then(Value::as_object)
164        .or_else(|| {
165            ledger
166                .and_then(|l| l.get("windowLimits"))
167                .and_then(Value::as_object)
168        });
169
170    let credits = ledger.map(|ledger| Credits {
171        monthly: finite(ledger.get("monthlyCredits")).unwrap_or(0.0),
172        purchased: finite(ledger.get("purchasedCredits")).unwrap_or(0.0),
173        free: finite(ledger.get("freeCredits")).unwrap_or(0.0),
174    });
175
176    let snapshot = Snapshot {
177        plan: None,
178        five_hour: windows
179            .and_then(|w| parse_window(w.get("fiveHour"), "fiveHour"))
180            .transpose()?,
181        weekly: windows
182            .and_then(|w| parse_window(w.get("weekly"), "weekly"))
183            .transpose()?,
184        credits,
185        credit_pool: None,
186        period_end: None,
187    };
188
189    if snapshot.five_hour.is_none() && snapshot.weekly.is_none() && snapshot.credits.is_none() {
190        return Err("Command Code credits response has no windows or ledger".to_string());
191    }
192    Ok(snapshot)
193}
194
195/// Fold `/alpha/billing/subscriptions` into a snapshot: plan label and the
196/// allowance its tier includes.
197pub fn apply_subscription(snapshot: &mut Snapshot, value: &Value) {
198    let Some(plan_id) = value
199        .get("data")
200        .and_then(|data| data.get("planId"))
201        .and_then(Value::as_str)
202        .map(str::trim)
203        .filter(|id| !id.is_empty())
204    else {
205        return;
206    };
207    snapshot.plan = Some(plan_label(plan_id));
208    snapshot.credit_pool = plan_credits(plan_id);
209    // The billing period end is when the monthly ledger refills. Parse
210    // defensively: a missing or malformed field costs only this line.
211    snapshot.period_end = value
212        .get("data")
213        .and_then(|data| data.get("currentPeriodEnd"))
214        .and_then(Value::as_str)
215        .and_then(|text| text.trim().parse::<DateTime<chrono::FixedOffset>>().ok())
216        .map(|parsed| parsed.with_timezone(&Utc));
217}
218
219fn parse_window(value: Option<&Value>, name: &str) -> Option<Result<SpendWindow, String>> {
220    let value = value?;
221    if value.is_null() {
222        return None;
223    }
224    let Some(object) = value.as_object() else {
225        return Some(Err(format!("windowLimits.{name} must be an object")));
226    };
227    let (Some(used), Some(cap)) = (finite(object.get("used")), finite(object.get("cap"))) else {
228        return Some(Err(format!(
229            "windowLimits.{name} must carry finite used and cap"
230        )));
231    };
232    if used < 0.0 || cap < 0.0 {
233        return Some(Err(format!("windowLimits.{name} must not be negative")));
234    }
235    Some(Ok(SpendWindow {
236        used,
237        cap,
238        resets_at: object.get("resetAt").and_then(parse_reset),
239    }))
240}
241
242/// Resets arrive as millisecond epochs, which is why this cannot lean on the
243/// RFC3339 parsing every other vendor uses. A string is accepted too, in case
244/// the field ever changes shape.
245fn parse_reset(value: &Value) -> Option<DateTime<Utc>> {
246    if let Some(millis) = value.as_i64() {
247        return DateTime::from_timestamp_millis(millis);
248    }
249    if let Some(text) = value.as_str() {
250        if let Ok(parsed) = text.parse::<DateTime<chrono::FixedOffset>>() {
251            return Some(parsed.with_timezone(&Utc));
252        }
253        if let Ok(millis) = text.parse::<i64>() {
254            return DateTime::from_timestamp_millis(millis);
255        }
256    }
257    None
258}
259
260fn finite(value: Option<&Value>) -> Option<f64> {
261    value.and_then(Value::as_f64).filter(|n| n.is_finite())
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    fn credits_value() -> Value {
269        serde_json::from_str(include_str!(
270            "../../tests/fixtures/commandcode/credits.json"
271        ))
272        .expect("fixture JSON must be valid")
273    }
274
275    #[test]
276    fn parses_the_live_credits_fixture() {
277        let snapshot = parse_credits(&credits_value()).expect("fixture must parse");
278
279        let five_hour = snapshot.five_hour.expect("fiveHour window");
280        assert_eq!(five_hour.cap, 14.0);
281        assert_eq!(five_hour.pct(), 25);
282        assert_eq!(
283            five_hour.resets_at.expect("reset").to_rfc3339(),
284            "2026-08-27T20:00:00+00:00"
285        );
286
287        let weekly = snapshot.weekly.expect("weekly window");
288        assert_eq!(weekly.cap, 35.0);
289        assert_eq!(weekly.pct(), 30);
290
291        let credits = snapshot.credits.expect("ledger");
292        assert_eq!(credits.remaining(), 42.0);
293    }
294
295    #[test]
296    fn millisecond_epoch_resets_become_utc_timestamps() {
297        // The API reports resets as ms epochs, not the RFC3339 every other
298        // vendor sends; a naive parser silently drops the countdown.
299        let value = serde_json::json!({
300            "windowLimits": {"weekly": {"used": 1, "cap": 4, "resetAt": 1788374172830_i64}}
301        });
302
303        let weekly = parse_credits(&value).unwrap().weekly.expect("weekly");
304
305        assert_eq!(
306            weekly.resets_at.expect("reset").to_rfc3339(),
307            "2026-09-02T18:36:12.830+00:00"
308        );
309    }
310
311    #[test]
312    fn accepts_windows_nested_beside_the_ledger() {
313        let value = serde_json::json!({
314            "credits": {
315                "monthlyCredits": 5.0,
316                "windowLimits": {"weekly": {"used": 1, "cap": 4, "resetAt": null}}
317            }
318        });
319
320        let snapshot = parse_credits(&value).expect("nested windows must parse");
321
322        assert_eq!(snapshot.weekly.expect("weekly").pct(), 25);
323        assert_eq!(snapshot.credits.expect("ledger").remaining(), 5.0);
324    }
325
326    #[test]
327    fn sums_every_credit_pool() {
328        let value = serde_json::json!({
329            "credits": {"monthlyCredits": 10.5, "purchasedCredits": 4.0, "freeCredits": 0.5},
330            "windowLimits": {"weekly": {"used": 1, "cap": 4}}
331        });
332
333        let credits = parse_credits(&value).unwrap().credits.expect("ledger");
334
335        assert_eq!(credits.remaining(), 15.0);
336    }
337
338    #[test]
339    fn rejects_error_envelopes_and_empty_documents() {
340        for value in [
341            serde_json::json!({}),
342            serde_json::json!({"error": "unauthorized"}),
343            serde_json::json!({"windowLimits": {}}),
344        ] {
345            assert!(parse_credits(&value).is_err(), "accepted {value}");
346        }
347        assert!(parse_credits(&serde_json::json!("nope")).is_err());
348    }
349
350    #[test]
351    fn rejects_malformed_or_negative_windows() {
352        for window in [
353            serde_json::json!("not-an-object"),
354            serde_json::json!({"used": -1, "cap": 4}),
355            serde_json::json!({"used": 1}),
356            serde_json::json!({"used": "1", "cap": 4}),
357        ] {
358            let value = serde_json::json!({"windowLimits": {"weekly": window}});
359            assert!(parse_credits(&value).is_err(), "accepted {window}");
360        }
361    }
362
363    #[test]
364    fn additive_fields_and_null_windows_are_tolerated() {
365        // `exceeded` and `limited` already ship alongside the windows, and a
366        // plan without a five-hour cap sends null rather than omitting it.
367        let value = serde_json::json!({
368            "credits": {"monthlyCredits": 1.0, "unexpected": true},
369            "windowLimits": {
370                "limited": true,
371                "exceeded": null,
372                "fiveHour": null,
373                "weekly": {"used": 1, "cap": 4, "exceeded": false, "future": 1}
374            }
375        });
376
377        let snapshot = parse_credits(&value).expect("must tolerate additive fields");
378
379        assert!(snapshot.five_hour.is_none());
380        assert_eq!(snapshot.weekly.expect("weekly").pct(), 25);
381    }
382
383    #[test]
384    fn percentage_is_clamped_and_safe_at_a_zero_cap() {
385        assert_eq!(
386            SpendWindow {
387                used: 9.0,
388                cap: 0.0,
389                resets_at: None
390            }
391            .pct(),
392            0
393        );
394        assert_eq!(
395            SpendWindow {
396                used: 9.0,
397                cap: 4.0,
398                resets_at: None
399            }
400            .pct(),
401            100
402        );
403    }
404
405    #[test]
406    fn subscription_supplies_the_plan_label_and_its_allowance() {
407        let subscription: Value = serde_json::from_str(include_str!(
408            "../../tests/fixtures/commandcode/subscriptions.json"
409        ))
410        .expect("fixture JSON must be valid");
411        let mut snapshot = parse_credits(&credits_value()).unwrap();
412
413        apply_subscription(&mut snapshot, &subscription);
414
415        assert_eq!(snapshot.plan.as_deref(), Some("GOAT"));
416        assert_eq!(snapshot.credit_pool, Some(70.0));
417        // The $70 allowance less the $42 still on the ledger.
418        assert_eq!(snapshot.credits_spent(), Some(28.0));
419        // The billing period end doubles as the monthly credit reset.
420        assert_eq!(
421            snapshot.period_end.expect("period end").to_rfc3339(),
422            "2026-09-19T16:39:05+00:00"
423        );
424    }
425
426    #[test]
427    fn a_malformed_period_end_is_dropped_not_fatal() {
428        let mut snapshot = parse_credits(&credits_value()).unwrap();
429
430        apply_subscription(
431            &mut snapshot,
432            &serde_json::json!({"data": {"planId": "individual-goat", "currentPeriodEnd": "not-a-date"}}),
433        );
434
435        assert_eq!(snapshot.plan.as_deref(), Some("GOAT"));
436        assert_eq!(snapshot.period_end, None);
437    }
438
439    #[test]
440    fn unknown_plan_keeps_its_id_and_claims_no_allowance() {
441        let mut snapshot = parse_credits(&credits_value()).unwrap();
442
443        apply_subscription(
444            &mut snapshot,
445            &serde_json::json!({"data": {"planId": "individual-future"}}),
446        );
447
448        assert_eq!(snapshot.plan.as_deref(), Some("individual-future"));
449        assert_eq!(snapshot.credit_pool, None);
450        assert_eq!(snapshot.credits_spent(), None);
451    }
452
453    #[test]
454    fn missing_subscription_leaves_the_snapshot_untouched() {
455        let mut snapshot = parse_credits(&credits_value()).unwrap();
456
457        apply_subscription(&mut snapshot, &serde_json::json!({"success": false}));
458
459        assert!(snapshot.plan.is_none());
460        assert!(snapshot.weekly.is_some());
461    }
462
463    #[test]
464    fn worst_window_leads_the_bar_text() {
465        let snapshot = Snapshot {
466            five_hour: Some(SpendWindow {
467                used: 1.0,
468                cap: 10.0,
469                resets_at: None,
470            }),
471            weekly: Some(SpendWindow {
472                used: 8.0,
473                cap: 10.0,
474                resets_at: None,
475            }),
476            ..Snapshot::default()
477        };
478
479        assert_eq!(snapshot.worst_pct(), 80);
480        assert_eq!(Snapshot::default().worst_pct(), 0);
481    }
482}