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