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};
16
17use crate::error::{AppError, Result};
18
19/// Reject a non-finite monetary value. A NaN or infinity reaching a balance
20/// field means the payload was not what we think it is; displaying it as money
21/// (or caching it as authoritative) is worse than failing loudly.
22pub fn finite_amount(vendor: &str, field: &str, v: f64) -> Result<f64> {
23    if v.is_finite() {
24        Ok(v)
25    } else {
26        Err(AppError::Schema(format!(
27            "{vendor}: `{field}` is not a finite number"
28        )))
29    }
30}
31
32/// Parse a monetary field that the wire encodes as a string. A malformed or
33/// empty value is a schema error, **not** a zero balance — silently reporting
34/// $0.00 for an error envelope is the failure mode this guards against.
35pub fn parse_amount(vendor: &str, field: &str, s: &str) -> Result<f64> {
36    let t = s.trim();
37    if t.is_empty() {
38        return Err(AppError::Schema(format!("{vendor}: `{field}` is empty")));
39    }
40    let v: f64 = t
41        .parse()
42        .map_err(|_| AppError::Schema(format!("{vendor}: `{field}` is not numeric (got {t:?})")))?;
43    finite_amount(vendor, field, v)
44}
45
46/// A single usage window — generic enough that every vendor with a notion of
47/// "% used vs. when does it reset" can express itself with it.
48///
49/// `utilization_pct` is `0..=100` (integer percent, matching claudebar's units).
50/// `resets_at` is `None` when the vendor doesn't report a reset time.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct UsageWindow {
53    pub utilization_pct: i32,
54    pub resets_at: Option<DateTime<Utc>>,
55    /// Window length (used for pacing math).
56    pub window_duration: chrono::Duration,
57}
58
59/// Money in minor currency units (historically always cents; see
60/// `ExtraUsage::decimal_places` for the actual scale) to dodge float roundoff.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub struct Cents(pub i64);
63
64impl Cents {
65    /// Format as `[-]$D.CC`. Negative values render `-$D.CC` (not `$-D.CC`),
66    /// matching claudebar's `_fmt_dollars` (claudebar:532-537).
67    pub fn fmt_dollars(self) -> String {
68        let (sign, abs) = if self.0 < 0 {
69            ("-", -self.0)
70        } else {
71            ("", self.0)
72        };
73        format!("{sign}${}.{:02}", abs / 100, abs % 100)
74    }
75}
76
77/// Anthropic-specific snapshot — three rolling windows plus optional
78/// pay-as-you-go credit balance.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct AnthropicSnapshot {
81    /// "Claude Pro", "Claude Max 5x", "Claude Max 20x", etc.
82    pub plan: String,
83    pub session: UsageWindow,
84    pub weekly: UsageWindow,
85    /// Some vendors of Claude (Pro, some Max tiers) don't have a separate
86    /// Sonnet bucket — in which case this is None.
87    pub sonnet: Option<UsageWindow>,
88    /// Model-scoped weekly windows from the newer `limits[]` array
89    /// (`kind == "weekly_scoped"`), e.g. the Fable weekly cap. Labels come
90    /// from the API (`scope.model.display_name`), so new models show up
91    /// without a code change. Empty when the account has none.
92    pub scoped: Vec<ScopedWindow>,
93    /// `None` when `extra_usage.is_enabled` is false or the block is absent.
94    pub extra: Option<ExtraUsage>,
95}
96
97/// A usage window scoped to a specific model, labeled by the API
98/// (e.g. "Fable"). Weekly (7d) duration.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct ScopedWindow {
101    pub label: String,
102    pub window: UsageWindow,
103}
104
105/// "Extra usage" pay-as-you-go block (claudebar's `extra_usage`).
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ExtraUsage {
108    /// `None` when the payload carries no usable `monthly_limit` — an
109    /// explicit null (observed for plans without a spending cap, e.g. Claude
110    /// Pro, #30) or an absent field. Either way the spend is real and stays
111    /// visible; only the limit is unreported, and the renderers say exactly
112    /// that rather than inferring a plan tier from it.
113    pub limit: Option<Cents>,
114    pub spent: Cents,
115    /// ISO code from the block (`"BRL"`, `"USD"`, …). `None` on older payloads
116    /// that predate the field — formatted as `$` for back-compat, which was
117    /// the only behaviour before the field existed.
118    pub currency: Option<String>,
119    /// Minor-unit digits from the block's `decimal_places` (BRL/USD = 2,
120    /// JPY/KRW = 0). `None` means the wire did not report the scale. We keep
121    /// that absence instead of guessing from an incomplete currency table.
122    pub decimal_places: Option<u32>,
123}
124
125impl ExtraUsage {
126    /// Integer percentage of the monthly limit consumed (0..=100, saturating
127    /// at 0 when limit is non-positive — matches claudebar:540-542).
128    ///
129    /// With no cap there is no denominator, so no meaningful percentage
130    /// exists; 0 keeps the bar and severity calm rather than inventing one.
131    pub fn percent(&self) -> i32 {
132        match self.limit {
133            Some(l) if l.0 > 0 => ((self.spent.0 * 100) / l.0) as i32,
134            _ => 0,
135        }
136    }
137
138    pub fn fmt_spent(&self) -> String {
139        self.fmt_amount(self.spent)
140    }
141
142    pub fn fmt_limit(&self) -> Option<String> {
143        self.limit.map(|l| self.fmt_amount(l))
144    }
145
146    fn fmt_amount(&self, amount: Cents) -> String {
147        match (self.decimal_places, self.currency.as_deref()) {
148            (Some(decimal_places), currency) => fmt_minor(amount.0, decimal_places, currency),
149            // Legacy payloads predate both fields and were always cents/USD.
150            // Preserve that established behaviour only when neither field can
151            // tell us otherwise.
152            (None, None) => fmt_minor(amount.0, 2, None),
153            // A currency code alone does not determine its ISO minor-unit
154            // exponent. Keep the amount truthful instead of silently dividing
155            // zero-, three-, or four-decimal currencies by the wrong scale.
156            (None, Some(currency)) => fmt_minor_units(amount.0, currency),
157        }
158    }
159}
160
161fn fmt_minor_units(minor: i64, currency: &str) -> String {
162    let sign = if minor < 0 { "-" } else { "" };
163    format!("{sign}{} minor units {currency}", minor.unsigned_abs())
164}
165
166/// Format an amount in minor units with its own currency and scale.
167///
168/// The scale is this function's own — `money` cannot express a zero- or
169/// three-decimal currency — but the *symbol* comes from
170/// [`crate::format::with_currency`], so the two cannot disagree about what a
171/// given code looks like.
172pub fn fmt_minor(minor: i64, decimal_places: u32, currency: Option<&str>) -> String {
173    let scale = 10_u64.pow(decimal_places);
174    // `unsigned_abs`, not negation: `-i64::MIN` overflows. Unreachable from
175    // the wire (the parse gate rejects negatives) but this is a pub fn.
176    let sign = if minor < 0 { "-" } else { "" };
177    let abs = minor.unsigned_abs();
178    let number = if decimal_places == 0 {
179        format!("{abs}")
180    } else {
181        format!(
182            "{}.{:0width$}",
183            abs / scale,
184            abs % scale,
185            width = decimal_places as usize
186        )
187    };
188    crate::format::with_currency(sign, &number, currency)
189}
190
191/// DeepSeek — credit balance from `/user/balance`.
192#[derive(Debug, Clone, PartialEq)]
193pub struct DeepseekSnapshot {
194    pub is_available: bool,
195    /// Current balance (prefer USD, fallback to CNY).
196    pub balance: f64,
197    /// Free-granted credits component.
198    pub granted: f64,
199    /// Topped-up (purchased) credits component.
200    pub topped_up: f64,
201    /// The currency of the above amounts (currently "USD" or "CNY").
202    pub currency: String,
203}
204
205impl Eq for DeepseekSnapshot {}
206
207impl Default for DeepseekSnapshot {
208    fn default() -> Self {
209        Self {
210            is_available: false,
211            balance: 0.0,
212            granted: 0.0,
213            topped_up: 0.0,
214            currency: String::new(),
215        }
216    }
217}
218
219/// Cursor — the two included-usage pools the dashboard shows, from the
220/// undocumented `cursor.com/api/usage-summary` endpoint (the same one the
221/// dashboard's own frontend calls), authenticated with the session token the
222/// Cursor IDE wrote to its local `state.vscdb`.
223///
224/// Since Cursor's mid-2026 pricing, a plan's included compute is split into two
225/// quota pools, each shown as a percentage: **Cursor Models** (Auto + Composer,
226/// `autoPercentUsed`) and **Other Models** (named / third-party, `apiPercentUsed`).
227/// Overflow past either pool falls to on-demand spend. Percentages are integers
228/// (rounded from the wire floats) to match the dashboard and every other
229/// vendor's integer-percent convention; they can exceed 100 when a pool is over
230/// its included allowance.
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct CursorSnapshot {
233    /// Membership label, title-cased from `membershipType` (e.g. "Ultra").
234    pub plan: String,
235    /// "Cursor Models" pool — Auto + Composer (`autoPercentUsed`, rounded).
236    pub auto_pct: i32,
237    /// "Other Models" pool — named / third-party (`apiPercentUsed`, rounded).
238    pub api_pct: i32,
239    /// Overall included usage (`totalPercentUsed`, rounded) — the dashboard's
240    /// "you've used N% of your included total usage" headline.
241    pub total_pct: i32,
242    /// `true` when the plan reports `isUnlimited` — the pools don't cap and the
243    /// percentages are not meaningful.
244    pub unlimited: bool,
245    /// Whether on-demand (overage) spend is turned on (`onDemand.enabled`).
246    pub on_demand_enabled: bool,
247    /// End of the current billing cycle (`billingCycleEnd`) — when the pools
248    /// reset.
249    pub reset_at: Option<DateTime<Utc>>,
250}
251
252impl CursorSnapshot {
253    /// The binding pool — whichever is closest to (or furthest past) its cap.
254    /// Drives the bar color and the single generic `session_pct` alias.
255    pub fn worst_pct(&self) -> i32 {
256        self.auto_pct.max(self.api_pct)
257    }
258}
259
260/// Kiro CLI (AWS CodeWhisperer / Q Developer backend) — a single credit pool
261/// from `AmazonCodeWhispererService.GetUsageLimits`, the same call kiro-cli's
262/// own `/usage` slash command makes. Authenticated with the AWS SSO OIDC
263/// bearer token kiro-cli already cached locally, refreshed with the paired
264/// refresh token when it's close to expiry — see `kiro::db` and `kiro::oauth`.
265#[derive(Debug, Clone, PartialEq)]
266pub struct KiroSnapshot {
267    /// Subscription tier label (`subscriptionInfo.subscriptionTitle`, e.g.
268    /// "KIRO POWER").
269    pub plan: String,
270    /// Credits consumed this cycle (`currentUsageWithPrecision`).
271    pub used: f64,
272    /// Credits included in the plan (`usageLimitWithPrecision`).
273    pub limit: f64,
274    /// When the credit pool resets (`nextDateReset`).
275    pub reset_at: Option<DateTime<Utc>>,
276}
277
278impl Eq for KiroSnapshot {}
279
280impl KiroSnapshot {
281    /// Percentage of the credit pool consumed, rounded. `0` when `limit` is
282    /// not positive — defensive; the API has not been observed to send that.
283    pub fn pct(&self) -> i32 {
284        if self.limit <= 0.0 {
285            return 0;
286        }
287        ((self.used / self.limit) * 100.0)
288            .round()
289            .clamp(0.0, 9999.0) as i32
290    }
291}
292
293/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct KimiSnapshot {
296    pub plan: Option<String>,
297    pub weekly_limit: u64,
298    pub weekly_used: u64,
299    pub weekly_remaining: u64,
300    pub weekly_reset_at: Option<DateTime<Utc>>,
301    pub window_limit: u64,
302    pub window_used: u64,
303    pub window_remaining: u64,
304    pub window_reset_at: Option<DateTime<Utc>>,
305}
306
307impl KimiSnapshot {
308    fn pct(used: u64, limit: u64) -> i32 {
309        if limit == 0 {
310            0
311        } else {
312            // Keep all quota values exact: f64 loses integer precision above
313            // 2^53. This is the integer equivalent of round(used / limit *
314            // 100), with saturation for inconsistent upstream counters.
315            let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
316            pct.min(100) as i32
317        }
318    }
319
320    /// Percentage of the weekly subscription quota consumed (0..=100).
321    pub fn weekly_pct(&self) -> i32 {
322        Self::pct(self.weekly_used, self.weekly_limit)
323    }
324
325    /// Percentage of the rolling rate-limit window consumed (0..=100).
326    pub fn window_pct(&self) -> i32 {
327        Self::pct(self.window_used, self.window_limit)
328    }
329}
330
331/// Discriminated union of vendor-specific snapshots. The widget and TUI match
332/// on this to pick a renderer.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub enum VendorSnapshot {
335    Anthropic(AnthropicSnapshot),
336    Openai(OpenAiSnapshot),
337    Zai(ZaiSnapshot),
338    Openrouter(OpenRouterSnapshot),
339    Deepseek(DeepseekSnapshot),
340    Kimi(KimiSnapshot),
341    Kilo(KiloSnapshot),
342    Novita(NovitaSnapshot),
343    Moonshot(MoonshotSnapshot),
344    Grok(GrokSnapshot),
345    SuperGrok(SuperGrokSnapshot),
346    AnthropicApi(AnthropicApiSnapshot),
347    Antigravity(AntigravitySnapshot),
348    Cursor(CursorSnapshot),
349    Minimax(MinimaxSnapshot),
350    Kiro(KiroSnapshot),
351    NousResearch(crate::nous::types::AccountSnapshot),
352    OpenCodeGo(crate::opencode_go::types::Usage),
353    CommandCode(crate::commandcode::types::Snapshot),
354}
355
356/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
357/// and third-party (Claude/GPT) buckets, and each group carries its own 5-hour
358/// and weekly window — four independent windows in total.
359#[derive(Debug, Clone, PartialEq)]
360pub struct AntigravitySnapshot {
361    pub plan: String,
362    /// Fingerprint of the signed-in account. Never displayed — it exists so a
363    /// cache written for one Google account is not served for another.
364    pub account: String,
365    /// Gemini group, 5-hour window.
366    pub session: UsageWindow,
367    /// Gemini group, weekly window.
368    pub weekly: UsageWindow,
369    /// Claude/GPT group, 5-hour window.
370    pub third_party_session: Option<UsageWindow>,
371    /// Claude/GPT group, weekly window.
372    pub third_party_weekly: Option<UsageWindow>,
373}
374
375impl Eq for AntigravitySnapshot {}
376
377/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
378/// bucket (`general` for text/coding, `video`), and each row carries its own
379/// rolling interval window plus a weekly window.
380///
381/// Two things the payload dictates rather than convention: the interval length
382/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
383/// duration is derived from the row's own start/end rather than assumed; and
384/// the API reports the percentage **remaining**, which is inverted on the way
385/// in so these windows carry consumed-% like every other vendor's.
386#[derive(Debug, Clone, PartialEq, Eq)]
387pub struct MinimaxSnapshot {
388    pub plan: String,
389    /// `general` bucket — rolling interval window (5h on the observed plans).
390    pub session: UsageWindow,
391    /// `general` bucket — weekly window.
392    pub weekly: UsageWindow,
393    /// `video` bucket, `None` on plans that carry no video quota.
394    pub video_session: Option<UsageWindow>,
395    pub video_weekly: Option<UsageWindow>,
396}
397
398/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
399/// monthly `limit` is supplied from config (the API exposes neither the limit
400/// nor the remaining prepaid credit balance).
401#[derive(Debug, Clone, PartialEq)]
402pub struct AnthropicApiSnapshot {
403    pub spent: f64,
404    pub limit: Option<f64>,
405}
406
407impl Eq for AnthropicApiSnapshot {}
408
409impl AnthropicApiSnapshot {
410    /// Spend as an integer percentage of the configured limit; `None` when no
411    /// positive limit is set.
412    pub fn pct(&self) -> Option<i32> {
413        self.limit
414            .filter(|l| l.is_finite() && *l > 0.0)
415            .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
416    }
417}
418
419/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
420/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
421#[derive(Debug, Clone, PartialEq)]
422pub struct KiloSnapshot {
423    pub label: String,
424    pub balance: f64,
425}
426
427impl Eq for KiloSnapshot {}
428
429/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
430/// all amounts already converted from the API's 1/10000-USD integers to USD.
431#[derive(Debug, Clone, PartialEq)]
432pub struct NovitaSnapshot {
433    /// Spendable credit balance (`availableBalance`).
434    pub available: f64,
435    /// Remaining top-up (`cashBalance`).
436    pub cash: f64,
437    /// Credit limit — max you can owe (`creditLimit`).
438    pub credit_limit: f64,
439    /// Amount currently owed (`outstandingInvoices`).
440    pub outstanding: f64,
441}
442
443impl Eq for NovitaSnapshot {}
444
445/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
446/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
447/// field in the response, so it's carried here from the region config.
448#[derive(Debug, Clone, PartialEq)]
449pub struct MoonshotSnapshot {
450    /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
451    /// the inference API.
452    pub available: f64,
453    /// Voucher credit (`voucher_balance`).
454    pub voucher: f64,
455    /// Cash balance (`cash_balance`); can be negative (debt).
456    pub cash: f64,
457    /// "USD" or "CNY", implied by the host.
458    pub currency: String,
459}
460
461impl Eq for MoonshotSnapshot {}
462
463/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
464/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
465#[derive(Debug, Clone, PartialEq)]
466pub struct GrokSnapshot {
467    pub balance: f64,
468}
469
470impl Eq for GrokSnapshot {}
471
472/// SuperGrok subscription usage returned by the official Grok Build CLI's
473/// credential-owning `x.ai/billing` ACP extension. Distinct from
474/// [`GrokSnapshot`] (Management API prepaid balance).
475#[derive(Debug, Clone, PartialEq)]
476pub struct SuperGrokSnapshot {
477    /// Subscription tier label when the billing response supplies one
478    /// (e.g. "SuperGrok", "SuperGrok Heavy"); otherwise `"SuperGrok"`.
479    pub plan: String,
480    /// Opaque digest of Grok auth/config state. Never displayed — cache
481    /// isolation only.
482    pub account: String,
483    /// Current included-credit usage percent. The field name is retained as a
484    /// compatibility alias for format/render code; [`Self::period`] says
485    /// whether the server's actual window is weekly or monthly.
486    pub weekly_pct: i32,
487    pub period: SuperGrokPeriod,
488    /// When the current usage period ends.
489    pub reset_at: Option<DateTime<Utc>>,
490    /// Remaining prepaid (purchased) API credit in USD, when present.
491    pub prepaid_balance: Option<f64>,
492}
493
494impl Eq for SuperGrokSnapshot {}
495
496#[derive(Debug, Clone, Copy, PartialEq, Eq)]
497pub enum SuperGrokPeriod {
498    Weekly,
499    Monthly,
500    Unknown,
501}
502
503impl SuperGrokPeriod {
504    pub fn label(self) -> &'static str {
505        match self {
506            Self::Weekly => "Weekly",
507            Self::Monthly => "Monthly",
508            Self::Unknown => "Current period",
509        }
510    }
511
512    pub fn short(self) -> &'static str {
513        match self {
514            Self::Weekly => "wk",
515            Self::Monthly => "mo",
516            Self::Unknown => "period",
517        }
518    }
519}
520
521/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
522#[derive(Debug, Clone, PartialEq, Eq)]
523pub struct OpenAiSnapshot {
524    pub plan: String,
525    /// 5h window, identified by its duration rather than its wire position.
526    pub session: Option<UsageWindow>,
527    /// 7d window, identified by its duration rather than its wire position.
528    pub weekly: Option<UsageWindow>,
529    /// Optional 7d code-review bucket.
530    pub code_review: Option<UsageWindow>,
531    /// Optional credit balance + approximate message-count ranges.
532    pub credits: Option<OpenAiCredits>,
533    /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
534    /// the placeholder set and the "OpenAI does not expose this for Plus"
535    /// tooltip when the OAuth path isn't available.
536    pub source: OpenAiSource,
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq)]
540pub enum OpenAiSource {
541    CodexOauth,
542    AdminKeyMtd,
543    Unavailable,
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub struct OpenAiCredits {
548    /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
549    /// a string because OpenAI returns it that way.
550    pub balance: String,
551    pub has_credits: bool,
552    pub unlimited: bool,
553    pub approx_local_messages: Option<(i64, i64)>,
554    pub approx_cloud_messages: Option<(i64, i64)>,
555}
556
557/// Z.AI / BigModel — list of buckets with discriminated types. We project the
558/// two we care about into named fields (5h tokens, weekly tokens, MCP).
559#[derive(Debug, Clone, PartialEq, Eq)]
560pub struct ZaiSnapshot {
561    pub plan: String,
562    pub session: Option<UsageWindow>,
563    pub weekly: Option<UsageWindow>,
564    pub mcp: Option<UsageWindow>,
565}
566
567/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
568/// `/api/v1/credits` and `/api/v1/key`.
569#[derive(Debug, Clone, PartialEq)]
570pub struct OpenRouterSnapshot {
571    pub label: String,
572    pub total_credits: f64,
573    pub total_usage: f64,
574    pub usage_daily: f64,
575    pub usage_weekly: f64,
576    pub usage_monthly: f64,
577    pub is_free_tier: bool,
578    pub limit: Option<f64>,
579    pub limit_remaining: Option<f64>,
580}
581
582impl Eq for OpenRouterSnapshot {}
583
584impl OpenRouterSnapshot {
585    /// Spendable credit, **which can be negative**: OpenRouter lets an account
586    /// run into debt, and clamping that to zero would report a healthy-looking
587    /// `$0.00` to someone who has to top up before anything works again. The
588    /// wire fields are each non-negative (see `openrouter::types`), so a
589    /// negative result only ever means usage has overrun credits.
590    pub fn balance(&self) -> f64 {
591        self.total_credits - self.total_usage
592    }
593    /// Percentage of total_credits consumed (0..=100). Returns 0 when
594    /// `total_credits` is 0 (free-tier-only accounts) — there is no
595    /// denominator to be a percentage of. Severity does not come from this
596    /// number alone: see [`crate::openrouter::vendor::severity`], which treats
597    /// a negative [`Self::balance`] as critical regardless of the percentage.
598    pub fn consumed_pct(&self) -> i32 {
599        if self.total_credits <= 0.0 {
600            return 0;
601        }
602        ((self.total_usage / self.total_credits) * 100.0)
603            .round()
604            .clamp(0.0, 100.0) as i32
605    }
606}
607
608/// Worst-of severity class for the Waybar bar text color. Mirrors
609/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
610pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
611    let mut max = snap.session.utilization_pct;
612    if snap.weekly.utilization_pct > max {
613        max = snap.weekly.utilization_pct;
614    }
615    if let Some(s) = &snap.sonnet
616        && s.utilization_pct > max
617    {
618        max = s.utilization_pct;
619    }
620    for sw in &snap.scoped {
621        if sw.window.utilization_pct > max {
622            max = sw.window.utilization_pct;
623        }
624    }
625    // Extra usage only promotes severity if a rate-limit window is at 100%.
626    let any_at_cap = snap.session.utilization_pct >= 100
627        || snap.weekly.utilization_pct >= 100
628        || snap
629            .sonnet
630            .as_ref()
631            .is_some_and(|s| s.utilization_pct >= 100)
632        || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
633    if any_at_cap && let Some(extra) = snap.extra.as_ref() {
634        let p = extra.percent();
635        if p > max {
636            max = p;
637        }
638    }
639    crate::pango::severity_for(max)
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use crate::pacing::PaceSeverity;
646    use chrono::Duration;
647
648    fn w(pct: i32) -> UsageWindow {
649        UsageWindow {
650            utilization_pct: pct,
651            resets_at: None,
652            window_duration: Duration::hours(5),
653        }
654    }
655
656    fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
657        AnthropicSnapshot {
658            plan: "Max 5x".into(),
659            session: w(s),
660            weekly: w(w_),
661            sonnet: sonnet.map(w),
662            scoped: vec![],
663            extra: extra.map(|(limit, spent)| ExtraUsage {
664                limit: Some(Cents(limit)),
665                spent: Cents(spent),
666                currency: None,
667                decimal_places: Some(2),
668            }),
669        }
670    }
671
672    #[test]
673    fn fmt_minor_honors_currency_and_scale() {
674        // No currency (older payloads) keeps the historical `$`.
675        assert_eq!(fmt_minor(250, 2, None), "$2.50");
676        // The #30 reporter's actual figures: BRL must not be claimed as `$`.
677        assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
678        assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
679        // Zero-exponent currency: no decimal point, no /100.
680        assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
681        // Sign precedes the symbol, matching `fmt_dollars`.
682        assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
683        // Unknown code stays truthful as a suffix rather than guessing a symbol.
684        assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
685    }
686
687    #[test]
688    fn extra_usage_formats_in_its_own_currency() {
689        let e = ExtraUsage {
690            limit: None,
691            spent: Cents(14157),
692            currency: Some("BRL".into()),
693            decimal_places: Some(2),
694        };
695        assert_eq!(e.fmt_spent(), "R$141.57");
696        assert_eq!(e.fmt_limit(), None);
697
698        let capped = ExtraUsage {
699            limit: Some(Cents(5000)),
700            spent: Cents(250),
701            currency: None,
702            decimal_places: Some(2),
703        };
704        assert_eq!(capped.fmt_spent(), "$2.50");
705        assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
706    }
707
708    #[test]
709    fn cents_format_positive() {
710        assert_eq!(Cents(0).fmt_dollars(), "$0.00");
711        assert_eq!(Cents(50).fmt_dollars(), "$0.50");
712        assert_eq!(Cents(250).fmt_dollars(), "$2.50");
713        assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
714    }
715
716    #[test]
717    fn cents_format_negative_uses_leading_sign() {
718        // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
719        assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
720        assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
721    }
722
723    #[test]
724    fn extra_percent_with_zero_limit_is_zero() {
725        assert_eq!(
726            ExtraUsage {
727                limit: Some(Cents(0)),
728                spent: Cents(100),
729                currency: None,
730                decimal_places: Some(2),
731            }
732            .percent(),
733            0
734        );
735    }
736
737    #[test]
738    fn extra_percent_truncates() {
739        // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
740        assert_eq!(
741            ExtraUsage {
742                limit: Some(Cents(10000)),
743                spent: Cents(3333),
744                currency: None,
745                decimal_places: Some(2),
746            }
747            .percent(),
748            33
749        );
750    }
751
752    #[test]
753    fn severity_picks_worst_of_three_windows() {
754        let s = snap(40, 60, Some(80), None);
755        assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
756    }
757
758    #[test]
759    fn severity_ignores_extra_when_no_cap_hit() {
760        // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
761        let s = snap(50, 60, None, Some((10000, 9500)));
762        assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
763    }
764
765    #[test]
766    fn severity_promotes_extra_when_session_at_100() {
767        let s = snap(100, 50, None, Some((10000, 9500)));
768        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
769    }
770
771    #[test]
772    fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
773        // session = 100, weekly = 50, extra = 100% → max should be 100.
774        let s = snap(100, 50, None, Some((10000, 10000)));
775        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
776    }
777
778    fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
779        s.scoped.push(ScopedWindow {
780            label: "Fable".into(),
781            window: w(pct),
782        });
783        s
784    }
785
786    #[test]
787    fn severity_includes_scoped_windows() {
788        // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
789        // week at 84 → the bar class must escalate to High.
790        let s = with_scoped(snap(10, 55, None, None), 84);
791        assert_eq!(anthropic_severity(&s), PaceSeverity::High);
792    }
793
794    #[test]
795    fn severity_promotes_extra_when_scoped_at_100() {
796        // A scoped window at cap counts as a rate-limit cap hit, so extra
797        // usage above the window max is promoted — same rule as session/weekly.
798        let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
799        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
800    }
801
802    #[test]
803    fn kimi_percent_is_exact_above_f64_precision() {
804        let snap = KimiSnapshot {
805            plan: None,
806            weekly_limit: (1 << 53) + 1,
807            weekly_used: 1 << 52,
808            weekly_remaining: 0,
809            weekly_reset_at: None,
810            window_limit: u64::MAX,
811            window_used: u64::MAX - 1,
812            window_remaining: 0,
813            window_reset_at: None,
814        };
815        assert_eq!(snap.weekly_pct(), 50);
816        assert_eq!(snap.window_pct(), 100);
817    }
818
819    #[test]
820    fn kiro_pct_is_zero_without_a_positive_limit() {
821        let snap = KiroSnapshot {
822            plan: "FREE".into(),
823            used: 5.0,
824            limit: 0.0,
825            reset_at: None,
826        };
827        assert_eq!(snap.pct(), 0);
828    }
829
830    #[test]
831    fn kiro_pct_rounds_the_credit_ratio() {
832        let snap = KiroSnapshot {
833            plan: "KIRO POWER".into(),
834            used: 1.0,
835            limit: 3.0,
836            reset_at: None,
837        };
838        assert_eq!(snap.pct(), 33);
839    }
840}