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, CloudCredits, ExtraUsage, ExtraUsageStatus, ResetCredit,
12    ResetCredits, ScopedWindow, UsageCredits, UsageWindow, checked_reset_title,
13};
14
15/// Top-level response from `GET /api/oauth/usage`.
16///
17/// No `PartialEq`: `iguana_necktie` and `spend` are raw JSON (`RawValue`
18/// has no equality), and the snapshot — the value frontends compare —
19/// carries the equality that matters.
20#[derive(Debug, Default, Clone, Deserialize, Serialize)]
21pub struct UsageResponse {
22    #[serde(default)]
23    pub five_hour: Option<Window>,
24    #[serde(default)]
25    pub seven_day: Option<Window>,
26    #[serde(default)]
27    pub seven_day_sonnet: Option<Window>,
28    #[serde(default)]
29    pub extra_usage: Option<ExtraUsageBlock>,
30    /// Newer per-limit array. Carries model-scoped weekly windows
31    /// (`kind == "weekly_scoped"`, e.g. the Fable weekly cap) that have no
32    /// dedicated `seven_day_*` field.
33    #[serde(default)]
34    pub limits: Vec<LimitEntry>,
35    /// Banked limit resets. Present only when the request asks for them
36    /// (`?cedar_ember=1`) *and* the endpoint accepts the caller's surface —
37    /// otherwise the key is absent or null, which is not an error: most
38    /// accounts have no grant most of the time.
39    #[serde(default)]
40    pub cedar_ember: Option<ResetGrantsBlock>,
41    /// Cloud session credits (the money Claude Code's cloud sessions draw
42    /// from). Absent for plans without the grant. Captured as raw JSON and
43    /// interpreted after the fact, so a value the parser itself cannot
44    /// materialize (a `1e400` overflows every number type) degrades to
45    /// `None` for the block alone — like every block here, drift never
46    /// fails the response.
47    #[serde(default)]
48    pub iguana_necktie: Option<Box<serde_json::value::RawValue>>,
49    /// The prepaid usage-credit state. Captured raw for the same reason
50    /// `iguana_necktie` is: the filled-in shape of `balance` has never been
51    /// observed and is interpreted after the fact, and a number the parser
52    /// cannot materialize must cost the block, not the response.
53    #[serde(default)]
54    pub spend: Option<Box<serde_json::value::RawValue>>,
55}
56
57/// The `cedar_ember` block. Every field is optional because the endpoint
58/// answers with a partial block for an ineligible caller (`eligible: false`,
59/// `ineligible_reason: "surface" | "cli_version" | …`) and the reason is not
60/// ours to render — a status bar has nothing to say about an offer the
61/// account cannot take.
62#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
63pub struct ResetGrantsBlock {
64    #[serde(default)]
65    pub eligible: bool,
66    #[serde(default)]
67    pub grants: Vec<ResetGrant>,
68}
69
70/// One banked grant. `id` is the handle that *spends* the reset, so — as with
71/// Codex's `credits[].id` and SuperGrok's `token_id` — it is never
72/// deserialized, and nothing downstream can leak what was never held.
73#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
74pub struct ResetGrant {
75    /// e.g. "Claude Opus 5.5 launch: one usage-limit reset for Team members".
76    #[serde(default)]
77    pub label: Option<String>,
78    /// How many of this grant's resets are still unspent. A grant that has
79    /// been fully redeemed stays in the array at 0.
80    #[serde(default)]
81    pub resets_left: u32,
82    /// RFC3339. The deadline the user is actually racing.
83    #[serde(default)]
84    pub ends_at: Option<String>,
85    /// The server's own verdict on whether the reset can be redeemed right
86    /// now — it accounts for `starts_at`, cooldowns, and campaign state.
87    /// Defaulting to false matches the official client and keeps a grant we
88    /// cannot vouch for off the bar.
89    #[serde(default)]
90    pub usable_now: bool,
91    #[serde(default)]
92    pub paused: bool,
93}
94
95/// One entry of the `limits[]` array. Only `weekly_scoped` entries with a
96/// model display name are lifted into the snapshot; everything else in the
97/// array duplicates `five_hour`/`seven_day`.
98#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
99pub struct LimitEntry {
100    #[serde(default)]
101    pub kind: Option<String>,
102    #[serde(default, deserialize_with = "de_percent_opt")]
103    pub percent: Option<f64>,
104    #[serde(default)]
105    pub resets_at: Option<String>,
106    #[serde(default)]
107    pub scope: Option<LimitScope>,
108}
109
110#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
111pub struct LimitScope {
112    #[serde(default)]
113    pub model: Option<LimitModel>,
114}
115
116#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
117pub struct LimitModel {
118    #[serde(default)]
119    pub display_name: Option<String>,
120}
121
122/// A single usage window — `utilization` is `0..=100` (integer percent).
123#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
124pub struct Window {
125    #[serde(default, deserialize_with = "de_percent")]
126    pub utilization: f64,
127    #[serde(default)]
128    pub resets_at: Option<String>,
129}
130
131/// Pay-as-you-go extra usage. Both money values are non-negative integer minor
132/// units, but the API sometimes returns integral floats (e.g. `0.0`). Missing
133/// values remain absent so an enabled but incomplete block cannot manufacture
134/// a zero balance.
135#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
136pub struct ExtraUsageBlock {
137    #[serde(default)]
138    pub is_enabled: bool,
139    #[serde(default, deserialize_with = "de_opt_cents")]
140    pub monthly_limit: Option<i64>,
141    #[serde(default, deserialize_with = "de_opt_cents")]
142    pub used_credits: Option<i64>,
143    /// ISO currency code (`"BRL"`, `"USD"`, …). Absent on older payloads,
144    /// which were always formatted as `$`.
145    #[serde(default, deserialize_with = "de_opt_currency")]
146    pub currency: Option<String>,
147    /// Minor-unit digits for both money fields. Gated at the parse boundary:
148    /// an absurd scale would corrupt every formatted amount downstream.
149    #[serde(default, deserialize_with = "de_opt_decimal_places")]
150    pub decimal_places: Option<u32>,
151    #[serde(default)]
152    pub user_disabled: bool,
153    #[serde(default)]
154    pub spend_limit_reached: bool,
155    #[serde(default)]
156    pub credits_ever_enabled: bool,
157    #[serde(default)]
158    pub disabled_reason: Option<String>,
159}
160
161/// The `iguana_necktie` block: the cloud session credits Claude Code's
162/// cloud sessions draw from. Every field is `Option` — the block arrives
163/// incomplete across plan tiers and over time — and every field deserializes
164/// leniently, so a field of an unexpected type becomes `None` for that field
165/// alone instead of failing the whole response. `utilization` and
166/// `used_dollars` are not lifted into the snapshot and are not read here.
167#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
168pub struct CloudCreditsBlock {
169    /// Parsed as the raw JSON number, not as an `f64`: the conversion to
170    /// cents is exact integer arithmetic (`dollars_to_cents`), and a number
171    /// the parser cannot materialize at all fails this re-parse, degrading
172    /// the block rather than the response.
173    #[serde(default, deserialize_with = "de_opt_number")]
174    pub limit_dollars: Option<serde_json::Number>,
175    #[serde(default, deserialize_with = "de_opt_number")]
176    pub remaining_dollars: Option<serde_json::Number>,
177    /// RFC 3339 on the wire; the snapshot keeps `None` when it does not parse.
178    #[serde(default, deserialize_with = "de_opt_plain_string")]
179    pub resets_at: Option<String>,
180    /// A non-empty string is a lock; the text itself is not rendered,
181    /// so it is never lifted out of the parser.
182    #[serde(default, deserialize_with = "de_opt_plain_string")]
183    pub locked_reason: Option<String>,
184}
185
186/// The `spend` block, read raw. The endpoint's filled-in `balance` has never
187/// been observed, so the two fields that are read stay JSON and are
188/// interpreted after the fact; the rest of the block is not read in this
189/// slice.
190#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
191pub struct SpendBlock {
192    #[serde(default)]
193    pub balance: Option<serde_json::Value>,
194    #[serde(default)]
195    pub auto_reload: Option<serde_json::Value>,
196}
197
198/// Re-parse a raw-captured block into its typed form, in two passes.
199///
200/// The first pass is to `Value` — this is where a number the parser cannot
201/// materialize (`1e400`) fails, costing only the block. The second accepts
202/// only an object, because an all-`#[serde(default)]` struct also
203/// deserializes from a *sequence*, filling its fields positionally: without
204/// the gate, `[1, 2]` would read as `limit_dollars: 1, remaining_dollars: 2`,
205/// the fabrication the drift discipline exists to prevent.
206fn raw_object_block<T>(raw: &serde_json::value::RawValue) -> Option<T>
207where
208    T: serde::de::DeserializeOwned,
209{
210    let v = serde_json::from_str::<serde_json::Value>(raw.get()).ok()?;
211    match v {
212        serde_json::Value::Object(_) => serde_json::from_value::<T>(v).ok(),
213        _ => None,
214    }
215}
216
217/// A raw JSON number that may be absent, null, or the wrong type: each
218/// degradation is `None` for that field alone. The number is handed on
219/// unconverted — the finite/range discipline lives in `dollars_to_cents`,
220/// in exact arithmetic, where a float round-trip could not prove it.
221fn de_opt_number<'de, D>(d: D) -> std::result::Result<Option<serde_json::Number>, D::Error>
222where
223    D: serde::Deserializer<'de>,
224{
225    match serde_json::Value::deserialize(d)? {
226        serde_json::Value::Null => Ok(None),
227        serde_json::Value::Number(n) => Ok(Some(n)),
228        _ => Ok(None),
229    }
230}
231
232/// Dollars on the wire → whole cents in exact arithmetic. One function for
233/// `limit_dollars` and `remaining_dollars` so the two cannot drift.
234///
235/// The size gate runs on the *value*, not the notation: the parser's own
236/// text for `1e14` carries no `e` (it prints `1000000000000000`), so a
237/// notation-only check would let a $100-trillion "limit" through as a
238/// confident figure. Past 1e13 dollars the number is beyond any grant this
239/// endpoint states, and 1e13 keeps `* 100` far inside i64
240/// (1e15 << 9.2e18), so the scientific-notation branch cannot overflow.
241///
242/// The plain-decimal path splits integer and fractional parts and works in
243/// integer arithmetic: `(f64 * 100.0).round()` loses monetary ties, because
244/// the product is binary (1.005 dollars is 100.5 binary cents, which rounds
245/// to 100 instead of the 101 the money says). The first two fractional
246/// digits are exact; the third decides the tie, half rounding up.
247///
248/// Negative, non-finite, or past the gate: `None`. A missing figure is never
249/// a fabricated one.
250fn dollars_to_cents(n: &serde_json::Number) -> Option<i64> {
251    let v = n.as_f64()?;
252    if !v.is_finite() || v < 0.0 || v > 1e13 {
253        return None;
254    }
255    // -0 passes the sign check but prints as "-0.0", which the digit walk
256    // below would reject: an exhausted grant reported as -0 is still $0.00.
257    if v == 0.0 {
258        return Some(0);
259    }
260    let text = n.to_string();
261    if text.contains(['e', 'E']) {
262        return Some((v * 100.0).round() as i64);
263    }
264    let (int_part, frac_part) = match text.split_once('.') {
265        Some((i, f)) => (i, f),
266        None => (text.as_str(), ""),
267    };
268    if int_part.is_empty() || int_part.bytes().any(|b| !b.is_ascii_digit()) {
269        return None;
270    }
271    let mut cents: i64 = 0;
272    for b in int_part.bytes() {
273        cents = cents.checked_mul(10)?.checked_add(i64::from(b - b'0'))?;
274    }
275    cents = cents.checked_mul(100)?;
276    let digits: Vec<u8> = frac_part.bytes().filter(|b| b.is_ascii_digit()).collect();
277    let first = i64::from(digits.first().copied().unwrap_or(b'0') - b'0');
278    let second = i64::from(digits.get(1).copied().unwrap_or(b'0') - b'0');
279    let half_up = i64::from(digits.get(2).is_some_and(|d| *d >= b'5'));
280    cents.checked_add(first * 10 + second + half_up)
281}
282
283/// A string field that may be absent, null, or the wrong type: each
284/// degradation is `None` for that field alone.
285fn de_opt_plain_string<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
286where
287    D: serde::Deserializer<'de>,
288{
289    match serde_json::Value::deserialize(d)? {
290        serde_json::Value::Null => Ok(None),
291        serde_json::Value::String(s) => Ok(Some(s)),
292        _ => Ok(None),
293    }
294}
295
296/// Accept a plausible minor-unit scale (0..=6 covers every ISO 4217 currency;
297/// the largest real exponent is 4). Integral floats are tolerated for the same
298/// reason `de_opt_cents` tolerates them: this endpoint emits them (the #30
299/// payload carries `used_credits: 14157.0`), and rejecting `2.0` would fail
300/// the whole response over a value that is unambiguous. Null/absent remains
301/// absent: a currency code alone is not enough to infer every ISO exponent.
302/// Anything else is drift: a wire `decimal_places: 100` would overflow the
303/// scale and mis-state every amount, so it fails loudly as `⚠` instead.
304fn de_opt_decimal_places<'de, D>(d: D) -> std::result::Result<Option<u32>, D::Error>
305where
306    D: serde::Deserializer<'de>,
307{
308    let n = match serde_json::Value::deserialize(d)? {
309        serde_json::Value::Null => return Ok(None),
310        serde_json::Value::Number(n) => n
311            .as_i64()
312            .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
313        _ => None,
314    };
315    match n {
316        Some(n) if (0..=6).contains(&n) => Ok(Some(n as u32)),
317        _ => Err(serde::de::Error::custom(
318            "decimal_places must be an integer in 0..=6",
319        )),
320    }
321}
322
323/// Gate the currency to a plausible ISO 4217 alpha code. The value is embedded
324/// verbatim in Pango bar markup and in the `;;`-delimited desktop FORMAT
325/// protocol, so an arbitrary string is an injection vector as well as drift;
326/// three ASCII uppercase letters can be neither.
327fn de_opt_currency<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
328where
329    D: serde::Deserializer<'de>,
330{
331    match Option::<String>::deserialize(d)? {
332        None => Ok(None),
333        Some(s) if s.len() == 3 && s.bytes().all(|b| b.is_ascii_uppercase()) => Ok(Some(s)),
334        Some(s) => Err(serde::de::Error::custom(format!(
335            "currency {s:?} is not an ISO 4217 alpha code"
336        ))),
337    }
338}
339
340fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
341where
342    D: serde::Deserializer<'de>,
343{
344    let v = serde_json::Value::deserialize(d)?;
345    match v {
346        serde_json::Value::Null => Ok(None),
347        serde_json::Value::Number(n) => {
348            if let Some(i) = n.as_i64() {
349                if i >= 0 {
350                    Ok(Some(i))
351                } else {
352                    Err(serde::de::Error::custom("cents cannot be negative"))
353                }
354            } else if let Some(f) = n.as_f64() {
355                const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
356                if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
357                    Ok(Some(f as i64))
358                } else {
359                    Err(serde::de::Error::custom(
360                        "cents must be a non-negative integer in range",
361                    ))
362                }
363            } else {
364                Err(serde::de::Error::custom("number out of i64 range"))
365            }
366        }
367        other => Err(serde::de::Error::custom(format!(
368            "expected number or null, got {other:?}"
369        ))),
370    }
371}
372
373/// Slack tolerated above 100. The endpoint occasionally reports a hair over its
374/// own cap — `used/limit` rounding, or usage that landed just before the block
375/// did — and `to_window` saturates that back to 100.
376const PCT_SLACK: f64 = 1.0;
377
378/// Gate a wire percentage into `0..=100` (+ [`PCT_SLACK`]).
379///
380/// Rejecting here rather than in `to_window` is deliberate: `to_window` is
381/// infallible and `into_snapshot` has no error channel, so the parse boundary
382/// is the only place a bad value can still become a loud failure. A rejection
383/// surfaces as `AppError::Json` and reaches the user as `⚠` — never as a
384/// number we invented. Past the slack the field simply isn't a percentage on
385/// this scale (rescaled to per-mille, a raw counter, a sentinel), and clamping
386/// it would paint a "100%" bar we cannot vouch for. Non-finite values matter
387/// most: `f64::NAN as i32` is silently `0`.
388fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
389    if !v.is_finite() {
390        return Err(E::custom(format!("percentage {v} is not finite")));
391    }
392    if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
393        return Err(E::custom(format!("percentage {v} outside 0..=100")));
394    }
395    Ok(v)
396}
397
398fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
399where
400    D: serde::Deserializer<'de>,
401{
402    checked_percent(f64::deserialize(d)?)
403}
404
405fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
406where
407    D: serde::Deserializer<'de>,
408{
409    Option::<f64>::deserialize(d)?
410        .map(checked_percent)
411        .transpose()
412}
413
414impl UsageResponse {
415    /// Lift the wire response into our canonical [`AnthropicSnapshot`].
416    ///
417    /// `plan_label` is the rendered plan name ("Max 5x" etc.), derived from
418    /// the credentials file (since the usage endpoint doesn't include it).
419    pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
420        // Window durations are constants per claudebar:172-173.
421        const SESSION: chrono::Duration = chrono::Duration::hours(5);
422        const WEEKLY: chrono::Duration = chrono::Duration::days(7);
423
424        fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
425            let Some(w) = w else {
426                return UsageWindow {
427                    utilization_pct: 0,
428                    resets_at: None,
429                    window_duration: dur,
430                };
431            };
432            UsageWindow {
433                // Round to nearest, matching claudebar's `| round` jq filter,
434                // then absorb the overshoot `de_percent` deliberately lets
435                // through (100.4 → 100) so the bar never renders past full.
436                utilization_pct: i32::from(crate::format::clamp_pct(w.utilization)),
437                resets_at: w
438                    .resets_at
439                    .as_deref()
440                    .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
441                    .map(|dt| dt.with_timezone(&chrono::Utc)),
442                window_duration: dur,
443            }
444        }
445
446        let session = to_window(self.five_hour, SESSION);
447        let weekly = to_window(self.seven_day, WEEKLY);
448        let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
449        let extra = self.extra_usage.and_then(|e| {
450            let status = if e.is_enabled {
451                ExtraUsageStatus::Active
452            } else if e.user_disabled {
453                return None;
454            } else {
455                match e.disabled_reason.as_deref() {
456                    Some("out_of_credits") => ExtraUsageStatus::OutOfCredits,
457                    Some("monthly_limit_reached" | "spend_limit_reached") => {
458                        ExtraUsageStatus::MonthlyLimitReached
459                    }
460                    _ if e.spend_limit_reached => ExtraUsageStatus::MonthlyLimitReached,
461                    _ if e.credits_ever_enabled && e.used_credits.unwrap_or_default() > 0 => {
462                        ExtraUsageStatus::Unavailable
463                    }
464                    _ => return None,
465                }
466            };
467            Some(ExtraUsage {
468                // `monthly_limit: null` is semantic, not drift: the endpoint
469                // sends it for plans with no spending cap (e.g. Pro), so it
470                // maps to None instead of discarding the block — which hid
471                // real credit spend (#30). Only an unusable `used_credits`
472                // still drops it: without the spend there is nothing to show.
473                limit: e.monthly_limit.map(Cents),
474                spent: Cents(e.used_credits?),
475                // Preserve an absent scale. Currency codes span zero through
476                // four decimal minor units, so guessing would fabricate the
477                // displayed major-unit amount.
478                decimal_places: e.decimal_places,
479                currency: e.currency,
480                status,
481            })
482        });
483        let scoped = self
484            .limits
485            .into_iter()
486            .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
487            .filter_map(|l| {
488                let label = l.scope?.model?.display_name?;
489                // Same `?` discipline as the label above: an entry without a
490                // percentage is dropped, not defaulted. `unwrap_or(0.0)` drew a
491                // confident "Fable 0%" bar under a real model name — a number
492                // the API never sent, which is worse than no bar at all.
493                let utilization = l.percent?;
494                let window = to_window(
495                    Some(Window {
496                        utilization,
497                        resets_at: l.resets_at,
498                    }),
499                    WEEKLY,
500                );
501                Some(ScopedWindow { label, window })
502            })
503            .collect();
504
505        let cloud_credits = self
506            .iguana_necktie
507            .as_ref()
508            .and_then(|raw| raw_object_block::<CloudCreditsBlock>(raw))
509            .and_then(cloud_credits_from);
510        let usage_credits = self
511            .spend
512            .as_ref()
513            .and_then(|raw| raw_object_block::<SpendBlock>(raw))
514            .and_then(usage_credits_from);
515        AnthropicSnapshot {
516            plan: plan_label,
517            session,
518            weekly,
519            sonnet,
520            scoped,
521            extra,
522            reset_credits: reset_credits(self.cedar_ember),
523            cloud_credits,
524            usage_credits,
525        }
526    }
527}
528
529/// Lift the `iguana_necktie` block into snapshot credits.
530///
531/// Only a finite, positive limit paired with a finite, non-negative
532/// remaining is a block we can state: with no limit there is no denominator
533/// for "left of", and a negative remaining is drift, not a balance. `used`
534/// is deliberately not kept — the panel meters the remaining against the
535/// limit, and storing a third money field the renderers would never read is
536/// where copies drift.
537fn cloud_credits_from(block: CloudCreditsBlock) -> Option<CloudCredits> {
538    // Dollars → whole cents at the boundary: the snapshot derives `Eq`, so
539    // the money lives as integer cents, not the float the wire sent.
540    let limit = dollars_to_cents(block.limit_dollars.as_ref()?)?;
541    let remaining = dollars_to_cents(block.remaining_dollars.as_ref()?)?;
542    // The gate runs on the *converted* cents: a positive dollar limit that
543    // the conversion rounds to zero (e.g. 0.004) would divide by zero
544    // downstream and report a confident 0% meter for a fully spent grant.
545    if limit <= 0 || remaining < 0 {
546        return None;
547    }
548    Some(CloudCredits {
549        limit: Cents(limit),
550        remaining: Cents(remaining),
551        expires_at: block
552            .resets_at
553            .as_deref()
554            .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
555            .map(|dt| dt.with_timezone(&chrono::Utc)),
556        // A non-empty reason is the lock. The text itself is untrusted and
557        // never rendered, so only the flag crosses this boundary.
558        locked: block.locked_reason.is_some_and(|r| !r.is_empty()),
559    })
560}
561
562/// A JSON number in the sense the balance fields use it: an integer, or an
563/// integral float — this endpoint floats its numbers (`used_credits: 14157.0`
564/// is a real #30 wire value), and rejecting `1500.0` would fail over a
565/// value that is unambiguous.
566fn json_int(v: &serde_json::Value) -> Option<i64> {
567    let n = v.as_number()?;
568    // The integer is the value it claims to be. A `u64` count only passes
569    // when it also fits `i64` — `u64::MAX` converted to cents would
570    // silently wrap to a negative balance.
571    if let Some(i) = n.as_i64() {
572        return Some(i);
573    }
574    if let Some(u) = n.as_u64() {
575        return i64::try_from(u).ok();
576    }
577    // This endpoint floats its numbers (`used_credits: 14157.0` is a real
578    // #30 wire value), so an integral float is the same count — but only
579    // while `f64` can still represent every integer it holds (|v| ≤ 2^53);
580    // beyond that the "integer" has already lost digits.
581    n.as_f64()
582        .filter(|f| f.is_finite() && f.fract() == 0.0 && f.abs() <= 2_f64.powi(53))
583        .map(|f| f as i64)
584}
585
586/// Interpret the raw `spend` block into [`UsageCredits`].
587///
588/// The filled-in shape of `spend.balance` has never been observed on the
589/// wire, so the parser hands it over as JSON and this decides, after the
590/// fact, which of the two plausible shapes it is. Anything else is `None`:
591/// a balance the response does not report in a shape we can express is not
592/// shown — never as `$0.00`.
593fn usage_credits_from(spend: SpendBlock) -> Option<UsageCredits> {
594    let balance_value = spend.balance?;
595    let balance = balance_value.as_object()?;
596    let currency = balance
597        .get("currency")
598        .and_then(|v| v.as_str())
599        .map(str::to_string);
600
601    // Shape (a): `{"amount_minor", "currency", "exponent"}` — minor units
602    // with their own scale. When all three fields are usable it wins over
603    // (b): it carries the exact scale that (b) could only guess. An object
604    // missing one of the three is not shape (a) — it may still be shape (b),
605    // which is tested next. (b) is `{"amount", "currency"}` with `amount` in
606    // minor units, whose scale is only knowable for USD (two minor digits);
607    // another currency in that shape is dropped, not rescaled.
608    let shaped = (|| {
609        let minor = balance.get("amount_minor").and_then(json_int)?;
610        let exponent = balance
611            .get("exponent")
612            .and_then(json_int)
613            .filter(|e| (0..=4).contains(e))?;
614        let currency = currency.as_ref()?;
615        Some((minor, exponent as u32, currency.clone()))
616    })()
617    .or_else(|| {
618        let amount = balance.get("amount").and_then(json_int)?;
619        let currency = currency.as_ref().filter(|c| *c == "USD")?;
620        Some((amount, 2, currency.clone()))
621    });
622    let (amount, decimal_places, currency) = shaped?;
623
624    // `auto_reload`: each candidate is a boolean or it is not a signal at
625    // all. The top-level `spend.auto_reload.enabled` wins when it is one;
626    // a non-boolean there (the richer shape reports a word) falls through
627    // to the balance's own `auto_reload_settings.enabled` when that is a
628    // boolean; otherwise not reported — `None`, never a guessed "off".
629    let auto_reload = spend
630        .auto_reload
631        .as_ref()
632        .and_then(|v| v.get("enabled"))
633        .and_then(|v| v.as_bool())
634        .or_else(|| {
635            balance
636                .get("auto_reload_settings")
637                .and_then(|v| v.get("enabled"))
638                .and_then(|v| v.as_bool())
639        });
640
641    Some(UsageCredits {
642        balance: Cents(amount),
643        decimal_places,
644        currency: Some(currency),
645        auto_reload,
646    })
647}
648
649/// Project the `cedar_ember` block onto the shared banked-reset model.
650///
651/// Only grants the server says are redeemable are counted: `eligible` gates
652/// the whole block, and a grant must be unspent, not paused, and `usable_now`.
653/// A grant that has not opened yet (`starts_at` in the future) reports
654/// `usable_now: false`, and counting it would put a reset on the bar that the
655/// account cannot actually use.
656///
657/// Expiry is left to the renderer rather than filtered here: `into_snapshot`
658/// has no clock, and [`crate::format::reset_credit_lines`] already renders a
659/// lapsed credit as "expired <date>" — truthful either way, and the server
660/// retires the grant on its own schedule.
661fn reset_credits(block: Option<ResetGrantsBlock>) -> ResetCredits {
662    let Some(block) = block.filter(|block| block.eligible) else {
663        return ResetCredits::default();
664    };
665    let usable = block
666        .grants
667        .into_iter()
668        .filter(|grant| grant.usable_now && !grant.paused && grant.resets_left > 0);
669    let mut credits = ResetCredits::default();
670    for grant in usable {
671        credits.available = credits.available.saturating_add(grant.resets_left);
672        credits.credits.push(ResetCredit {
673            title: checked_reset_title(grant.label),
674            expires_at: grant
675                .ends_at
676                .as_deref()
677                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
678                .map(|dt| dt.with_timezone(&chrono::Utc)),
679        });
680    }
681    credits
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn parses_full_response() {
690        let raw = r#"{
691            "five_hour":         {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
692            "seven_day":         {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
693            "seven_day_sonnet":  {"utilization":  4.2, "resets_at": "2026-05-30T12:00:00Z"},
694            "extra_usage":       {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
695        }"#;
696        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
697        let snap = resp.into_snapshot("Max 5x".into());
698        assert_eq!(snap.session.utilization_pct, 43); // rounded
699        assert_eq!(snap.weekly.utilization_pct, 27);
700        assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
701        let extra = snap.extra.as_ref().unwrap();
702        assert_eq!(extra.limit, Some(Cents(5000)));
703        assert_eq!(extra.spent.0, 250);
704        assert!(snap.session.resets_at.is_some());
705    }
706
707    #[test]
708    fn parses_weekly_scoped_limits() {
709        // Real shape observed 2026-07-08: the Fable weekly cap only exists
710        // inside `limits[]`; there is no `seven_day_fable` field.
711        let raw = r#"{
712            "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
713            "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
714            "limits": [
715                {"kind": "session", "group": "session", "percent": 10,
716                 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
717                 "scope": null, "is_active": false},
718                {"kind": "weekly_all", "group": "weekly", "percent": 55,
719                 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
720                 "scope": null, "is_active": false},
721                {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
722                 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
723                 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
724                 "is_active": true}
725            ]
726        }"#;
727        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
728        let snap = resp.into_snapshot("Pro".into());
729        assert_eq!(snap.scoped.len(), 1);
730        assert_eq!(snap.scoped[0].label, "Fable");
731        assert_eq!(snap.scoped[0].window.utilization_pct, 84);
732        assert!(snap.scoped[0].window.resets_at.is_some());
733        // Unscoped entries never duplicate into `scoped`.
734        assert_eq!(snap.weekly.utilization_pct, 55);
735    }
736
737    #[test]
738    fn missing_limits_array_yields_empty_scoped() {
739        let raw = r#"{
740            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
741            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
742        }"#;
743        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
744        let snap = resp.into_snapshot("Pro".into());
745        assert!(snap.scoped.is_empty());
746    }
747
748    #[test]
749    fn missing_sonnet_and_extra_are_none() {
750        let raw = r#"{
751            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
752            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
753        }"#;
754        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
755        let snap = resp.into_snapshot("Pro".into());
756        assert!(snap.sonnet.is_none());
757        assert!(snap.extra.is_none());
758    }
759
760    #[test]
761    fn user_disabled_extra_usage_becomes_none() {
762        let raw = r#"{
763            "five_hour": {"utilization": 0},
764            "seven_day": {"utilization": 0},
765            "extra_usage": {"is_enabled": false, "user_disabled": true,
766                            "monthly_limit": 5000, "used_credits": 0}
767        }"#;
768        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
769        let snap = resp.into_snapshot("Pro".into());
770        assert!(snap.extra.is_none());
771    }
772
773    #[test]
774    fn provider_disabled_extra_usage_keeps_spend_and_reason_visible() {
775        let raw = r#"{
776            "extra_usage": {
777                "is_enabled": false,
778                "monthly_limit": null,
779                "used_credits": 1234.0,
780                "currency": "USD",
781                "decimal_places": 2,
782                "disabled_reason": "out_of_credits",
783                "user_disabled": false,
784                "spend_limit_reached": false,
785                "credits_ever_enabled": true
786            }
787        }"#;
788        let extra = serde_json::from_str::<UsageResponse>(raw)
789            .unwrap()
790            .into_snapshot("Pro".into())
791            .extra
792            .unwrap();
793        assert_eq!(extra.fmt_spent(), "$12.34");
794        assert_eq!(extra.status, ExtraUsageStatus::OutOfCredits);
795        assert_eq!(extra.status_note(), Some("out of credits"));
796    }
797
798    #[test]
799    fn enabled_extra_usage_without_spend_is_dropped() {
800        // `used_credits` is the essential datum: without it there is nothing
801        // truthful to display, so the block is dropped rather than inventing
802        // a $0.00 spend.
803        for raw in [
804            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
805            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000,"used_credits":null}}"#,
806        ] {
807            let resp: UsageResponse = serde_json::from_str(raw).unwrap();
808            assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
809        }
810    }
811
812    #[test]
813    fn uncapped_plan_keeps_real_spend_visible() {
814        // The #30 regression: the endpoint sends `monthly_limit: null` for
815        // plans with no spending cap (e.g. Pro). Discarding the block hid
816        // genuine credit spend — this fixture is the reporter's actual cached
817        // response (R$ 141.57, an integral float).
818        let resp: UsageResponse = serde_json::from_str(
819            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,
820                "used_credits":14157.0,"currency":"BRL","decimal_places":2,
821                "disabled_reason":null}}"#,
822        )
823        .unwrap();
824        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
825        assert_eq!(extra.limit, None);
826        assert_eq!(extra.spent.0, 14157);
827        // No denominator → no invented percentage; bar stays calm.
828        assert_eq!(extra.percent(), 0);
829        // The block's own currency and scale propagate, so the renderer can
830        // say R$141.57 instead of claiming `$` for reais.
831        assert_eq!(extra.currency.as_deref(), Some("BRL"));
832        assert_eq!(extra.decimal_places, Some(2));
833        assert_eq!(extra.fmt_spent(), "R$141.57");
834
835        // An *absent* limit renders the same way: with `#[serde(default)]`,
836        // absent and explicit null are indistinguishable at the struct level,
837        // and hiding real spend because a secondary field went missing is the
838        // exact failure mode of #30. Nothing is fabricated either way — the
839        // spend shown is exactly what the API sent.
840        let resp: UsageResponse =
841            serde_json::from_str(r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#)
842                .unwrap();
843        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
844        assert_eq!(extra.limit, None);
845        assert_eq!(extra.spent.0, 250);
846    }
847
848    #[test]
849    fn implausible_decimal_places_is_schema_drift() {
850        // A wire scale outside 0..=6 would mis-state every formatted amount
851        // (10^100 overflows outright), so it fails loudly instead.
852        for value in ["7", "-1", "100", "2.5"] {
853            let raw = format!(
854                r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"decimal_places":{value}}}}}"#
855            );
856            assert!(
857                serde_json::from_str::<UsageResponse>(&raw).is_err(),
858                "{raw}"
859            );
860        }
861        // An integral float is fine — this endpoint floats its numbers (the
862        // #30 payload has `used_credits: 14157.0`), and rejecting 2.0 would
863        // fail the whole response over an unambiguous value. Worse: the fetch
864        // caches the body BEFORE parsing, so a rejected response would evict
865        // the last good payload and leave a persistent ⚠.
866        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":2.0}}"#;
867        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
868        assert_eq!(
869            resp.into_snapshot("Pro".into())
870                .extra
871                .unwrap()
872                .decimal_places,
873            Some(2)
874        );
875        // Null and absent stay absent. With no currency, legacy payloads still
876        // render using their historical USD/cent convention.
877        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":null}}"#;
878        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
879        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
880        assert_eq!(extra.decimal_places, None);
881        assert_eq!(extra.fmt_spent(), "$2.50");
882
883        // If a currency is present without its exponent, expose the raw minor
884        // units rather than guessing. KRW is zero-decimal and KWD is
885        // three-decimal; a blanket cent fallback corrupts both.
886        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW"}}"#;
887        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
888        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
889        assert_eq!(extra.decimal_places, None);
890        assert_eq!(extra.fmt_spent(), "500 minor units KRW");
891
892        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD"}}"#;
893        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
894        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
895        assert_eq!(extra.decimal_places, None);
896        assert_eq!(extra.fmt_spent(), "1234 minor units KWD");
897
898        // Explicit exponents remain authoritative, including zero and three.
899        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW","decimal_places":0}}"#;
900        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
901        assert_eq!(
902            resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
903            "500 KRW"
904        );
905        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD","decimal_places":3}}"#;
906        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
907        assert_eq!(
908            resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
909            "1.234 KWD"
910        );
911    }
912
913    #[test]
914    fn currency_must_be_an_iso_alpha_code() {
915        // The value lands verbatim in Pango markup and in the `;;`-delimited
916        // desktop FORMAT protocol, so anything but three ASCII uppercase
917        // letters is rejected as drift — it is an injection vector besides.
918        for value in [r#""brl""#, r#""""#, r#""R$""#, r#""USD;;0""#, r#""<b>""#] {
919            let raw = format!(
920                r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"currency":{value}}}}}"#
921            );
922            assert!(
923                serde_json::from_str::<UsageResponse>(&raw).is_err(),
924                "{raw}"
925            );
926        }
927        // Null stays acceptable — same as absent.
928        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"currency":null}}"#;
929        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
930        assert_eq!(
931            resp.into_snapshot("Pro".into()).extra.unwrap().currency,
932            None
933        );
934    }
935
936    #[test]
937    fn malformed_cent_values_are_schema_drift() {
938        for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
939            let raw = format!(
940                r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
941            );
942            assert!(
943                serde_json::from_str::<UsageResponse>(&raw).is_err(),
944                "{raw}"
945            );
946        }
947
948        let resp: UsageResponse = serde_json::from_str(
949            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
950        )
951        .unwrap();
952        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
953        assert_eq!(extra.limit, Some(Cents(5000)));
954        assert_eq!(extra.spent.0, 250);
955    }
956
957    #[test]
958    fn empty_object_yields_neutral_snapshot() {
959        let resp: UsageResponse = serde_json::from_str("{}").unwrap();
960        let snap = resp.into_snapshot("Unknown".into());
961        assert_eq!(snap.session.utilization_pct, 0);
962        assert_eq!(snap.weekly.utilization_pct, 0);
963        assert!(snap.session.resets_at.is_none());
964    }
965
966    #[test]
967    fn benign_overshoot_saturates_to_hundred() {
968        // A hair over the cap is rounding noise, not drift — it must render as
969        // a full bar, not break the widget.
970        let raw = r#"{
971            "five_hour": {"utilization": 100.4},
972            "seven_day": {"utilization": 100.6}
973        }"#;
974        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
975        let snap = resp.into_snapshot("Pro".into());
976        assert_eq!(snap.session.utilization_pct, 100);
977        assert_eq!(snap.weekly.utilization_pct, 100); // rounds to 101, saturated
978    }
979
980    #[test]
981    fn out_of_range_utilization_is_rejected_not_clamped() {
982        for raw in [
983            r#"{"five_hour": {"utilization": 500}}"#,
984            r#"{"five_hour": {"utilization": -1}}"#,
985            r#"{"seven_day": {"utilization": 101.5}}"#,
986        ] {
987            let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
988            assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
989        }
990    }
991
992    #[test]
993    fn out_of_range_scoped_percent_is_rejected() {
994        // `limits[].percent` reaches the same bar via the synthesized Window.
995        let raw = r#"{
996            "limits": [{"kind": "weekly_scoped", "percent": 420,
997                        "scope": {"model": {"display_name": "Fable"}}}]
998        }"#;
999        let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
1000        assert!(err.to_string().contains("outside 0..=100"), "{err}");
1001    }
1002
1003    #[test]
1004    fn non_finite_percentage_is_rejected() {
1005        // `f64::NAN as i32` is silently 0 — the fabricated number this gate
1006        // exists to prevent. JSON has no NaN literal, so drive it directly.
1007        for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1008            assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
1009        }
1010    }
1011
1012    #[test]
1013    fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
1014        // The gate rejects impossible numbers; an absent one is not a parse
1015        // failure. But it must not become a bar either: `unwrap_or(0.0)` drew a
1016        // confident "Fable 0%" under a real model name that the API never sent.
1017        let raw = r#"{
1018            "limits": [{"kind": "weekly_scoped", "percent": null,
1019                        "scope": {"model": {"display_name": "Fable"}}}]
1020        }"#;
1021        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1022        let snap = resp.into_snapshot("Pro".into());
1023        assert!(
1024            snap.scoped.is_empty(),
1025            "a scoped limit with no percentage must not render a fabricated bar"
1026        );
1027
1028        // A real percentage still produces the window, so the drop is targeted.
1029        let ok = r#"{
1030            "limits": [{"kind": "weekly_scoped", "percent": 84,
1031                        "scope": {"model": {"display_name": "Fable"}}}]
1032        }"#;
1033        let resp: UsageResponse = serde_json::from_str(ok).unwrap();
1034        let snap = resp.into_snapshot("Pro".into());
1035        assert_eq!(snap.scoped[0].label, "Fable");
1036        assert_eq!(snap.scoped[0].window.utilization_pct, 84);
1037    }
1038
1039    /// The `cedar_ember` block as the endpoint actually returned it on
1040    /// 2026-09-24 for a Team account holding the Opus 5.5 launch grant.
1041    const LAUNCH_GRANT: &str = r#"{
1042        "five_hour": {"utilization": 2, "resets_at": "2026-09-24T17:49:59Z"},
1043        "seven_day": {"utilization": 63, "resets_at": "2026-09-25T08:59:59Z"},
1044        "cedar_ember": {
1045            "eligible": true,
1046            "ineligible_reason": null,
1047            "at_limit": false,
1048            "exhausted": [],
1049            "grants": [{
1050                "id": "a-redemption-handle",
1051                "label": "Claude Opus 5.5 launch: one usage-limit reset for Team members",
1052                "resets_total": 1,
1053                "resets_left": 1,
1054                "starts_at": "2026-09-22T16:00:00+00:00",
1055                "ends_at": "2026-10-22T16:00:00+00:00",
1056                "clears": ["five_hour", "seven_day", "seven_day_overage_included"],
1057                "paused": false,
1058                "usable_now": true,
1059                "use_requires_limit": false,
1060                "percent_used": {"five_hour": 2, "seven_day": 63},
1061                "blocking": [],
1062                "arm": null
1063            }],
1064            "next_grant_id": "a-redemption-handle",
1065            "weekly_resets_at": "2026-09-25T09:00:00+00:00",
1066            "cooldown_until": null,
1067            "event_props": null
1068        }
1069    }"#;
1070
1071    #[test]
1072    fn parses_banked_resets_from_the_cedar_ember_block() {
1073        let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
1074        let snap = resp.into_snapshot("Max 20x".into());
1075
1076        assert_eq!(snap.reset_credits.available, 1);
1077        assert_eq!(
1078            snap.reset_credits.credits[0].title.as_deref(),
1079            Some("Claude Opus 5.5 launch: one usage-limit reset for Team members")
1080        );
1081        assert_eq!(
1082            snap.reset_credits.next_expiry(),
1083            Some("2026-10-22T16:00:00Z".parse().unwrap())
1084        );
1085        // The rest of the payload is untouched by the added query parameter.
1086        assert_eq!(snap.session.utilization_pct, 2);
1087        assert_eq!(snap.weekly.utilization_pct, 63);
1088    }
1089
1090    /// `id` is what *spends* the grant. It must not survive parsing, so that
1091    /// no later cache write, tooltip, or error message can carry it.
1092    #[test]
1093    fn the_redemption_grant_id_never_leaves_the_parser() {
1094        let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
1095        let snap = resp.into_snapshot("Max 20x".into());
1096        assert!(!format!("{snap:?}").contains("a-redemption-handle"));
1097    }
1098
1099    /// An ineligible caller still gets a well-formed block. Counting its
1100    /// grants would put a reset on the bar that the account cannot redeem.
1101    #[test]
1102    fn an_ineligible_block_reports_no_resets() {
1103        let raw = r#"{
1104            "cedar_ember": {
1105                "eligible": false,
1106                "ineligible_reason": "cli_version",
1107                "grants": [{"id": "g", "resets_left": 1, "usable_now": true,
1108                            "ends_at": "2026-10-22T16:00:00Z"}]
1109            }
1110        }"#;
1111        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1112        assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
1113    }
1114
1115    /// Three ways a grant can sit in the array without being yours to use:
1116    /// not yet open (`usable_now: false`, which is how a future `starts_at`
1117    /// arrives), paused mid-campaign, or already spent down to zero.
1118    #[test]
1119    fn only_redeemable_grants_are_counted() {
1120        let raw = r#"{
1121            "cedar_ember": {
1122                "eligible": true,
1123                "grants": [
1124                    {"id": "a", "label": "not open yet", "resets_left": 1,
1125                     "usable_now": false, "paused": false},
1126                    {"id": "b", "label": "paused", "resets_left": 1,
1127                     "usable_now": true, "paused": true},
1128                    {"id": "c", "label": "spent", "resets_left": 0,
1129                     "usable_now": true, "paused": false},
1130                    {"id": "d", "label": "yours", "resets_left": 2,
1131                     "usable_now": true, "paused": false,
1132                     "ends_at": "2026-10-22T16:00:00Z"}
1133                ]
1134            }
1135        }"#;
1136        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1137        let credits = resp.into_snapshot("Pro".into()).reset_credits;
1138        assert_eq!(credits.available, 2, "only the redeemable grant counts");
1139        assert_eq!(credits.credits.len(), 1);
1140        assert_eq!(credits.credits[0].title.as_deref(), Some("yours"));
1141    }
1142
1143    /// Most accounts, most of the time. An absent or null block is the normal
1144    /// answer, not drift, so it must not fail the whole response.
1145    #[test]
1146    fn an_absent_or_null_block_is_not_a_parse_failure() {
1147        for raw in [
1148            r#"{"five_hour": {"utilization": 5}}"#,
1149            r#"{"cedar_ember": null}"#,
1150        ] {
1151            let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1152            assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
1153        }
1154    }
1155
1156    /// The label is rendered verbatim into Pango markup and the desktop
1157    /// FORMAT protocol. A hostile one is dropped without taking the expiry —
1158    /// the only actionable part — down with it.
1159    #[test]
1160    fn a_control_bearing_label_is_dropped_but_the_expiry_survives() {
1161        let raw = r#"{
1162            "cedar_ember": {
1163                "eligible": true,
1164                "grants": [{"id": "a", "label": "line\u0000break", "resets_left": 1,
1165                            "usable_now": true, "ends_at": "2026-10-22T16:00:00Z"}]
1166            }
1167        }"#;
1168        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1169        let credits = resp.into_snapshot("Pro".into()).reset_credits;
1170        assert_eq!(credits.available, 1);
1171        assert!(credits.credits[0].title.is_none());
1172        assert!(credits.credits[0].expires_at.is_some());
1173    }
1174
1175    #[test]
1176    fn unparseable_reset_becomes_none() {
1177        let raw = r#"{
1178            "five_hour": {"utilization": 50, "resets_at": "not a date"},
1179            "seven_day": {"utilization": 0}
1180        }"#;
1181        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1182        let snap = resp.into_snapshot("Pro".into());
1183        assert!(snap.session.resets_at.is_none());
1184        assert_eq!(snap.session.utilization_pct, 50);
1185    }
1186
1187    /// The two credit blocks as the endpoint actually returned them on
1188    /// 2026-10-26 for a Max account with cloud sessions and no usage-credit
1189    /// balance: `iguana_necktie` holds the cloud session credits, `spend`
1190    /// states `balance: null` even though the credits are enabled.
1191    const CREDITS_PAYLOAD: &str = r#"{
1192        "five_hour": {"utilization": 12, "resets_at": "2026-10-26T15:00:00Z"},
1193        "seven_day": {"utilization": 34, "resets_at": "2026-11-02T07:00:00Z"},
1194        "iguana_necktie": {"utilization":0.0,"resets_at":"2026-11-05T07:59:00+00:00","limit_dollars":250,"used_dollars":0.0,"remaining_dollars":250.0,"locked_reason":null},
1195        "spend": {"used":{"amount_minor":0,"currency":"USD","exponent":2},"limit":{"amount_minor":0,"currency":"USD","exponent":2},"percent":0,"severity":"normal","enabled":false,"disabled_reason":"org_level_disabled_until","cap":{"money":null,"credits":{"amount_minor":0,"exponent":2}},"balance":null,"auto_reload":null,"disclaimer":"Usage credits cover you when you hit your plan limits. [Learn more](https://support.claude.com/articles/12429409)","can_purchase_credits":false,"can_toggle":false}
1196    }"#;
1197
1198    #[test]
1199    fn the_real_credits_payload_lifts_cloud_credits_and_no_usage_balance() {
1200        let snap = serde_json::from_str::<UsageResponse>(CREDITS_PAYLOAD)
1201            .unwrap()
1202            .into_snapshot("Max 5x".into());
1203        let cloud = snap
1204            .cloud_credits
1205            .as_ref()
1206            .expect("iguana_necktie must lift into cloud_credits");
1207        assert_eq!(cloud.limit, Cents(25000));
1208        assert_eq!(cloud.remaining, Cents(25000));
1209        assert_eq!(
1210            cloud.expires_at,
1211            Some("2026-11-05T07:59:00Z".parse().unwrap())
1212        );
1213        assert!(!cloud.locked);
1214        // `spend.balance` is null: a balance the response does not report is
1215        // absent, never a fabricated $0.00.
1216        assert!(snap.usage_credits.is_none());
1217        // The windows keep parsing around the two new blocks.
1218        assert_eq!(snap.session.utilization_pct, 12);
1219        assert_eq!(snap.weekly.utilization_pct, 34);
1220    }
1221
1222    #[test]
1223    fn cloud_credits_carry_the_remaining_after_spend() {
1224        // 100 of 250 spent → 150 left: the remaining, not the used, is what
1225        // the panel meters against the limit.
1226        let raw = r#"{
1227            "iguana_necktie": {"resets_at": "2026-11-05T07:59:00+00:00",
1228                               "limit_dollars": 250, "used_dollars": 100,
1229                               "remaining_dollars": 150, "locked_reason": null}
1230        }"#;
1231        let snap = serde_json::from_str::<UsageResponse>(raw)
1232            .unwrap()
1233            .into_snapshot("Max 5x".into());
1234        let cloud = snap.cloud_credits.as_ref().unwrap();
1235        assert_eq!(cloud.limit, Cents(25000));
1236        assert_eq!(cloud.remaining, Cents(15000));
1237    }
1238
1239    #[test]
1240    fn iguana_necktie_drift_degrades_to_none_not_an_error() {
1241        // Absent, null, a zero limit, a negative remaining, a field of the
1242        // wrong type, and a block of the wrong type all yield `None` — and
1243        // the rest of the response keeps parsing in every case, because the
1244        // cache holds the last good payload and a rejected response would
1245        // evict it.
1246        for raw in [
1247            r#"{"five_hour": {"utilization": 5}}"#,
1248            r#"{"five_hour": {"utilization": 5}, "iguana_necktie": null}"#,
1249            r#"{"iguana_necktie": {"limit_dollars": 0, "remaining_dollars": 250}}"#,
1250            r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": -5}}"#,
1251            r#"{"iguana_necktie": {"limit_dollars": "a lot", "remaining_dollars": 250}}"#,
1252            r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": null}}"#,
1253            r#"{"iguana_necktie": [1, 2]}"#,
1254            r#"{"iguana_necktie": "hostile"}"#,
1255        ] {
1256            let resp: UsageResponse =
1257                serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1258            assert!(
1259                resp.into_snapshot("Pro".into()).cloud_credits.is_none(),
1260                "{raw}"
1261            );
1262        }
1263        // A hostile block must not take the windows down with it.
1264        let resp: UsageResponse = serde_json::from_str(
1265            r#"{"five_hour": {"utilization": 5}, "iguana_necktie": "hostile"}"#,
1266        )
1267        .unwrap();
1268        assert_eq!(resp.into_snapshot("Pro".into()).session.utilization_pct, 5);
1269    }
1270
1271    #[test]
1272    fn a_non_empty_locked_reason_locks_the_credits_without_leaving_the_parser() {
1273        // The reason text is untrusted terminal input; only the boolean lock
1274        // crosses the parse boundary. A `<b>`-bearing reason must come back
1275        // as `locked: true` and nowhere else.
1276        let raw = r#"{
1277            "iguana_necktie": {"resets_at": "2026-11-05T07:59:00Z",
1278                               "limit_dollars": 250, "remaining_dollars": 250,
1279                               "locked_reason": "<b>org_migrating</b>"}
1280        }"#;
1281        let snap = serde_json::from_str::<UsageResponse>(raw)
1282            .unwrap()
1283            .into_snapshot("Max 5x".into());
1284        let cloud = snap.cloud_credits.as_ref().unwrap();
1285        assert!(cloud.locked);
1286        assert!(!format!("{cloud:?}").contains("<b>"));
1287
1288        // An empty string is not a lock; a wrong-typed reason is not one
1289        // either — the block still lifts on its money fields.
1290        for (reason, locked) in [("\"\"", false), ("42", false), ("[1]", false)] {
1291            let raw = format!(
1292                r#"{{"iguana_necktie": {{"limit_dollars": 250, "remaining_dollars": 250, "locked_reason": {reason}}}}}"#
1293            );
1294            let snap = serde_json::from_str::<UsageResponse>(&raw)
1295                .unwrap()
1296                .into_snapshot("Max 5x".into());
1297            assert_eq!(snap.cloud_credits.as_ref().unwrap().locked, locked, "{raw}");
1298        }
1299
1300        // An unparseable resets_at loses the expiry, not the credits.
1301        let raw = r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": 250,
1302                                           "resets_at": "not a date"}}"#;
1303        let snap = serde_json::from_str::<UsageResponse>(raw)
1304            .unwrap()
1305            .into_snapshot("Max 5x".into());
1306        let cloud = snap.cloud_credits.as_ref().unwrap();
1307        assert!(cloud.expires_at.is_none());
1308        assert_eq!(cloud.limit, Cents(25000));
1309    }
1310
1311    #[test]
1312    fn spend_balance_shape_a_lifts_minor_units_with_its_own_scale() {
1313        let raw = r#"{
1314            "spend": {"balance": {"amount_minor": 1500, "currency": "USD", "exponent": 2},
1315                      "auto_reload": {"enabled": true}}
1316        }"#;
1317        let usage = serde_json::from_str::<UsageResponse>(raw)
1318            .unwrap()
1319            .into_snapshot("Pro".into())
1320            .usage_credits
1321            .expect("shape (a) must lift into usage_credits");
1322        assert_eq!(usage.balance, Cents(1500));
1323        assert_eq!(usage.decimal_places, 2);
1324        assert_eq!(usage.currency.as_deref(), Some("USD"));
1325        // `spend.auto_reload.enabled` is the first source for the flag.
1326        assert_eq!(usage.auto_reload, Some(true));
1327
1328        // This endpoint floats its numbers: an integral float is the same
1329        // shape, and a zero-exponent currency must not be divided by 100.
1330        let raw = r#"{
1331            "spend": {"balance": {"amount_minor": 500.0, "currency": "KRW", "exponent": 0.0},
1332                      "auto_reload": null}
1333        }"#;
1334        let usage = serde_json::from_str::<UsageResponse>(raw)
1335            .unwrap()
1336            .into_snapshot("Pro".into())
1337            .usage_credits
1338            .unwrap();
1339        assert_eq!(usage.balance, Cents(500));
1340        assert_eq!(usage.decimal_places, 0);
1341        assert_eq!(usage.currency.as_deref(), Some("KRW"));
1342        assert_eq!(usage.auto_reload, None);
1343    }
1344
1345    #[test]
1346    fn spend_balance_shape_b_lifts_usd_minor_units_and_nested_auto_reload() {
1347        // Shape (b): `amount` in minor units, whose scale is only knowable
1348        // for USD — and the auto_reload flag nested in the balance itself,
1349        // which is the source reached when the top-level one is not a
1350        // boolean object.
1351        let raw = r#"{
1352            "spend": {"balance": {"amount": 750, "currency": "USD",
1353                                  "auto_reload_settings": {"enabled": false}},
1354                      "auto_reload": null}
1355        }"#;
1356        let usage = serde_json::from_str::<UsageResponse>(raw)
1357            .unwrap()
1358            .into_snapshot("Pro".into())
1359            .usage_credits
1360            .expect("shape (b) must lift into usage_credits");
1361        assert_eq!(usage.balance, Cents(750));
1362        assert_eq!(usage.decimal_places, 2);
1363        assert_eq!(usage.currency.as_deref(), Some("USD"));
1364        assert_eq!(usage.auto_reload, Some(false));
1365
1366        // The top-level flag wins when both sources are booleans.
1367        let raw = r#"{
1368            "spend": {"balance": {"amount": 750, "currency": "USD",
1369                                  "auto_reload_settings": {"enabled": false}},
1370                      "auto_reload": {"enabled": true}}
1371        }"#;
1372        let usage = serde_json::from_str::<UsageResponse>(raw)
1373            .unwrap()
1374            .into_snapshot("Pro".into())
1375            .usage_credits
1376            .unwrap();
1377        assert_eq!(usage.auto_reload, Some(true));
1378
1379        // An object that is not complete shape (a) (no `exponent`) but is
1380        // complete shape (b) takes (b): the shapes are tested independently,
1381        // a complete (a) wins, and only then is the object dropped.
1382        let raw = r#"{"spend": {"balance": {"amount_minor": 5, "amount": 100,
1383                                          "currency": "USD"}}}"#;
1384        let usage = serde_json::from_str::<UsageResponse>(raw)
1385            .unwrap()
1386            .into_snapshot("Pro".into())
1387            .usage_credits
1388            .unwrap();
1389        assert_eq!(usage.balance, Cents(100));
1390        assert_eq!(usage.decimal_places, 2);
1391    }
1392
1393    #[test]
1394    fn spend_balances_we_cannot_express_are_absent_not_zero() {
1395        // None of these may become a $0.00 row: the response did not report
1396        // a balance in a shape we can state.
1397        for raw in [
1398            // The observed real shape: `balance: null`.
1399            r#"{"spend": {"balance": null}}"#,
1400            // Shape (b) in a non-USD currency: no scale we can vouch for.
1401            r#"{"spend": {"balance": {"amount": 500, "currency": "BRL"}}}"#,
1402            // Shape (a) with an exponent outside 0..=4.
1403            r#"{"spend": {"balance": {"amount_minor": 5, "currency": "USD", "exponent": 5}}}"#,
1404            // A number is not a balance object.
1405            r#"{"spend": {"balance": 42}}"#,
1406            // An unknown shape.
1407            r#"{"spend": {"balance": {"dollars": 4}}}"#,
1408            // Shape (a) missing its currency.
1409            r#"{"spend": {"balance": {"amount_minor": 5, "exponent": 2}}}"#,
1410            // A non-boolean `enabled` at either source is not a flag.
1411            r#"{"spend": {"balance": {"amount": 750, "currency": "USD",
1412                                  "auto_reload_settings": {"enabled": "on"}},
1413                      "auto_reload": 7}}"#,
1414        ] {
1415            let resp: UsageResponse =
1416                serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1417            let snap = resp.into_snapshot("Pro".into());
1418            if raw.contains(r#""auto_reload": 7"#) {
1419                // The balance still lifts; only the untyped flag is dropped.
1420                let usage = snap.usage_credits.as_ref().unwrap();
1421                assert_eq!(usage.auto_reload, None, "{raw}");
1422            } else {
1423                assert!(snap.usage_credits.is_none(), "{raw}");
1424            }
1425        }
1426    }
1427
1428    #[test]
1429    fn spend_drift_degrades_to_none_not_an_error() {
1430        // A `spend` of the wrong type, or with hostile field values, must
1431        // not fail the whole response.
1432        for raw in [
1433            r#"{"five_hour": {"utilization": 5}}"#,
1434            r#"{"spend": null}"#,
1435            r#"{"spend": [1]}"#,
1436            r#"{"spend": "hostile"}"#,
1437            r#"{"spend": {"balance": "not a balance", "auto_reload": {"enabled": "on"}}}"#,
1438        ] {
1439            let resp: UsageResponse =
1440                serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1441            let snap = resp.into_snapshot("Pro".into());
1442            assert!(snap.usage_credits.is_none(), "{raw}");
1443        }
1444        let resp: UsageResponse =
1445            serde_json::from_str(r#"{"five_hour": {"utilization": 5}, "spend": "hostile"}"#)
1446                .unwrap();
1447        assert_eq!(resp.into_snapshot("Pro".into()).session.utilization_pct, 5);
1448    }
1449
1450    // ----------------------------------------------------------------
1451    // Numbers the parser cannot materialize degrade the block, not the
1452    // response.
1453    // ----------------------------------------------------------------
1454
1455    #[test]
1456    fn a_number_the_parser_cannot_materialize_loses_only_its_block() {
1457        // 1e400 overflows every materialized number type serde_json has.
1458        // Captured raw, it costs its own block and nothing else: the five
1459        // hour window and the rest of the answer still parse.
1460        let raw = r#"{"five_hour":{"utilization":5},"iguana_necktie":{"limit_dollars":1e400,"remaining_dollars":1},"spend":{"balance":{"amount_minor":1e400,"currency":"USD","exponent":2}}}"#;
1461        let resp: UsageResponse =
1462            serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1463        let snap = resp.into_snapshot("Pro".into());
1464        assert_eq!(snap.session.utilization_pct, 5);
1465        assert!(snap.cloud_credits.is_none(), "1e400 dollars is not a limit");
1466        assert!(
1467            snap.usage_credits.is_none(),
1468            "1e400 minor units is not a balance"
1469        );
1470    }
1471
1472    #[test]
1473    fn a_huge_finite_float_inside_a_credit_block_degrades_the_block_only() {
1474        // 1e300 is finite but far past the i64 cents range: the block's
1475        // re-parse rejects it and the rest of the answer stands.
1476        let raw = r#"{"five_hour":{"utilization":5},"iguana_necktie":{"limit_dollars":1e300,"remaining_dollars":1}}"#;
1477        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1478        let snap = resp.into_snapshot("Pro".into());
1479        assert_eq!(snap.session.utilization_pct, 5);
1480        assert!(snap.cloud_credits.is_none());
1481    }
1482
1483    // ----------------------------------------------------------------
1484    // Dollars → cents in exact arithmetic, one function.
1485    // ----------------------------------------------------------------
1486
1487    #[test]
1488    fn dollars_to_cents_rounds_monetary_ties_up_in_exact_arithmetic() {
1489        // (f64 * 100.0).round() cannot prove these: 1.005 dollars is 100.5
1490        // binary cents, which rounds to 100, not the 101 the money says.
1491        fn cents(text: &str) -> i64 {
1492            let n: serde_json::Number = serde_json::from_str(text).unwrap();
1493            dollars_to_cents(&n).unwrap()
1494        }
1495        assert_eq!(cents("1.005"), 101);
1496        assert_eq!(cents("0.004"), 0);
1497        assert_eq!(cents("149.995"), 15000);
1498        assert_eq!(cents("250"), 25000);
1499        assert_eq!(cents("0.005"), 1);
1500        assert_eq!(cents("19.99"), 1999);
1501    }
1502
1503    #[test]
1504    fn dollars_to_cents_reads_negative_zero_as_an_exhausted_zero() {
1505        for text in ["-0", "-0.0"] {
1506            let n: serde_json::Number = serde_json::from_str(text).unwrap();
1507            assert_eq!(dollars_to_cents(&n), Some(0), "{text}");
1508        }
1509        let n: serde_json::Number = serde_json::from_str("-0.01").unwrap();
1510        assert_eq!(dollars_to_cents(&n), None);
1511    }
1512
1513    #[test]
1514    fn dollars_to_cents_rejects_what_cents_cannot_hold() {
1515        fn opt(text: &str) -> Option<i64> {
1516            serde_json::from_str::<serde_json::Number>(text)
1517                .ok()
1518                .and_then(|n| dollars_to_cents(&n))
1519        }
1520        // A negative limit is drift, not a balance the meter can pace.
1521        assert_eq!(opt("-5"), None);
1522        // u64::MAX dollars overflows the integer build of the cents.
1523        assert_eq!(opt("18446744073709551615"), None);
1524        // Past the size gate: 1e13 dollars is 1e15 cents, still inside i64;
1525        // 1e14 dollars is rejected by the gate, not by an overflow.
1526        assert_eq!(opt("1e13"), Some(1_000_000_000_000_000));
1527        assert_eq!(opt("1e14"), None);
1528    }
1529
1530    #[test]
1531    fn a_positive_dollar_limit_that_rounds_to_zero_cents_does_not_stand() {
1532        // 0.004 dollars is 0.4 cents: converted, it rounds to zero, and a
1533        // zero-cent limit must not stand — the meter would divide by zero
1534        // and read a confident 0% for a fully spent grant.
1535        let raw = r#"{"iguana_necktie":{"limit_dollars":0.004,"remaining_dollars":0.001,"resets_at":"2026-11-05T07:59:00Z"}}"#;
1536        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1537        assert!(
1538            resp.into_snapshot("Pro".into()).cloud_credits.is_none(),
1539            "a limit the conversion rounds to zero is absent, not a 0% meter"
1540        );
1541    }
1542
1543    #[test]
1544    fn json_int_rejects_integers_past_its_own_range() {
1545        // u64::MAX is a legal JSON number and a legal minor-unit count in
1546        // principle — converting it to i64 cents would silently wrap to a
1547        // negative balance.
1548        assert_eq!(json_int(&serde_json::json!(18446744073709551615u64)), None);
1549        // The integral float the endpoint really sends still passes.
1550        assert_eq!(json_int(&serde_json::json!(1500.0)), Some(1500));
1551    }
1552
1553    // ----------------------------------------------------------------
1554    // auto_reload falls through a non-boolean primary.
1555    // ----------------------------------------------------------------
1556
1557    #[test]
1558    fn a_non_boolean_primary_auto_reload_falls_to_the_balance_setting() {
1559        // The top level reported a word, not a boolean: that is not a
1560        // signal, and the balance's own setting still stands.
1561        let raw = r#"{"spend":{"balance":{"amount":750,"currency":"USD","auto_reload_settings":{"enabled":true}},"auto_reload":{"enabled":"unknown"}}}"#;
1562        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1563        let snap = resp.into_snapshot("Pro".into());
1564        let credits = snap
1565            .usage_credits
1566            .expect("the balance is still shape (b) with a USD amount");
1567        assert_eq!(credits.balance, Cents(750));
1568        assert_eq!(credits.auto_reload, Some(true));
1569    }
1570}