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 up to
5//! two 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};
16use serde::{Deserialize, Serialize};
17
18use crate::error::{AppError, Result};
19
20/// Reject a non-finite monetary value. A NaN or infinity reaching a balance
21/// field means the payload was not what we think it is; displaying it as money
22/// (or caching it as authoritative) is worse than failing loudly.
23pub fn finite_amount(vendor: &str, field: &str, v: f64) -> Result<f64> {
24    if v.is_finite() {
25        Ok(v)
26    } else {
27        Err(AppError::Schema(format!(
28            "{vendor}: `{field}` is not a finite number"
29        )))
30    }
31}
32
33/// Parse a monetary field that the wire encodes as a string. A malformed or
34/// empty value is a schema error, **not** a zero balance — silently reporting
35/// $0.00 for an error envelope is the failure mode this guards against.
36pub fn parse_amount(vendor: &str, field: &str, s: &str) -> Result<f64> {
37    let t = s.trim();
38    if t.is_empty() {
39        return Err(AppError::Schema(format!("{vendor}: `{field}` is empty")));
40    }
41    let v: f64 = t
42        .parse()
43        .map_err(|_| AppError::Schema(format!("{vendor}: `{field}` is not numeric (got {t:?})")))?;
44    finite_amount(vendor, field, v)
45}
46
47/// A single usage window — generic enough that every vendor with a notion of
48/// "% used vs. when does it reset" can express itself with it.
49///
50/// `utilization_pct` is `0..=100` (integer percent, matching claudebar's units).
51/// `resets_at` is `None` when the vendor doesn't report a reset time.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct UsageWindow {
54    pub utilization_pct: i32,
55    pub resets_at: Option<DateTime<Utc>>,
56    /// Window length (used for pacing math).
57    pub window_duration: chrono::Duration,
58}
59
60/// Money in minor currency units (historically always cents; see
61/// `ExtraUsage::decimal_places` for the actual scale) to dodge float roundoff.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub struct Cents(pub i64);
64
65/// Anthropic-specific snapshot — three rolling windows plus optional
66/// pay-as-you-go credit balance.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct AnthropicSnapshot {
69    /// "Claude Pro", "Claude Max 5x", "Claude Max 20x", etc.
70    pub plan: String,
71    pub session: UsageWindow,
72    pub weekly: UsageWindow,
73    /// Some vendors of Claude (Pro, some Max tiers) don't have a separate
74    /// Sonnet bucket — in which case this is None.
75    pub sonnet: Option<UsageWindow>,
76    /// Model-scoped weekly windows from the newer `limits[]` array
77    /// (`kind == "weekly_scoped"`), e.g. the Fable weekly cap. Labels come
78    /// from the API (`scope.model.display_name`), so new models show up
79    /// without a code change. Empty when the account has none.
80    pub scoped: Vec<ScopedWindow>,
81    /// `None` when `extra_usage.is_enabled` is false or the block is absent.
82    pub extra: Option<ExtraUsage>,
83    /// Banked limit resets from the `cedar_ember` block — the same idea as
84    /// Codex's rate-limit reset credits and SuperGrok's remaining resets.
85    /// Empty when the account has no grant or the endpoint withheld the block.
86    pub reset_credits: ResetCredits,
87}
88
89/// A usage window scoped to a specific model, labeled by the API
90/// (e.g. "Fable"). Weekly (7d) duration.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct ScopedWindow {
93    pub label: String,
94    pub window: UsageWindow,
95}
96
97/// "Extra usage" pay-as-you-go block (claudebar's `extra_usage`).
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ExtraUsage {
100    /// `None` when the payload carries no usable `monthly_limit` — an
101    /// explicit null (observed for plans without a spending cap, e.g. Claude
102    /// Pro, #30) or an absent field. Either way the spend is real and stays
103    /// visible; only the limit is unreported, and the renderers say exactly
104    /// that rather than inferring a plan tier from it.
105    pub limit: Option<Cents>,
106    pub spent: Cents,
107    /// ISO code from the block (`"BRL"`, `"USD"`, …). `None` on older payloads
108    /// that predate the field — formatted as `$` for back-compat, which was
109    /// the only behaviour before the field existed.
110    pub currency: Option<String>,
111    /// Minor-unit digits from the block's `decimal_places` (BRL/USD = 2,
112    /// JPY/KRW = 0). `None` means the wire did not report the scale. We keep
113    /// that absence instead of guessing from an incomplete currency table.
114    pub decimal_places: Option<u32>,
115}
116
117impl ExtraUsage {
118    /// Integer percentage of the monthly limit consumed (0..=100, saturating
119    /// at 0 when limit is non-positive — matches claudebar:540-542).
120    ///
121    /// With no cap there is no denominator, so no meaningful percentage
122    /// exists; 0 keeps the bar and severity calm rather than inventing one.
123    pub fn percent(&self) -> i32 {
124        match self.limit {
125            Some(l) if l.0 > 0 => ((self.spent.0 * 100) / l.0) as i32,
126            _ => 0,
127        }
128    }
129
130    pub fn fmt_spent(&self) -> String {
131        self.fmt_amount(self.spent)
132    }
133
134    pub fn fmt_limit(&self) -> Option<String> {
135        self.limit.map(|l| self.fmt_amount(l))
136    }
137
138    fn fmt_amount(&self, amount: Cents) -> String {
139        match (self.decimal_places, self.currency.as_deref()) {
140            (Some(decimal_places), currency) => fmt_minor(amount.0, decimal_places, currency),
141            // Legacy payloads predate both fields and were always cents/USD.
142            // Preserve that established behaviour only when neither field can
143            // tell us otherwise.
144            (None, None) => fmt_minor(amount.0, 2, None),
145            // A currency code alone does not determine its ISO minor-unit
146            // exponent. Keep the amount truthful instead of silently dividing
147            // zero-, three-, or four-decimal currencies by the wrong scale.
148            (None, Some(currency)) => fmt_minor_units(amount.0, currency),
149        }
150    }
151}
152
153fn fmt_minor_units(minor: i64, currency: &str) -> String {
154    let sign = if minor < 0 { "-" } else { "" };
155    format!("{sign}{} minor units {currency}", minor.unsigned_abs())
156}
157
158/// Format an amount in minor units with its own currency and scale.
159///
160/// The scale is this function's own — `money` cannot express a zero- or
161/// three-decimal currency — but the *symbol* comes from
162/// [`crate::format::with_currency`], so the two cannot disagree about what a
163/// given code looks like.
164pub fn fmt_minor(minor: i64, decimal_places: u32, currency: Option<&str>) -> String {
165    let scale = 10_u64.pow(decimal_places);
166    // `unsigned_abs`, not negation: `-i64::MIN` overflows. Unreachable from
167    // the wire (the parse gate rejects negatives) but this is a pub fn.
168    let sign = if minor < 0 { "-" } else { "" };
169    let abs = minor.unsigned_abs();
170    let number = if decimal_places == 0 {
171        format!("{abs}")
172    } else {
173        format!(
174            "{}.{:0width$}",
175            abs / scale,
176            abs % scale,
177            width = decimal_places as usize
178        )
179    };
180    crate::format::with_currency(sign, &number, currency)
181}
182
183/// DeepSeek — credit balance from `/user/balance`.
184#[derive(Debug, Clone, PartialEq)]
185pub struct DeepseekSnapshot {
186    pub is_available: bool,
187    /// Current balance (prefer USD, fallback to CNY).
188    pub balance: f64,
189    /// Free-granted credits component.
190    pub granted: f64,
191    /// Topped-up (purchased) credits component.
192    pub topped_up: f64,
193    /// The currency of the above amounts (currently "USD" or "CNY").
194    pub currency: String,
195}
196
197impl Eq for DeepseekSnapshot {}
198
199impl Default for DeepseekSnapshot {
200    fn default() -> Self {
201        Self {
202            is_available: false,
203            balance: 0.0,
204            granted: 0.0,
205            topped_up: 0.0,
206            currency: String::new(),
207        }
208    }
209}
210
211/// DeepInfra prepaid balance and current-month usage from the documented
212/// `/payment/checklist` and `/payment/usage` billing endpoints.
213#[derive(Debug, Clone, PartialEq)]
214pub struct DeepInfraSnapshot {
215    /// General-purpose prepaid credit remaining, in US dollars.
216    pub balance: f64,
217    /// Current calendar-month spend, in US dollars.
218    pub monthly_spend: f64,
219    /// Optional monthly spending limit, in US dollars.
220    pub monthly_limit: Option<f64>,
221    /// Usage period in the API's `YYYY.MM` form.
222    pub period: String,
223}
224
225impl Eq for DeepInfraSnapshot {}
226
227impl DeepInfraSnapshot {
228    pub fn monthly_consumed_pct(&self) -> Option<i32> {
229        let limit = self.monthly_limit.filter(|limit| *limit > 0.0)?;
230        Some(
231            ((self.monthly_spend / limit) * 100.0)
232                .round()
233                .clamp(0.0, 9999.0) as i32,
234        )
235    }
236}
237
238/// One promo balance from Cursor's spending page
239/// (`GetClientVisibleCreditGrants`). Amounts are USD cents.
240/// `remaining_cents` is the figure to the left of the slash (`$21/$25`).
241///
242/// The grant may cover only one product (Cloud Agents, Bugbot, a single
243/// model). `display_name` is that product title when Cursor sent one. The
244/// row still says "Credits" when the name is not a title Cursor shows.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct CursorCreditGrant {
247    pub remaining_cents: i64,
248    pub total_cents: i64,
249    /// When the grant lapses. `None` when the payload has no expiry — some
250    /// account credits never do.
251    pub expires_at: Option<DateTime<Utc>>,
252    /// Vendor label. Empty when the grant has none.
253    pub display_name: String,
254}
255
256/// Cursor — the two included-usage pools the dashboard shows, from the
257/// undocumented `cursor.com/api/usage-summary` endpoint (the same one the
258/// dashboard's own frontend calls), authenticated with the session token the
259/// Cursor IDE wrote to its local `state.vscdb`.
260///
261/// Since Cursor's mid-2026 pricing, a plan's included compute is split into two
262/// quota pools, each shown as a percentage: **Cursor Models** (Auto + Composer,
263/// `autoPercentUsed`) and **Other Models** (named / third-party, `apiPercentUsed`).
264/// Overflow past either pool falls to on-demand spend. Percentages are integers
265/// (rounded from the wire floats) to match the dashboard and every other
266/// vendor's integer-percent convention; they can exceed 100 when a pool is over
267/// its included allowance.
268///
269/// [`CursorSnapshot::credits`] is a second, best-effort call
270/// (`GetClientVisibleCreditGrants`). An empty list means the account has no
271/// visible grant, or that call failed; either way the pools above still stand.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub struct CursorSnapshot {
274    /// Membership label, title-cased from `membershipType` (e.g. "Ultra").
275    pub plan: String,
276    /// "Cursor Models" pool — Auto + Composer (`autoPercentUsed`, rounded).
277    pub auto_pct: i32,
278    /// "Other Models" pool — named / third-party (`apiPercentUsed`, rounded).
279    pub api_pct: i32,
280    /// Overall included usage (`totalPercentUsed`, rounded) — the dashboard's
281    /// "you've used N% of your included total usage" headline.
282    pub total_pct: i32,
283    /// `true` when the plan reports `isUnlimited` — the pools don't cap and the
284    /// percentages are not meaningful.
285    pub unlimited: bool,
286    /// Whether on-demand (overage) spend is turned on (`onDemand.enabled`).
287    pub on_demand_enabled: bool,
288    /// On-demand spend in cents (`onDemand.used`), when Cursor reports it.
289    pub on_demand_used_cents: Option<i64>,
290    /// Configured on-demand spending limit in cents (`onDemand.limit`).
291    pub on_demand_limit_cents: Option<i64>,
292    /// End of the current billing cycle (`billingCycleEnd`) — when the pools
293    /// reset.
294    pub reset_at: Option<DateTime<Utc>>,
295    /// Start of the current billing cycle (`billingCycleStart`), when the API
296    /// sends it. With `reset_at` it gives the exact window length the pace
297    /// projection needs; absent, no window length is reported at all.
298    pub cycle_start: Option<DateTime<Utc>>,
299    /// Spending-page credit grants, soonest expiry first. Empty when the
300    /// account has none or the grant call did not succeed.
301    pub credits: Vec<CursorCreditGrant>,
302}
303
304impl CursorSnapshot {
305    /// Length of the current billing cycle, but only when the API stated both
306    /// ends and they are ordered. `None` otherwise — older responses and every
307    /// snapshot cached before `billingCycleStart` existed omit the start, and a
308    /// guessed month would reach a frontend as an exact window and be paced as
309    /// one. `window_secs` is absent instead; the reset time still shows.
310    pub fn cycle_window(&self) -> Option<chrono::Duration> {
311        match (self.cycle_start, self.reset_at) {
312            (Some(start), Some(end)) if end > start => Some(end - start),
313            _ => None,
314        }
315    }
316
317    /// The binding pool — whichever is closest to (or furthest past) its cap.
318    /// Drives the bar color and the single generic `session_pct` alias.
319    pub fn worst_pct(&self) -> i32 {
320        self.auto_pct.max(self.api_pct)
321    }
322}
323
324/// Kiro CLI (AWS CodeWhisperer / Q Developer backend) — a single credit pool
325/// from `AmazonCodeWhispererService.GetUsageLimits`, the same call kiro-cli's
326/// own `/usage` slash command makes. Authenticated with the AWS SSO OIDC
327/// bearer token kiro-cli already cached locally, refreshed with the paired
328/// refresh token when it's close to expiry — see `kiro::db` and `kiro::oauth`.
329#[derive(Debug, Clone, PartialEq)]
330pub struct KiroSnapshot {
331    /// Subscription tier label (`subscriptionInfo.subscriptionTitle`, e.g.
332    /// "KIRO POWER").
333    pub plan: String,
334    /// Credits consumed this cycle (`currentUsageWithPrecision`).
335    pub used: f64,
336    /// Credits included in the plan (`usageLimitWithPrecision`).
337    pub limit: f64,
338    /// When the credit pool resets (`nextDateReset`).
339    pub reset_at: Option<DateTime<Utc>>,
340}
341
342impl Eq for KiroSnapshot {}
343
344impl KiroSnapshot {
345    /// Percentage of the credit pool consumed, rounded. `0` when `limit` is
346    /// not positive — defensive; the API has not been observed to send that.
347    pub fn pct(&self) -> i32 {
348        if self.limit <= 0.0 {
349            return 0;
350        }
351        ((self.used / self.limit) * 100.0)
352            .round()
353            .clamp(0.0, 9999.0) as i32
354    }
355}
356
357/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
358///
359/// Accounts on the newer `/coding/v1/usages` response shape have no weekly
360/// counters at all: the top-level `usage` block is replaced by a `usages` map
361/// of ratios, of which only `limit_month_total` — the combined monthly pool —
362/// is read. On such accounts `has_weekly` is false, the weekly counters stay
363/// zero (never fabricated), and the monthly pool arrives as
364/// `monthly_pct`/`monthly_reset_at`.
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct KimiSnapshot {
367    pub plan: Option<String>,
368    pub weekly_limit: u64,
369    pub weekly_used: u64,
370    pub weekly_remaining: u64,
371    pub weekly_reset_at: Option<DateTime<Utc>>,
372    /// `false` on the newer `usages`-map shape, which exposes no weekly
373    /// bucket; renderers must drop the weekly row rather than draw zeros.
374    pub has_weekly: bool,
375    /// Combined monthly pool usage (0..=100) on the newer shape. The
376    /// `limit_month_code` entry is the Code slice *inside* that pool, never
377    /// its own allowance, so it is not carried here.
378    pub monthly_pct: Option<i32>,
379    pub monthly_reset_at: Option<DateTime<Utc>>,
380    pub window_limit: u64,
381    pub window_used: u64,
382    pub window_remaining: u64,
383    pub window_reset_at: Option<DateTime<Utc>>,
384}
385
386impl KimiSnapshot {
387    fn pct(used: u64, limit: u64) -> i32 {
388        if limit == 0 {
389            0
390        } else {
391            // Keep all quota values exact: f64 loses integer precision above
392            // 2^53. This is the integer equivalent of round(used / limit *
393            // 100), with saturation for inconsistent upstream counters.
394            let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
395            pct.min(100) as i32
396        }
397    }
398
399    /// Percentage of the weekly subscription quota consumed (0..=100).
400    pub fn weekly_pct(&self) -> i32 {
401        Self::pct(self.weekly_used, self.weekly_limit)
402    }
403
404    /// Percentage of the rolling rate-limit window consumed (0..=100).
405    pub fn window_pct(&self) -> i32 {
406        Self::pct(self.window_used, self.window_limit)
407    }
408
409    /// Worst percentage across the windows this snapshot actually has: the
410    /// rolling window, the weekly quota when present, and the monthly pool
411    /// when present.
412    pub fn worst_pct(&self) -> i32 {
413        let mut worst = self.window_pct();
414        if self.has_weekly {
415            worst = worst.max(self.weekly_pct());
416        }
417        if let Some(monthly) = self.monthly_pct {
418            worst = worst.max(monthly);
419        }
420        worst
421    }
422}
423
424/// Discriminated union of vendor-specific snapshots. The widget and TUI match
425/// on this to pick a renderer.
426#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum VendorSnapshot {
428    Anthropic(AnthropicSnapshot),
429    Openai(OpenAiSnapshot),
430    Copilot(crate::copilot::types::Snapshot),
431    Zai(ZaiSnapshot),
432    Openrouter(OpenRouterSnapshot),
433    Deepseek(DeepseekSnapshot),
434    Deepinfra(DeepInfraSnapshot),
435    Kimi(KimiSnapshot),
436    Kilo(KiloSnapshot),
437    Novita(NovitaSnapshot),
438    Moonshot(MoonshotSnapshot),
439    Grok(GrokSnapshot),
440    SuperGrok(SuperGrokSnapshot),
441    Grokbot(GrokbotSnapshot),
442    AnthropicApi(AnthropicApiSnapshot),
443    Antigravity(AntigravitySnapshot),
444    Cursor(CursorSnapshot),
445    Minimax(MinimaxSnapshot),
446    Kiro(KiroSnapshot),
447    NousResearch(crate::nous::types::AccountSnapshot),
448    OpenCodeGo(crate::opencode_go::types::Usage),
449    CommandCode(crate::commandcode::types::Snapshot),
450    Ollama(OllamaSnapshot),
451    OrcaRouter(OrcaRouterSnapshot),
452    ModelStudio(ModelStudioSnapshot),
453    Lyceum(LyceumSnapshot),
454    Devin(DevinSnapshot),
455    /// A `[[custom]]` provider. Which one is not in the snapshot: the caller
456    /// that fetched it holds the `CustomProviderConfig`, and the cache
457    /// directory is keyed by its `id`.
458    Custom(crate::custom::types::CustomSnapshot),
459}
460
461impl VendorSnapshot {
462    /// Banked, user-redeemable resets, for the vendors that have them.
463    ///
464    /// The one place this table lives. It had already been written twice —
465    /// once for the report's `reset_credits` field and once for the
466    /// expiry notifications — and adding a third provider to only one of them
467    /// is a silent half-feature: the sidebar lists a grant the notifier never
468    /// warns about. `None` is the honest answer for every other vendor; it is
469    /// not the same as an empty [`ResetCredits`], which means "this provider
470    /// banks resets and you currently hold none".
471    pub fn reset_credits(&self) -> Option<&ResetCredits> {
472        match self {
473            Self::Anthropic(snapshot) => Some(&snapshot.reset_credits),
474            Self::Openai(snapshot) => Some(&snapshot.reset_credits),
475            Self::SuperGrok(snapshot) => Some(&snapshot.reset_credits),
476            _ => None,
477        }
478    }
479}
480
481/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
482/// and third-party (Claude/GPT) buckets, and each group may carry a 5-hour and
483/// a weekly window — up to four, and not every product or plan offers all of
484/// them. Antigravity CLI 1.1.22 returns weekly buckets only, so every window is
485/// optional and a snapshot is valid when at least one arrived.
486#[derive(Debug, Clone, PartialEq)]
487pub struct AntigravitySnapshot {
488    pub plan: String,
489    /// Fingerprint of the signed-in account. Never displayed — it exists so a
490    /// cache written for one Google account is not served for another.
491    pub account: String,
492    /// Where the figures came from: a running local product, or the Cloud
493    /// Code API reached with the saved Google session when no local server
494    /// can answer — none is running, or `agy` withholds its CSRF token.
495    pub source: AntigravitySource,
496    /// Gemini group, 5-hour window.
497    pub session: Option<UsageWindow>,
498    /// Gemini group, weekly window.
499    pub weekly: Option<UsageWindow>,
500    /// Claude/GPT group, 5-hour window.
501    pub third_party_session: Option<UsageWindow>,
502    /// Claude/GPT group, weekly window.
503    pub third_party_weekly: Option<UsageWindow>,
504}
505
506impl Eq for AntigravitySnapshot {}
507
508/// Which path produced an [`AntigravitySnapshot`]. The local language server
509/// is the primary source; the remote API is the fallback for when no product
510/// is running, and the panel says so because the two can disagree briefly.
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
512pub enum AntigravitySource {
513    #[default]
514    Local,
515    Remote,
516    /// The running `agy` CLI's official status-line payload.
517    Statusline,
518}
519
520impl AntigravitySource {
521    pub fn as_str(self) -> &'static str {
522        match self {
523            AntigravitySource::Local => "local",
524            AntigravitySource::Remote => "remote",
525            AntigravitySource::Statusline => "statusline",
526        }
527    }
528
529    /// Inverse of [`as_str`](Self::as_str); anything unrecognised is `None` so
530    /// a cache reader can fall back to the default rather than guess.
531    pub fn parse(s: &str) -> Option<Self> {
532        match s {
533            "local" => Some(AntigravitySource::Local),
534            "remote" => Some(AntigravitySource::Remote),
535            "statusline" => Some(AntigravitySource::Statusline),
536            _ => None,
537        }
538    }
539}
540
541/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
542/// bucket (`general` for text/coding, `video`), and each row carries its own
543/// rolling interval window plus a weekly window.
544///
545/// Two things the payload dictates rather than convention: the interval length
546/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
547/// duration is derived from the row's own start/end rather than assumed; and
548/// the API reports the percentage **remaining**, which is inverted on the way
549/// in so these windows carry consumed-% like every other vendor's.
550#[derive(Debug, Clone, PartialEq, Eq)]
551pub struct MinimaxSnapshot {
552    pub plan: String,
553    /// `general` bucket — rolling interval window (5h on the observed plans).
554    pub session: UsageWindow,
555    /// `general` bucket — weekly window.
556    pub weekly: UsageWindow,
557    /// `video` bucket, `None` on plans that carry no video quota.
558    pub video_session: Option<UsageWindow>,
559    pub video_weekly: Option<UsageWindow>,
560}
561
562/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
563/// monthly `limit` is supplied from config (the API exposes neither the limit
564/// nor the remaining prepaid credit balance).
565#[derive(Debug, Clone, PartialEq)]
566pub struct AnthropicApiSnapshot {
567    pub spent: f64,
568    pub limit: Option<f64>,
569}
570
571impl Eq for AnthropicApiSnapshot {}
572
573impl AnthropicApiSnapshot {
574    /// Spend as an integer percentage of the configured limit; `None` when no
575    /// positive limit is set.
576    pub fn pct(&self) -> Option<i32> {
577        self.limit
578            .filter(|l| l.is_finite() && *l > 0.0)
579            .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
580    }
581}
582
583/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
584/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
585#[derive(Debug, Clone, PartialEq)]
586pub struct KiloSnapshot {
587    pub label: String,
588    pub balance: f64,
589}
590
591impl Eq for KiloSnapshot {}
592
593/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
594/// all amounts already converted from the API's 1/10000-USD integers to USD.
595#[derive(Debug, Clone, PartialEq)]
596pub struct NovitaSnapshot {
597    /// Spendable credit balance (`availableBalance`).
598    pub available: f64,
599    /// Remaining top-up (`cashBalance`).
600    pub cash: f64,
601    /// Credit limit — max you can owe (`creditLimit`).
602    pub credit_limit: f64,
603    /// Amount currently owed (`outstandingInvoices`).
604    pub outstanding: f64,
605}
606
607impl Eq for NovitaSnapshot {}
608
609/// Lyceum's platform credit balance is denominated in USD, not a percentage quota.
610#[derive(Debug, Clone, PartialEq, Serialize)]
611pub struct LyceumSnapshot {
612    pub available_credits: f64,
613    pub used_credits: f64,
614    pub total_credits_used: f64,
615    pub remaining_credits: f64,
616    pub monthly_free_credits: f64,
617    pub purchased_credits: f64,
618}
619impl Eq for LyceumSnapshot {}
620
621/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
622/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
623/// field in the response, so it's carried here from the region config.
624#[derive(Debug, Clone, PartialEq)]
625pub struct MoonshotSnapshot {
626    /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
627    /// the inference API.
628    pub available: f64,
629    /// Voucher credit (`voucher_balance`).
630    pub voucher: f64,
631    /// Cash balance (`cash_balance`); can be negative (debt).
632    pub cash: f64,
633    /// "USD" or "CNY", implied by the host.
634    pub currency: String,
635}
636
637impl Eq for MoonshotSnapshot {}
638
639/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
640/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
641#[derive(Debug, Clone, PartialEq)]
642pub struct GrokSnapshot {
643    pub balance: f64,
644}
645
646impl Eq for GrokSnapshot {}
647
648/// SuperGrok subscription usage from Grok Build's billing endpoint (ACP as
649/// fallback) plus banked remaining-resets. Distinct from [`GrokSnapshot`]
650/// (Management API prepaid balance).
651#[derive(Debug, Clone, PartialEq)]
652pub struct SuperGrokSnapshot {
653    /// Subscription tier label when the billing response supplies one
654    /// (e.g. "SuperGrok", "SuperGrok Heavy"); otherwise `"SuperGrok"`.
655    pub plan: String,
656    /// Opaque digest of Grok auth/config state. Never displayed — cache
657    /// isolation only.
658    pub account: String,
659    /// Current included-credit usage percent. The field name is retained as a
660    /// compatibility alias for format/render code; [`Self::period`] says
661    /// whether the server's actual window is weekly or monthly.
662    pub weekly_pct: i32,
663    pub period: SuperGrokPeriod,
664    /// When the current usage period ends.
665    pub reset_at: Option<DateTime<Utc>>,
666    /// Remaining prepaid (purchased) API credit in USD, when present.
667    pub prepaid_balance: Option<f64>,
668    pub reset_credits: ResetCredits,
669    /// Per-product slices of the same included-credit pool (`GrokBuild`,
670    /// `GrokChat`, `GrokImagine`, …). Empty when the billing document omits
671    /// `productUsage`.
672    pub products: Vec<SuperGrokProduct>,
673}
674
675/// One SuperGrok product's share of the current included-credit window.
676#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
677pub struct SuperGrokProduct {
678    pub label: String,
679    pub percent: i32,
680}
681
682impl Eq for SuperGrokSnapshot {}
683
684#[derive(Debug, Clone, Copy, PartialEq, Eq)]
685pub enum SuperGrokPeriod {
686    Weekly,
687    Monthly,
688    Unknown,
689}
690
691impl SuperGrokPeriod {
692    pub fn label(self) -> &'static str {
693        match self {
694            Self::Weekly => "Weekly",
695            Self::Monthly => "Monthly",
696            Self::Unknown => "Current period",
697        }
698    }
699
700    pub fn short(self) -> &'static str {
701        match self {
702            Self::Weekly => "wk",
703            Self::Monthly => "mo",
704            Self::Unknown => "period",
705        }
706    }
707}
708
709/// Grok Bot desktop app — the weekly included-usage pool from
710/// `aiserver.v1.DashboardService/GetSandUsageStatus` (Connect-RPC), read with
711/// the app's own OAuth session. Distinct from [`GrokSnapshot`] (Management
712/// API prepaid dollars) and [`SuperGrokSnapshot`] (Grok Build subscription).
713#[derive(Debug, Clone, PartialEq, Eq)]
714pub struct GrokbotSnapshot {
715    /// `grokPlanLabel`, falling back to `cursorPlanName`, then "Grok Bot".
716    pub plan: String,
717    /// The subscription that bills the pool, from `billingBrand` and the plan
718    /// reported for it ("Cursor Ultra"). `None` for a brand not recognized yet,
719    /// which is left unnamed rather than guessed.
720    pub billed_by: Option<String>,
721    /// `hasNonZeroIncludedLimit`. When false the account carries no included
722    /// allowance at all — a distinct "no included allowance" state, never a
723    /// fabricated 0% meter.
724    pub has_included_allowance: bool,
725    /// `usagePercent` of the included pool (0..=100). Meaningful only when
726    /// `has_included_allowance` is set.
727    pub weekly_pct: i32,
728    /// `hasAvailableUsage` — the account can still serve requests, which at
729    /// 100% of the included pool means on-demand is picking up the rest.
730    pub has_available_usage: bool,
731    /// `onDemandSettings.enabled` — pay-as-you-go past the included pool.
732    pub on_demand_enabled: bool,
733    /// `currentPeriodStart`.
734    pub period_start: Option<DateTime<Utc>>,
735    /// `nextResetTimestampUtc`.
736    pub reset_at: Option<DateTime<Utc>>,
737    /// `reset_at − period_start` when both are reported (7 days on the
738    /// captured account) — computed, never assumed.
739    pub window: Option<chrono::Duration>,
740}
741
742impl GrokbotSnapshot {
743    /// The plan a frontend shows: the subscription that bills the pool
744    /// ("Cursor Ultra") over the app's own label, which reads "Grok Bot Plan"
745    /// on every account.
746    pub fn display_plan(&self) -> &str {
747        self.billed_by.as_deref().unwrap_or(&self.plan)
748    }
749
750    /// At 100% of the included pool, `hasAvailableUsage` can still be true
751    /// because on-demand keeps serving — say so, but only when the account
752    /// actually has on-demand switched on.
753    pub fn on_demand_note(&self) -> Option<&'static str> {
754        (self.has_included_allowance
755            && self.weekly_pct >= 100
756            && self.has_available_usage
757            && self.on_demand_enabled)
758            .then_some("included pool exhausted — on-demand may still be serving usage")
759    }
760}
761
762/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
763#[derive(Debug, Clone, PartialEq, Eq)]
764pub struct OpenAiSnapshot {
765    pub plan: String,
766    /// 5h window, identified by its duration rather than its wire position.
767    pub session: Option<UsageWindow>,
768    /// 7d window, identified by its duration rather than its wire position.
769    pub weekly: Option<UsageWindow>,
770    /// Optional 7d code-review bucket.
771    pub code_review: Option<UsageWindow>,
772    /// Named limits beside the main one, each with its own windows. Empty for
773    /// an account that has none.
774    pub additional_limits: Vec<OpenAiNamedLimit>,
775    /// Models the account currently cannot dispatch to, with the time they
776    /// return when the API states one. Only unavailable models are kept: a
777    /// list of everything that *is* working is noise, and the reason this
778    /// exists is to explain a refusal no percentage accounts for.
779    pub unavailable_models: Vec<OpenAiUnavailableModel>,
780    /// Optional credit balance + approximate message-count ranges.
781    pub credits: Option<OpenAiCredits>,
782    pub reset_credits: ResetCredits,
783    /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
784    /// the placeholder set and the "OpenAI does not expose this for Plus"
785    /// tooltip when the OAuth path isn't available.
786    pub source: OpenAiSource,
787}
788
789/// A named limit that sits beside Codex's main window — a reserved pool or a
790/// model-specific allowance. It can be exhausted while the headline window is
791/// nearly untouched, which is the case it exists to make visible.
792#[derive(Debug, Clone, PartialEq, Eq)]
793pub struct OpenAiNamedLimit {
794    /// The API's own name for it, shown as given.
795    pub name: String,
796    pub session: Option<UsageWindow>,
797    pub weekly: Option<UsageWindow>,
798}
799
800/// A model the account cannot currently dispatch to.
801#[derive(Debug, Clone, PartialEq, Eq)]
802pub struct OpenAiUnavailableModel {
803    pub model: String,
804    /// When the API says it returns. `None` means it did not say.
805    pub available_at: Option<DateTime<Utc>>,
806}
807
808/// Banked, user-redeemable quota resets — Codex's "rate limit reset credits"
809/// and SuperGrok's "remaining resets" are the same idea under two names: a
810/// count you have earned, each with its own expiry, redeemed by hand rather
811/// than arriving on the window's own schedule. Distinct from a
812/// [`UsageWindow::resets_at`], which needs no action and cannot be banked.
813///
814/// The redemption identifier each provider returns alongside these
815/// (`credits[].id`, `tokens[].token_id`) is deliberately *not* carried here:
816/// it is the handle that spends the credit, and nothing that renders a status
817/// bar needs it.
818#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
819pub struct ResetCredits {
820    pub available: u32,
821    /// One row per credit the provider described. May be shorter than
822    /// `available` — Codex's usage endpoint gives the count without the
823    /// per-credit detail, and the detail call is allowed to fail on its own.
824    #[serde(default)]
825    pub credits: Vec<ResetCredit>,
826}
827
828#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
829pub struct ResetCredit {
830    /// Provider label when one exists ("Full reset (Weekly + 5 hr)"). SuperGrok
831    /// tokens have no title.
832    #[serde(default, skip_serializing_if = "Option::is_none")]
833    pub title: Option<String>,
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub expires_at: Option<DateTime<Utc>>,
836}
837
838/// A provider's own label for a banked reset, rendered verbatim in Pango bar
839/// markup and in the `;;`-delimited desktop FORMAT protocol. Both vendors that
840/// carry one gate it here rather than each keeping a copy: an over-long or
841/// control-character-bearing title is dropped, leaving the expiry line alone,
842/// which still says everything the user has to act on.
843pub fn checked_reset_title(value: Option<String>) -> Option<String> {
844    const MAX_RESET_TITLE_CHARS: usize = 80;
845    let value = value
846        .map(|s| s.trim().to_string())
847        .filter(|s| !s.is_empty())?;
848    if value.chars().count() > MAX_RESET_TITLE_CHARS || value.chars().any(char::is_control) {
849        None
850    } else {
851        Some(value)
852    }
853}
854
855impl ResetCredits {
856    pub fn is_empty(&self) -> bool {
857        self.available == 0
858    }
859
860    pub fn next_expiry(&self) -> Option<DateTime<Utc>> {
861        self.credits
862            .iter()
863            .filter_map(|credit| credit.expires_at)
864            .min()
865    }
866}
867
868#[derive(Debug, Clone, Copy, PartialEq, Eq)]
869pub enum OpenAiSource {
870    CodexOauth,
871    AdminKeyMtd,
872    Unavailable,
873}
874
875#[derive(Debug, Clone, PartialEq, Eq)]
876pub struct OpenAiCredits {
877    /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
878    /// a string because OpenAI returns it that way.
879    pub balance: String,
880    pub has_credits: bool,
881    pub unlimited: bool,
882    pub approx_local_messages: Option<(i64, i64)>,
883    pub approx_cloud_messages: Option<(i64, i64)>,
884}
885
886/// Z.AI / BigModel — list of buckets with discriminated types. We project the
887/// two we care about into named fields (5h tokens, weekly tokens, MCP).
888#[derive(Debug, Clone, PartialEq, Eq)]
889pub struct ZaiSnapshot {
890    pub plan: String,
891    pub session: Option<UsageWindow>,
892    pub weekly: Option<UsageWindow>,
893    pub mcp: Option<UsageWindow>,
894}
895
896/// Ollama Cloud — credit balances or legacy quota windows from
897/// `ollama.com/api/balance`. Older cached `/api/usage` payloads can also
898/// carry per-model counts and activity cost.
899#[derive(Debug, Clone, PartialEq, Eq)]
900pub struct OllamaSnapshot {
901    /// Display label, taken from the `[ollama] plan` config field. The API
902    /// itself does not report a plan name.
903    pub plan: String,
904    /// 5h rolling window (`limits.session`). `None` when the account has not
905    /// touched the cloud tier yet (the window is omitted from the response,
906    /// not reported as zero).
907    pub session: Option<UsageWindow>,
908    /// 7d rolling window (`limits.weekly`).
909    pub weekly: Option<UsageWindow>,
910    /// Calendar-month window (`limits.monthly`). Some Pro accounts report
911    /// this in place of `session`/`weekly` instead of alongside them.
912    pub monthly: Option<UsageWindow>,
913    /// Per-model request counts inside the session window, in the order the
914    /// API returned them. Renderers sort and truncate this for the tooltip.
915    pub session_models: Vec<OllamaModelUsage>,
916    /// Per-model request counts inside the weekly window.
917    pub weekly_models: Vec<OllamaModelUsage>,
918    /// Per-model request counts inside the monthly window.
919    pub monthly_models: Vec<OllamaModelUsage>,
920    /// `activity.cost` as a pre-formatted dollar string (`"0.00000"`,
921    /// `"1.23456"`). Already a string on the wire — the renderer decides
922    /// whether to keep it verbatim or reformat.
923    pub activity_cost: Option<String>,
924    /// `activity.period.type` (`"last_4_weeks"` and friends). A short
925    /// human-readable label the renderer can show next to the cost.
926    pub activity_period: Option<String>,
927    /// Monthly included credits and optional purchased credits, formatted
928    /// through the shared money formatter for all desktop surfaces.
929    pub credits: Option<OllamaCredits>,
930}
931
932#[derive(Debug, Clone, PartialEq, Eq)]
933pub struct OllamaCredits {
934    pub balance: String,
935    pub allowance: String,
936    pub purchased: Option<String>,
937}
938
939/// One row of `OllamaSnapshot::{session,weekly}_models`. The API carries the
940/// per-model request count; the percentage of the window that this single
941/// model represents is not reported, so the renderer derives it locally.
942#[derive(Debug, Clone, PartialEq, Eq)]
943pub struct OllamaModelUsage {
944    pub name: String,
945    pub request_count: u64,
946}
947
948/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
949/// `/api/v1/credits` and `/api/v1/key`.
950#[derive(Debug, Clone, PartialEq)]
951pub struct OpenRouterSnapshot {
952    pub label: String,
953    pub total_credits: f64,
954    pub total_usage: f64,
955    pub usage_daily: f64,
956    pub usage_weekly: f64,
957    pub usage_monthly: f64,
958    pub is_free_tier: bool,
959    pub limit: Option<f64>,
960    pub limit_remaining: Option<f64>,
961    pub recent_models: Vec<String>,
962}
963
964impl Eq for OpenRouterSnapshot {}
965
966impl OpenRouterSnapshot {
967    /// Spendable credit, **which can be negative**: OpenRouter lets an account
968    /// run into debt, and clamping that to zero would report a healthy-looking
969    /// `$0.00` to someone who has to top up before anything works again. The
970    /// wire fields are each non-negative (see `openrouter::types`), so a
971    /// negative result only ever means usage has overrun credits.
972    pub fn balance(&self) -> f64 {
973        self.total_credits - self.total_usage
974    }
975    /// Percentage of total_credits consumed (0..=100). Returns 0 when
976    /// `total_credits` is 0 (free-tier-only accounts) — there is no
977    /// denominator to be a percentage of. Severity does not come from this
978    /// number alone: see [`crate::openrouter::vendor::severity`], which treats
979    /// a negative [`Self::balance`] as critical regardless of the percentage.
980    pub fn consumed_pct(&self) -> i32 {
981        if self.total_credits <= 0.0 {
982            return 0;
983        }
984        i32::from(crate::format::clamp_pct(
985            (self.total_usage / self.total_credits) * 100.0,
986        ))
987    }
988}
989
990/// OrcaRouter — prepaid credit card from the one-api compatible dashboard
991/// billing endpoints (`/v1/dashboard/billing/usage` + `/subscription`), over an
992/// API key. Usage arrives in **US cents** (`total_usage: 275` = $2.75); the
993/// subscription's limit fields are USD and mean the *total* credit limit
994/// (remaining + used), with `100000000` as the unlimited sentinel.
995#[derive(Debug, Clone, PartialEq, Eq)]
996pub struct OrcaRouterSnapshot {
997    /// Cumulative spend, exact US cents (`total_usage`).
998    pub spent_cents: i64,
999    /// Total credit limit in exact US cents. `None` for unlimited keys (the
1000    /// `100000000` sentinel) or when the subscription response carried no
1001    /// limit field at all — either way the card is spend-only.
1002    pub limit_cents: Option<i64>,
1003    /// Key expiry (`access_until`, Unix seconds); `None` = no expiry (a wire
1004    /// `0` means the same thing).
1005    pub access_until: Option<DateTime<Utc>>,
1006}
1007
1008impl OrcaRouterSnapshot {
1009    pub fn spent_usd(&self) -> f64 {
1010        self.spent_cents as f64 / 100.0
1011    }
1012
1013    pub fn limit_usd(&self) -> Option<f64> {
1014        self.limit_cents.map(|c| c as f64 / 100.0)
1015    }
1016
1017    /// Remaining credit in exact cents. Can be negative (spend past the
1018    /// limit) — the sign belongs outside the symbol, like OpenRouter debt.
1019    pub fn remaining_cents(&self) -> Option<i64> {
1020        self.limit_cents.map(|limit| limit - self.spent_cents)
1021    }
1022
1023    pub fn remaining_usd(&self) -> Option<f64> {
1024        self.remaining_cents().map(|c| c as f64 / 100.0)
1025    }
1026
1027    /// Integer-percentage of the limit consumed, computed in cents so no
1028    /// float division is involved. `None` when there is no limit — an
1029    /// unlimited key has no percentage to be exact *about*.
1030    pub fn consumed_pct(&self) -> Option<i32> {
1031        self.limit_cents.filter(|l| *l > 0).map(|limit| {
1032            let pct = (self.spent_cents.saturating_mul(100)) / limit;
1033            pct.clamp(0, 100) as i32
1034        })
1035    }
1036}
1037
1038/// Alibaba Cloud Model Studio Token Plan — a 5-hour, a weekly and a monthly
1039/// ratio window, any of which the console account may not report. An absent
1040/// window is no-data (possibly unlimited), never 0%.
1041#[derive(Debug, Clone, PartialEq, Eq)]
1042pub struct ModelStudioSnapshot {
1043    /// 5-hour window. `None` when `per5HourPercentage` was absent.
1044    pub session: Option<UsageWindow>,
1045    /// Weekly window. `None` when `per1WeekPercentage` was absent.
1046    pub weekly: Option<UsageWindow>,
1047    /// Monthly window. `None` when `per1MonthPercentage` was absent. The
1048    /// reset is exact; the cycle length is plan-dependent, so no exact
1049    /// window duration is declared for it (same rule as Ollama's calendar
1050    /// month) and pacing is omitted wherever it would guess a denominator.
1051    pub monthly: Option<UsageWindow>,
1052}
1053
1054/// Devin CLI quota snapshot. The CLI reports remaining percentages, while
1055/// renderers and report consumers use consumed percentages consistently.
1056#[derive(Debug, Clone, PartialEq, Eq)]
1057pub struct DevinSnapshot {
1058    pub daily: Option<UsageWindow>,
1059    pub weekly: Option<UsageWindow>,
1060    /// Exact value from the CLI's overageBalanceMicros field. Its USD meaning
1061    /// is empirically observed for the tested account, not a universal API
1062    /// contract; only renderers perform that display conversion.
1063    pub overage_balance_micros: Option<i64>,
1064}
1065
1066/// Worst-of severity class for the Waybar bar text color. Mirrors
1067/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
1068pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
1069    let mut max = snap.session.utilization_pct;
1070    if snap.weekly.utilization_pct > max {
1071        max = snap.weekly.utilization_pct;
1072    }
1073    if let Some(s) = &snap.sonnet
1074        && s.utilization_pct > max
1075    {
1076        max = s.utilization_pct;
1077    }
1078    for sw in &snap.scoped {
1079        if sw.window.utilization_pct > max {
1080            max = sw.window.utilization_pct;
1081        }
1082    }
1083    // Extra usage only promotes severity if a rate-limit window is at 100%.
1084    let any_at_cap = snap.session.utilization_pct >= 100
1085        || snap.weekly.utilization_pct >= 100
1086        || snap
1087            .sonnet
1088            .as_ref()
1089            .is_some_and(|s| s.utilization_pct >= 100)
1090        || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
1091    if any_at_cap && let Some(extra) = snap.extra.as_ref() {
1092        let p = extra.percent();
1093        if p > max {
1094            max = p;
1095        }
1096    }
1097    crate::pango::severity_for(max)
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use super::*;
1103    use crate::pacing::PaceSeverity;
1104    use chrono::Duration;
1105
1106    fn w(pct: i32) -> UsageWindow {
1107        UsageWindow {
1108            utilization_pct: pct,
1109            resets_at: None,
1110            window_duration: Duration::hours(5),
1111        }
1112    }
1113
1114    fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
1115        AnthropicSnapshot {
1116            plan: "Max 5x".into(),
1117            session: w(s),
1118            weekly: w(w_),
1119            sonnet: sonnet.map(w),
1120            scoped: vec![],
1121            extra: extra.map(|(limit, spent)| ExtraUsage {
1122                limit: Some(Cents(limit)),
1123                spent: Cents(spent),
1124                currency: None,
1125                decimal_places: Some(2),
1126            }),
1127            reset_credits: Default::default(),
1128        }
1129    }
1130
1131    #[test]
1132    fn fmt_minor_honors_currency_and_scale() {
1133        // No currency (older payloads) keeps the historical `$`.
1134        assert_eq!(fmt_minor(250, 2, None), "$2.50");
1135        // The #30 reporter's actual figures: BRL must not be claimed as `$`.
1136        assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
1137        assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
1138        // Zero-exponent currency: no decimal point, no /100.
1139        assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
1140        // Sign precedes the symbol, as `format::money` spells it.
1141        assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
1142        // Unknown code stays truthful as a suffix rather than guessing a symbol.
1143        assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
1144    }
1145
1146    #[test]
1147    fn extra_usage_formats_in_its_own_currency() {
1148        let e = ExtraUsage {
1149            limit: None,
1150            spent: Cents(14157),
1151            currency: Some("BRL".into()),
1152            decimal_places: Some(2),
1153        };
1154        assert_eq!(e.fmt_spent(), "R$141.57");
1155        assert_eq!(e.fmt_limit(), None);
1156
1157        let capped = ExtraUsage {
1158            limit: Some(Cents(5000)),
1159            spent: Cents(250),
1160            currency: None,
1161            decimal_places: Some(2),
1162        };
1163        assert_eq!(capped.fmt_spent(), "$2.50");
1164        assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
1165    }
1166
1167    #[test]
1168    fn extra_percent_with_zero_limit_is_zero() {
1169        assert_eq!(
1170            ExtraUsage {
1171                limit: Some(Cents(0)),
1172                spent: Cents(100),
1173                currency: None,
1174                decimal_places: Some(2),
1175            }
1176            .percent(),
1177            0
1178        );
1179    }
1180
1181    #[test]
1182    fn extra_percent_truncates() {
1183        // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
1184        assert_eq!(
1185            ExtraUsage {
1186                limit: Some(Cents(10000)),
1187                spent: Cents(3333),
1188                currency: None,
1189                decimal_places: Some(2),
1190            }
1191            .percent(),
1192            33
1193        );
1194    }
1195
1196    #[test]
1197    fn severity_picks_worst_of_three_windows() {
1198        let s = snap(40, 60, Some(80), None);
1199        assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
1200    }
1201
1202    #[test]
1203    fn severity_ignores_extra_when_no_cap_hit() {
1204        // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
1205        let s = snap(50, 60, None, Some((10000, 9500)));
1206        assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
1207    }
1208
1209    #[test]
1210    fn severity_promotes_extra_when_session_at_100() {
1211        let s = snap(100, 50, None, Some((10000, 9500)));
1212        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
1213    }
1214
1215    #[test]
1216    fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
1217        // session = 100, weekly = 50, extra = 100% → max should be 100.
1218        let s = snap(100, 50, None, Some((10000, 10000)));
1219        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
1220    }
1221
1222    fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
1223        s.scoped.push(ScopedWindow {
1224            label: "Fable".into(),
1225            window: w(pct),
1226        });
1227        s
1228    }
1229
1230    #[test]
1231    fn severity_includes_scoped_windows() {
1232        // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
1233        // week at 84 → the bar class must escalate to High.
1234        let s = with_scoped(snap(10, 55, None, None), 84);
1235        assert_eq!(anthropic_severity(&s), PaceSeverity::High);
1236    }
1237
1238    #[test]
1239    fn severity_promotes_extra_when_scoped_at_100() {
1240        // A scoped window at cap counts as a rate-limit cap hit, so extra
1241        // usage above the window max is promoted — same rule as session/weekly.
1242        let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
1243        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
1244    }
1245
1246    #[test]
1247    fn kimi_percent_is_exact_above_f64_precision() {
1248        let snap = KimiSnapshot {
1249            plan: None,
1250            weekly_limit: (1 << 53) + 1,
1251            weekly_used: 1 << 52,
1252            weekly_remaining: 0,
1253            weekly_reset_at: None,
1254            has_weekly: true,
1255            monthly_pct: None,
1256            monthly_reset_at: None,
1257            window_limit: u64::MAX,
1258            window_used: u64::MAX - 1,
1259            window_remaining: 0,
1260            window_reset_at: None,
1261        };
1262        assert_eq!(snap.weekly_pct(), 50);
1263        assert_eq!(snap.window_pct(), 100);
1264    }
1265
1266    #[test]
1267    fn kiro_pct_is_zero_without_a_positive_limit() {
1268        let snap = KiroSnapshot {
1269            plan: "FREE".into(),
1270            used: 5.0,
1271            limit: 0.0,
1272            reset_at: None,
1273        };
1274        assert_eq!(snap.pct(), 0);
1275    }
1276
1277    #[test]
1278    fn kiro_pct_rounds_the_credit_ratio() {
1279        let snap = KiroSnapshot {
1280            plan: "KIRO POWER".into(),
1281            used: 1.0,
1282            limit: 3.0,
1283            reset_at: None,
1284        };
1285        assert_eq!(snap.pct(), 33);
1286    }
1287}