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