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    NousResearch(crate::nous::types::AccountSnapshot),
358    OpenCodeGo(crate::opencode_go::types::Usage),
359}
360
361/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
362/// and third-party (Claude/GPT) buckets, and each group carries its own 5-hour
363/// and weekly window — four independent windows in total.
364#[derive(Debug, Clone, PartialEq)]
365pub struct AntigravitySnapshot {
366    pub plan: String,
367    /// Fingerprint of the signed-in account. Never displayed — it exists so a
368    /// cache written for one Google account is not served for another.
369    pub account: String,
370    /// Gemini group, 5-hour window.
371    pub session: UsageWindow,
372    /// Gemini group, weekly window.
373    pub weekly: UsageWindow,
374    /// Claude/GPT group, 5-hour window.
375    pub third_party_session: Option<UsageWindow>,
376    /// Claude/GPT group, weekly window.
377    pub third_party_weekly: Option<UsageWindow>,
378}
379
380impl Eq for AntigravitySnapshot {}
381
382/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
383/// bucket (`general` for text/coding, `video`), and each row carries its own
384/// rolling interval window plus a weekly window.
385///
386/// Two things the payload dictates rather than convention: the interval length
387/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
388/// duration is derived from the row's own start/end rather than assumed; and
389/// the API reports the percentage **remaining**, which is inverted on the way
390/// in so these windows carry consumed-% like every other vendor's.
391#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct MinimaxSnapshot {
393    pub plan: String,
394    /// `general` bucket — rolling interval window (5h on the observed plans).
395    pub session: UsageWindow,
396    /// `general` bucket — weekly window.
397    pub weekly: UsageWindow,
398    /// `video` bucket, `None` on plans that carry no video quota.
399    pub video_session: Option<UsageWindow>,
400    pub video_weekly: Option<UsageWindow>,
401}
402
403/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
404/// monthly `limit` is supplied from config (the API exposes neither the limit
405/// nor the remaining prepaid credit balance).
406#[derive(Debug, Clone, PartialEq)]
407pub struct AnthropicApiSnapshot {
408    pub spent: f64,
409    pub limit: Option<f64>,
410}
411
412impl Eq for AnthropicApiSnapshot {}
413
414impl AnthropicApiSnapshot {
415    /// Spend as an integer percentage of the configured limit; `None` when no
416    /// positive limit is set.
417    pub fn pct(&self) -> Option<i32> {
418        self.limit
419            .filter(|l| l.is_finite() && *l > 0.0)
420            .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
421    }
422}
423
424/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
425/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
426#[derive(Debug, Clone, PartialEq)]
427pub struct KiloSnapshot {
428    pub label: String,
429    pub balance: f64,
430}
431
432impl Eq for KiloSnapshot {}
433
434/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
435/// all amounts already converted from the API's 1/10000-USD integers to USD.
436#[derive(Debug, Clone, PartialEq)]
437pub struct NovitaSnapshot {
438    /// Spendable credit balance (`availableBalance`).
439    pub available: f64,
440    /// Remaining top-up (`cashBalance`).
441    pub cash: f64,
442    /// Credit limit — max you can owe (`creditLimit`).
443    pub credit_limit: f64,
444    /// Amount currently owed (`outstandingInvoices`).
445    pub outstanding: f64,
446}
447
448impl Eq for NovitaSnapshot {}
449
450/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
451/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
452/// field in the response, so it's carried here from the region config.
453#[derive(Debug, Clone, PartialEq)]
454pub struct MoonshotSnapshot {
455    /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
456    /// the inference API.
457    pub available: f64,
458    /// Voucher credit (`voucher_balance`).
459    pub voucher: f64,
460    /// Cash balance (`cash_balance`); can be negative (debt).
461    pub cash: f64,
462    /// "USD" or "CNY", implied by the host.
463    pub currency: String,
464}
465
466impl Eq for MoonshotSnapshot {}
467
468/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
469/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
470#[derive(Debug, Clone, PartialEq)]
471pub struct GrokSnapshot {
472    pub balance: f64,
473}
474
475impl Eq for GrokSnapshot {}
476
477/// SuperGrok subscription usage returned by the official Grok Build CLI's
478/// credential-owning `x.ai/billing` ACP extension. Distinct from
479/// [`GrokSnapshot`] (Management API prepaid balance).
480#[derive(Debug, Clone, PartialEq)]
481pub struct SuperGrokSnapshot {
482    /// Subscription tier label when the billing response supplies one
483    /// (e.g. "SuperGrok", "SuperGrok Heavy"); otherwise `"SuperGrok"`.
484    pub plan: String,
485    /// Opaque digest of Grok auth/config state. Never displayed — cache
486    /// isolation only.
487    pub account: String,
488    /// Current included-credit usage percent. The field name is retained as a
489    /// compatibility alias for format/render code; [`Self::period`] says
490    /// whether the server's actual window is weekly or monthly.
491    pub weekly_pct: i32,
492    pub period: SuperGrokPeriod,
493    /// When the current usage period ends.
494    pub reset_at: Option<DateTime<Utc>>,
495    /// Remaining prepaid (purchased) API credit in USD, when present.
496    pub prepaid_balance: Option<f64>,
497}
498
499impl Eq for SuperGrokSnapshot {}
500
501#[derive(Debug, Clone, Copy, PartialEq, Eq)]
502pub enum SuperGrokPeriod {
503    Weekly,
504    Monthly,
505    Unknown,
506}
507
508impl SuperGrokPeriod {
509    pub fn label(self) -> &'static str {
510        match self {
511            Self::Weekly => "Weekly",
512            Self::Monthly => "Monthly",
513            Self::Unknown => "Current period",
514        }
515    }
516
517    pub fn short(self) -> &'static str {
518        match self {
519            Self::Weekly => "wk",
520            Self::Monthly => "mo",
521            Self::Unknown => "period",
522        }
523    }
524}
525
526/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
527#[derive(Debug, Clone, PartialEq, Eq)]
528pub struct OpenAiSnapshot {
529    pub plan: String,
530    /// 5h window, identified by its duration rather than its wire position.
531    pub session: Option<UsageWindow>,
532    /// 7d window, identified by its duration rather than its wire position.
533    pub weekly: Option<UsageWindow>,
534    /// Optional 7d code-review bucket.
535    pub code_review: Option<UsageWindow>,
536    /// Optional credit balance + approximate message-count ranges.
537    pub credits: Option<OpenAiCredits>,
538    /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
539    /// the placeholder set and the "OpenAI does not expose this for Plus"
540    /// tooltip when the OAuth path isn't available.
541    pub source: OpenAiSource,
542}
543
544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545pub enum OpenAiSource {
546    CodexOauth,
547    AdminKeyMtd,
548    Unavailable,
549}
550
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub struct OpenAiCredits {
553    /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
554    /// a string because OpenAI returns it that way.
555    pub balance: String,
556    pub has_credits: bool,
557    pub unlimited: bool,
558    pub approx_local_messages: Option<(i64, i64)>,
559    pub approx_cloud_messages: Option<(i64, i64)>,
560}
561
562/// Z.AI / BigModel — list of buckets with discriminated types. We project the
563/// two we care about into named fields (5h tokens, weekly tokens, MCP).
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct ZaiSnapshot {
566    pub plan: String,
567    pub session: Option<UsageWindow>,
568    pub weekly: Option<UsageWindow>,
569    pub mcp: Option<UsageWindow>,
570}
571
572/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
573/// `/api/v1/credits` and `/api/v1/key`.
574#[derive(Debug, Clone, PartialEq)]
575pub struct OpenRouterSnapshot {
576    pub label: String,
577    pub total_credits: f64,
578    pub total_usage: f64,
579    pub usage_daily: f64,
580    pub usage_weekly: f64,
581    pub usage_monthly: f64,
582    pub is_free_tier: bool,
583    pub limit: Option<f64>,
584    pub limit_remaining: Option<f64>,
585}
586
587impl Eq for OpenRouterSnapshot {}
588
589impl OpenRouterSnapshot {
590    pub fn balance(&self) -> f64 {
591        (self.total_credits - self.total_usage).max(0.0)
592    }
593    /// Percentage of total_credits consumed (0..=100). Returns 0 when
594    /// `total_credits` is 0 (free-tier-only accounts).
595    pub fn consumed_pct(&self) -> i32 {
596        if self.total_credits <= 0.0 {
597            return 0;
598        }
599        ((self.total_usage / self.total_credits) * 100.0)
600            .round()
601            .clamp(0.0, 100.0) as i32
602    }
603}
604
605/// Worst-of severity class for the Waybar bar text color. Mirrors
606/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
607pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
608    let mut max = snap.session.utilization_pct;
609    if snap.weekly.utilization_pct > max {
610        max = snap.weekly.utilization_pct;
611    }
612    if let Some(s) = &snap.sonnet
613        && s.utilization_pct > max
614    {
615        max = s.utilization_pct;
616    }
617    for sw in &snap.scoped {
618        if sw.window.utilization_pct > max {
619            max = sw.window.utilization_pct;
620        }
621    }
622    // Extra usage only promotes severity if a rate-limit window is at 100%.
623    let any_at_cap = snap.session.utilization_pct >= 100
624        || snap.weekly.utilization_pct >= 100
625        || snap
626            .sonnet
627            .as_ref()
628            .is_some_and(|s| s.utilization_pct >= 100)
629        || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
630    if any_at_cap && let Some(extra) = snap.extra.as_ref() {
631        let p = extra.percent();
632        if p > max {
633            max = p;
634        }
635    }
636    crate::pango::severity_for(max)
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642    use crate::pacing::PaceSeverity;
643    use chrono::Duration;
644
645    fn w(pct: i32) -> UsageWindow {
646        UsageWindow {
647            utilization_pct: pct,
648            resets_at: None,
649            window_duration: Duration::hours(5),
650        }
651    }
652
653    fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
654        AnthropicSnapshot {
655            plan: "Max 5x".into(),
656            session: w(s),
657            weekly: w(w_),
658            sonnet: sonnet.map(w),
659            scoped: vec![],
660            extra: extra.map(|(limit, spent)| ExtraUsage {
661                limit: Some(Cents(limit)),
662                spent: Cents(spent),
663                currency: None,
664                decimal_places: Some(2),
665            }),
666        }
667    }
668
669    #[test]
670    fn fmt_minor_honors_currency_and_scale() {
671        // No currency (older payloads) keeps the historical `$`.
672        assert_eq!(fmt_minor(250, 2, None), "$2.50");
673        // The #30 reporter's actual figures: BRL must not be claimed as `$`.
674        assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
675        assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
676        // Zero-exponent currency: no decimal point, no /100.
677        assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
678        // Sign precedes the symbol, matching `fmt_dollars`.
679        assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
680        // Unknown code stays truthful as a suffix rather than guessing a symbol.
681        assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
682    }
683
684    #[test]
685    fn extra_usage_formats_in_its_own_currency() {
686        let e = ExtraUsage {
687            limit: None,
688            spent: Cents(14157),
689            currency: Some("BRL".into()),
690            decimal_places: Some(2),
691        };
692        assert_eq!(e.fmt_spent(), "R$141.57");
693        assert_eq!(e.fmt_limit(), None);
694
695        let capped = ExtraUsage {
696            limit: Some(Cents(5000)),
697            spent: Cents(250),
698            currency: None,
699            decimal_places: Some(2),
700        };
701        assert_eq!(capped.fmt_spent(), "$2.50");
702        assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
703    }
704
705    #[test]
706    fn cents_format_positive() {
707        assert_eq!(Cents(0).fmt_dollars(), "$0.00");
708        assert_eq!(Cents(50).fmt_dollars(), "$0.50");
709        assert_eq!(Cents(250).fmt_dollars(), "$2.50");
710        assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
711    }
712
713    #[test]
714    fn cents_format_negative_uses_leading_sign() {
715        // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
716        assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
717        assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
718    }
719
720    #[test]
721    fn extra_percent_with_zero_limit_is_zero() {
722        assert_eq!(
723            ExtraUsage {
724                limit: Some(Cents(0)),
725                spent: Cents(100),
726                currency: None,
727                decimal_places: Some(2),
728            }
729            .percent(),
730            0
731        );
732    }
733
734    #[test]
735    fn extra_percent_truncates() {
736        // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
737        assert_eq!(
738            ExtraUsage {
739                limit: Some(Cents(10000)),
740                spent: Cents(3333),
741                currency: None,
742                decimal_places: Some(2),
743            }
744            .percent(),
745            33
746        );
747    }
748
749    #[test]
750    fn severity_picks_worst_of_three_windows() {
751        let s = snap(40, 60, Some(80), None);
752        assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
753    }
754
755    #[test]
756    fn severity_ignores_extra_when_no_cap_hit() {
757        // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
758        let s = snap(50, 60, None, Some((10000, 9500)));
759        assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
760    }
761
762    #[test]
763    fn severity_promotes_extra_when_session_at_100() {
764        let s = snap(100, 50, None, Some((10000, 9500)));
765        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
766    }
767
768    #[test]
769    fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
770        // session = 100, weekly = 50, extra = 100% → max should be 100.
771        let s = snap(100, 50, None, Some((10000, 10000)));
772        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
773    }
774
775    fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
776        s.scoped.push(ScopedWindow {
777            label: "Fable".into(),
778            window: w(pct),
779        });
780        s
781    }
782
783    #[test]
784    fn severity_includes_scoped_windows() {
785        // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
786        // week at 84 → the bar class must escalate to High.
787        let s = with_scoped(snap(10, 55, None, None), 84);
788        assert_eq!(anthropic_severity(&s), PaceSeverity::High);
789    }
790
791    #[test]
792    fn severity_promotes_extra_when_scoped_at_100() {
793        // A scoped window at cap counts as a rate-limit cap hit, so extra
794        // usage above the window max is promoted — same rule as session/weekly.
795        let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
796        assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
797    }
798
799    #[test]
800    fn kimi_percent_is_exact_above_f64_precision() {
801        let snap = KimiSnapshot {
802            plan: None,
803            weekly_limit: (1 << 53) + 1,
804            weekly_used: 1 << 52,
805            weekly_remaining: 0,
806            weekly_reset_at: None,
807            window_limit: u64::MAX,
808            window_used: u64::MAX - 1,
809            window_remaining: 0,
810            window_reset_at: None,
811        };
812        assert_eq!(snap.weekly_pct(), 50);
813        assert_eq!(snap.window_pct(), 100);
814    }
815
816    #[test]
817    fn kiro_pct_is_zero_without_a_positive_limit() {
818        let snap = KiroSnapshot {
819            plan: "FREE".into(),
820            used: 5.0,
821            limit: 0.0,
822            reset_at: None,
823        };
824        assert_eq!(snap.pct(), 0);
825    }
826
827    #[test]
828    fn kiro_pct_rounds_the_credit_ratio() {
829        let snap = KiroSnapshot {
830            plan: "KIRO POWER".into(),
831            used: 1.0,
832            limit: 3.0,
833            reset_at: None,
834        };
835        assert_eq!(snap.pct(), 33);
836    }
837}