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}