Skip to main content

ai_usagebar/anthropic/
types.rs

1//! Wire types for the Anthropic OAuth usage endpoint.
2//!
3//! Every field is `Option<T>` or has `#[serde(default)]` — the endpoint is
4//! undocumented and the shape varies across plan tiers and over time. The
5//! lossy `serde(default)` approach matches claudebar's jq pattern of
6//! `.field // empty`.
7
8use serde::{Deserialize, Serialize};
9
10use crate::usage::{
11    AnthropicSnapshot, Cents, ExtraUsage, ExtraUsageStatus, ResetCredit, ResetCredits,
12    ScopedWindow, UsageWindow, checked_reset_title,
13};
14
15/// Top-level response from `GET /api/oauth/usage`.
16#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
17pub struct UsageResponse {
18    #[serde(default)]
19    pub five_hour: Option<Window>,
20    #[serde(default)]
21    pub seven_day: Option<Window>,
22    #[serde(default)]
23    pub seven_day_sonnet: Option<Window>,
24    #[serde(default)]
25    pub extra_usage: Option<ExtraUsageBlock>,
26    /// Newer per-limit array. Carries model-scoped weekly windows
27    /// (`kind == "weekly_scoped"`, e.g. the Fable weekly cap) that have no
28    /// dedicated `seven_day_*` field.
29    #[serde(default)]
30    pub limits: Vec<LimitEntry>,
31    /// Banked limit resets. Present only when the request asks for them
32    /// (`?cedar_ember=1`) *and* the endpoint accepts the caller's surface —
33    /// otherwise the key is absent or null, which is not an error: most
34    /// accounts have no grant most of the time.
35    #[serde(default)]
36    pub cedar_ember: Option<ResetGrantsBlock>,
37}
38
39/// The `cedar_ember` block. Every field is optional because the endpoint
40/// answers with a partial block for an ineligible caller (`eligible: false`,
41/// `ineligible_reason: "surface" | "cli_version" | …`) and the reason is not
42/// ours to render — a status bar has nothing to say about an offer the
43/// account cannot take.
44#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
45pub struct ResetGrantsBlock {
46    #[serde(default)]
47    pub eligible: bool,
48    #[serde(default)]
49    pub grants: Vec<ResetGrant>,
50}
51
52/// One banked grant. `id` is the handle that *spends* the reset, so — as with
53/// Codex's `credits[].id` and SuperGrok's `token_id` — it is never
54/// deserialized, and nothing downstream can leak what was never held.
55#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
56pub struct ResetGrant {
57    /// e.g. "Claude Opus 5.5 launch: one usage-limit reset for Team members".
58    #[serde(default)]
59    pub label: Option<String>,
60    /// How many of this grant's resets are still unspent. A grant that has
61    /// been fully redeemed stays in the array at 0.
62    #[serde(default)]
63    pub resets_left: u32,
64    /// RFC3339. The deadline the user is actually racing.
65    #[serde(default)]
66    pub ends_at: Option<String>,
67    /// The server's own verdict on whether the reset can be redeemed right
68    /// now — it accounts for `starts_at`, cooldowns, and campaign state.
69    /// Defaulting to false matches the official client and keeps a grant we
70    /// cannot vouch for off the bar.
71    #[serde(default)]
72    pub usable_now: bool,
73    #[serde(default)]
74    pub paused: bool,
75}
76
77/// One entry of the `limits[]` array. Only `weekly_scoped` entries with a
78/// model display name are lifted into the snapshot; everything else in the
79/// array duplicates `five_hour`/`seven_day`.
80#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
81pub struct LimitEntry {
82    #[serde(default)]
83    pub kind: Option<String>,
84    #[serde(default, deserialize_with = "de_percent_opt")]
85    pub percent: Option<f64>,
86    #[serde(default)]
87    pub resets_at: Option<String>,
88    #[serde(default)]
89    pub scope: Option<LimitScope>,
90}
91
92#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
93pub struct LimitScope {
94    #[serde(default)]
95    pub model: Option<LimitModel>,
96}
97
98#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
99pub struct LimitModel {
100    #[serde(default)]
101    pub display_name: Option<String>,
102}
103
104/// A single usage window — `utilization` is `0..=100` (integer percent).
105#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
106pub struct Window {
107    #[serde(default, deserialize_with = "de_percent")]
108    pub utilization: f64,
109    #[serde(default)]
110    pub resets_at: Option<String>,
111}
112
113/// Pay-as-you-go extra usage. Both money values are non-negative integer minor
114/// units, but the API sometimes returns integral floats (e.g. `0.0`). Missing
115/// values remain absent so an enabled but incomplete block cannot manufacture
116/// a zero balance.
117#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
118pub struct ExtraUsageBlock {
119    #[serde(default)]
120    pub is_enabled: bool,
121    #[serde(default, deserialize_with = "de_opt_cents")]
122    pub monthly_limit: Option<i64>,
123    #[serde(default, deserialize_with = "de_opt_cents")]
124    pub used_credits: Option<i64>,
125    /// ISO currency code (`"BRL"`, `"USD"`, …). Absent on older payloads,
126    /// which were always formatted as `$`.
127    #[serde(default, deserialize_with = "de_opt_currency")]
128    pub currency: Option<String>,
129    /// Minor-unit digits for both money fields. Gated at the parse boundary:
130    /// an absurd scale would corrupt every formatted amount downstream.
131    #[serde(default, deserialize_with = "de_opt_decimal_places")]
132    pub decimal_places: Option<u32>,
133    #[serde(default)]
134    pub user_disabled: bool,
135    #[serde(default)]
136    pub spend_limit_reached: bool,
137    #[serde(default)]
138    pub credits_ever_enabled: bool,
139    #[serde(default)]
140    pub disabled_reason: Option<String>,
141}
142
143/// Accept a plausible minor-unit scale (0..=6 covers every ISO 4217 currency;
144/// the largest real exponent is 4). Integral floats are tolerated for the same
145/// reason `de_opt_cents` tolerates them: this endpoint emits them (the #30
146/// payload carries `used_credits: 14157.0`), and rejecting `2.0` would fail
147/// the whole response over a value that is unambiguous. Null/absent remains
148/// absent: a currency code alone is not enough to infer every ISO exponent.
149/// Anything else is drift: a wire `decimal_places: 100` would overflow the
150/// scale and mis-state every amount, so it fails loudly as `⚠` instead.
151fn de_opt_decimal_places<'de, D>(d: D) -> std::result::Result<Option<u32>, D::Error>
152where
153    D: serde::Deserializer<'de>,
154{
155    let n = match serde_json::Value::deserialize(d)? {
156        serde_json::Value::Null => return Ok(None),
157        serde_json::Value::Number(n) => n
158            .as_i64()
159            .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
160        _ => None,
161    };
162    match n {
163        Some(n) if (0..=6).contains(&n) => Ok(Some(n as u32)),
164        _ => Err(serde::de::Error::custom(
165            "decimal_places must be an integer in 0..=6",
166        )),
167    }
168}
169
170/// Gate the currency to a plausible ISO 4217 alpha code. The value is embedded
171/// verbatim in Pango bar markup and in the `;;`-delimited desktop FORMAT
172/// protocol, so an arbitrary string is an injection vector as well as drift;
173/// three ASCII uppercase letters can be neither.
174fn de_opt_currency<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
175where
176    D: serde::Deserializer<'de>,
177{
178    match Option::<String>::deserialize(d)? {
179        None => Ok(None),
180        Some(s) if s.len() == 3 && s.bytes().all(|b| b.is_ascii_uppercase()) => Ok(Some(s)),
181        Some(s) => Err(serde::de::Error::custom(format!(
182            "currency {s:?} is not an ISO 4217 alpha code"
183        ))),
184    }
185}
186
187fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
188where
189    D: serde::Deserializer<'de>,
190{
191    let v = serde_json::Value::deserialize(d)?;
192    match v {
193        serde_json::Value::Null => Ok(None),
194        serde_json::Value::Number(n) => {
195            if let Some(i) = n.as_i64() {
196                if i >= 0 {
197                    Ok(Some(i))
198                } else {
199                    Err(serde::de::Error::custom("cents cannot be negative"))
200                }
201            } else if let Some(f) = n.as_f64() {
202                const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
203                if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
204                    Ok(Some(f as i64))
205                } else {
206                    Err(serde::de::Error::custom(
207                        "cents must be a non-negative integer in range",
208                    ))
209                }
210            } else {
211                Err(serde::de::Error::custom("number out of i64 range"))
212            }
213        }
214        other => Err(serde::de::Error::custom(format!(
215            "expected number or null, got {other:?}"
216        ))),
217    }
218}
219
220/// Slack tolerated above 100. The endpoint occasionally reports a hair over its
221/// own cap — `used/limit` rounding, or usage that landed just before the block
222/// did — and `to_window` saturates that back to 100.
223const PCT_SLACK: f64 = 1.0;
224
225/// Gate a wire percentage into `0..=100` (+ [`PCT_SLACK`]).
226///
227/// Rejecting here rather than in `to_window` is deliberate: `to_window` is
228/// infallible and `into_snapshot` has no error channel, so the parse boundary
229/// is the only place a bad value can still become a loud failure. A rejection
230/// surfaces as `AppError::Json` and reaches the user as `⚠` — never as a
231/// number we invented. Past the slack the field simply isn't a percentage on
232/// this scale (rescaled to per-mille, a raw counter, a sentinel), and clamping
233/// it would paint a "100%" bar we cannot vouch for. Non-finite values matter
234/// most: `f64::NAN as i32` is silently `0`.
235fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
236    if !v.is_finite() {
237        return Err(E::custom(format!("percentage {v} is not finite")));
238    }
239    if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
240        return Err(E::custom(format!("percentage {v} outside 0..=100")));
241    }
242    Ok(v)
243}
244
245fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
246where
247    D: serde::Deserializer<'de>,
248{
249    checked_percent(f64::deserialize(d)?)
250}
251
252fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
253where
254    D: serde::Deserializer<'de>,
255{
256    Option::<f64>::deserialize(d)?
257        .map(checked_percent)
258        .transpose()
259}
260
261impl UsageResponse {
262    /// Lift the wire response into our canonical [`AnthropicSnapshot`].
263    ///
264    /// `plan_label` is the rendered plan name ("Max 5x" etc.), derived from
265    /// the credentials file (since the usage endpoint doesn't include it).
266    pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
267        // Window durations are constants per claudebar:172-173.
268        const SESSION: chrono::Duration = chrono::Duration::hours(5);
269        const WEEKLY: chrono::Duration = chrono::Duration::days(7);
270
271        fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
272            let Some(w) = w else {
273                return UsageWindow {
274                    utilization_pct: 0,
275                    resets_at: None,
276                    window_duration: dur,
277                };
278            };
279            UsageWindow {
280                // Round to nearest, matching claudebar's `| round` jq filter,
281                // then absorb the overshoot `de_percent` deliberately lets
282                // through (100.4 → 100) so the bar never renders past full.
283                utilization_pct: i32::from(crate::format::clamp_pct(w.utilization)),
284                resets_at: w
285                    .resets_at
286                    .as_deref()
287                    .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
288                    .map(|dt| dt.with_timezone(&chrono::Utc)),
289                window_duration: dur,
290            }
291        }
292
293        let session = to_window(self.five_hour, SESSION);
294        let weekly = to_window(self.seven_day, WEEKLY);
295        let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
296        let extra = self.extra_usage.and_then(|e| {
297            let status = if e.is_enabled {
298                ExtraUsageStatus::Active
299            } else if e.user_disabled {
300                return None;
301            } else {
302                match e.disabled_reason.as_deref() {
303                    Some("out_of_credits") => ExtraUsageStatus::OutOfCredits,
304                    Some("monthly_limit_reached" | "spend_limit_reached") => {
305                        ExtraUsageStatus::MonthlyLimitReached
306                    }
307                    _ if e.spend_limit_reached => ExtraUsageStatus::MonthlyLimitReached,
308                    _ if e.credits_ever_enabled && e.used_credits.unwrap_or_default() > 0 => {
309                        ExtraUsageStatus::Unavailable
310                    }
311                    _ => return None,
312                }
313            };
314            Some(ExtraUsage {
315                // `monthly_limit: null` is semantic, not drift: the endpoint
316                // sends it for plans with no spending cap (e.g. Pro), so it
317                // maps to None instead of discarding the block — which hid
318                // real credit spend (#30). Only an unusable `used_credits`
319                // still drops it: without the spend there is nothing to show.
320                limit: e.monthly_limit.map(Cents),
321                spent: Cents(e.used_credits?),
322                // Preserve an absent scale. Currency codes span zero through
323                // four decimal minor units, so guessing would fabricate the
324                // displayed major-unit amount.
325                decimal_places: e.decimal_places,
326                currency: e.currency,
327                status,
328            })
329        });
330        let scoped = self
331            .limits
332            .into_iter()
333            .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
334            .filter_map(|l| {
335                let label = l.scope?.model?.display_name?;
336                // Same `?` discipline as the label above: an entry without a
337                // percentage is dropped, not defaulted. `unwrap_or(0.0)` drew a
338                // confident "Fable 0%" bar under a real model name — a number
339                // the API never sent, which is worse than no bar at all.
340                let utilization = l.percent?;
341                let window = to_window(
342                    Some(Window {
343                        utilization,
344                        resets_at: l.resets_at,
345                    }),
346                    WEEKLY,
347                );
348                Some(ScopedWindow { label, window })
349            })
350            .collect();
351
352        AnthropicSnapshot {
353            plan: plan_label,
354            session,
355            weekly,
356            sonnet,
357            scoped,
358            extra,
359            reset_credits: reset_credits(self.cedar_ember),
360        }
361    }
362}
363
364/// Project the `cedar_ember` block onto the shared banked-reset model.
365///
366/// Only grants the server says are redeemable are counted: `eligible` gates
367/// the whole block, and a grant must be unspent, not paused, and `usable_now`.
368/// A grant that has not opened yet (`starts_at` in the future) reports
369/// `usable_now: false`, and counting it would put a reset on the bar that the
370/// account cannot actually use.
371///
372/// Expiry is left to the renderer rather than filtered here: `into_snapshot`
373/// has no clock, and [`crate::format::reset_credit_lines`] already renders a
374/// lapsed credit as "expired <date>" — truthful either way, and the server
375/// retires the grant on its own schedule.
376fn reset_credits(block: Option<ResetGrantsBlock>) -> ResetCredits {
377    let Some(block) = block.filter(|block| block.eligible) else {
378        return ResetCredits::default();
379    };
380    let usable = block
381        .grants
382        .into_iter()
383        .filter(|grant| grant.usable_now && !grant.paused && grant.resets_left > 0);
384    let mut credits = ResetCredits::default();
385    for grant in usable {
386        credits.available = credits.available.saturating_add(grant.resets_left);
387        credits.credits.push(ResetCredit {
388            title: checked_reset_title(grant.label),
389            expires_at: grant
390                .ends_at
391                .as_deref()
392                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
393                .map(|dt| dt.with_timezone(&chrono::Utc)),
394        });
395    }
396    credits
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    #[test]
404    fn parses_full_response() {
405        let raw = r#"{
406            "five_hour":         {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
407            "seven_day":         {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
408            "seven_day_sonnet":  {"utilization":  4.2, "resets_at": "2026-05-30T12:00:00Z"},
409            "extra_usage":       {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
410        }"#;
411        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
412        let snap = resp.into_snapshot("Max 5x".into());
413        assert_eq!(snap.session.utilization_pct, 43); // rounded
414        assert_eq!(snap.weekly.utilization_pct, 27);
415        assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
416        let extra = snap.extra.as_ref().unwrap();
417        assert_eq!(extra.limit, Some(Cents(5000)));
418        assert_eq!(extra.spent.0, 250);
419        assert!(snap.session.resets_at.is_some());
420    }
421
422    #[test]
423    fn parses_weekly_scoped_limits() {
424        // Real shape observed 2026-07-08: the Fable weekly cap only exists
425        // inside `limits[]`; there is no `seven_day_fable` field.
426        let raw = r#"{
427            "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
428            "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
429            "limits": [
430                {"kind": "session", "group": "session", "percent": 10,
431                 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
432                 "scope": null, "is_active": false},
433                {"kind": "weekly_all", "group": "weekly", "percent": 55,
434                 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
435                 "scope": null, "is_active": false},
436                {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
437                 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
438                 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
439                 "is_active": true}
440            ]
441        }"#;
442        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
443        let snap = resp.into_snapshot("Pro".into());
444        assert_eq!(snap.scoped.len(), 1);
445        assert_eq!(snap.scoped[0].label, "Fable");
446        assert_eq!(snap.scoped[0].window.utilization_pct, 84);
447        assert!(snap.scoped[0].window.resets_at.is_some());
448        // Unscoped entries never duplicate into `scoped`.
449        assert_eq!(snap.weekly.utilization_pct, 55);
450    }
451
452    #[test]
453    fn missing_limits_array_yields_empty_scoped() {
454        let raw = r#"{
455            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
456            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
457        }"#;
458        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
459        let snap = resp.into_snapshot("Pro".into());
460        assert!(snap.scoped.is_empty());
461    }
462
463    #[test]
464    fn missing_sonnet_and_extra_are_none() {
465        let raw = r#"{
466            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
467            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
468        }"#;
469        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
470        let snap = resp.into_snapshot("Pro".into());
471        assert!(snap.sonnet.is_none());
472        assert!(snap.extra.is_none());
473    }
474
475    #[test]
476    fn user_disabled_extra_usage_becomes_none() {
477        let raw = r#"{
478            "five_hour": {"utilization": 0},
479            "seven_day": {"utilization": 0},
480            "extra_usage": {"is_enabled": false, "user_disabled": true,
481                            "monthly_limit": 5000, "used_credits": 0}
482        }"#;
483        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
484        let snap = resp.into_snapshot("Pro".into());
485        assert!(snap.extra.is_none());
486    }
487
488    #[test]
489    fn provider_disabled_extra_usage_keeps_spend_and_reason_visible() {
490        let raw = r#"{
491            "extra_usage": {
492                "is_enabled": false,
493                "monthly_limit": null,
494                "used_credits": 1234.0,
495                "currency": "USD",
496                "decimal_places": 2,
497                "disabled_reason": "out_of_credits",
498                "user_disabled": false,
499                "spend_limit_reached": false,
500                "credits_ever_enabled": true
501            }
502        }"#;
503        let extra = serde_json::from_str::<UsageResponse>(raw)
504            .unwrap()
505            .into_snapshot("Pro".into())
506            .extra
507            .unwrap();
508        assert_eq!(extra.fmt_spent(), "$12.34");
509        assert_eq!(extra.status, ExtraUsageStatus::OutOfCredits);
510        assert_eq!(extra.status_note(), Some("out of credits"));
511    }
512
513    #[test]
514    fn enabled_extra_usage_without_spend_is_dropped() {
515        // `used_credits` is the essential datum: without it there is nothing
516        // truthful to display, so the block is dropped rather than inventing
517        // a $0.00 spend.
518        for raw in [
519            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
520            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000,"used_credits":null}}"#,
521        ] {
522            let resp: UsageResponse = serde_json::from_str(raw).unwrap();
523            assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
524        }
525    }
526
527    #[test]
528    fn uncapped_plan_keeps_real_spend_visible() {
529        // The #30 regression: the endpoint sends `monthly_limit: null` for
530        // plans with no spending cap (e.g. Pro). Discarding the block hid
531        // genuine credit spend — this fixture is the reporter's actual cached
532        // response (R$ 141.57, an integral float).
533        let resp: UsageResponse = serde_json::from_str(
534            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,
535                "used_credits":14157.0,"currency":"BRL","decimal_places":2,
536                "disabled_reason":null}}"#,
537        )
538        .unwrap();
539        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
540        assert_eq!(extra.limit, None);
541        assert_eq!(extra.spent.0, 14157);
542        // No denominator → no invented percentage; bar stays calm.
543        assert_eq!(extra.percent(), 0);
544        // The block's own currency and scale propagate, so the renderer can
545        // say R$141.57 instead of claiming `$` for reais.
546        assert_eq!(extra.currency.as_deref(), Some("BRL"));
547        assert_eq!(extra.decimal_places, Some(2));
548        assert_eq!(extra.fmt_spent(), "R$141.57");
549
550        // An *absent* limit renders the same way: with `#[serde(default)]`,
551        // absent and explicit null are indistinguishable at the struct level,
552        // and hiding real spend because a secondary field went missing is the
553        // exact failure mode of #30. Nothing is fabricated either way — the
554        // spend shown is exactly what the API sent.
555        let resp: UsageResponse =
556            serde_json::from_str(r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#)
557                .unwrap();
558        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
559        assert_eq!(extra.limit, None);
560        assert_eq!(extra.spent.0, 250);
561    }
562
563    #[test]
564    fn implausible_decimal_places_is_schema_drift() {
565        // A wire scale outside 0..=6 would mis-state every formatted amount
566        // (10^100 overflows outright), so it fails loudly instead.
567        for value in ["7", "-1", "100", "2.5"] {
568            let raw = format!(
569                r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"decimal_places":{value}}}}}"#
570            );
571            assert!(
572                serde_json::from_str::<UsageResponse>(&raw).is_err(),
573                "{raw}"
574            );
575        }
576        // An integral float is fine — this endpoint floats its numbers (the
577        // #30 payload has `used_credits: 14157.0`), and rejecting 2.0 would
578        // fail the whole response over an unambiguous value. Worse: the fetch
579        // caches the body BEFORE parsing, so a rejected response would evict
580        // the last good payload and leave a persistent ⚠.
581        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":2.0}}"#;
582        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
583        assert_eq!(
584            resp.into_snapshot("Pro".into())
585                .extra
586                .unwrap()
587                .decimal_places,
588            Some(2)
589        );
590        // Null and absent stay absent. With no currency, legacy payloads still
591        // render using their historical USD/cent convention.
592        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":null}}"#;
593        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
594        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
595        assert_eq!(extra.decimal_places, None);
596        assert_eq!(extra.fmt_spent(), "$2.50");
597
598        // If a currency is present without its exponent, expose the raw minor
599        // units rather than guessing. KRW is zero-decimal and KWD is
600        // three-decimal; a blanket cent fallback corrupts both.
601        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW"}}"#;
602        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
603        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
604        assert_eq!(extra.decimal_places, None);
605        assert_eq!(extra.fmt_spent(), "500 minor units KRW");
606
607        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD"}}"#;
608        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
609        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
610        assert_eq!(extra.decimal_places, None);
611        assert_eq!(extra.fmt_spent(), "1234 minor units KWD");
612
613        // Explicit exponents remain authoritative, including zero and three.
614        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW","decimal_places":0}}"#;
615        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
616        assert_eq!(
617            resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
618            "500 KRW"
619        );
620        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD","decimal_places":3}}"#;
621        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
622        assert_eq!(
623            resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
624            "1.234 KWD"
625        );
626    }
627
628    #[test]
629    fn currency_must_be_an_iso_alpha_code() {
630        // The value lands verbatim in Pango markup and in the `;;`-delimited
631        // desktop FORMAT protocol, so anything but three ASCII uppercase
632        // letters is rejected as drift — it is an injection vector besides.
633        for value in [r#""brl""#, r#""""#, r#""R$""#, r#""USD;;0""#, r#""<b>""#] {
634            let raw = format!(
635                r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"currency":{value}}}}}"#
636            );
637            assert!(
638                serde_json::from_str::<UsageResponse>(&raw).is_err(),
639                "{raw}"
640            );
641        }
642        // Null stays acceptable — same as absent.
643        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"currency":null}}"#;
644        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
645        assert_eq!(
646            resp.into_snapshot("Pro".into()).extra.unwrap().currency,
647            None
648        );
649    }
650
651    #[test]
652    fn malformed_cent_values_are_schema_drift() {
653        for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
654            let raw = format!(
655                r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
656            );
657            assert!(
658                serde_json::from_str::<UsageResponse>(&raw).is_err(),
659                "{raw}"
660            );
661        }
662
663        let resp: UsageResponse = serde_json::from_str(
664            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
665        )
666        .unwrap();
667        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
668        assert_eq!(extra.limit, Some(Cents(5000)));
669        assert_eq!(extra.spent.0, 250);
670    }
671
672    #[test]
673    fn empty_object_yields_neutral_snapshot() {
674        let resp: UsageResponse = serde_json::from_str("{}").unwrap();
675        let snap = resp.into_snapshot("Unknown".into());
676        assert_eq!(snap.session.utilization_pct, 0);
677        assert_eq!(snap.weekly.utilization_pct, 0);
678        assert!(snap.session.resets_at.is_none());
679    }
680
681    #[test]
682    fn benign_overshoot_saturates_to_hundred() {
683        // A hair over the cap is rounding noise, not drift — it must render as
684        // a full bar, not break the widget.
685        let raw = r#"{
686            "five_hour": {"utilization": 100.4},
687            "seven_day": {"utilization": 100.6}
688        }"#;
689        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
690        let snap = resp.into_snapshot("Pro".into());
691        assert_eq!(snap.session.utilization_pct, 100);
692        assert_eq!(snap.weekly.utilization_pct, 100); // rounds to 101, saturated
693    }
694
695    #[test]
696    fn out_of_range_utilization_is_rejected_not_clamped() {
697        for raw in [
698            r#"{"five_hour": {"utilization": 500}}"#,
699            r#"{"five_hour": {"utilization": -1}}"#,
700            r#"{"seven_day": {"utilization": 101.5}}"#,
701        ] {
702            let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
703            assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
704        }
705    }
706
707    #[test]
708    fn out_of_range_scoped_percent_is_rejected() {
709        // `limits[].percent` reaches the same bar via the synthesized Window.
710        let raw = r#"{
711            "limits": [{"kind": "weekly_scoped", "percent": 420,
712                        "scope": {"model": {"display_name": "Fable"}}}]
713        }"#;
714        let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
715        assert!(err.to_string().contains("outside 0..=100"), "{err}");
716    }
717
718    #[test]
719    fn non_finite_percentage_is_rejected() {
720        // `f64::NAN as i32` is silently 0 — the fabricated number this gate
721        // exists to prevent. JSON has no NaN literal, so drive it directly.
722        for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
723            assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
724        }
725    }
726
727    #[test]
728    fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
729        // The gate rejects impossible numbers; an absent one is not a parse
730        // failure. But it must not become a bar either: `unwrap_or(0.0)` drew a
731        // confident "Fable 0%" under a real model name that the API never sent.
732        let raw = r#"{
733            "limits": [{"kind": "weekly_scoped", "percent": null,
734                        "scope": {"model": {"display_name": "Fable"}}}]
735        }"#;
736        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
737        let snap = resp.into_snapshot("Pro".into());
738        assert!(
739            snap.scoped.is_empty(),
740            "a scoped limit with no percentage must not render a fabricated bar"
741        );
742
743        // A real percentage still produces the window, so the drop is targeted.
744        let ok = r#"{
745            "limits": [{"kind": "weekly_scoped", "percent": 84,
746                        "scope": {"model": {"display_name": "Fable"}}}]
747        }"#;
748        let resp: UsageResponse = serde_json::from_str(ok).unwrap();
749        let snap = resp.into_snapshot("Pro".into());
750        assert_eq!(snap.scoped[0].label, "Fable");
751        assert_eq!(snap.scoped[0].window.utilization_pct, 84);
752    }
753
754    /// The `cedar_ember` block as the endpoint actually returned it on
755    /// 2026-09-24 for a Team account holding the Opus 5.5 launch grant.
756    const LAUNCH_GRANT: &str = r#"{
757        "five_hour": {"utilization": 2, "resets_at": "2026-09-24T17:49:59Z"},
758        "seven_day": {"utilization": 63, "resets_at": "2026-09-25T08:59:59Z"},
759        "cedar_ember": {
760            "eligible": true,
761            "ineligible_reason": null,
762            "at_limit": false,
763            "exhausted": [],
764            "grants": [{
765                "id": "a-redemption-handle",
766                "label": "Claude Opus 5.5 launch: one usage-limit reset for Team members",
767                "resets_total": 1,
768                "resets_left": 1,
769                "starts_at": "2026-09-22T16:00:00+00:00",
770                "ends_at": "2026-10-22T16:00:00+00:00",
771                "clears": ["five_hour", "seven_day", "seven_day_overage_included"],
772                "paused": false,
773                "usable_now": true,
774                "use_requires_limit": false,
775                "percent_used": {"five_hour": 2, "seven_day": 63},
776                "blocking": [],
777                "arm": null
778            }],
779            "next_grant_id": "a-redemption-handle",
780            "weekly_resets_at": "2026-09-25T09:00:00+00:00",
781            "cooldown_until": null,
782            "event_props": null
783        }
784    }"#;
785
786    #[test]
787    fn parses_banked_resets_from_the_cedar_ember_block() {
788        let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
789        let snap = resp.into_snapshot("Max 20x".into());
790
791        assert_eq!(snap.reset_credits.available, 1);
792        assert_eq!(
793            snap.reset_credits.credits[0].title.as_deref(),
794            Some("Claude Opus 5.5 launch: one usage-limit reset for Team members")
795        );
796        assert_eq!(
797            snap.reset_credits.next_expiry(),
798            Some("2026-10-22T16:00:00Z".parse().unwrap())
799        );
800        // The rest of the payload is untouched by the added query parameter.
801        assert_eq!(snap.session.utilization_pct, 2);
802        assert_eq!(snap.weekly.utilization_pct, 63);
803    }
804
805    /// `id` is what *spends* the grant. It must not survive parsing, so that
806    /// no later cache write, tooltip, or error message can carry it.
807    #[test]
808    fn the_redemption_grant_id_never_leaves_the_parser() {
809        let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
810        let snap = resp.into_snapshot("Max 20x".into());
811        assert!(!format!("{snap:?}").contains("a-redemption-handle"));
812    }
813
814    /// An ineligible caller still gets a well-formed block. Counting its
815    /// grants would put a reset on the bar that the account cannot redeem.
816    #[test]
817    fn an_ineligible_block_reports_no_resets() {
818        let raw = r#"{
819            "cedar_ember": {
820                "eligible": false,
821                "ineligible_reason": "cli_version",
822                "grants": [{"id": "g", "resets_left": 1, "usable_now": true,
823                            "ends_at": "2026-10-22T16:00:00Z"}]
824            }
825        }"#;
826        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
827        assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
828    }
829
830    /// Three ways a grant can sit in the array without being yours to use:
831    /// not yet open (`usable_now: false`, which is how a future `starts_at`
832    /// arrives), paused mid-campaign, or already spent down to zero.
833    #[test]
834    fn only_redeemable_grants_are_counted() {
835        let raw = r#"{
836            "cedar_ember": {
837                "eligible": true,
838                "grants": [
839                    {"id": "a", "label": "not open yet", "resets_left": 1,
840                     "usable_now": false, "paused": false},
841                    {"id": "b", "label": "paused", "resets_left": 1,
842                     "usable_now": true, "paused": true},
843                    {"id": "c", "label": "spent", "resets_left": 0,
844                     "usable_now": true, "paused": false},
845                    {"id": "d", "label": "yours", "resets_left": 2,
846                     "usable_now": true, "paused": false,
847                     "ends_at": "2026-10-22T16:00:00Z"}
848                ]
849            }
850        }"#;
851        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
852        let credits = resp.into_snapshot("Pro".into()).reset_credits;
853        assert_eq!(credits.available, 2, "only the redeemable grant counts");
854        assert_eq!(credits.credits.len(), 1);
855        assert_eq!(credits.credits[0].title.as_deref(), Some("yours"));
856    }
857
858    /// Most accounts, most of the time. An absent or null block is the normal
859    /// answer, not drift, so it must not fail the whole response.
860    #[test]
861    fn an_absent_or_null_block_is_not_a_parse_failure() {
862        for raw in [
863            r#"{"five_hour": {"utilization": 5}}"#,
864            r#"{"cedar_ember": null}"#,
865        ] {
866            let resp: UsageResponse = serde_json::from_str(raw).unwrap();
867            assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
868        }
869    }
870
871    /// The label is rendered verbatim into Pango markup and the desktop
872    /// FORMAT protocol. A hostile one is dropped without taking the expiry —
873    /// the only actionable part — down with it.
874    #[test]
875    fn a_control_bearing_label_is_dropped_but_the_expiry_survives() {
876        let raw = r#"{
877            "cedar_ember": {
878                "eligible": true,
879                "grants": [{"id": "a", "label": "line\u0000break", "resets_left": 1,
880                            "usable_now": true, "ends_at": "2026-10-22T16:00:00Z"}]
881            }
882        }"#;
883        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
884        let credits = resp.into_snapshot("Pro".into()).reset_credits;
885        assert_eq!(credits.available, 1);
886        assert!(credits.credits[0].title.is_none());
887        assert!(credits.credits[0].expires_at.is_some());
888    }
889
890    #[test]
891    fn unparseable_reset_becomes_none() {
892        let raw = r#"{
893            "five_hour": {"utilization": 50, "resets_at": "not a date"},
894            "seven_day": {"utilization": 0}
895        }"#;
896        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
897        let snap = resp.into_snapshot("Pro".into());
898        assert!(snap.session.resets_at.is_none());
899        assert_eq!(snap.session.utilization_pct, 50);
900    }
901}