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::{AnthropicSnapshot, Cents, ExtraUsage, ScopedWindow, UsageWindow};
11
12/// Top-level response from `GET /api/oauth/usage`.
13#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
14pub struct UsageResponse {
15    #[serde(default)]
16    pub five_hour: Option<Window>,
17    #[serde(default)]
18    pub seven_day: Option<Window>,
19    #[serde(default)]
20    pub seven_day_sonnet: Option<Window>,
21    #[serde(default)]
22    pub extra_usage: Option<ExtraUsageBlock>,
23    /// Newer per-limit array. Carries model-scoped weekly windows
24    /// (`kind == "weekly_scoped"`, e.g. the Fable weekly cap) that have no
25    /// dedicated `seven_day_*` field.
26    #[serde(default)]
27    pub limits: Vec<LimitEntry>,
28}
29
30/// One entry of the `limits[]` array. Only `weekly_scoped` entries with a
31/// model display name are lifted into the snapshot; everything else in the
32/// array duplicates `five_hour`/`seven_day`.
33#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
34pub struct LimitEntry {
35    #[serde(default)]
36    pub kind: Option<String>,
37    #[serde(default, deserialize_with = "de_percent_opt")]
38    pub percent: Option<f64>,
39    #[serde(default)]
40    pub resets_at: Option<String>,
41    #[serde(default)]
42    pub scope: Option<LimitScope>,
43}
44
45#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
46pub struct LimitScope {
47    #[serde(default)]
48    pub model: Option<LimitModel>,
49}
50
51#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
52pub struct LimitModel {
53    #[serde(default)]
54    pub display_name: Option<String>,
55}
56
57/// A single usage window — `utilization` is `0..=100` (integer percent).
58#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
59pub struct Window {
60    #[serde(default, deserialize_with = "de_percent")]
61    pub utilization: f64,
62    #[serde(default)]
63    pub resets_at: Option<String>,
64}
65
66/// Pay-as-you-go extra usage. Both money values are non-negative integer minor
67/// units, but the API sometimes returns integral floats (e.g. `0.0`). Missing
68/// values remain absent so an enabled but incomplete block cannot manufacture
69/// a zero balance.
70#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
71pub struct ExtraUsageBlock {
72    #[serde(default)]
73    pub is_enabled: bool,
74    #[serde(default, deserialize_with = "de_opt_cents")]
75    pub monthly_limit: Option<i64>,
76    #[serde(default, deserialize_with = "de_opt_cents")]
77    pub used_credits: Option<i64>,
78    /// ISO currency code (`"BRL"`, `"USD"`, …). Absent on older payloads,
79    /// which were always formatted as `$`.
80    #[serde(default, deserialize_with = "de_opt_currency")]
81    pub currency: Option<String>,
82    /// Minor-unit digits for both money fields. Gated at the parse boundary:
83    /// an absurd scale would corrupt every formatted amount downstream.
84    #[serde(default, deserialize_with = "de_opt_decimal_places")]
85    pub decimal_places: Option<u32>,
86}
87
88/// Accept a plausible minor-unit scale (0..=6 covers every ISO 4217 currency;
89/// the largest real exponent is 4). Integral floats are tolerated for the same
90/// reason `de_opt_cents` tolerates them: this endpoint emits them (the #30
91/// payload carries `used_credits: 14157.0`), and rejecting `2.0` would fail
92/// the whole response over a value that is unambiguous. Null/absent remains
93/// absent: a currency code alone is not enough to infer every ISO exponent.
94/// Anything else is drift: a wire `decimal_places: 100` would overflow the
95/// scale and mis-state every amount, so it fails loudly as `⚠` instead.
96fn de_opt_decimal_places<'de, D>(d: D) -> std::result::Result<Option<u32>, D::Error>
97where
98    D: serde::Deserializer<'de>,
99{
100    let n = match serde_json::Value::deserialize(d)? {
101        serde_json::Value::Null => return Ok(None),
102        serde_json::Value::Number(n) => n
103            .as_i64()
104            .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
105        _ => None,
106    };
107    match n {
108        Some(n) if (0..=6).contains(&n) => Ok(Some(n as u32)),
109        _ => Err(serde::de::Error::custom(
110            "decimal_places must be an integer in 0..=6",
111        )),
112    }
113}
114
115/// Gate the currency to a plausible ISO 4217 alpha code. The value is embedded
116/// verbatim in Pango bar markup and in the `;;`-delimited desktop FORMAT
117/// protocol, so an arbitrary string is an injection vector as well as drift;
118/// three ASCII uppercase letters can be neither.
119fn de_opt_currency<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
120where
121    D: serde::Deserializer<'de>,
122{
123    match Option::<String>::deserialize(d)? {
124        None => Ok(None),
125        Some(s) if s.len() == 3 && s.bytes().all(|b| b.is_ascii_uppercase()) => Ok(Some(s)),
126        Some(s) => Err(serde::de::Error::custom(format!(
127            "currency {s:?} is not an ISO 4217 alpha code"
128        ))),
129    }
130}
131
132fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
133where
134    D: serde::Deserializer<'de>,
135{
136    let v = serde_json::Value::deserialize(d)?;
137    match v {
138        serde_json::Value::Null => Ok(None),
139        serde_json::Value::Number(n) => {
140            if let Some(i) = n.as_i64() {
141                if i >= 0 {
142                    Ok(Some(i))
143                } else {
144                    Err(serde::de::Error::custom("cents cannot be negative"))
145                }
146            } else if let Some(f) = n.as_f64() {
147                const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
148                if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
149                    Ok(Some(f as i64))
150                } else {
151                    Err(serde::de::Error::custom(
152                        "cents must be a non-negative integer in range",
153                    ))
154                }
155            } else {
156                Err(serde::de::Error::custom("number out of i64 range"))
157            }
158        }
159        other => Err(serde::de::Error::custom(format!(
160            "expected number or null, got {other:?}"
161        ))),
162    }
163}
164
165/// Slack tolerated above 100. The endpoint occasionally reports a hair over its
166/// own cap — `used/limit` rounding, or usage that landed just before the block
167/// did — and `to_window` saturates that back to 100.
168const PCT_SLACK: f64 = 1.0;
169
170/// Gate a wire percentage into `0..=100` (+ [`PCT_SLACK`]).
171///
172/// Rejecting here rather than in `to_window` is deliberate: `to_window` is
173/// infallible and `into_snapshot` has no error channel, so the parse boundary
174/// is the only place a bad value can still become a loud failure. A rejection
175/// surfaces as `AppError::Json` and reaches the user as `⚠` — never as a
176/// number we invented. Past the slack the field simply isn't a percentage on
177/// this scale (rescaled to per-mille, a raw counter, a sentinel), and clamping
178/// it would paint a "100%" bar we cannot vouch for. Non-finite values matter
179/// most: `f64::NAN as i32` is silently `0`.
180fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
181    if !v.is_finite() {
182        return Err(E::custom(format!("percentage {v} is not finite")));
183    }
184    if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
185        return Err(E::custom(format!("percentage {v} outside 0..=100")));
186    }
187    Ok(v)
188}
189
190fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
191where
192    D: serde::Deserializer<'de>,
193{
194    checked_percent(f64::deserialize(d)?)
195}
196
197fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
198where
199    D: serde::Deserializer<'de>,
200{
201    Option::<f64>::deserialize(d)?
202        .map(checked_percent)
203        .transpose()
204}
205
206impl UsageResponse {
207    /// Lift the wire response into our canonical [`AnthropicSnapshot`].
208    ///
209    /// `plan_label` is the rendered plan name ("Max 5x" etc.), derived from
210    /// the credentials file (since the usage endpoint doesn't include it).
211    pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
212        // Window durations are constants per claudebar:172-173.
213        const SESSION: chrono::Duration = chrono::Duration::hours(5);
214        const WEEKLY: chrono::Duration = chrono::Duration::days(7);
215
216        fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
217            let Some(w) = w else {
218                return UsageWindow {
219                    utilization_pct: 0,
220                    resets_at: None,
221                    window_duration: dur,
222                };
223            };
224            UsageWindow {
225                // Round to nearest, matching claudebar's `| round` jq filter,
226                // then absorb the overshoot `de_percent` deliberately lets
227                // through (100.4 → 100) so the bar never renders past full.
228                utilization_pct: (w.utilization.round() as i32).clamp(0, 100),
229                resets_at: w
230                    .resets_at
231                    .as_deref()
232                    .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
233                    .map(|dt| dt.with_timezone(&chrono::Utc)),
234                window_duration: dur,
235            }
236        }
237
238        let session = to_window(self.five_hour, SESSION);
239        let weekly = to_window(self.seven_day, WEEKLY);
240        let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
241        let extra = self.extra_usage.filter(|e| e.is_enabled).and_then(|e| {
242            Some(ExtraUsage {
243                // `monthly_limit: null` is semantic, not drift: the endpoint
244                // sends it for plans with no spending cap (e.g. Pro), so it
245                // maps to None instead of discarding the block — which hid
246                // real credit spend (#30). Only an unusable `used_credits`
247                // still drops it: without the spend there is nothing to show.
248                limit: e.monthly_limit.map(Cents),
249                spent: Cents(e.used_credits?),
250                // Preserve an absent scale. Currency codes span zero through
251                // four decimal minor units, so guessing would fabricate the
252                // displayed major-unit amount.
253                decimal_places: e.decimal_places,
254                currency: e.currency,
255            })
256        });
257        let scoped = self
258            .limits
259            .into_iter()
260            .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
261            .filter_map(|l| {
262                let label = l.scope?.model?.display_name?;
263                // Same `?` discipline as the label above: an entry without a
264                // percentage is dropped, not defaulted. `unwrap_or(0.0)` drew a
265                // confident "Fable 0%" bar under a real model name — a number
266                // the API never sent, which is worse than no bar at all.
267                let utilization = l.percent?;
268                let window = to_window(
269                    Some(Window {
270                        utilization,
271                        resets_at: l.resets_at,
272                    }),
273                    WEEKLY,
274                );
275                Some(ScopedWindow { label, window })
276            })
277            .collect();
278
279        AnthropicSnapshot {
280            plan: plan_label,
281            session,
282            weekly,
283            sonnet,
284            scoped,
285            extra,
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn parses_full_response() {
296        let raw = r#"{
297            "five_hour":         {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
298            "seven_day":         {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
299            "seven_day_sonnet":  {"utilization":  4.2, "resets_at": "2026-05-30T12:00:00Z"},
300            "extra_usage":       {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
301        }"#;
302        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
303        let snap = resp.into_snapshot("Max 5x".into());
304        assert_eq!(snap.session.utilization_pct, 43); // rounded
305        assert_eq!(snap.weekly.utilization_pct, 27);
306        assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
307        let extra = snap.extra.as_ref().unwrap();
308        assert_eq!(extra.limit, Some(Cents(5000)));
309        assert_eq!(extra.spent.0, 250);
310        assert!(snap.session.resets_at.is_some());
311    }
312
313    #[test]
314    fn parses_weekly_scoped_limits() {
315        // Real shape observed 2026-07-08: the Fable weekly cap only exists
316        // inside `limits[]`; there is no `seven_day_fable` field.
317        let raw = r#"{
318            "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
319            "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
320            "limits": [
321                {"kind": "session", "group": "session", "percent": 10,
322                 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
323                 "scope": null, "is_active": false},
324                {"kind": "weekly_all", "group": "weekly", "percent": 55,
325                 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
326                 "scope": null, "is_active": false},
327                {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
328                 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
329                 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
330                 "is_active": true}
331            ]
332        }"#;
333        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
334        let snap = resp.into_snapshot("Pro".into());
335        assert_eq!(snap.scoped.len(), 1);
336        assert_eq!(snap.scoped[0].label, "Fable");
337        assert_eq!(snap.scoped[0].window.utilization_pct, 84);
338        assert!(snap.scoped[0].window.resets_at.is_some());
339        // Unscoped entries never duplicate into `scoped`.
340        assert_eq!(snap.weekly.utilization_pct, 55);
341    }
342
343    #[test]
344    fn missing_limits_array_yields_empty_scoped() {
345        let raw = r#"{
346            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
347            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
348        }"#;
349        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
350        let snap = resp.into_snapshot("Pro".into());
351        assert!(snap.scoped.is_empty());
352    }
353
354    #[test]
355    fn missing_sonnet_and_extra_are_none() {
356        let raw = r#"{
357            "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
358            "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
359        }"#;
360        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
361        let snap = resp.into_snapshot("Pro".into());
362        assert!(snap.sonnet.is_none());
363        assert!(snap.extra.is_none());
364    }
365
366    #[test]
367    fn disabled_extra_usage_becomes_none() {
368        let raw = r#"{
369            "five_hour": {"utilization": 0},
370            "seven_day": {"utilization": 0},
371            "extra_usage": {"is_enabled": false, "monthly_limit": 5000, "used_credits": 0}
372        }"#;
373        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
374        let snap = resp.into_snapshot("Pro".into());
375        assert!(snap.extra.is_none());
376    }
377
378    #[test]
379    fn enabled_extra_usage_without_spend_is_dropped() {
380        // `used_credits` is the essential datum: without it there is nothing
381        // truthful to display, so the block is dropped rather than inventing
382        // a $0.00 spend.
383        for raw in [
384            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
385            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000,"used_credits":null}}"#,
386        ] {
387            let resp: UsageResponse = serde_json::from_str(raw).unwrap();
388            assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
389        }
390    }
391
392    #[test]
393    fn uncapped_plan_keeps_real_spend_visible() {
394        // The #30 regression: the endpoint sends `monthly_limit: null` for
395        // plans with no spending cap (e.g. Pro). Discarding the block hid
396        // genuine credit spend — this fixture is the reporter's actual cached
397        // response (R$ 141.57, an integral float).
398        let resp: UsageResponse = serde_json::from_str(
399            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,
400                "used_credits":14157.0,"currency":"BRL","decimal_places":2,
401                "disabled_reason":null}}"#,
402        )
403        .unwrap();
404        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
405        assert_eq!(extra.limit, None);
406        assert_eq!(extra.spent.0, 14157);
407        // No denominator → no invented percentage; bar stays calm.
408        assert_eq!(extra.percent(), 0);
409        // The block's own currency and scale propagate, so the renderer can
410        // say R$141.57 instead of claiming `$` for reais.
411        assert_eq!(extra.currency.as_deref(), Some("BRL"));
412        assert_eq!(extra.decimal_places, Some(2));
413        assert_eq!(extra.fmt_spent(), "R$141.57");
414
415        // An *absent* limit renders the same way: with `#[serde(default)]`,
416        // absent and explicit null are indistinguishable at the struct level,
417        // and hiding real spend because a secondary field went missing is the
418        // exact failure mode of #30. Nothing is fabricated either way — the
419        // spend shown is exactly what the API sent.
420        let resp: UsageResponse =
421            serde_json::from_str(r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#)
422                .unwrap();
423        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
424        assert_eq!(extra.limit, None);
425        assert_eq!(extra.spent.0, 250);
426    }
427
428    #[test]
429    fn implausible_decimal_places_is_schema_drift() {
430        // A wire scale outside 0..=6 would mis-state every formatted amount
431        // (10^100 overflows outright), so it fails loudly instead.
432        for value in ["7", "-1", "100", "2.5"] {
433            let raw = format!(
434                r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"decimal_places":{value}}}}}"#
435            );
436            assert!(
437                serde_json::from_str::<UsageResponse>(&raw).is_err(),
438                "{raw}"
439            );
440        }
441        // An integral float is fine — this endpoint floats its numbers (the
442        // #30 payload has `used_credits: 14157.0`), and rejecting 2.0 would
443        // fail the whole response over an unambiguous value. Worse: the fetch
444        // caches the body BEFORE parsing, so a rejected response would evict
445        // the last good payload and leave a persistent ⚠.
446        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":2.0}}"#;
447        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
448        assert_eq!(
449            resp.into_snapshot("Pro".into())
450                .extra
451                .unwrap()
452                .decimal_places,
453            Some(2)
454        );
455        // Null and absent stay absent. With no currency, legacy payloads still
456        // render using their historical USD/cent convention.
457        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":null}}"#;
458        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
459        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
460        assert_eq!(extra.decimal_places, None);
461        assert_eq!(extra.fmt_spent(), "$2.50");
462
463        // If a currency is present without its exponent, expose the raw minor
464        // units rather than guessing. KRW is zero-decimal and KWD is
465        // three-decimal; a blanket cent fallback corrupts both.
466        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW"}}"#;
467        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
468        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
469        assert_eq!(extra.decimal_places, None);
470        assert_eq!(extra.fmt_spent(), "500 minor units KRW");
471
472        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD"}}"#;
473        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
474        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
475        assert_eq!(extra.decimal_places, None);
476        assert_eq!(extra.fmt_spent(), "1234 minor units KWD");
477
478        // Explicit exponents remain authoritative, including zero and three.
479        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW","decimal_places":0}}"#;
480        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
481        assert_eq!(
482            resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
483            "500 KRW"
484        );
485        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD","decimal_places":3}}"#;
486        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
487        assert_eq!(
488            resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
489            "1.234 KWD"
490        );
491    }
492
493    #[test]
494    fn currency_must_be_an_iso_alpha_code() {
495        // The value lands verbatim in Pango markup and in the `;;`-delimited
496        // desktop FORMAT protocol, so anything but three ASCII uppercase
497        // letters is rejected as drift — it is an injection vector besides.
498        for value in [r#""brl""#, r#""""#, r#""R$""#, r#""USD;;0""#, r#""<b>""#] {
499            let raw = format!(
500                r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"currency":{value}}}}}"#
501            );
502            assert!(
503                serde_json::from_str::<UsageResponse>(&raw).is_err(),
504                "{raw}"
505            );
506        }
507        // Null stays acceptable — same as absent.
508        let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"currency":null}}"#;
509        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
510        assert_eq!(
511            resp.into_snapshot("Pro".into()).extra.unwrap().currency,
512            None
513        );
514    }
515
516    #[test]
517    fn malformed_cent_values_are_schema_drift() {
518        for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
519            let raw = format!(
520                r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
521            );
522            assert!(
523                serde_json::from_str::<UsageResponse>(&raw).is_err(),
524                "{raw}"
525            );
526        }
527
528        let resp: UsageResponse = serde_json::from_str(
529            r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
530        )
531        .unwrap();
532        let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
533        assert_eq!(extra.limit, Some(Cents(5000)));
534        assert_eq!(extra.spent.0, 250);
535    }
536
537    #[test]
538    fn empty_object_yields_neutral_snapshot() {
539        let resp: UsageResponse = serde_json::from_str("{}").unwrap();
540        let snap = resp.into_snapshot("Unknown".into());
541        assert_eq!(snap.session.utilization_pct, 0);
542        assert_eq!(snap.weekly.utilization_pct, 0);
543        assert!(snap.session.resets_at.is_none());
544    }
545
546    #[test]
547    fn benign_overshoot_saturates_to_hundred() {
548        // A hair over the cap is rounding noise, not drift — it must render as
549        // a full bar, not break the widget.
550        let raw = r#"{
551            "five_hour": {"utilization": 100.4},
552            "seven_day": {"utilization": 100.6}
553        }"#;
554        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
555        let snap = resp.into_snapshot("Pro".into());
556        assert_eq!(snap.session.utilization_pct, 100);
557        assert_eq!(snap.weekly.utilization_pct, 100); // rounds to 101, saturated
558    }
559
560    #[test]
561    fn out_of_range_utilization_is_rejected_not_clamped() {
562        for raw in [
563            r#"{"five_hour": {"utilization": 500}}"#,
564            r#"{"five_hour": {"utilization": -1}}"#,
565            r#"{"seven_day": {"utilization": 101.5}}"#,
566        ] {
567            let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
568            assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
569        }
570    }
571
572    #[test]
573    fn out_of_range_scoped_percent_is_rejected() {
574        // `limits[].percent` reaches the same bar via the synthesized Window.
575        let raw = r#"{
576            "limits": [{"kind": "weekly_scoped", "percent": 420,
577                        "scope": {"model": {"display_name": "Fable"}}}]
578        }"#;
579        let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
580        assert!(err.to_string().contains("outside 0..=100"), "{err}");
581    }
582
583    #[test]
584    fn non_finite_percentage_is_rejected() {
585        // `f64::NAN as i32` is silently 0 — the fabricated number this gate
586        // exists to prevent. JSON has no NaN literal, so drive it directly.
587        for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
588            assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
589        }
590    }
591
592    #[test]
593    fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
594        // The gate rejects impossible numbers; an absent one is not a parse
595        // failure. But it must not become a bar either: `unwrap_or(0.0)` drew a
596        // confident "Fable 0%" under a real model name that the API never sent.
597        let raw = r#"{
598            "limits": [{"kind": "weekly_scoped", "percent": null,
599                        "scope": {"model": {"display_name": "Fable"}}}]
600        }"#;
601        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
602        let snap = resp.into_snapshot("Pro".into());
603        assert!(
604            snap.scoped.is_empty(),
605            "a scoped limit with no percentage must not render a fabricated bar"
606        );
607
608        // A real percentage still produces the window, so the drop is targeted.
609        let ok = r#"{
610            "limits": [{"kind": "weekly_scoped", "percent": 84,
611                        "scope": {"model": {"display_name": "Fable"}}}]
612        }"#;
613        let resp: UsageResponse = serde_json::from_str(ok).unwrap();
614        let snap = resp.into_snapshot("Pro".into());
615        assert_eq!(snap.scoped[0].label, "Fable");
616        assert_eq!(snap.scoped[0].window.utilization_pct, 84);
617    }
618
619    #[test]
620    fn unparseable_reset_becomes_none() {
621        let raw = r#"{
622            "five_hour": {"utilization": 50, "resets_at": "not a date"},
623            "seven_day": {"utilization": 0}
624        }"#;
625        let resp: UsageResponse = serde_json::from_str(raw).unwrap();
626        let snap = resp.into_snapshot("Pro".into());
627        assert!(snap.session.resets_at.is_none());
628        assert_eq!(snap.session.utilization_pct, 50);
629    }
630}