Skip to main content

ai_usagebar/cursor/
types.rs

1//! Wire types for `GET cursor.com/api/usage-summary`.
2//!
3//! **Undocumented** — the endpoint the Cursor dashboard's own frontend calls
4//! to draw the "Cursor Models" / "Other Models" usage bars. Confirmed against a
5//! live Ultra account:
6//!
7//! ```json
8//! {
9//!   "billingCycleStart": "2026-07-04T00:35:51.000Z",
10//!   "billingCycleEnd":   "2026-08-04T00:35:51.000Z",
11//!   "membershipType": "ultra",
12//!   "isUnlimited": false,
13//!   "individualUsage": {
14//!     "plan": { "autoPercentUsed": 98.1, "apiPercentUsed": 100, "totalPercentUsed": 98.5 },
15//!     "onDemand": { "enabled": false }
16//!   }
17//! }
18//! ```
19//!
20//! Team accounts (and personal token-based enterprise contracts) report no
21//! `individualUsage.plan` at all — there's no per-seat request quota for
22//! `pct()` to read.
23//!
24//! **Unverified against a live team account** — we don't have one to test
25//! against. The fallback below is inferred from an independent
26//! reverse-engineering of this same endpoint
27//! (`github.com/WoojinAhn/CursorMeter`'s `UsageModels.swift`, whose doc
28//! comments say it was confirmed against live token-based enterprise
29//! contracts): on those accounts, `teamUsage` itself carries no percentages
30//! (only an `onDemand` flag) and `individualUsage.overall.limit` — the
31//! numerator needed to turn `used` cents into a percentage — comes back
32//! `null`; Cursor doesn't put the per-seat limit on *this* endpoint for that
33//! account type at all. The **only** percentage this payload exposes for such
34//! accounts is the human-readable `autoModelSelectedDisplayMessage` /
35//! `namedModelSelectedDisplayMessage` strings (e.g. `"You've used 42% of your
36//! included total usage"`), and those line up with the auto/named pools
37//! respectively — the same two pools `individualUsage.plan.{autoPercentUsed,
38//! apiPercentUsed}` reports on personal accounts (confirmed by the `SAMPLE`
39//! fixture below, where both messages' percentages match the corresponding
40//! `plan` fields exactly). So when `individualUsage.plan` is missing,
41//! `to_snapshot` parses both display messages and only accepts them as a
42//! team/enterprise snapshot if **both** parse — a single stray message is
43//! unrecognized schema drift, not a half-guessed snapshot. A payload with
44//! neither `individualUsage.plan` nor two parseable display messages is
45//! schema drift, never a fabricated zero.
46
47use chrono::{DateTime, Utc};
48use serde::Deserialize;
49
50use crate::error::{AppError, Result};
51use crate::usage::{CursorCreditGrant, CursorSnapshot};
52
53#[derive(Debug, Clone, Deserialize)]
54pub struct UsageSummary {
55    #[serde(rename = "membershipType", default)]
56    pub membership_type: String,
57    #[serde(rename = "isUnlimited", default)]
58    pub is_unlimited: bool,
59    /// RFC3339 end of the current billing cycle — when the pools reset.
60    /// Required: without it there is no reset to show, so its absence is
61    /// schema drift, not a "no reset" state.
62    #[serde(rename = "billingCycleEnd")]
63    pub billing_cycle_end: String,
64    /// RFC3339 start of the current billing cycle. Optional: it only feeds
65    /// the pace window, so a response without it still parses.
66    #[serde(rename = "billingCycleStart", default)]
67    pub billing_cycle_start: Option<String>,
68    #[serde(rename = "individualUsage")]
69    pub individual_usage: Option<IndividualUsage>,
70    /// Team-account usage. Present (possibly `{}`) whether or not the caller
71    /// is on a team; only its `onDemand` flag is modeled — see the module
72    /// doc for why the pool percentages come from the display-message
73    /// fallback below instead of from this object.
74    #[serde(rename = "teamUsage", default)]
75    pub team_usage: Option<TeamUsage>,
76    /// e.g. `"You've used 42% of your included total usage"` — the auto
77    /// (Cursor Models) pool's percentage in prose form. Redundant with
78    /// `individualUsage.plan.autoPercentUsed` on personal accounts; the only
79    /// source of that percentage on accounts with no `plan` object.
80    #[serde(rename = "autoModelSelectedDisplayMessage", default)]
81    pub auto_model_selected_display_message: Option<String>,
82    /// Same idea as above for the named/API pool, e.g. `"You've used 100% of
83    /// your included API usage"`.
84    #[serde(rename = "namedModelSelectedDisplayMessage", default)]
85    pub named_model_selected_display_message: Option<String>,
86}
87
88#[derive(Debug, Clone, Deserialize)]
89pub struct IndividualUsage {
90    pub plan: Option<PlanUsage>,
91    // ponytail: pre-existing field lacked `rename = "onDemand"`, so this
92    // never actually deserialized (always None) — `on_demand_enabled` was
93    // silently always false. Fixed in passing since the new `TeamUsage` below
94    // needs the same field to actually work.
95    #[serde(rename = "onDemand", default)]
96    pub on_demand: Option<OnDemand>,
97}
98
99#[derive(Debug, Clone, Deserialize)]
100pub struct TeamUsage {
101    #[serde(rename = "onDemand", default)]
102    pub on_demand: Option<OnDemand>,
103}
104
105#[derive(Debug, Clone, Deserialize)]
106pub struct PlanUsage {
107    /// "Cursor Models" pool (Auto + Composer).
108    #[serde(rename = "autoPercentUsed")]
109    pub auto_percent_used: f64,
110    /// "Other Models" pool (named / third-party).
111    #[serde(rename = "apiPercentUsed")]
112    pub api_percent_used: f64,
113    /// Overall included usage — the dashboard headline percentage.
114    /// Required because the Overview treats this value as authoritative; a
115    /// missing field is endpoint drift, not a real zero.
116    #[serde(rename = "totalPercentUsed")]
117    pub total_percent_used: f64,
118}
119
120#[derive(Debug, Clone, Deserialize)]
121pub struct OnDemand {
122    #[serde(default)]
123    pub enabled: bool,
124    #[serde(default)]
125    pub used: Option<i64>,
126    #[serde(default)]
127    pub limit: Option<i64>,
128}
129
130/// Round a wire percentage to an integer, matching the dashboard's whole-number
131/// display and the integer-percent convention used across every vendor here. A
132/// non-finite value (NaN/inf) means the payload wasn't what we think it is —
133/// surfaced as schema drift rather than silently rendered.
134fn pct(field: &str, v: f64) -> Result<i32> {
135    if !v.is_finite() {
136        return Err(AppError::Schema(format!(
137            "cursor: `{field}` is not a finite number"
138        )));
139    }
140    // Clamp only the low end: a pool can legitimately exceed 100% when it is
141    // over its included allowance, and callers clamp for bar width themselves.
142    // Reject absurd values before narrowing instead of saturating them to an
143    // unrelated i32 endpoint.
144    let rounded = v.round().max(0.0);
145    if rounded > f64::from(i32::MAX) {
146        return Err(AppError::Schema(format!(
147            "cursor: `{field}` is too large to represent"
148        )));
149    }
150    Ok(rounded as i32)
151}
152
153pub fn to_snapshot(resp: UsageSummary) -> Result<CursorSnapshot> {
154    let reset_at = DateTime::parse_from_rfc3339(&resp.billing_cycle_end)
155        .map_err(|e| {
156            AppError::Schema(format!(
157                "cursor: `billingCycleEnd` is not RFC3339 ({:?}): {e}",
158                resp.billing_cycle_end
159            ))
160        })?
161        .with_timezone(&Utc);
162    // Best-effort: an unparsable start only costs the exact pace window.
163    let cycle_start = resp
164        .billing_cycle_start
165        .as_deref()
166        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
167        .map(|dt| dt.with_timezone(&Utc));
168
169    let plan = title_case(&resp.membership_type);
170
171    // Unlimited plans report no meaningful pool percentages; represent them as
172    // zeros with the `unlimited` flag so renderers say "unlimited" rather than
173    // painting a bogus bar.
174    if resp.is_unlimited {
175        return Ok(CursorSnapshot {
176            plan,
177            auto_pct: 0,
178            api_pct: 0,
179            total_pct: 0,
180            unlimited: true,
181            on_demand_enabled: false,
182            on_demand_used_cents: None,
183            on_demand_limit_cents: None,
184            reset_at: Some(reset_at),
185            cycle_start,
186            credits: Vec::new(),
187        });
188    }
189
190    // `onDemand` can live under either `individualUsage` (personal accounts)
191    // or `teamUsage` (per CursorMeter's `TeamUsage`, which models nothing
192    // else there) — check both rather than assuming one.
193    let on_demand = resp
194        .individual_usage
195        .as_ref()
196        .and_then(|u| u.on_demand.as_ref())
197        .or_else(|| resp.team_usage.as_ref().and_then(|t| t.on_demand.as_ref()));
198    let on_demand_enabled = on_demand.is_some_and(|o| o.enabled);
199    let on_demand_used_cents = on_demand.and_then(|o| o.used).filter(|v| *v >= 0);
200    let on_demand_limit_cents = on_demand.and_then(|o| o.limit).filter(|v| *v >= 0);
201
202    if let Some(plan_usage) = resp.individual_usage.as_ref().and_then(|u| u.plan.as_ref()) {
203        return Ok(CursorSnapshot {
204            plan,
205            auto_pct: pct("autoPercentUsed", plan_usage.auto_percent_used)?,
206            api_pct: pct("apiPercentUsed", plan_usage.api_percent_used)?,
207            total_pct: pct("totalPercentUsed", plan_usage.total_percent_used)?,
208            unlimited: false,
209            on_demand_enabled,
210            on_demand_used_cents,
211            on_demand_limit_cents,
212            reset_at: Some(reset_at),
213            cycle_start,
214            credits: Vec::new(),
215        });
216    }
217
218    // No numeric `plan` object — the team/enterprise fallback described in
219    // the module doc. Both display messages must parse or this isn't the
220    // shape we think it is; see the module doc for why "both or neither".
221    let team_pcts = resp
222        .auto_model_selected_display_message
223        .as_deref()
224        .and_then(parse_percent_from_message)
225        .zip(
226            resp.named_model_selected_display_message
227                .as_deref()
228                .and_then(parse_percent_from_message),
229        );
230    if let Some((auto_raw, api_raw)) = team_pcts {
231        let auto_pct = pct("autoModelSelectedDisplayMessage", auto_raw)?;
232        let api_pct = pct("namedModelSelectedDisplayMessage", api_raw)?;
233        return Ok(CursorSnapshot {
234            // Flag this as the best-effort team path — distinct from the
235            // membership label alone, since the number came from prose, not
236            // the numeric `plan` object.
237            plan: format!("{plan} (team)"),
238            auto_pct,
239            api_pct,
240            // No distinct blended-total signal exists on this path (no
241            // `totalPercentUsed` equivalent message); the worse of the two
242            // pools is the best-effort stand-in.
243            total_pct: auto_pct.max(api_pct),
244            unlimited: false,
245            on_demand_enabled,
246            on_demand_used_cents,
247            on_demand_limit_cents,
248            reset_at: Some(reset_at),
249            cycle_start,
250            credits: Vec::new(),
251        });
252    }
253
254    Err(AppError::Schema(
255        "cursor: response has no `individualUsage.plan` and no parseable team-usage \
256         display message (unrecognized team-account shape)"
257            .into(),
258    ))
259}
260
261/// Pulls the leading `N` or `N.N` out of a `"…N%…"` prose string, e.g.
262/// `"You've used 98% of your included total usage"` -> `Some(98.0)`. Returns
263/// `None` if there's no `%` or nothing number-shaped precedes it, so callers
264/// treat a reworded message as unparseable rather than misreading it.
265fn parse_percent_from_message(msg: &str) -> Option<f64> {
266    let pct_idx = msg.find('%')?;
267    let before = &msg[..pct_idx];
268    let start = before
269        .rfind(|c: char| !c.is_ascii_digit() && c != '.')
270        .map(|i| i + 1)
271        .unwrap_or(0);
272    before[start..].parse::<f64>().ok()
273}
274
275/// "ultra" -> "Ultra". Cursor's `membershipType` is lowercase; the dashboard
276/// shows it title-cased.
277fn title_case(s: &str) -> String {
278    let mut chars = s.chars();
279    match chars.next() {
280        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
281        None => "Cursor".to_string(),
282    }
283}
284
285/// `GetClientVisibleCreditGrants` — the spending-page card. A body that is
286/// not that shape, including an error document, is no grants: the caller has
287/// already decided a failed grant call must not take down the usage bars.
288///
289/// Connect-RPC JSON encodes proto3 `int64` as either a number or a string.
290/// One bad grant is dropped; its siblings are kept. Soonest expiry comes first
291/// so a tooltip lists the grant that lapses next.
292pub fn parse_credit_grants(bytes: &[u8]) -> Vec<CursorCreditGrant> {
293    let Ok(value) = serde_json::from_slice::<serde_json::Value>(bytes) else {
294        return Vec::new();
295    };
296    let Some(items) = value.get("grants").and_then(serde_json::Value::as_array) else {
297        return Vec::new();
298    };
299    let mut grants: Vec<CursorCreditGrant> = items.iter().filter_map(grant_from_value).collect();
300    grants.sort_by(|a, b| match (a.expires_at, b.expires_at) {
301        (Some(left), Some(right)) => left.cmp(&right),
302        (Some(_), None) => std::cmp::Ordering::Less,
303        (None, Some(_)) => std::cmp::Ordering::Greater,
304        (None, None) => std::cmp::Ordering::Equal,
305    });
306    grants
307}
308
309fn grant_from_value(value: &serde_json::Value) -> Option<CursorCreditGrant> {
310    let obj = value.as_object()?;
311    let total_cents = json_i64(obj, &["totalCents", "total_cents"])?;
312    let remaining_cents = json_i64(obj, &["remainingCents", "remaining_cents"])?;
313    if total_cents <= 0 || remaining_cents < 0 {
314        return None;
315    }
316    let expires_at = match json_field(obj, &["expiresAtMs", "expires_at_ms"]) {
317        None | Some(serde_json::Value::Null) => None,
318        Some(raw) => {
319            let ms = json_i64_value(raw)?;
320            if ms <= 0 {
321                None
322            } else {
323                Some(DateTime::from_timestamp_millis(ms)?)
324            }
325        }
326    };
327    let display_name = json_field(obj, &["displayName", "display_name"])
328        .and_then(serde_json::Value::as_str)
329        .unwrap_or("")
330        .trim()
331        .chars()
332        .take(160)
333        .collect();
334    Some(CursorCreditGrant {
335        remaining_cents,
336        total_cents,
337        expires_at,
338        display_name,
339    })
340}
341
342fn json_field<'a>(
343    obj: &'a serde_json::Map<String, serde_json::Value>,
344    names: &[&str],
345) -> Option<&'a serde_json::Value> {
346    names.iter().find_map(|name| obj.get(*name))
347}
348
349fn json_i64(obj: &serde_json::Map<String, serde_json::Value>, names: &[&str]) -> Option<i64> {
350    json_i64_value(json_field(obj, names)?)
351}
352
353/// Proto3 JSON `int64`: a number, or a decimal string. A fractional number is
354/// not a cent count.
355fn json_i64_value(value: &serde_json::Value) -> Option<i64> {
356    match value {
357        serde_json::Value::Number(number) => {
358            if let Some(integer) = number.as_i64() {
359                return Some(integer);
360            }
361            let float = number.as_f64()?;
362            if !float.is_finite() || float.fract() != 0.0 || float.abs() > (1_i64 << 53) as f64 {
363                return None;
364            }
365            Some(float as i64)
366        }
367        serde_json::Value::String(text) => text.trim().parse().ok(),
368        _ => None,
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    #[test]
375    fn on_demand_tolerates_missing_null_and_present() {
376        // v1.11.0 shipped a regression where #[serde(default)] covered a missing
377        // field but not an explicit null, because the type was a Vec. Option
378        // handles null itself — proving that here rather than trusting it.
379        let missing: OnDemand = serde_json::from_str(r#"{"enabled":true}"#).unwrap();
380        assert_eq!(missing.used, None, "missing");
381        let null: OnDemand =
382            serde_json::from_str(r#"{"enabled":true,"used":null,"limit":null}"#).unwrap();
383        assert_eq!(
384            null.used, None,
385            "explicit null must not fail the whole parse"
386        );
387        let present: OnDemand =
388            serde_json::from_str(r#"{"enabled":true,"used":42,"limit":99}"#).unwrap();
389        assert_eq!(present.used, Some(42));
390    }
391    use super::*;
392    use chrono::TimeZone;
393
394    const SAMPLE: &str = r#"{
395        "billingCycleStart": "2026-07-04T00:35:51.000Z",
396        "billingCycleEnd": "2026-08-04T00:35:51.000Z",
397        "membershipType": "ultra",
398        "limitType": "user",
399        "isUnlimited": false,
400        "autoModelSelectedDisplayMessage": "You've used 98% of your included total usage",
401        "namedModelSelectedDisplayMessage": "You've used 100% of your included API usage",
402        "individualUsage": {
403            "plan": {
404                "enabled": true, "used": 40000, "limit": 40000, "remaining": 0,
405                "autoPercentUsed": 98.109, "apiPercentUsed": 100, "totalPercentUsed": 98.5128
406            },
407            "onDemand": { "enabled": true, "used": 1785, "limit": 35000, "remaining": 33215 }
408        },
409        "teamUsage": {}
410    }"#;
411
412    #[test]
413    fn parses_the_live_ultra_shape() {
414        let resp: UsageSummary = serde_json::from_str(SAMPLE).unwrap();
415        let snap = to_snapshot(resp).unwrap();
416        assert_eq!(snap.plan, "Ultra");
417        assert_eq!(snap.auto_pct, 98); // 98.109 rounds to 98 (matches the dashboard bar)
418        assert_eq!(snap.api_pct, 100);
419        assert_eq!(snap.total_pct, 99); // 98.5128 rounds to 99
420        assert!(!snap.unlimited);
421        assert!(snap.on_demand_enabled);
422        assert_eq!(snap.on_demand_used_cents, Some(1785));
423        assert_eq!(snap.on_demand_limit_cents, Some(35000));
424        assert_eq!(
425            snap.reset_at,
426            Some(Utc.with_ymd_and_hms(2026, 8, 4, 0, 35, 51).unwrap())
427        );
428        assert_eq!(snap.worst_pct(), 100);
429    }
430
431    #[test]
432    fn over_allowance_percentage_is_kept_above_100() {
433        let raw = r#"{
434            "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
435            "individualUsage": { "plan": { "autoPercentUsed": 142.7, "apiPercentUsed": 5, "totalPercentUsed": 80 } }
436        }"#;
437        let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
438        assert_eq!(
439            snap.auto_pct, 143,
440            "an over-quota pool must not clamp to 100"
441        );
442        assert_eq!(snap.worst_pct(), 143);
443    }
444
445    #[test]
446    fn unlimited_plan_reports_no_pool_percentages() {
447        let raw = r#"{
448            "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "enterprise",
449            "isUnlimited": true
450        }"#;
451        let snap = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap();
452        assert!(snap.unlimited);
453        assert_eq!(snap.worst_pct(), 0);
454        assert_eq!(snap.plan, "Enterprise");
455    }
456
457    #[test]
458    fn missing_individual_plan_is_schema_drift_not_zero() {
459        // A team-only or unexpected shape must not read as "0% used".
460        let raw = r#"{
461            "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
462            "teamUsage": {}
463        }"#;
464        let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
465        assert!(matches!(err, AppError::Schema(_)));
466    }
467
468    #[test]
469    fn missing_billing_cycle_end_is_a_parse_error() {
470        let raw = r#"{ "membershipType": "pro",
471            "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2, "totalPercentUsed": 1 } } }"#;
472        // `billingCycleEnd` is required by serde → a missing field fails to parse.
473        assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
474    }
475
476    #[test]
477    fn missing_total_percentage_is_a_parse_error_not_zero() {
478        let raw = r#"{
479            "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "pro",
480            "individualUsage": { "plan": { "autoPercentUsed": 1, "apiPercentUsed": 2 } }
481        }"#;
482        assert!(serde_json::from_str::<UsageSummary>(raw).is_err());
483    }
484
485    #[test]
486    fn non_finite_percentage_is_rejected() {
487        // serde rejects a non-finite JSON literal at parse time, so exercise the
488        // guard directly: a NaN reaching a percentage field must be a schema
489        // error, never rendered as a bar.
490        let resp = UsageSummary {
491            membership_type: "pro".into(),
492            is_unlimited: false,
493            billing_cycle_end: "2026-08-04T00:00:00Z".into(),
494            billing_cycle_start: None,
495            individual_usage: Some(IndividualUsage {
496                plan: Some(PlanUsage {
497                    auto_percent_used: f64::NAN,
498                    api_percent_used: 2.0,
499                    total_percent_used: 1.0,
500                }),
501                on_demand: None,
502            }),
503            team_usage: None,
504            auto_model_selected_display_message: None,
505            named_model_selected_display_message: None,
506        };
507        assert!(matches!(to_snapshot(resp), Err(AppError::Schema(_))));
508    }
509
510    #[test]
511    fn percentage_too_large_for_the_snapshot_is_rejected() {
512        assert!(matches!(
513            pct("autoPercentUsed", f64::from(i32::MAX) + 1.0),
514            Err(AppError::Schema(_))
515        ));
516    }
517
518    /// The fixture backing the team-account fallback. **Unverified against a
519    /// live team account** — see the module doc for the reasoning: no
520    /// `individualUsage.plan`, `teamUsage` carries only `onDemand`, and the
521    /// two pool percentages come from the display-message strings instead.
522    const TEAM_SAMPLE: &str = r#"{
523        "billingCycleEnd": "2026-08-04T00:35:51.000Z",
524        "membershipType": "team",
525        "isUnlimited": false,
526        "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
527        "namedModelSelectedDisplayMessage": "You've used 15% of your included API usage",
528        "teamUsage": { "onDemand": { "enabled": true } }
529    }"#;
530
531    #[test]
532    fn team_account_falls_back_to_display_message_percentages() {
533        let resp: UsageSummary = serde_json::from_str(TEAM_SAMPLE).unwrap();
534        let snap = to_snapshot(resp).unwrap();
535        assert_eq!(snap.plan, "Team (team)");
536        assert_eq!(snap.auto_pct, 42);
537        assert_eq!(snap.api_pct, 15);
538        assert_eq!(
539            snap.total_pct, 42,
540            "no blended-total signal exists; worst pool stands in"
541        );
542        assert!(!snap.unlimited);
543        assert!(
544            snap.on_demand_enabled,
545            "onDemand lives under teamUsage for a team account, not individualUsage"
546        );
547        assert_eq!(snap.worst_pct(), 42);
548    }
549
550    #[test]
551    fn team_account_with_only_one_parseable_message_is_still_schema_drift() {
552        // Guards the "both or neither" rule: a half-recognized shape must not
553        // silently render one real pool and one fabricated zero.
554        let raw = r#"{
555            "billingCycleEnd": "2026-08-04T00:00:00Z", "membershipType": "team",
556            "autoModelSelectedDisplayMessage": "You've used 42% of your included total usage",
557            "namedModelSelectedDisplayMessage": "unavailable"
558        }"#;
559        let err = to_snapshot(serde_json::from_str(raw).unwrap()).unwrap_err();
560        assert!(matches!(err, AppError::Schema(_)));
561    }
562
563    #[test]
564    fn parse_percent_from_message_reads_leading_number_before_percent_sign() {
565        assert_eq!(
566            parse_percent_from_message("You've used 98% of your included total usage"),
567            Some(98.0)
568        );
569        assert_eq!(
570            parse_percent_from_message("You've used 100% of your included API usage"),
571            Some(100.0)
572        );
573        assert_eq!(parse_percent_from_message("no percent here"), None);
574        assert_eq!(parse_percent_from_message("unavailable"), None);
575    }
576
577    #[test]
578    fn credit_grants_accept_string_and_number_cents_and_skip_a_bad_sibling() {
579        let raw = r#"{
580            "grants": [
581                {"remainingCents": "2100", "totalCents": 2500, "expiresAtMs": "1793577600000", "displayName": " Promo "},
582                {"remainingCents": 100, "total_cents": 0},
583                {"remainingCents": -1, "totalCents": 500},
584                {"totalCents": 800, "remainingCents": 800, "expiresAtMs": 1790000000000}
585            ]
586        }"#;
587        let grants = parse_credit_grants(raw.as_bytes());
588        assert_eq!(grants.len(), 2);
589        // Soonest expiry first. The zero-total and negative-remaining grants
590        // are not a balance the spending page would draw.
591        assert_eq!(grants[0].remaining_cents, 800);
592        assert_eq!(grants[0].total_cents, 800);
593        assert!(grants[0].display_name.is_empty());
594        assert_eq!(grants[1].remaining_cents, 2100);
595        assert_eq!(grants[1].total_cents, 2500);
596        assert_eq!(grants[1].display_name, "Promo");
597        assert_eq!(
598            grants[1].expires_at,
599            DateTime::from_timestamp_millis(1_793_577_600_000)
600        );
601    }
602
603    #[test]
604    fn a_grant_with_nothing_left_is_kept() {
605        let raw = r#"{"grants":[{"remainingCents":0,"totalCents":2500,"displayName":"Power user grant"}]}"#;
606        let grants = parse_credit_grants(raw.as_bytes());
607        assert_eq!(grants.len(), 1);
608        assert_eq!(grants[0].remaining_cents, 0);
609        assert_eq!(grants[0].total_cents, 2500);
610    }
611
612    #[test]
613    fn credit_grants_without_a_list_are_none() {
614        assert!(parse_credit_grants(br#"{"grants":[]}"#).is_empty());
615        assert!(parse_credit_grants(br#"{}"#).is_empty());
616        assert!(parse_credit_grants(br#"{"grants":null}"#).is_empty());
617        assert!(parse_credit_grants(b"not json").is_empty());
618    }
619
620    #[test]
621    fn a_zero_expiry_is_a_grant_with_no_date() {
622        let raw = r#"{"grants":[{"remainingCents":500,"totalCents":500,"expiresAtMs":0}]}"#;
623        let grants = parse_credit_grants(raw.as_bytes());
624        assert_eq!(grants.len(), 1);
625        assert!(grants[0].expires_at.is_none());
626    }
627}