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/// Kimi Code — weekly subscription quota plus a 5h rolling rate-limit window.
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct KimiSnapshot {
269 pub plan: Option<String>,
270 pub weekly_limit: u64,
271 pub weekly_used: u64,
272 pub weekly_remaining: u64,
273 pub weekly_reset_at: Option<DateTime<Utc>>,
274 pub window_limit: u64,
275 pub window_used: u64,
276 pub window_remaining: u64,
277 pub window_reset_at: Option<DateTime<Utc>>,
278}
279
280impl KimiSnapshot {
281 fn pct(used: u64, limit: u64) -> i32 {
282 if limit == 0 {
283 0
284 } else {
285 // Keep all quota values exact: f64 loses integer precision above
286 // 2^53. This is the integer equivalent of round(used / limit *
287 // 100), with saturation for inconsistent upstream counters.
288 let pct = ((used as u128 * 100) + (limit as u128 / 2)) / limit as u128;
289 pct.min(100) as i32
290 }
291 }
292
293 /// Percentage of the weekly subscription quota consumed (0..=100).
294 pub fn weekly_pct(&self) -> i32 {
295 Self::pct(self.weekly_used, self.weekly_limit)
296 }
297
298 /// Percentage of the rolling rate-limit window consumed (0..=100).
299 pub fn window_pct(&self) -> i32 {
300 Self::pct(self.window_used, self.window_limit)
301 }
302}
303
304/// Discriminated union of vendor-specific snapshots. The widget and TUI match
305/// on this to pick a renderer.
306#[derive(Debug, Clone, PartialEq, Eq)]
307pub enum VendorSnapshot {
308 Anthropic(AnthropicSnapshot),
309 Openai(OpenAiSnapshot),
310 Zai(ZaiSnapshot),
311 Openrouter(OpenRouterSnapshot),
312 Deepseek(DeepseekSnapshot),
313 Kimi(KimiSnapshot),
314 Kilo(KiloSnapshot),
315 Novita(NovitaSnapshot),
316 Moonshot(MoonshotSnapshot),
317 Grok(GrokSnapshot),
318 AnthropicApi(AnthropicApiSnapshot),
319 Antigravity(AntigravitySnapshot),
320 Cursor(CursorSnapshot),
321 Minimax(MinimaxSnapshot),
322}
323
324/// Google Antigravity 2.0 / CLI snapshot. The API groups models into Gemini
325/// and third-party (Claude/GPT) buckets, and each group carries its own 5-hour
326/// and weekly window — four independent windows in total.
327#[derive(Debug, Clone, PartialEq)]
328pub struct AntigravitySnapshot {
329 pub plan: String,
330 /// Fingerprint of the signed-in account. Never displayed — it exists so a
331 /// cache written for one Google account is not served for another.
332 pub account: String,
333 /// Gemini group, 5-hour window.
334 pub session: UsageWindow,
335 /// Gemini group, weekly window.
336 pub weekly: UsageWindow,
337 /// Claude/GPT group, 5-hour window.
338 pub third_party_session: Option<UsageWindow>,
339 /// Claude/GPT group, weekly window.
340 pub third_party_weekly: Option<UsageWindow>,
341}
342
343impl Eq for AntigravitySnapshot {}
344
345/// MiniMax Token Plan — `/v1/token_plan/remains` returns one row per model
346/// bucket (`general` for text/coding, `video`), and each row carries its own
347/// rolling interval window plus a weekly window.
348///
349/// Two things the payload dictates rather than convention: the interval length
350/// is **not fixed** (`general` rolls every 5h, `video` every 24h), so the
351/// duration is derived from the row's own start/end rather than assumed; and
352/// the API reports the percentage **remaining**, which is inverted on the way
353/// in so these windows carry consumed-% like every other vendor's.
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub struct MinimaxSnapshot {
356 pub plan: String,
357 /// `general` bucket — rolling interval window (5h on the observed plans).
358 pub session: UsageWindow,
359 /// `general` bucket — weekly window.
360 pub weekly: UsageWindow,
361 /// `video` bucket, `None` on plans that carry no video quota.
362 pub video_session: Option<UsageWindow>,
363 pub video_weekly: Option<UsageWindow>,
364}
365
366/// Anthropic Admin API — month-to-date spend (USD) from the cost report. The
367/// monthly `limit` is supplied from config (the API exposes neither the limit
368/// nor the remaining prepaid credit balance).
369#[derive(Debug, Clone, PartialEq)]
370pub struct AnthropicApiSnapshot {
371 pub spent: f64,
372 pub limit: Option<f64>,
373}
374
375impl Eq for AnthropicApiSnapshot {}
376
377impl AnthropicApiSnapshot {
378 /// Spend as an integer percentage of the configured limit; `None` when no
379 /// positive limit is set.
380 pub fn pct(&self) -> Option<i32> {
381 self.limit
382 .filter(|l| l.is_finite() && *l > 0.0)
383 .map(|l| ((self.spent / l) * 100.0).round().clamp(0.0, 9999.0) as i32)
384 }
385}
386
387/// Kilo Code — remaining credit balance from `/api/profile/balance` (USD).
388/// No purchased-total is exposed on that endpoint, so there's no consumed-%.
389#[derive(Debug, Clone, PartialEq)]
390pub struct KiloSnapshot {
391 pub label: String,
392 pub balance: f64,
393}
394
395impl Eq for KiloSnapshot {}
396
397/// Novita AI — account balance from `/openapi/v1/billing/balance/detail`, with
398/// all amounts already converted from the API's 1/10000-USD integers to USD.
399#[derive(Debug, Clone, PartialEq)]
400pub struct NovitaSnapshot {
401 /// Spendable credit balance (`availableBalance`).
402 pub available: f64,
403 /// Remaining top-up (`cashBalance`).
404 pub cash: f64,
405 /// Credit limit — max you can owe (`creditLimit`).
406 pub credit_limit: f64,
407 /// Amount currently owed (`outstandingInvoices`).
408 pub outstanding: f64,
409}
410
411impl Eq for NovitaSnapshot {}
412
413/// Moonshot / Kimi — account balance from `/v1/users/me/balance`. Currency is
414/// USD (`api.moonshot.ai`) or CNY (`api.moonshot.cn`); there's no currency
415/// field in the response, so it's carried here from the region config.
416#[derive(Debug, Clone, PartialEq)]
417pub struct MoonshotSnapshot {
418 /// Spendable balance (`available_balance` = cash + voucher). `<= 0` blocks
419 /// the inference API.
420 pub available: f64,
421 /// Voucher credit (`voucher_balance`).
422 pub voucher: f64,
423 /// Cash balance (`cash_balance`); can be negative (debt).
424 pub cash: f64,
425 /// "USD" or "CNY", implied by the host.
426 pub currency: String,
427}
428
429impl Eq for MoonshotSnapshot {}
430
431/// xAI (Grok) — prepaid credit balance in USD, derived from the Management
432/// API's `total.val` (USD cents, inverted-ledger; see `grok::types`).
433#[derive(Debug, Clone, PartialEq)]
434pub struct GrokSnapshot {
435 pub balance: f64,
436}
437
438impl Eq for GrokSnapshot {}
439
440/// OpenAI Codex OAuth — exposes whichever rolling windows the API reports.
441#[derive(Debug, Clone, PartialEq, Eq)]
442pub struct OpenAiSnapshot {
443 pub plan: String,
444 /// 5h window, identified by its duration rather than its wire position.
445 pub session: Option<UsageWindow>,
446 /// 7d window, identified by its duration rather than its wire position.
447 pub weekly: Option<UsageWindow>,
448 /// Optional 7d code-review bucket.
449 pub code_review: Option<UsageWindow>,
450 /// Optional credit balance + approximate message-count ranges.
451 pub credits: Option<OpenAiCredits>,
452 /// Source of the snapshot — Codex OAuth vs admin-key fallback. Drives
453 /// the placeholder set and the "OpenAI does not expose this for Plus"
454 /// tooltip when the OAuth path isn't available.
455 pub source: OpenAiSource,
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum OpenAiSource {
460 CodexOauth,
461 AdminKeyMtd,
462 Unavailable,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct OpenAiCredits {
467 /// Credit balance, formatted dollars ("$0.00", "$5.00", etc.) — kept as
468 /// a string because OpenAI returns it that way.
469 pub balance: String,
470 pub has_credits: bool,
471 pub unlimited: bool,
472 pub approx_local_messages: Option<(i64, i64)>,
473 pub approx_cloud_messages: Option<(i64, i64)>,
474}
475
476/// Z.AI / BigModel — list of buckets with discriminated types. We project the
477/// two we care about into named fields (5h tokens, weekly tokens, MCP).
478#[derive(Debug, Clone, PartialEq, Eq)]
479pub struct ZaiSnapshot {
480 pub plan: String,
481 pub session: Option<UsageWindow>,
482 pub weekly: Option<UsageWindow>,
483 pub mcp: Option<UsageWindow>,
484}
485
486/// OpenRouter — credit balance + lifetime/daily/weekly/monthly usage from
487/// `/api/v1/credits` and `/api/v1/key`.
488#[derive(Debug, Clone, PartialEq)]
489pub struct OpenRouterSnapshot {
490 pub label: String,
491 pub total_credits: f64,
492 pub total_usage: f64,
493 pub usage_daily: f64,
494 pub usage_weekly: f64,
495 pub usage_monthly: f64,
496 pub is_free_tier: bool,
497 pub limit: Option<f64>,
498 pub limit_remaining: Option<f64>,
499}
500
501impl Eq for OpenRouterSnapshot {}
502
503impl OpenRouterSnapshot {
504 pub fn balance(&self) -> f64 {
505 (self.total_credits - self.total_usage).max(0.0)
506 }
507 /// Percentage of total_credits consumed (0..=100). Returns 0 when
508 /// `total_credits` is 0 (free-tier-only accounts).
509 pub fn consumed_pct(&self) -> i32 {
510 if self.total_credits <= 0.0 {
511 return 0;
512 }
513 ((self.total_usage / self.total_credits) * 100.0)
514 .round()
515 .clamp(0.0, 100.0) as i32
516 }
517}
518
519/// Worst-of severity class for the Waybar bar text color. Mirrors
520/// claudebar:606-620 — "extra usage only matters when a rate limit hits 100%".
521pub fn anthropic_severity(snap: &AnthropicSnapshot) -> crate::pacing::PaceSeverity {
522 let mut max = snap.session.utilization_pct;
523 if snap.weekly.utilization_pct > max {
524 max = snap.weekly.utilization_pct;
525 }
526 if let Some(s) = &snap.sonnet
527 && s.utilization_pct > max
528 {
529 max = s.utilization_pct;
530 }
531 for sw in &snap.scoped {
532 if sw.window.utilization_pct > max {
533 max = sw.window.utilization_pct;
534 }
535 }
536 // Extra usage only promotes severity if a rate-limit window is at 100%.
537 let any_at_cap = snap.session.utilization_pct >= 100
538 || snap.weekly.utilization_pct >= 100
539 || snap
540 .sonnet
541 .as_ref()
542 .is_some_and(|s| s.utilization_pct >= 100)
543 || snap.scoped.iter().any(|s| s.window.utilization_pct >= 100);
544 if any_at_cap && let Some(extra) = snap.extra.as_ref() {
545 let p = extra.percent();
546 if p > max {
547 max = p;
548 }
549 }
550 crate::pango::severity_for(max)
551}
552
553#[cfg(test)]
554mod tests {
555 use super::*;
556 use crate::pacing::PaceSeverity;
557 use chrono::Duration;
558
559 fn w(pct: i32) -> UsageWindow {
560 UsageWindow {
561 utilization_pct: pct,
562 resets_at: None,
563 window_duration: Duration::hours(5),
564 }
565 }
566
567 fn snap(s: i32, w_: i32, sonnet: Option<i32>, extra: Option<(i64, i64)>) -> AnthropicSnapshot {
568 AnthropicSnapshot {
569 plan: "Max 5x".into(),
570 session: w(s),
571 weekly: w(w_),
572 sonnet: sonnet.map(w),
573 scoped: vec![],
574 extra: extra.map(|(limit, spent)| ExtraUsage {
575 limit: Some(Cents(limit)),
576 spent: Cents(spent),
577 currency: None,
578 decimal_places: Some(2),
579 }),
580 }
581 }
582
583 #[test]
584 fn fmt_minor_honors_currency_and_scale() {
585 // No currency (older payloads) keeps the historical `$`.
586 assert_eq!(fmt_minor(250, 2, None), "$2.50");
587 // The #30 reporter's actual figures: BRL must not be claimed as `$`.
588 assert_eq!(fmt_minor(14157, 2, Some("BRL")), "R$141.57");
589 assert_eq!(fmt_minor(14157, 2, Some("USD")), "$141.57");
590 // Zero-exponent currency: no decimal point, no /100.
591 assert_eq!(fmt_minor(500, 0, Some("JPY")), "¥500");
592 // Sign precedes the symbol, matching `fmt_dollars`.
593 assert_eq!(fmt_minor(-150, 2, Some("BRL")), "-R$1.50");
594 // Unknown code stays truthful as a suffix rather than guessing a symbol.
595 assert_eq!(fmt_minor(1234, 2, Some("CHF")), "12.34 CHF");
596 }
597
598 #[test]
599 fn extra_usage_formats_in_its_own_currency() {
600 let e = ExtraUsage {
601 limit: None,
602 spent: Cents(14157),
603 currency: Some("BRL".into()),
604 decimal_places: Some(2),
605 };
606 assert_eq!(e.fmt_spent(), "R$141.57");
607 assert_eq!(e.fmt_limit(), None);
608
609 let capped = ExtraUsage {
610 limit: Some(Cents(5000)),
611 spent: Cents(250),
612 currency: None,
613 decimal_places: Some(2),
614 };
615 assert_eq!(capped.fmt_spent(), "$2.50");
616 assert_eq!(capped.fmt_limit().as_deref(), Some("$50.00"));
617 }
618
619 #[test]
620 fn cents_format_positive() {
621 assert_eq!(Cents(0).fmt_dollars(), "$0.00");
622 assert_eq!(Cents(50).fmt_dollars(), "$0.50");
623 assert_eq!(Cents(250).fmt_dollars(), "$2.50");
624 assert_eq!(Cents(5000).fmt_dollars(), "$50.00");
625 }
626
627 #[test]
628 fn cents_format_negative_uses_leading_sign() {
629 // claudebar bug-fix: never "$-1.-50" — sign goes before the dollar sign.
630 assert_eq!(Cents(-150).fmt_dollars(), "-$1.50");
631 assert_eq!(Cents(-1).fmt_dollars(), "-$0.01");
632 }
633
634 #[test]
635 fn extra_percent_with_zero_limit_is_zero() {
636 assert_eq!(
637 ExtraUsage {
638 limit: Some(Cents(0)),
639 spent: Cents(100),
640 currency: None,
641 decimal_places: Some(2),
642 }
643 .percent(),
644 0
645 );
646 }
647
648 #[test]
649 fn extra_percent_truncates() {
650 // Bash integer division — 33/100 -> 33%, 50/100 -> 50%.
651 assert_eq!(
652 ExtraUsage {
653 limit: Some(Cents(10000)),
654 spent: Cents(3333),
655 currency: None,
656 decimal_places: Some(2),
657 }
658 .percent(),
659 33
660 );
661 }
662
663 #[test]
664 fn severity_picks_worst_of_three_windows() {
665 let s = snap(40, 60, Some(80), None);
666 assert_eq!(anthropic_severity(&s), PaceSeverity::High); // 80 → high
667 }
668
669 #[test]
670 fn severity_ignores_extra_when_no_cap_hit() {
671 // Extra at 95% but no rate-limit at 100% → extra is NOT promoted.
672 let s = snap(50, 60, None, Some((10000, 9500)));
673 assert_eq!(anthropic_severity(&s), PaceSeverity::Mid); // capped at 60
674 }
675
676 #[test]
677 fn severity_promotes_extra_when_session_at_100() {
678 let s = snap(100, 50, None, Some((10000, 9500)));
679 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical); // 100 → critical
680 }
681
682 #[test]
683 fn severity_falls_through_to_extra_when_extra_higher_than_capped_window() {
684 // session = 100, weekly = 50, extra = 100% → max should be 100.
685 let s = snap(100, 50, None, Some((10000, 10000)));
686 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
687 }
688
689 fn with_scoped(mut s: AnthropicSnapshot, pct: i32) -> AnthropicSnapshot {
690 s.scoped.push(ScopedWindow {
691 label: "Fable".into(),
692 window: w(pct),
693 });
694 s
695 }
696
697 #[test]
698 fn severity_includes_scoped_windows() {
699 // The PR #19 scenario: overall weekly at 55 (Mid) but a scoped Fable
700 // week at 84 → the bar class must escalate to High.
701 let s = with_scoped(snap(10, 55, None, None), 84);
702 assert_eq!(anthropic_severity(&s), PaceSeverity::High);
703 }
704
705 #[test]
706 fn severity_promotes_extra_when_scoped_at_100() {
707 // A scoped window at cap counts as a rate-limit cap hit, so extra
708 // usage above the window max is promoted — same rule as session/weekly.
709 let s = with_scoped(snap(10, 50, None, Some((10000, 9900))), 100);
710 assert_eq!(anthropic_severity(&s), PaceSeverity::Critical);
711 }
712
713 #[test]
714 fn kimi_percent_is_exact_above_f64_precision() {
715 let snap = KimiSnapshot {
716 plan: None,
717 weekly_limit: (1 << 53) + 1,
718 weekly_used: 1 << 52,
719 weekly_remaining: 0,
720 weekly_reset_at: None,
721 window_limit: u64::MAX,
722 window_used: u64::MAX - 1,
723 window_remaining: 0,
724 window_reset_at: None,
725 };
726 assert_eq!(snap.weekly_pct(), 50);
727 assert_eq!(snap.window_pct(), 100);
728 }
729}