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