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