1use serde::{Deserialize, Serialize};
9
10use super::creds::ULTRA;
11use crate::usage::{
12 AnthropicSnapshot, Cents, CloudCredits, ExtraUsage, ExtraUsageStatus, ResetCredit,
13 ResetCredits, ScopedWindow, UsageCredits, UsageWindow, checked_reset_title,
14};
15
16#[derive(Debug, Default, Clone, Deserialize, Serialize)]
22pub struct UsageResponse {
23 #[serde(default)]
24 pub five_hour: Option<Window>,
25 #[serde(default)]
26 pub seven_day: Option<Window>,
27 #[serde(default)]
28 pub seven_day_sonnet: Option<Window>,
29 #[serde(default)]
30 pub extra_usage: Option<ExtraUsageBlock>,
31 #[serde(default)]
35 pub limits: Vec<LimitEntry>,
36 #[serde(default)]
41 pub cedar_ember: Option<ResetGrantsBlock>,
42 #[serde(default)]
49 pub iguana_necktie: Option<Box<serde_json::value::RawValue>>,
50 #[serde(default)]
55 pub spend: Option<Box<serde_json::value::RawValue>>,
56}
57
58#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
64pub struct ResetGrantsBlock {
65 #[serde(default)]
66 pub eligible: bool,
67 #[serde(default)]
68 pub grants: Vec<ResetGrant>,
69}
70
71#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
75pub struct ResetGrant {
76 #[serde(default)]
78 pub label: Option<String>,
79 #[serde(default)]
82 pub resets_left: u32,
83 #[serde(default)]
85 pub ends_at: Option<String>,
86 #[serde(default)]
91 pub usable_now: bool,
92 #[serde(default)]
93 pub paused: bool,
94}
95
96#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
100pub struct LimitEntry {
101 #[serde(default)]
102 pub kind: Option<String>,
103 #[serde(default, deserialize_with = "de_percent_opt")]
104 pub percent: Option<f64>,
105 #[serde(default)]
106 pub resets_at: Option<String>,
107 #[serde(default)]
108 pub scope: Option<LimitScope>,
109}
110
111#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
112pub struct LimitScope {
113 #[serde(default)]
114 pub model: Option<LimitModel>,
115}
116
117#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
118pub struct LimitModel {
119 #[serde(default)]
120 pub display_name: Option<String>,
121}
122
123#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
125pub struct Window {
126 #[serde(default, deserialize_with = "de_percent")]
127 pub utilization: f64,
128 #[serde(default)]
129 pub resets_at: Option<String>,
130}
131
132#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
137pub struct ExtraUsageBlock {
138 #[serde(default)]
139 pub is_enabled: bool,
140 #[serde(default, deserialize_with = "de_opt_cents")]
141 pub monthly_limit: Option<i64>,
142 #[serde(default, deserialize_with = "de_opt_cents")]
143 pub used_credits: Option<i64>,
144 #[serde(default, deserialize_with = "de_opt_currency")]
147 pub currency: Option<String>,
148 #[serde(default, deserialize_with = "de_opt_decimal_places")]
151 pub decimal_places: Option<u32>,
152 #[serde(default)]
153 pub user_disabled: bool,
154 #[serde(default)]
155 pub spend_limit_reached: bool,
156 #[serde(default)]
157 pub credits_ever_enabled: bool,
158 #[serde(default)]
159 pub disabled_reason: Option<String>,
160}
161
162#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
169pub struct CloudCreditsBlock {
170 #[serde(default, deserialize_with = "de_opt_number")]
175 pub limit_dollars: Option<serde_json::Number>,
176 #[serde(default, deserialize_with = "de_opt_number")]
177 pub remaining_dollars: Option<serde_json::Number>,
178 #[serde(default, deserialize_with = "de_opt_plain_string")]
180 pub resets_at: Option<String>,
181 #[serde(default, deserialize_with = "de_opt_plain_string")]
184 pub locked_reason: Option<String>,
185}
186
187#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
192pub struct SpendBlock {
193 #[serde(default)]
194 pub balance: Option<serde_json::Value>,
195 #[serde(default)]
196 pub auto_reload: Option<serde_json::Value>,
197}
198
199fn raw_object_block<T>(raw: &serde_json::value::RawValue) -> Option<T>
208where
209 T: serde::de::DeserializeOwned,
210{
211 let v = serde_json::from_str::<serde_json::Value>(raw.get()).ok()?;
212 match v {
213 serde_json::Value::Object(_) => serde_json::from_value::<T>(v).ok(),
214 _ => None,
215 }
216}
217
218fn de_opt_number<'de, D>(d: D) -> std::result::Result<Option<serde_json::Number>, D::Error>
223where
224 D: serde::Deserializer<'de>,
225{
226 match serde_json::Value::deserialize(d)? {
227 serde_json::Value::Null => Ok(None),
228 serde_json::Value::Number(n) => Ok(Some(n)),
229 _ => Ok(None),
230 }
231}
232
233fn dollars_to_cents(n: &serde_json::Number) -> Option<i64> {
252 let v = n.as_f64()?;
253 if !v.is_finite() || v < 0.0 || v > 1e13 {
254 return None;
255 }
256 if v == 0.0 {
259 return Some(0);
260 }
261 let text = n.to_string();
262 if text.contains(['e', 'E']) {
263 return Some((v * 100.0).round() as i64);
264 }
265 let (int_part, frac_part) = match text.split_once('.') {
266 Some((i, f)) => (i, f),
267 None => (text.as_str(), ""),
268 };
269 if int_part.is_empty() || int_part.bytes().any(|b| !b.is_ascii_digit()) {
270 return None;
271 }
272 let mut cents: i64 = 0;
273 for b in int_part.bytes() {
274 cents = cents.checked_mul(10)?.checked_add(i64::from(b - b'0'))?;
275 }
276 cents = cents.checked_mul(100)?;
277 let digits: Vec<u8> = frac_part.bytes().filter(|b| b.is_ascii_digit()).collect();
278 let first = i64::from(digits.first().copied().unwrap_or(b'0') - b'0');
279 let second = i64::from(digits.get(1).copied().unwrap_or(b'0') - b'0');
280 let half_up = i64::from(digits.get(2).is_some_and(|d| *d >= b'5'));
281 cents.checked_add(first * 10 + second + half_up)
282}
283
284fn de_opt_plain_string<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
287where
288 D: serde::Deserializer<'de>,
289{
290 match serde_json::Value::deserialize(d)? {
291 serde_json::Value::Null => Ok(None),
292 serde_json::Value::String(s) => Ok(Some(s)),
293 _ => Ok(None),
294 }
295}
296
297fn de_opt_decimal_places<'de, D>(d: D) -> std::result::Result<Option<u32>, D::Error>
306where
307 D: serde::Deserializer<'de>,
308{
309 let n = match serde_json::Value::deserialize(d)? {
310 serde_json::Value::Null => return Ok(None),
311 serde_json::Value::Number(n) => n
312 .as_i64()
313 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
314 _ => None,
315 };
316 match n {
317 Some(n) if (0..=6).contains(&n) => Ok(Some(n as u32)),
318 _ => Err(serde::de::Error::custom(
319 "decimal_places must be an integer in 0..=6",
320 )),
321 }
322}
323
324fn de_opt_currency<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
329where
330 D: serde::Deserializer<'de>,
331{
332 match Option::<String>::deserialize(d)? {
333 None => Ok(None),
334 Some(s) if s.len() == 3 && s.bytes().all(|b| b.is_ascii_uppercase()) => Ok(Some(s)),
335 Some(s) => Err(serde::de::Error::custom(format!(
336 "currency {s:?} is not an ISO 4217 alpha code"
337 ))),
338 }
339}
340
341fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
342where
343 D: serde::Deserializer<'de>,
344{
345 let v = serde_json::Value::deserialize(d)?;
346 match v {
347 serde_json::Value::Null => Ok(None),
348 serde_json::Value::Number(n) => {
349 if let Some(i) = n.as_i64() {
350 if i >= 0 {
351 Ok(Some(i))
352 } else {
353 Err(serde::de::Error::custom("cents cannot be negative"))
354 }
355 } else if let Some(f) = n.as_f64() {
356 const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
357 if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
358 Ok(Some(f as i64))
359 } else {
360 Err(serde::de::Error::custom(
361 "cents must be a non-negative integer in range",
362 ))
363 }
364 } else {
365 Err(serde::de::Error::custom("number out of i64 range"))
366 }
367 }
368 other => Err(serde::de::Error::custom(format!(
369 "expected number or null, got {other:?}"
370 ))),
371 }
372}
373
374const PCT_SLACK: f64 = 1.0;
378
379fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
390 if !v.is_finite() {
391 return Err(E::custom(format!("percentage {v} is not finite")));
392 }
393 if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
394 return Err(E::custom(format!("percentage {v} outside 0..=100")));
395 }
396 Ok(v)
397}
398
399fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
400where
401 D: serde::Deserializer<'de>,
402{
403 checked_percent(f64::deserialize(d)?)
404}
405
406fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
407where
408 D: serde::Deserializer<'de>,
409{
410 Option::<f64>::deserialize(d)?
411 .map(checked_percent)
412 .transpose()
413}
414
415impl UsageResponse {
416 pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
429 const SESSION: chrono::Duration = chrono::Duration::hours(5);
431 const WEEKLY: chrono::Duration = chrono::Duration::days(7);
432
433 fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
434 let Some(w) = w else {
435 return UsageWindow {
436 utilization_pct: 0,
437 resets_at: None,
438 window_duration: dur,
439 };
440 };
441 UsageWindow {
442 utilization_pct: i32::from(crate::format::clamp_pct(w.utilization)),
446 resets_at: w
447 .resets_at
448 .as_deref()
449 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
450 .map(|dt| dt.with_timezone(&chrono::Utc)),
451 window_duration: dur,
452 }
453 }
454
455 let session = match self.five_hour {
456 None if plan_label == ULTRA => None,
457 five_hour => Some(to_window(five_hour, SESSION)),
458 };
459 let weekly = to_window(self.seven_day, WEEKLY);
460 let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
461 let extra = self.extra_usage.and_then(|e| {
462 let status = if e.is_enabled {
463 ExtraUsageStatus::Active
464 } else if e.user_disabled {
465 return None;
466 } else {
467 match e.disabled_reason.as_deref() {
468 Some("out_of_credits") => ExtraUsageStatus::OutOfCredits,
469 Some("monthly_limit_reached" | "spend_limit_reached") => {
470 ExtraUsageStatus::MonthlyLimitReached
471 }
472 _ if e.spend_limit_reached => ExtraUsageStatus::MonthlyLimitReached,
473 _ if e.credits_ever_enabled && e.used_credits.unwrap_or_default() > 0 => {
474 ExtraUsageStatus::Unavailable
475 }
476 _ => return None,
477 }
478 };
479 Some(ExtraUsage {
480 limit: e.monthly_limit.map(Cents),
486 spent: Cents(e.used_credits?),
487 decimal_places: e.decimal_places,
491 currency: e.currency,
492 status,
493 })
494 });
495 let scoped = self
496 .limits
497 .into_iter()
498 .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
499 .filter_map(|l| {
500 let label = l.scope?.model?.display_name?;
501 let utilization = l.percent?;
506 let window = to_window(
507 Some(Window {
508 utilization,
509 resets_at: l.resets_at,
510 }),
511 WEEKLY,
512 );
513 Some(ScopedWindow { label, window })
514 })
515 .collect();
516
517 let cloud_credits = self
518 .iguana_necktie
519 .as_ref()
520 .and_then(|raw| raw_object_block::<CloudCreditsBlock>(raw))
521 .and_then(cloud_credits_from);
522 let usage_credits = self
523 .spend
524 .as_ref()
525 .and_then(|raw| raw_object_block::<SpendBlock>(raw))
526 .and_then(usage_credits_from);
527 AnthropicSnapshot {
528 plan: plan_label,
529 session,
530 weekly,
531 sonnet,
532 scoped,
533 extra,
534 reset_credits: reset_credits(self.cedar_ember),
535 cloud_credits,
536 usage_credits,
537 }
538 }
539}
540
541fn cloud_credits_from(block: CloudCreditsBlock) -> Option<CloudCredits> {
550 let limit = dollars_to_cents(block.limit_dollars.as_ref()?)?;
553 let remaining = dollars_to_cents(block.remaining_dollars.as_ref()?)?;
554 if limit <= 0 || remaining < 0 {
558 return None;
559 }
560 Some(CloudCredits {
561 limit: Cents(limit),
562 remaining: Cents(remaining),
563 expires_at: block
564 .resets_at
565 .as_deref()
566 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
567 .map(|dt| dt.with_timezone(&chrono::Utc)),
568 locked: block.locked_reason.is_some_and(|r| !r.is_empty()),
571 })
572}
573
574fn json_int(v: &serde_json::Value) -> Option<i64> {
579 let n = v.as_number()?;
580 if let Some(i) = n.as_i64() {
584 return Some(i);
585 }
586 if let Some(u) = n.as_u64() {
587 return i64::try_from(u).ok();
588 }
589 n.as_f64()
594 .filter(|f| f.is_finite() && f.fract() == 0.0 && f.abs() <= 2_f64.powi(53))
595 .map(|f| f as i64)
596}
597
598fn usage_credits_from(spend: SpendBlock) -> Option<UsageCredits> {
606 let balance_value = spend.balance?;
607 let balance = balance_value.as_object()?;
608 let currency = balance
609 .get("currency")
610 .and_then(|v| v.as_str())
611 .map(str::to_string);
612
613 let shaped = (|| {
621 let minor = balance.get("amount_minor").and_then(json_int)?;
622 let exponent = balance
623 .get("exponent")
624 .and_then(json_int)
625 .filter(|e| (0..=4).contains(e))?;
626 let currency = currency.as_ref()?;
627 Some((minor, exponent as u32, currency.clone()))
628 })()
629 .or_else(|| {
630 let amount = balance.get("amount").and_then(json_int)?;
631 let currency = currency.as_ref().filter(|c| *c == "USD")?;
632 Some((amount, 2, currency.clone()))
633 });
634 let (amount, decimal_places, currency) = shaped?;
635
636 let auto_reload = spend
642 .auto_reload
643 .as_ref()
644 .and_then(|v| v.get("enabled"))
645 .and_then(|v| v.as_bool())
646 .or_else(|| {
647 balance
648 .get("auto_reload_settings")
649 .and_then(|v| v.get("enabled"))
650 .and_then(|v| v.as_bool())
651 });
652
653 Some(UsageCredits {
654 balance: Cents(amount),
655 decimal_places,
656 currency: Some(currency),
657 auto_reload,
658 })
659}
660
661fn reset_credits(block: Option<ResetGrantsBlock>) -> ResetCredits {
674 let Some(block) = block.filter(|block| block.eligible) else {
675 return ResetCredits::default();
676 };
677 let usable = block
678 .grants
679 .into_iter()
680 .filter(|grant| grant.usable_now && !grant.paused && grant.resets_left > 0);
681 let mut credits = ResetCredits::default();
682 for grant in usable {
683 credits.available = credits.available.saturating_add(grant.resets_left);
684 credits.credits.push(ResetCredit {
685 title: checked_reset_title(grant.label),
686 expires_at: grant
687 .ends_at
688 .as_deref()
689 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
690 .map(|dt| dt.with_timezone(&chrono::Utc)),
691 });
692 }
693 credits
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn parses_full_response() {
702 let raw = r#"{
703 "five_hour": {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
704 "seven_day": {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
705 "seven_day_sonnet": {"utilization": 4.2, "resets_at": "2026-05-30T12:00:00Z"},
706 "extra_usage": {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
707 }"#;
708 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
709 let snap = resp.into_snapshot("Max 5x".into());
710 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 43); assert_eq!(snap.weekly.utilization_pct, 27);
712 assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
713 let extra = snap.extra.as_ref().unwrap();
714 assert_eq!(extra.limit, Some(Cents(5000)));
715 assert_eq!(extra.spent.0, 250);
716 assert!(snap.session.as_ref().unwrap().resets_at.is_some());
717 }
718
719 #[test]
720 fn parses_weekly_scoped_limits() {
721 let raw = r#"{
724 "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
725 "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
726 "limits": [
727 {"kind": "session", "group": "session", "percent": 10,
728 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
729 "scope": null, "is_active": false},
730 {"kind": "weekly_all", "group": "weekly", "percent": 55,
731 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
732 "scope": null, "is_active": false},
733 {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
734 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
735 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
736 "is_active": true}
737 ]
738 }"#;
739 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
740 let snap = resp.into_snapshot("Pro".into());
741 assert_eq!(snap.scoped.len(), 1);
742 assert_eq!(snap.scoped[0].label, "Fable");
743 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
744 assert!(snap.scoped[0].window.resets_at.is_some());
745 assert_eq!(snap.weekly.utilization_pct, 55);
747 }
748
749 const VERIFIED_ULTRA: &str = r#"{
753 "five_hour": null,
754 "seven_day": {"utilization": 41.0, "resets_at": "2026-11-06T18:00:00.123456+00:00",
755 "limit_dollars": null, "used_dollars": null,
756 "remaining_dollars": null, "locked_reason": null},
757 "seven_day_sonnet": null,
758 "limits": [
759 {"kind": "weekly_all", "group": "weekly", "percent": 41,
760 "severity": "normal", "resets_at": "2026-11-06T18:00:00.123456+00:00",
761 "scope": null, "is_active": true},
762 {"kind": "weekly_scoped", "group": "weekly", "percent": 33,
763 "severity": "normal", "resets_at": "2026-11-06T18:00:00.123456+00:00",
764 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
765 "is_active": false}
766 ]
767 }"#;
768
769 #[test]
770 fn a_verified_ultra_account_has_no_session_window() {
771 let resp: UsageResponse = serde_json::from_str(VERIFIED_ULTRA).unwrap();
772 let snap = resp.into_snapshot(ULTRA.into());
773 assert_eq!(snap.session, None);
774 assert_eq!(snap.weekly.utilization_pct, 41);
775 assert!(snap.weekly.resets_at.is_some());
776 assert_eq!(snap.scoped.len(), 1);
777 assert_eq!(snap.scoped[0].label, "Fable");
778 assert_eq!(snap.scoped[0].window.utilization_pct, 33);
779 }
780
781 #[test]
784 fn an_unverified_ultra_account_keeps_its_five_hour_window() {
785 let raw = r#"{
786 "five_hour": {"utilization": 12.0, "resets_at": "2026-11-02T23:00:00Z"},
787 "seven_day": {"utilization": 41.0, "resets_at": "2026-11-06T18:00:00Z"}
788 }"#;
789 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
790 let session = resp.into_snapshot(ULTRA.into()).session.unwrap();
791 assert_eq!(session.utilization_pct, 12);
792 assert!(session.resets_at.is_some());
793 }
794
795 #[test]
798 fn other_plans_keep_the_idle_fallback_for_a_missing_five_hour_window() {
799 for plan in ["Max", "Pro", "Team", "Unknown"] {
800 let resp: UsageResponse = serde_json::from_str(VERIFIED_ULTRA).unwrap();
801 let session = resp.into_snapshot(plan.into()).session;
802 assert_eq!(
803 session.as_ref().map(|s| s.utilization_pct),
804 Some(0),
805 "{plan}"
806 );
807 assert_eq!(session.and_then(|s| s.resets_at), None, "{plan}");
808 }
809 }
810
811 #[test]
812 fn missing_limits_array_yields_empty_scoped() {
813 let raw = r#"{
814 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
815 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
816 }"#;
817 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
818 let snap = resp.into_snapshot("Pro".into());
819 assert!(snap.scoped.is_empty());
820 }
821
822 #[test]
823 fn missing_sonnet_and_extra_are_none() {
824 let raw = r#"{
825 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
826 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
827 }"#;
828 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
829 let snap = resp.into_snapshot("Pro".into());
830 assert!(snap.sonnet.is_none());
831 assert!(snap.extra.is_none());
832 }
833
834 #[test]
835 fn user_disabled_extra_usage_becomes_none() {
836 let raw = r#"{
837 "five_hour": {"utilization": 0},
838 "seven_day": {"utilization": 0},
839 "extra_usage": {"is_enabled": false, "user_disabled": true,
840 "monthly_limit": 5000, "used_credits": 0}
841 }"#;
842 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
843 let snap = resp.into_snapshot("Pro".into());
844 assert!(snap.extra.is_none());
845 }
846
847 #[test]
848 fn provider_disabled_extra_usage_keeps_spend_and_reason_visible() {
849 let raw = r#"{
850 "extra_usage": {
851 "is_enabled": false,
852 "monthly_limit": null,
853 "used_credits": 1234.0,
854 "currency": "USD",
855 "decimal_places": 2,
856 "disabled_reason": "out_of_credits",
857 "user_disabled": false,
858 "spend_limit_reached": false,
859 "credits_ever_enabled": true
860 }
861 }"#;
862 let extra = serde_json::from_str::<UsageResponse>(raw)
863 .unwrap()
864 .into_snapshot("Pro".into())
865 .extra
866 .unwrap();
867 assert_eq!(extra.fmt_spent(), "$12.34");
868 assert_eq!(extra.status, ExtraUsageStatus::OutOfCredits);
869 assert_eq!(extra.status_note(), Some("out of credits"));
870 }
871
872 #[test]
873 fn enabled_extra_usage_without_spend_is_dropped() {
874 for raw in [
878 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
879 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000,"used_credits":null}}"#,
880 ] {
881 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
882 assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
883 }
884 }
885
886 #[test]
887 fn uncapped_plan_keeps_real_spend_visible() {
888 let resp: UsageResponse = serde_json::from_str(
893 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,
894 "used_credits":14157.0,"currency":"BRL","decimal_places":2,
895 "disabled_reason":null}}"#,
896 )
897 .unwrap();
898 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
899 assert_eq!(extra.limit, None);
900 assert_eq!(extra.spent.0, 14157);
901 assert_eq!(extra.percent(), 0);
903 assert_eq!(extra.currency.as_deref(), Some("BRL"));
906 assert_eq!(extra.decimal_places, Some(2));
907 assert_eq!(extra.fmt_spent(), "R$141.57");
908
909 let resp: UsageResponse =
915 serde_json::from_str(r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#)
916 .unwrap();
917 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
918 assert_eq!(extra.limit, None);
919 assert_eq!(extra.spent.0, 250);
920 }
921
922 #[test]
923 fn implausible_decimal_places_is_schema_drift() {
924 for value in ["7", "-1", "100", "2.5"] {
927 let raw = format!(
928 r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"decimal_places":{value}}}}}"#
929 );
930 assert!(
931 serde_json::from_str::<UsageResponse>(&raw).is_err(),
932 "{raw}"
933 );
934 }
935 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":2.0}}"#;
941 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
942 assert_eq!(
943 resp.into_snapshot("Pro".into())
944 .extra
945 .unwrap()
946 .decimal_places,
947 Some(2)
948 );
949 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":null}}"#;
952 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
953 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
954 assert_eq!(extra.decimal_places, None);
955 assert_eq!(extra.fmt_spent(), "$2.50");
956
957 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW"}}"#;
961 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
962 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
963 assert_eq!(extra.decimal_places, None);
964 assert_eq!(extra.fmt_spent(), "500 minor units KRW");
965
966 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD"}}"#;
967 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
968 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
969 assert_eq!(extra.decimal_places, None);
970 assert_eq!(extra.fmt_spent(), "1234 minor units KWD");
971
972 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW","decimal_places":0}}"#;
974 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
975 assert_eq!(
976 resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
977 "500 KRW"
978 );
979 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD","decimal_places":3}}"#;
980 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
981 assert_eq!(
982 resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
983 "1.234 KWD"
984 );
985 }
986
987 #[test]
988 fn currency_must_be_an_iso_alpha_code() {
989 for value in [r#""brl""#, r#""""#, r#""R$""#, r#""USD;;0""#, r#""<b>""#] {
993 let raw = format!(
994 r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"currency":{value}}}}}"#
995 );
996 assert!(
997 serde_json::from_str::<UsageResponse>(&raw).is_err(),
998 "{raw}"
999 );
1000 }
1001 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"currency":null}}"#;
1003 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1004 assert_eq!(
1005 resp.into_snapshot("Pro".into()).extra.unwrap().currency,
1006 None
1007 );
1008 }
1009
1010 #[test]
1011 fn malformed_cent_values_are_schema_drift() {
1012 for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
1013 let raw = format!(
1014 r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
1015 );
1016 assert!(
1017 serde_json::from_str::<UsageResponse>(&raw).is_err(),
1018 "{raw}"
1019 );
1020 }
1021
1022 let resp: UsageResponse = serde_json::from_str(
1023 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
1024 )
1025 .unwrap();
1026 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
1027 assert_eq!(extra.limit, Some(Cents(5000)));
1028 assert_eq!(extra.spent.0, 250);
1029 }
1030
1031 #[test]
1032 fn empty_object_yields_neutral_snapshot() {
1033 let resp: UsageResponse = serde_json::from_str("{}").unwrap();
1034 let snap = resp.into_snapshot("Unknown".into());
1035 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 0);
1036 assert_eq!(snap.weekly.utilization_pct, 0);
1037 assert!(snap.session.as_ref().unwrap().resets_at.is_none());
1038 }
1039
1040 #[test]
1041 fn benign_overshoot_saturates_to_hundred() {
1042 let raw = r#"{
1045 "five_hour": {"utilization": 100.4},
1046 "seven_day": {"utilization": 100.6}
1047 }"#;
1048 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1049 let snap = resp.into_snapshot("Pro".into());
1050 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 100);
1051 assert_eq!(snap.weekly.utilization_pct, 100); }
1053
1054 #[test]
1055 fn out_of_range_utilization_is_rejected_not_clamped() {
1056 for raw in [
1057 r#"{"five_hour": {"utilization": 500}}"#,
1058 r#"{"five_hour": {"utilization": -1}}"#,
1059 r#"{"seven_day": {"utilization": 101.5}}"#,
1060 ] {
1061 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
1062 assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
1063 }
1064 }
1065
1066 #[test]
1067 fn out_of_range_scoped_percent_is_rejected() {
1068 let raw = r#"{
1070 "limits": [{"kind": "weekly_scoped", "percent": 420,
1071 "scope": {"model": {"display_name": "Fable"}}}]
1072 }"#;
1073 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
1074 assert!(err.to_string().contains("outside 0..=100"), "{err}");
1075 }
1076
1077 #[test]
1078 fn non_finite_percentage_is_rejected() {
1079 for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1082 assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
1083 }
1084 }
1085
1086 #[test]
1087 fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
1088 let raw = r#"{
1092 "limits": [{"kind": "weekly_scoped", "percent": null,
1093 "scope": {"model": {"display_name": "Fable"}}}]
1094 }"#;
1095 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1096 let snap = resp.into_snapshot("Pro".into());
1097 assert!(
1098 snap.scoped.is_empty(),
1099 "a scoped limit with no percentage must not render a fabricated bar"
1100 );
1101
1102 let ok = r#"{
1104 "limits": [{"kind": "weekly_scoped", "percent": 84,
1105 "scope": {"model": {"display_name": "Fable"}}}]
1106 }"#;
1107 let resp: UsageResponse = serde_json::from_str(ok).unwrap();
1108 let snap = resp.into_snapshot("Pro".into());
1109 assert_eq!(snap.scoped[0].label, "Fable");
1110 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
1111 }
1112
1113 const LAUNCH_GRANT: &str = r#"{
1116 "five_hour": {"utilization": 2, "resets_at": "2026-09-24T17:49:59Z"},
1117 "seven_day": {"utilization": 63, "resets_at": "2026-09-25T08:59:59Z"},
1118 "cedar_ember": {
1119 "eligible": true,
1120 "ineligible_reason": null,
1121 "at_limit": false,
1122 "exhausted": [],
1123 "grants": [{
1124 "id": "a-redemption-handle",
1125 "label": "Claude Opus 5.5 launch: one usage-limit reset for Team members",
1126 "resets_total": 1,
1127 "resets_left": 1,
1128 "starts_at": "2026-09-22T16:00:00+00:00",
1129 "ends_at": "2026-10-22T16:00:00+00:00",
1130 "clears": ["five_hour", "seven_day", "seven_day_overage_included"],
1131 "paused": false,
1132 "usable_now": true,
1133 "use_requires_limit": false,
1134 "percent_used": {"five_hour": 2, "seven_day": 63},
1135 "blocking": [],
1136 "arm": null
1137 }],
1138 "next_grant_id": "a-redemption-handle",
1139 "weekly_resets_at": "2026-09-25T09:00:00+00:00",
1140 "cooldown_until": null,
1141 "event_props": null
1142 }
1143 }"#;
1144
1145 #[test]
1146 fn parses_banked_resets_from_the_cedar_ember_block() {
1147 let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
1148 let snap = resp.into_snapshot("Max 20x".into());
1149
1150 assert_eq!(snap.reset_credits.available, 1);
1151 assert_eq!(
1152 snap.reset_credits.credits[0].title.as_deref(),
1153 Some("Claude Opus 5.5 launch: one usage-limit reset for Team members")
1154 );
1155 assert_eq!(
1156 snap.reset_credits.next_expiry(),
1157 Some("2026-10-22T16:00:00Z".parse().unwrap())
1158 );
1159 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 2);
1161 assert_eq!(snap.weekly.utilization_pct, 63);
1162 }
1163
1164 #[test]
1167 fn the_redemption_grant_id_never_leaves_the_parser() {
1168 let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
1169 let snap = resp.into_snapshot("Max 20x".into());
1170 assert!(!format!("{snap:?}").contains("a-redemption-handle"));
1171 }
1172
1173 #[test]
1176 fn an_ineligible_block_reports_no_resets() {
1177 let raw = r#"{
1178 "cedar_ember": {
1179 "eligible": false,
1180 "ineligible_reason": "cli_version",
1181 "grants": [{"id": "g", "resets_left": 1, "usable_now": true,
1182 "ends_at": "2026-10-22T16:00:00Z"}]
1183 }
1184 }"#;
1185 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1186 assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
1187 }
1188
1189 #[test]
1193 fn only_redeemable_grants_are_counted() {
1194 let raw = r#"{
1195 "cedar_ember": {
1196 "eligible": true,
1197 "grants": [
1198 {"id": "a", "label": "not open yet", "resets_left": 1,
1199 "usable_now": false, "paused": false},
1200 {"id": "b", "label": "paused", "resets_left": 1,
1201 "usable_now": true, "paused": true},
1202 {"id": "c", "label": "spent", "resets_left": 0,
1203 "usable_now": true, "paused": false},
1204 {"id": "d", "label": "yours", "resets_left": 2,
1205 "usable_now": true, "paused": false,
1206 "ends_at": "2026-10-22T16:00:00Z"}
1207 ]
1208 }
1209 }"#;
1210 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1211 let credits = resp.into_snapshot("Pro".into()).reset_credits;
1212 assert_eq!(credits.available, 2, "only the redeemable grant counts");
1213 assert_eq!(credits.credits.len(), 1);
1214 assert_eq!(credits.credits[0].title.as_deref(), Some("yours"));
1215 }
1216
1217 #[test]
1220 fn an_absent_or_null_block_is_not_a_parse_failure() {
1221 for raw in [
1222 r#"{"five_hour": {"utilization": 5}}"#,
1223 r#"{"cedar_ember": null}"#,
1224 ] {
1225 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1226 assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
1227 }
1228 }
1229
1230 #[test]
1234 fn a_control_bearing_label_is_dropped_but_the_expiry_survives() {
1235 let raw = r#"{
1236 "cedar_ember": {
1237 "eligible": true,
1238 "grants": [{"id": "a", "label": "line\u0000break", "resets_left": 1,
1239 "usable_now": true, "ends_at": "2026-10-22T16:00:00Z"}]
1240 }
1241 }"#;
1242 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1243 let credits = resp.into_snapshot("Pro".into()).reset_credits;
1244 assert_eq!(credits.available, 1);
1245 assert!(credits.credits[0].title.is_none());
1246 assert!(credits.credits[0].expires_at.is_some());
1247 }
1248
1249 #[test]
1250 fn unparseable_reset_becomes_none() {
1251 let raw = r#"{
1252 "five_hour": {"utilization": 50, "resets_at": "not a date"},
1253 "seven_day": {"utilization": 0}
1254 }"#;
1255 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1256 let snap = resp.into_snapshot("Pro".into());
1257 assert!(snap.session.as_ref().unwrap().resets_at.is_none());
1258 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 50);
1259 }
1260
1261 const CREDITS_PAYLOAD: &str = r#"{
1266 "five_hour": {"utilization": 12, "resets_at": "2026-10-26T15:00:00Z"},
1267 "seven_day": {"utilization": 34, "resets_at": "2026-11-02T07:00:00Z"},
1268 "iguana_necktie": {"utilization":0.0,"resets_at":"2026-11-05T07:59:00+00:00","limit_dollars":250,"used_dollars":0.0,"remaining_dollars":250.0,"locked_reason":null},
1269 "spend": {"used":{"amount_minor":0,"currency":"USD","exponent":2},"limit":{"amount_minor":0,"currency":"USD","exponent":2},"percent":0,"severity":"normal","enabled":false,"disabled_reason":"org_level_disabled_until","cap":{"money":null,"credits":{"amount_minor":0,"exponent":2}},"balance":null,"auto_reload":null,"disclaimer":"Usage credits cover you when you hit your plan limits. [Learn more](https://support.claude.com/articles/12429409)","can_purchase_credits":false,"can_toggle":false}
1270 }"#;
1271
1272 #[test]
1273 fn the_real_credits_payload_lifts_cloud_credits_and_no_usage_balance() {
1274 let snap = serde_json::from_str::<UsageResponse>(CREDITS_PAYLOAD)
1275 .unwrap()
1276 .into_snapshot("Max 5x".into());
1277 let cloud = snap
1278 .cloud_credits
1279 .as_ref()
1280 .expect("iguana_necktie must lift into cloud_credits");
1281 assert_eq!(cloud.limit, Cents(25000));
1282 assert_eq!(cloud.remaining, Cents(25000));
1283 assert_eq!(
1284 cloud.expires_at,
1285 Some("2026-11-05T07:59:00Z".parse().unwrap())
1286 );
1287 assert!(!cloud.locked);
1288 assert!(snap.usage_credits.is_none());
1291 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 12);
1293 assert_eq!(snap.weekly.utilization_pct, 34);
1294 }
1295
1296 #[test]
1297 fn cloud_credits_carry_the_remaining_after_spend() {
1298 let raw = r#"{
1301 "iguana_necktie": {"resets_at": "2026-11-05T07:59:00+00:00",
1302 "limit_dollars": 250, "used_dollars": 100,
1303 "remaining_dollars": 150, "locked_reason": null}
1304 }"#;
1305 let snap = serde_json::from_str::<UsageResponse>(raw)
1306 .unwrap()
1307 .into_snapshot("Max 5x".into());
1308 let cloud = snap.cloud_credits.as_ref().unwrap();
1309 assert_eq!(cloud.limit, Cents(25000));
1310 assert_eq!(cloud.remaining, Cents(15000));
1311 }
1312
1313 #[test]
1314 fn iguana_necktie_drift_degrades_to_none_not_an_error() {
1315 for raw in [
1321 r#"{"five_hour": {"utilization": 5}}"#,
1322 r#"{"five_hour": {"utilization": 5}, "iguana_necktie": null}"#,
1323 r#"{"iguana_necktie": {"limit_dollars": 0, "remaining_dollars": 250}}"#,
1324 r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": -5}}"#,
1325 r#"{"iguana_necktie": {"limit_dollars": "a lot", "remaining_dollars": 250}}"#,
1326 r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": null}}"#,
1327 r#"{"iguana_necktie": [1, 2]}"#,
1328 r#"{"iguana_necktie": "hostile"}"#,
1329 ] {
1330 let resp: UsageResponse =
1331 serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1332 assert!(
1333 resp.into_snapshot("Pro".into()).cloud_credits.is_none(),
1334 "{raw}"
1335 );
1336 }
1337 let resp: UsageResponse = serde_json::from_str(
1339 r#"{"five_hour": {"utilization": 5}, "iguana_necktie": "hostile"}"#,
1340 )
1341 .unwrap();
1342 assert_eq!(
1343 resp.into_snapshot("Pro".into())
1344 .session
1345 .as_ref()
1346 .unwrap()
1347 .utilization_pct,
1348 5
1349 );
1350 }
1351
1352 #[test]
1353 fn a_non_empty_locked_reason_locks_the_credits_without_leaving_the_parser() {
1354 let raw = r#"{
1358 "iguana_necktie": {"resets_at": "2026-11-05T07:59:00Z",
1359 "limit_dollars": 250, "remaining_dollars": 250,
1360 "locked_reason": "<b>org_migrating</b>"}
1361 }"#;
1362 let snap = serde_json::from_str::<UsageResponse>(raw)
1363 .unwrap()
1364 .into_snapshot("Max 5x".into());
1365 let cloud = snap.cloud_credits.as_ref().unwrap();
1366 assert!(cloud.locked);
1367 assert!(!format!("{cloud:?}").contains("<b>"));
1368
1369 for (reason, locked) in [("\"\"", false), ("42", false), ("[1]", false)] {
1372 let raw = format!(
1373 r#"{{"iguana_necktie": {{"limit_dollars": 250, "remaining_dollars": 250, "locked_reason": {reason}}}}}"#
1374 );
1375 let snap = serde_json::from_str::<UsageResponse>(&raw)
1376 .unwrap()
1377 .into_snapshot("Max 5x".into());
1378 assert_eq!(snap.cloud_credits.as_ref().unwrap().locked, locked, "{raw}");
1379 }
1380
1381 let raw = r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": 250,
1383 "resets_at": "not a date"}}"#;
1384 let snap = serde_json::from_str::<UsageResponse>(raw)
1385 .unwrap()
1386 .into_snapshot("Max 5x".into());
1387 let cloud = snap.cloud_credits.as_ref().unwrap();
1388 assert!(cloud.expires_at.is_none());
1389 assert_eq!(cloud.limit, Cents(25000));
1390 }
1391
1392 #[test]
1393 fn spend_balance_shape_a_lifts_minor_units_with_its_own_scale() {
1394 let raw = r#"{
1395 "spend": {"balance": {"amount_minor": 1500, "currency": "USD", "exponent": 2},
1396 "auto_reload": {"enabled": true}}
1397 }"#;
1398 let usage = serde_json::from_str::<UsageResponse>(raw)
1399 .unwrap()
1400 .into_snapshot("Pro".into())
1401 .usage_credits
1402 .expect("shape (a) must lift into usage_credits");
1403 assert_eq!(usage.balance, Cents(1500));
1404 assert_eq!(usage.decimal_places, 2);
1405 assert_eq!(usage.currency.as_deref(), Some("USD"));
1406 assert_eq!(usage.auto_reload, Some(true));
1408
1409 let raw = r#"{
1412 "spend": {"balance": {"amount_minor": 500.0, "currency": "KRW", "exponent": 0.0},
1413 "auto_reload": null}
1414 }"#;
1415 let usage = serde_json::from_str::<UsageResponse>(raw)
1416 .unwrap()
1417 .into_snapshot("Pro".into())
1418 .usage_credits
1419 .unwrap();
1420 assert_eq!(usage.balance, Cents(500));
1421 assert_eq!(usage.decimal_places, 0);
1422 assert_eq!(usage.currency.as_deref(), Some("KRW"));
1423 assert_eq!(usage.auto_reload, None);
1424 }
1425
1426 #[test]
1427 fn spend_balance_shape_b_lifts_usd_minor_units_and_nested_auto_reload() {
1428 let raw = r#"{
1433 "spend": {"balance": {"amount": 750, "currency": "USD",
1434 "auto_reload_settings": {"enabled": false}},
1435 "auto_reload": null}
1436 }"#;
1437 let usage = serde_json::from_str::<UsageResponse>(raw)
1438 .unwrap()
1439 .into_snapshot("Pro".into())
1440 .usage_credits
1441 .expect("shape (b) must lift into usage_credits");
1442 assert_eq!(usage.balance, Cents(750));
1443 assert_eq!(usage.decimal_places, 2);
1444 assert_eq!(usage.currency.as_deref(), Some("USD"));
1445 assert_eq!(usage.auto_reload, Some(false));
1446
1447 let raw = r#"{
1449 "spend": {"balance": {"amount": 750, "currency": "USD",
1450 "auto_reload_settings": {"enabled": false}},
1451 "auto_reload": {"enabled": true}}
1452 }"#;
1453 let usage = serde_json::from_str::<UsageResponse>(raw)
1454 .unwrap()
1455 .into_snapshot("Pro".into())
1456 .usage_credits
1457 .unwrap();
1458 assert_eq!(usage.auto_reload, Some(true));
1459
1460 let raw = r#"{"spend": {"balance": {"amount_minor": 5, "amount": 100,
1464 "currency": "USD"}}}"#;
1465 let usage = serde_json::from_str::<UsageResponse>(raw)
1466 .unwrap()
1467 .into_snapshot("Pro".into())
1468 .usage_credits
1469 .unwrap();
1470 assert_eq!(usage.balance, Cents(100));
1471 assert_eq!(usage.decimal_places, 2);
1472 }
1473
1474 #[test]
1475 fn spend_balances_we_cannot_express_are_absent_not_zero() {
1476 for raw in [
1479 r#"{"spend": {"balance": null}}"#,
1481 r#"{"spend": {"balance": {"amount": 500, "currency": "BRL"}}}"#,
1483 r#"{"spend": {"balance": {"amount_minor": 5, "currency": "USD", "exponent": 5}}}"#,
1485 r#"{"spend": {"balance": 42}}"#,
1487 r#"{"spend": {"balance": {"dollars": 4}}}"#,
1489 r#"{"spend": {"balance": {"amount_minor": 5, "exponent": 2}}}"#,
1491 r#"{"spend": {"balance": {"amount": 750, "currency": "USD",
1493 "auto_reload_settings": {"enabled": "on"}},
1494 "auto_reload": 7}}"#,
1495 ] {
1496 let resp: UsageResponse =
1497 serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1498 let snap = resp.into_snapshot("Pro".into());
1499 if raw.contains(r#""auto_reload": 7"#) {
1500 let usage = snap.usage_credits.as_ref().unwrap();
1502 assert_eq!(usage.auto_reload, None, "{raw}");
1503 } else {
1504 assert!(snap.usage_credits.is_none(), "{raw}");
1505 }
1506 }
1507 }
1508
1509 #[test]
1510 fn spend_drift_degrades_to_none_not_an_error() {
1511 for raw in [
1514 r#"{"five_hour": {"utilization": 5}}"#,
1515 r#"{"spend": null}"#,
1516 r#"{"spend": [1]}"#,
1517 r#"{"spend": "hostile"}"#,
1518 r#"{"spend": {"balance": "not a balance", "auto_reload": {"enabled": "on"}}}"#,
1519 ] {
1520 let resp: UsageResponse =
1521 serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1522 let snap = resp.into_snapshot("Pro".into());
1523 assert!(snap.usage_credits.is_none(), "{raw}");
1524 }
1525 let resp: UsageResponse =
1526 serde_json::from_str(r#"{"five_hour": {"utilization": 5}, "spend": "hostile"}"#)
1527 .unwrap();
1528 assert_eq!(
1529 resp.into_snapshot("Pro".into())
1530 .session
1531 .as_ref()
1532 .unwrap()
1533 .utilization_pct,
1534 5
1535 );
1536 }
1537
1538 #[test]
1544 fn a_number_the_parser_cannot_materialize_loses_only_its_block() {
1545 let raw = r#"{"five_hour":{"utilization":5},"iguana_necktie":{"limit_dollars":1e400,"remaining_dollars":1},"spend":{"balance":{"amount_minor":1e400,"currency":"USD","exponent":2}}}"#;
1549 let resp: UsageResponse =
1550 serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1551 let snap = resp.into_snapshot("Pro".into());
1552 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 5);
1553 assert!(snap.cloud_credits.is_none(), "1e400 dollars is not a limit");
1554 assert!(
1555 snap.usage_credits.is_none(),
1556 "1e400 minor units is not a balance"
1557 );
1558 }
1559
1560 #[test]
1561 fn a_huge_finite_float_inside_a_credit_block_degrades_the_block_only() {
1562 let raw = r#"{"five_hour":{"utilization":5},"iguana_necktie":{"limit_dollars":1e300,"remaining_dollars":1}}"#;
1565 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1566 let snap = resp.into_snapshot("Pro".into());
1567 assert_eq!(snap.session.as_ref().unwrap().utilization_pct, 5);
1568 assert!(snap.cloud_credits.is_none());
1569 }
1570
1571 #[test]
1576 fn dollars_to_cents_rounds_monetary_ties_up_in_exact_arithmetic() {
1577 fn cents(text: &str) -> i64 {
1580 let n: serde_json::Number = serde_json::from_str(text).unwrap();
1581 dollars_to_cents(&n).unwrap()
1582 }
1583 assert_eq!(cents("1.005"), 101);
1584 assert_eq!(cents("0.004"), 0);
1585 assert_eq!(cents("149.995"), 15000);
1586 assert_eq!(cents("250"), 25000);
1587 assert_eq!(cents("0.005"), 1);
1588 assert_eq!(cents("19.99"), 1999);
1589 }
1590
1591 #[test]
1592 fn dollars_to_cents_reads_negative_zero_as_an_exhausted_zero() {
1593 for text in ["-0", "-0.0"] {
1594 let n: serde_json::Number = serde_json::from_str(text).unwrap();
1595 assert_eq!(dollars_to_cents(&n), Some(0), "{text}");
1596 }
1597 let n: serde_json::Number = serde_json::from_str("-0.01").unwrap();
1598 assert_eq!(dollars_to_cents(&n), None);
1599 }
1600
1601 #[test]
1602 fn dollars_to_cents_rejects_what_cents_cannot_hold() {
1603 fn opt(text: &str) -> Option<i64> {
1604 serde_json::from_str::<serde_json::Number>(text)
1605 .ok()
1606 .and_then(|n| dollars_to_cents(&n))
1607 }
1608 assert_eq!(opt("-5"), None);
1610 assert_eq!(opt("18446744073709551615"), None);
1612 assert_eq!(opt("1e13"), Some(1_000_000_000_000_000));
1615 assert_eq!(opt("1e14"), None);
1616 }
1617
1618 #[test]
1619 fn a_positive_dollar_limit_that_rounds_to_zero_cents_does_not_stand() {
1620 let raw = r#"{"iguana_necktie":{"limit_dollars":0.004,"remaining_dollars":0.001,"resets_at":"2026-11-05T07:59:00Z"}}"#;
1624 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1625 assert!(
1626 resp.into_snapshot("Pro".into()).cloud_credits.is_none(),
1627 "a limit the conversion rounds to zero is absent, not a 0% meter"
1628 );
1629 }
1630
1631 #[test]
1632 fn json_int_rejects_integers_past_its_own_range() {
1633 assert_eq!(json_int(&serde_json::json!(18446744073709551615u64)), None);
1637 assert_eq!(json_int(&serde_json::json!(1500.0)), Some(1500));
1639 }
1640
1641 #[test]
1646 fn a_non_boolean_primary_auto_reload_falls_to_the_balance_setting() {
1647 let raw = r#"{"spend":{"balance":{"amount":750,"currency":"USD","auto_reload_settings":{"enabled":true}},"auto_reload":{"enabled":"unknown"}}}"#;
1650 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1651 let snap = resp.into_snapshot("Pro".into());
1652 let credits = snap
1653 .usage_credits
1654 .expect("the balance is still shape (b) with a USD amount");
1655 assert_eq!(credits.balance, Cents(750));
1656 assert_eq!(credits.auto_reload, Some(true));
1657 }
1658}