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