Skip to main content

ai_usagebar/
usage.rs

1//! Canonical in-memory representation of "how much have I used my plan".
2//!
3//! Each vendor's snapshot lives in its own variant — this is deliberate.
4//! Anthropic exposes three windows + extra credits; OpenAI Codex exposes two
5//! windows + credit balance + message-count ranges; OpenRouter is a single
6//! credit-balance number with daily/weekly/monthly totals; Z.AI is a list of
7//! token + MCP buckets; DeepSeek is a credit balance; Kimi is a weekly quota
8//! plus a 5h rolling rate-limit window. Forcing them into a shared shape would
9//! either drop information or paper over genuine differences.
10//!
11//! Renderers (widget tooltip, TUI tab) consume a `VendorSnapshot` directly,
12//! not a flattened shape — so each vendor controls its own presentation while
13//! sharing the pacing math, color thresholds, and Pango primitives.
14
15use chrono::{DateTime, Utc};
16
17use crate::error::{AppError, Result};
18
19/// Reject a non-finite monetary value. A NaN or infinity reaching a balance
20/// field means the payload was not what we think it is; displaying it as money
21/// (or caching it as authoritative) is worse than failing loudly.
22pub fn finite_amount(vendor: &str, field: &str, v: f64) -> Result<f64> {
23    if v.is_finite() {
24        Ok(v)
25    } else {
26        Err(AppError::Schema(format!(
27            "{vendor}: `{field}` is not a finite number"
28        )))
29    }
30}
31
32/// Parse a monetary field that the wire encodes as a string. A malformed or
33/// empty value is a schema error, **not** a zero balance — silently reporting
34/// $0.00 for an error envelope is the failure mode this guards against.
35pub fn parse_amount(vendor: &str, field: &str, s: &str) -> Result<f64> {
36    let t = s.trim();
37    if t.is_empty() {
38        return Err(AppError::Schema(format!("{vendor}: `{field}` is empty")));
39    }
40    let v: f64 = t
41        .parse()
42        .map_err(|_| AppError::Schema(format!("{vendor}: `{field}` is not numeric (got {t:?})")))?;
43    finite_amount(vendor, field, v)
44}
45
46/// A single usage window — generic enough that every vendor with a notion of
47/// "% used vs. when does it reset" can express itself with it.
48///
49/// `utilization_pct` is `0..=100` (integer percent, matching claudebar's units).
50/// `resets_at` is `None` when the vendor doesn't report a reset time.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct UsageWindow {
53    pub utilization_pct: i32,
54    pub resets_at: Option<DateTime<Utc>>,
55    /// Window length (used for pacing math).
56    pub window_duration: chrono::Duration,
57}
58
59/// Money in minor currency units (historically always cents; see
60/// `ExtraUsage::decimal_places` for the actual scale) to dodge float roundoff.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct Cents(pub i64);
63
64impl Cents {
65    /// Format as `[-]$D.CC`. Negative values render `-$D.CC` (not `$-D.CC`),
66    /// matching claudebar's `_fmt_dollars` (claudebar:532-537).
67    pub fn fmt_dollars(self) -> String {
68        let (sign, abs) = if self.0 < 0 {
69            ("-", -self.0)
70        } else {
71            ("", self.0)
72        };
73        format!("{sign}${}.{:02}", abs / 100, abs % 100)
74    }
75}
76
77/// Anthropic-specific snapshot — three rolling windows plus optional
78/// pay-as-you-go credit balance.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct AnthropicSnapshot {
81    /// "Claude Pro", "Claude Max 5x", "Claude Max 20x", etc.
82    pub plan: String,
83    pub session: UsageWindow,
84    pub weekly: UsageWindow,
85    /// Some vendors of Claude (Pro, some Max tiers) don't have a separate
86    /// Sonnet bucket — in which case this is None.
87    pub sonnet: Option<UsageWindow>,
88    /// Model-scoped weekly windows from the newer `limits[]` array
89    /// (`kind == "weekly_scoped"`), e.g. the Fable weekly cap. Labels come
90    /// from the API (`scope.model.display_name`), so new models show up
91    /// without a code change. Empty when the account has none.
92    pub scoped: Vec<ScopedWindow>,
93    /// `None` when `extra_usage.is_enabled` is false or the block is absent.
94    pub extra: Option<ExtraUsage>,
95}
96
97/// A usage window scoped to a specific model, labeled by the API
98/// (e.g. "Fable"). Weekly (7d) duration.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ScopedWindow {
101    pub label: String,
102    pub window: UsageWindow,
103}
104
105/// "Extra usage" pay-as-you-go block (claudebar's `extra_usage`).
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExtraUsage {
108    /// `None` when the payload carries no usable `monthly_limit` — an
109    /// explicit null (observed for plans without a spending cap, e.g. Claude
110    /// Pro, #30) or an absent field. Either way the spend is real and stays
111    /// visible; only the limit is unreported, and the renderers say exactly
112    /// that rather than inferring a plan tier from it.
113    pub limit: Option<Cents>,
114    pub spent: Cents,
115    /// ISO code from the block (`"BRL"`, `"USD"`, …). `None` on older payloads
116    /// that predate the field — formatted as `$` for back-compat, which was
117    /// the only behaviour before the field existed.
118    pub currency: Option<String>,
119    /// Minor-unit digits from the block's `decimal_places` (BRL/USD = 2,
120    /// JPY/KRW = 0). `None` means the wire did not report the scale. We keep
121    /// that absence instead of guessing from an incomplete currency table.
122    pub decimal_places: Option<u32>,
123}
124
125impl ExtraUsage {
126    /// Integer percentage of the monthly limit consumed (0..=100, saturating
127    /// at 0 when limit is non-positive — matches claudebar:540-542).
128    ///
129    /// With no cap there is no denominator, so no meaningful percentage
130    /// exists; 0 keeps the bar and severity calm rather than inventing one.
131    pub fn percent(&self) -> i32 {
132        match self.limit {
133            Some(l) if l.0 > 0 => ((self.spent.0 * 100) / l.0) as i32,
134            _ => 0,
135        }
136    }
137
138    pub fn fmt_spent(&self) -> String {
139        self.fmt_amount(self.spent)
140    }
141
142    pub fn fmt_limit(&self) -> Option<String> {
143        self.limit.map(|l| self.fmt_amount(l))
144    }
145
146    fn fmt_amount(&self, amount: Cents) -> String {
147        match (self.decimal_places, self.currency.as_deref()) {
148            (Some(decimal_places), currency) => fmt_minor(amount.0, decimal_places, currency),
149            // Legacy payloads predate both fields and were always cents/USD.
150            // Preserve that established behaviour only when neither field can
151            // tell us otherwise.
152            (None, None) => fmt_minor(amount.0, 2, None),
153            // A currency code alone does not determine its ISO minor-unit
154            // exponent. Keep the amount truthful instead of silently dividing
155            // zero-, three-, or four-decimal currencies by the wrong scale.
156            (None, Some(currency)) => fmt_minor_units(amount.0, currency),
157        }
158    }
159}
160
161fn fmt_minor_units(minor: i64, currency: &str) -> String {
162    let sign = if minor < 0 { "-" } else { "" };
163    format!("{sign}{} minor units {currency}", minor.unsigned_abs())
164}
165
166/// Format an amount in minor units with its own currency and scale. Rendering
167/// R$ 141.57 as "$141.57" is a claim about the wrong currency — the same class
168/// of defect as a fabricated number. Known codes get their symbol (mirroring
169/// `deepseek::format_money`); anything else renders as `AMOUNT CODE`, which is
170/// still truthful.
171pub fn fmt_minor(minor: i64, decimal_places: u32, currency: Option<&str>) -> String {
172    let scale = 10_u64.pow(decimal_places);
173    // `unsigned_abs`, not negation: `-i64::MIN` overflows. Unreachable from
174    // the wire (the parse gate rejects negatives) but this is a pub fn.
175    let sign = if minor < 0 { "-" } else { "" };
176    let abs = minor.unsigned_abs();
177    let number = if decimal_places == 0 {
178        format!("{abs}")
179    } else {
180        format!(
181            "{}.{:0width$}",
182            abs / scale,
183            abs % scale,
184            width = decimal_places as usize
185        )
186    };
187    match currency {
188        None | Some("USD") => format!("{sign}${number}"),
189        Some("BRL") => format!("{sign}R${number}"),
190        Some("EUR") => format!("{sign}€{number}"),
191        Some("GBP") => format!("{sign}£{number}"),
192        Some("JPY") | Some("CNY") => format!("{sign}¥{number}"),
193        Some(other) => format!("{sign}{number} {other}"),
194    }
195}
196
197/// DeepSeek — credit balance from `/user/balance`.
198#[derive(Debug, Clone, PartialEq)]
199pub struct DeepseekSnapshot {
200    pub is_available: bool,
201    /// Current balance (prefer USD, fallback to CNY).
202    pub balance: f64,
203    /// Free-granted credits component.
204    pub granted: f64,
205    /// Topped-up (purchased) credits component.
206    pub topped_up: f64,
207    /// The currency of the above amounts (currently "USD" or "CNY").
208    pub currency: String,
209}
210
211impl Eq for DeepseekSnapshot {}
212
213impl Default for DeepseekSnapshot {
214    fn default() -> Self {
215        Self {
216            is_available: false,
217            balance: 0.0,
218            granted: 0.0,
219            topped_up: 0.0,
220            currency: String::new(),
221        }
222    }
223}
224
225/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub struct KimiSnapshot {
228    pub plan: Option<String>,
229    pub weekly_limit: u64,
230    pub weekly_used: u64,
231    pub weekly_remaining: u64,
232    pub weekly_reset_at: Option<DateTime<Utc>>,
233    pub window_limit: u64,
234    pub window_used: u64,
235    pub window_remaining: u64,
236    pub window_reset_at: Option<DateTime<Utc>>,
237}
238
239impl KimiSnapshot {
240    fn pct(used: u64, limit: u64) -> i32 {
241        if limit == 0 {
242            0
243        } else {
244            // Keep all quota values exact: f64 loses integer precision above
245            // 2^53. This is the integer equivalent of round(used / limit *
246            // 100), with saturation for inconsistent upstream counters.
247            let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
248            pct.min(100) as i32
249        }
250    }
251
252    /// Percentage of the weekly subscription quota consumed (0..=100).
253    pub fn weekly_pct(&self) -> i32 {
254        Self::pct(self.weekly_used, self.weekly_limit)
255    }
256
257    /// Percentage of the rolling rate-limit window consumed (0..=100).
258    pub fn window_pct(&self) -> i32 {
259        Self::pct(self.window_used, self.window_limit)
260    }
261}
262
263/// Discriminated union of vendor-specific snapshots. The widget and TUI match
264/// on this to pick a renderer.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub enum VendorSnapshot {
267    Anthropic(AnthropicSnapshot),
268    Openai(OpenAiSnapshot),
269    Zai(ZaiSnapshot),
270    Openrouter(OpenRouterSnapshot),
271    Deepseek(DeepseekSnapshot),
272    Kimi(KimiSnapshot),
273    Kilo(KiloSnapshot),
274    Novita(NovitaSnapshot),
275    Moonshot(MoonshotSnapshot),
276    Grok(GrokSnapshot),
277    AnthropicApi(AnthropicApiSnapshot),
278    Antigravity(AntigravitySnapshot),
279}
280
281/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
282/// and third-party (Claude/GPT) buckets, and each group carries its own 5-hour
283/// and weekly window — four independent windows in total.
284#[derive(Debug, Clone, PartialEq)]
285pub struct AntigravitySnapshot {
286    pub plan: String,
287    /// Fingerprint of the signed-in account. Never displayed — it exists so a
288    /// cache written for one Google account is not served for another.
289    pub account: String,
290    /// Gemini group, 5-hour window.
291    pub session: UsageWindow,
292    /// Gemini group, weekly window.
293    pub weekly: UsageWindow,
294    /// Claude/GPT group, 5-hour window.
295    pub third_party_session: Option<UsageWindow>,
296    /// Claude/GPT group, weekly window.
297    pub third_party_weekly: Option<UsageWindow>,
298}
299
300impl Eq for AntigravitySnapshot {}
301
302/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
303/// monthly `limit` is supplied from config (the API exposes neither the limit
304/// nor the remaining prepaid credit balance).
305#[derive(Debug, Clone, PartialEq)]
306pub struct AnthropicApiSnapshot {
307    pub spent: f64,
308    pub limit: Option<f64>,
309}
310
311impl Eq for AnthropicApiSnapshot {}
312
313impl AnthropicApiSnapshot {
314    /// Spend as an integer percentage of the configured limit; `None` when no
315    /// positive limit is set.
316    pub fn pct(&self) -> Option<i32> {
317        self.limit
318            .filter(|l| l.is_finite() && *l > 0.0)
319            .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
320    }
321}
322
323/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
324/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
325#[derive(Debug, Clone, PartialEq)]
326pub struct KiloSnapshot {
327    pub label: String,
328    pub balance: f64,
329}
330
331impl Eq for KiloSnapshot {}
332
333/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
334/// all amounts already converted from the API's 1/10000-USD integers to USD.
335#[derive(Debug, Clone, PartialEq)]
336pub struct NovitaSnapshot {
337    /// Spendable credit balance (`availableBalance`).
338    pub available: f64,
339    /// Remaining top-up (`cashBalance`).
340    pub cash: f64,
341    /// Credit limit — max you can owe (`creditLimit`).
342    pub credit_limit: f64,
343    /// Amount currently owed (`outstandingInvoices`).
344    pub outstanding: f64,
345}
346
347impl Eq for NovitaSnapshot {}
348
349/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
350/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
351/// field in the response, so it's carried here from the region config.
352#[derive(Debug, Clone, PartialEq)]
353pub struct MoonshotSnapshot {
354    /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
355    /// the inference API.
356    pub available: f64,
357    /// Voucher credit (`voucher_balance`).
358    pub voucher: f64,
359    /// Cash balance (`cash_balance`); can be negative (debt).
360    pub cash: f64,
361    /// "USD" or "CNY", implied by the host.
362    pub currency: String,
363}
364
365impl Eq for MoonshotSnapshot {}
366
367/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
368/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
369#[derive(Debug, Clone, PartialEq)]
370pub struct GrokSnapshot {
371    pub balance: f64,
372}
373
374impl Eq for GrokSnapshot {}
375
376/// OpenAI Codex OAuth — mirrors Anthropic's two-window + extras pattern.
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct OpenAiSnapshot {
379    pub plan: String,
380    /// 5h window (Codex `rate_limit.primary_window`).
381    pub session: UsageWindow,
382    /// 7d window (Codex `rate_limit.secondary_window`).
383    pub weekly: UsageWindow,
384    /// Optional 7d code-review bucket.
385    pub code_review: Option<UsageWindow>,
386    /// Optional credit balance + approximate message-count ranges.
387    pub credits: Option<OpenAiCredits>,
388    /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
389    /// the placeholder set and the "OpenAI does not expose this for Plus"
390    /// tooltip when the OAuth path isn't available.
391    pub source: OpenAiSource,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum OpenAiSource {
396    CodexOauth,
397    AdminKeyMtd,
398    Unavailable,
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct OpenAiCredits {
403    /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
404    /// a string because OpenAI returns it that way.
405    pub balance: String,
406    pub has_credits: bool,
407    pub unlimited: bool,
408    pub approx_local_messages: Option<(i64, i64)>,
409    pub approx_cloud_messages: Option<(i64, i64)>,
410}
411
412/// Z.AI / BigModel — list of buckets with discriminated types. We project the
413/// two we care about into named fields (5h tokens, weekly tokens, MCP).
414#[derive(Debug, Clone, PartialEq, Eq)]
415pub struct ZaiSnapshot {
416    pub plan: String,
417    pub session: Option<UsageWindow>,
418    pub weekly: Option<UsageWindow>,
419    pub mcp: Option<UsageWindow>,
420}
421
422/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
423/// `/api/v1/credits` and `/api/v1/key`.
424#[derive(Debug, Clone, PartialEq)]
425pub struct OpenRouterSnapshot {
426    pub label: String,
427    pub total_credits: f64,
428    pub total_usage: f64,
429    pub usage_daily: f64,
430    pub usage_weekly: f64,
431    pub usage_monthly: f64,
432    pub is_free_tier: bool,
433    pub limit: Option<f64>,
434    pub limit_remaining: Option<f64>,
435}
436
437impl Eq for OpenRouterSnapshot {}
438
439impl OpenRouterSnapshot {
440    pub fn balance(&self) -> f64 {
441        (self.total_credits - self.total_usage).max(0.0)
442    }
443    /// Percentage of total_credits consumed (0..=100). Returns 0 when
444    /// `total_credits` is 0 (free-tier-only accounts).
445    pub fn consumed_pct(&self) -> i32 {
446        if self.total_credits <= 0.0 {
447            return 0;
448        }
449        ((self.total_usage / self.total_credits) * 100.0)
450            .round()
451            .clamp(0.0, 100.0) as i32
452    }
453}
454
455/// Worst-of severity class for the Waybar bar text color. Mirrors
456/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
457pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
458    let mut max = snap.session.utilization_pct;
459    if snap.weekly.utilization_pct > max {
460        max = snap.weekly.utilization_pct;
461    }
462    if let Some(s) = &snap.sonnet
463        && s.utilization_pct > max
464    {
465        max = s.utilization_pct;
466    }
467    for sw in &snap.scoped {
468        if sw.window.utilization_pct > max {
469            max = sw.window.utilization_pct;
470        }
471    }
472    // Extra usage only promotes severity if a rate-limit window is at 100%.
473    let any_at_cap = snap.session.utilization_pct >= 100
474        || snap.weekly.utilization_pct >= 100
475        || snap
476            .sonnet
477            .as_ref()
478            .is_some_and(|s| s.utilization_pct >= 100)
479        || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
480    if any_at_cap && let Some(extra) = snap.extra.as_ref() {
481        let p = extra.percent();
482        if p > max {
483            max = p;
484        }
485    }
486    crate::pango::severity_for(max)
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::pacing::PaceSeverity;
493    use chrono::Duration;
494
495    fn w(pct: i32) -> UsageWindow {
496        UsageWindow {
497            utilization_pct: pct,
498            resets_at: None,
499            window_duration: Duration::hours(5),
500        }
501    }
502
503    fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
504        AnthropicSnapshot {
505            plan: "Max 5x".into(),
506            session: w(s),
507            weekly: w(w_),
508            sonnet: sonnet.map(w),
509            scoped: vec![],
510            extra: extra.map(|(limit, spent)| ExtraUsage {
511                limit: Some(Cents(limit)),
512                spent: Cents(spent),
513                currency: None,
514                decimal_places: Some(2),
515            }),
516        }
517    }
518
519    #[test]
520    fn fmt_minor_honors_currency_and_scale() {
521        // No currency (older payloads) keeps the historical `$`.
522        assert_eq!(fmt_minor(250, 2, None), "$2.50");
523        // The #30 reporter's actual figures: BRL must not be claimed as `$`.
524        assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
525        assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
526        // Zero-exponent currency: no decimal point, no /100.
527        assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
528        // Sign precedes the symbol, matching `fmt_dollars`.
529        assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
530        // Unknown code stays truthful as a suffix rather than guessing a symbol.
531        assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
532    }
533
534    #[test]
535    fn extra_usage_formats_in_its_own_currency() {
536        let e = ExtraUsage {
537            limit: None,
538            spent: Cents(14157),
539            currency: Some("BRL".into()),
540            decimal_places: Some(2),
541        };
542        assert_eq!(e.fmt_spent(), "R$141.57");
543        assert_eq!(e.fmt_limit(), None);
544
545        let capped = ExtraUsage {
546            limit: Some(Cents(5000)),
547            spent: Cents(250),
548            currency: None,
549            decimal_places: Some(2),
550        };
551        assert_eq!(capped.fmt_spent(), "$2.50");
552        assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
553    }
554
555    #[test]
556    fn cents_format_positive() {
557        assert_eq!(Cents(0).fmt_dollars(), "$0.00");
558        assert_eq!(Cents(50).fmt_dollars(), "$0.50");
559        assert_eq!(Cents(250).fmt_dollars(), "$2.50");
560        assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
561    }
562
563    #[test]
564    fn cents_format_negative_uses_leading_sign() {
565        // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
566        assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
567        assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
568    }
569
570    #[test]
571    fn extra_percent_with_zero_limit_is_zero() {
572        assert_eq!(
573            ExtraUsage {
574                limit: Some(Cents(0)),
575                spent: Cents(100),
576                currency: None,
577                decimal_places: Some(2),
578            }
579            .percent(),
580            0
581        );
582    }
583
584    #[test]
585    fn extra_percent_truncates() {
586        // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
587        assert_eq!(
588            ExtraUsage {
589                limit: Some(Cents(10000)),
590                spent: Cents(3333),
591                currency: None,
592                decimal_places: Some(2),
593            }
594            .percent(),
595            33
596        );
597    }
598
599    #[test]
600    fn severity_picks_worst_of_three_windows() {
601        let s = snap(40, 60, Some(80), None);
602        assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
603    }
604
605    #[test]
606    fn severity_ignores_extra_when_no_cap_hit() {
607        // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
608        let s = snap(50, 60, None, Some((10000, 9500)));
609        assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
610    }
611
612    #[test]
613    fn severity_promotes_extra_when_session_at_100() {
614        let s = snap(100, 50, None, Some((10000, 9500)));
615        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
616    }
617
618    #[test]
619    fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
620        // session = 100, weekly = 50, extra = 100% → max should be 100.
621        let s = snap(100, 50, None, Some((10000, 10000)));
622        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
623    }
624
625    fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
626        s.scoped.push(ScopedWindow {
627            label: "Fable".into(),
628            window: w(pct),
629        });
630        s
631    }
632
633    #[test]
634    fn severity_includes_scoped_windows() {
635        // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
636        // week at 84 → the bar class must escalate to High.
637        let s = with_scoped(snap(10, 55, None, None), 84);
638        assert_eq!(anthropic_severity(&s), PaceSeverity::High);
639    }
640
641    #[test]
642    fn severity_promotes_extra_when_scoped_at_100() {
643        // A scoped window at cap counts as a rate-limit cap hit, so extra
644        // usage above the window max is promoted — same rule as session/weekly.
645        let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
646        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
647    }
648
649    #[test]
650    fn kimi_percent_is_exact_above_f64_precision() {
651        let snap = KimiSnapshot {
652            plan: None,
653            weekly_limit: (1 << 53) + 1,
654            weekly_used: 1 << 52,
655            weekly_remaining: 0,
656            weekly_reset_at: None,
657            window_limit: u64::MAX,
658            window_used: u64::MAX - 1,
659            window_remaining: 0,
660            window_reset_at: None,
661        };
662        assert_eq!(snap.weekly_pct(), 50);
663        assert_eq!(snap.window_pct(), 100);
664    }
665}