1use serde::{Deserialize, Serialize};
9
10use crate::usage::{
11 AnthropicSnapshot, Cents, CloudCredits, ExtraUsage, ExtraUsageStatus, ResetCredit,
12 ResetCredits, ScopedWindow, UsageCredits, UsageWindow, checked_reset_title,
13};
14
15#[derive(Debug, Default, Clone, Deserialize, Serialize)]
21pub struct UsageResponse {
22 #[serde(default)]
23 pub five_hour: Option<Window>,
24 #[serde(default)]
25 pub seven_day: Option<Window>,
26 #[serde(default)]
27 pub seven_day_sonnet: Option<Window>,
28 #[serde(default)]
29 pub extra_usage: Option<ExtraUsageBlock>,
30 #[serde(default)]
34 pub limits: Vec<LimitEntry>,
35 #[serde(default)]
40 pub cedar_ember: Option<ResetGrantsBlock>,
41 #[serde(default)]
48 pub iguana_necktie: Option<Box<serde_json::value::RawValue>>,
49 #[serde(default)]
54 pub spend: Option<Box<serde_json::value::RawValue>>,
55}
56
57#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
63pub struct ResetGrantsBlock {
64 #[serde(default)]
65 pub eligible: bool,
66 #[serde(default)]
67 pub grants: Vec<ResetGrant>,
68}
69
70#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
74pub struct ResetGrant {
75 #[serde(default)]
77 pub label: Option<String>,
78 #[serde(default)]
81 pub resets_left: u32,
82 #[serde(default)]
84 pub ends_at: Option<String>,
85 #[serde(default)]
90 pub usable_now: bool,
91 #[serde(default)]
92 pub paused: bool,
93}
94
95#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
99pub struct LimitEntry {
100 #[serde(default)]
101 pub kind: Option<String>,
102 #[serde(default, deserialize_with = "de_percent_opt")]
103 pub percent: Option<f64>,
104 #[serde(default)]
105 pub resets_at: Option<String>,
106 #[serde(default)]
107 pub scope: Option<LimitScope>,
108}
109
110#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
111pub struct LimitScope {
112 #[serde(default)]
113 pub model: Option<LimitModel>,
114}
115
116#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
117pub struct LimitModel {
118 #[serde(default)]
119 pub display_name: Option<String>,
120}
121
122#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
124pub struct Window {
125 #[serde(default, deserialize_with = "de_percent")]
126 pub utilization: f64,
127 #[serde(default)]
128 pub resets_at: Option<String>,
129}
130
131#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
136pub struct ExtraUsageBlock {
137 #[serde(default)]
138 pub is_enabled: bool,
139 #[serde(default, deserialize_with = "de_opt_cents")]
140 pub monthly_limit: Option<i64>,
141 #[serde(default, deserialize_with = "de_opt_cents")]
142 pub used_credits: Option<i64>,
143 #[serde(default, deserialize_with = "de_opt_currency")]
146 pub currency: Option<String>,
147 #[serde(default, deserialize_with = "de_opt_decimal_places")]
150 pub decimal_places: Option<u32>,
151 #[serde(default)]
152 pub user_disabled: bool,
153 #[serde(default)]
154 pub spend_limit_reached: bool,
155 #[serde(default)]
156 pub credits_ever_enabled: bool,
157 #[serde(default)]
158 pub disabled_reason: Option<String>,
159}
160
161#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
168pub struct CloudCreditsBlock {
169 #[serde(default, deserialize_with = "de_opt_number")]
174 pub limit_dollars: Option<serde_json::Number>,
175 #[serde(default, deserialize_with = "de_opt_number")]
176 pub remaining_dollars: Option<serde_json::Number>,
177 #[serde(default, deserialize_with = "de_opt_plain_string")]
179 pub resets_at: Option<String>,
180 #[serde(default, deserialize_with = "de_opt_plain_string")]
183 pub locked_reason: Option<String>,
184}
185
186#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
191pub struct SpendBlock {
192 #[serde(default)]
193 pub balance: Option<serde_json::Value>,
194 #[serde(default)]
195 pub auto_reload: Option<serde_json::Value>,
196}
197
198fn raw_object_block<T>(raw: &serde_json::value::RawValue) -> Option<T>
207where
208 T: serde::de::DeserializeOwned,
209{
210 let v = serde_json::from_str::<serde_json::Value>(raw.get()).ok()?;
211 match v {
212 serde_json::Value::Object(_) => serde_json::from_value::<T>(v).ok(),
213 _ => None,
214 }
215}
216
217fn de_opt_number<'de, D>(d: D) -> std::result::Result<Option<serde_json::Number>, D::Error>
222where
223 D: serde::Deserializer<'de>,
224{
225 match serde_json::Value::deserialize(d)? {
226 serde_json::Value::Null => Ok(None),
227 serde_json::Value::Number(n) => Ok(Some(n)),
228 _ => Ok(None),
229 }
230}
231
232fn dollars_to_cents(n: &serde_json::Number) -> Option<i64> {
251 let v = n.as_f64()?;
252 if !v.is_finite() || v < 0.0 || v > 1e13 {
253 return None;
254 }
255 if v == 0.0 {
258 return Some(0);
259 }
260 let text = n.to_string();
261 if text.contains(['e', 'E']) {
262 return Some((v * 100.0).round() as i64);
263 }
264 let (int_part, frac_part) = match text.split_once('.') {
265 Some((i, f)) => (i, f),
266 None => (text.as_str(), ""),
267 };
268 if int_part.is_empty() || int_part.bytes().any(|b| !b.is_ascii_digit()) {
269 return None;
270 }
271 let mut cents: i64 = 0;
272 for b in int_part.bytes() {
273 cents = cents.checked_mul(10)?.checked_add(i64::from(b - b'0'))?;
274 }
275 cents = cents.checked_mul(100)?;
276 let digits: Vec<u8> = frac_part.bytes().filter(|b| b.is_ascii_digit()).collect();
277 let first = i64::from(digits.first().copied().unwrap_or(b'0') - b'0');
278 let second = i64::from(digits.get(1).copied().unwrap_or(b'0') - b'0');
279 let half_up = i64::from(digits.get(2).is_some_and(|d| *d >= b'5'));
280 cents.checked_add(first * 10 + second + half_up)
281}
282
283fn de_opt_plain_string<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
286where
287 D: serde::Deserializer<'de>,
288{
289 match serde_json::Value::deserialize(d)? {
290 serde_json::Value::Null => Ok(None),
291 serde_json::Value::String(s) => Ok(Some(s)),
292 _ => Ok(None),
293 }
294}
295
296fn de_opt_decimal_places<'de, D>(d: D) -> std::result::Result<Option<u32>, D::Error>
305where
306 D: serde::Deserializer<'de>,
307{
308 let n = match serde_json::Value::deserialize(d)? {
309 serde_json::Value::Null => return Ok(None),
310 serde_json::Value::Number(n) => n
311 .as_i64()
312 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
313 _ => None,
314 };
315 match n {
316 Some(n) if (0..=6).contains(&n) => Ok(Some(n as u32)),
317 _ => Err(serde::de::Error::custom(
318 "decimal_places must be an integer in 0..=6",
319 )),
320 }
321}
322
323fn de_opt_currency<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
328where
329 D: serde::Deserializer<'de>,
330{
331 match Option::<String>::deserialize(d)? {
332 None => Ok(None),
333 Some(s) if s.len() == 3 && s.bytes().all(|b| b.is_ascii_uppercase()) => Ok(Some(s)),
334 Some(s) => Err(serde::de::Error::custom(format!(
335 "currency {s:?} is not an ISO 4217 alpha code"
336 ))),
337 }
338}
339
340fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
341where
342 D: serde::Deserializer<'de>,
343{
344 let v = serde_json::Value::deserialize(d)?;
345 match v {
346 serde_json::Value::Null => Ok(None),
347 serde_json::Value::Number(n) => {
348 if let Some(i) = n.as_i64() {
349 if i >= 0 {
350 Ok(Some(i))
351 } else {
352 Err(serde::de::Error::custom("cents cannot be negative"))
353 }
354 } else if let Some(f) = n.as_f64() {
355 const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
356 if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
357 Ok(Some(f as i64))
358 } else {
359 Err(serde::de::Error::custom(
360 "cents must be a non-negative integer in range",
361 ))
362 }
363 } else {
364 Err(serde::de::Error::custom("number out of i64 range"))
365 }
366 }
367 other => Err(serde::de::Error::custom(format!(
368 "expected number or null, got {other:?}"
369 ))),
370 }
371}
372
373const PCT_SLACK: f64 = 1.0;
377
378fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
389 if !v.is_finite() {
390 return Err(E::custom(format!("percentage {v} is not finite")));
391 }
392 if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
393 return Err(E::custom(format!("percentage {v} outside 0..=100")));
394 }
395 Ok(v)
396}
397
398fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
399where
400 D: serde::Deserializer<'de>,
401{
402 checked_percent(f64::deserialize(d)?)
403}
404
405fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
406where
407 D: serde::Deserializer<'de>,
408{
409 Option::<f64>::deserialize(d)?
410 .map(checked_percent)
411 .transpose()
412}
413
414impl UsageResponse {
415 pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
420 const SESSION: chrono::Duration = chrono::Duration::hours(5);
422 const WEEKLY: chrono::Duration = chrono::Duration::days(7);
423
424 fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
425 let Some(w) = w else {
426 return UsageWindow {
427 utilization_pct: 0,
428 resets_at: None,
429 window_duration: dur,
430 };
431 };
432 UsageWindow {
433 utilization_pct: i32::from(crate::format::clamp_pct(w.utilization)),
437 resets_at: w
438 .resets_at
439 .as_deref()
440 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
441 .map(|dt| dt.with_timezone(&chrono::Utc)),
442 window_duration: dur,
443 }
444 }
445
446 let session = to_window(self.five_hour, SESSION);
447 let weekly = to_window(self.seven_day, WEEKLY);
448 let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
449 let extra = self.extra_usage.and_then(|e| {
450 let status = if e.is_enabled {
451 ExtraUsageStatus::Active
452 } else if e.user_disabled {
453 return None;
454 } else {
455 match e.disabled_reason.as_deref() {
456 Some("out_of_credits") => ExtraUsageStatus::OutOfCredits,
457 Some("monthly_limit_reached" | "spend_limit_reached") => {
458 ExtraUsageStatus::MonthlyLimitReached
459 }
460 _ if e.spend_limit_reached => ExtraUsageStatus::MonthlyLimitReached,
461 _ if e.credits_ever_enabled && e.used_credits.unwrap_or_default() > 0 => {
462 ExtraUsageStatus::Unavailable
463 }
464 _ => return None,
465 }
466 };
467 Some(ExtraUsage {
468 limit: e.monthly_limit.map(Cents),
474 spent: Cents(e.used_credits?),
475 decimal_places: e.decimal_places,
479 currency: e.currency,
480 status,
481 })
482 });
483 let scoped = self
484 .limits
485 .into_iter()
486 .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
487 .filter_map(|l| {
488 let label = l.scope?.model?.display_name?;
489 let utilization = l.percent?;
494 let window = to_window(
495 Some(Window {
496 utilization,
497 resets_at: l.resets_at,
498 }),
499 WEEKLY,
500 );
501 Some(ScopedWindow { label, window })
502 })
503 .collect();
504
505 let cloud_credits = self
506 .iguana_necktie
507 .as_ref()
508 .and_then(|raw| raw_object_block::<CloudCreditsBlock>(raw))
509 .and_then(cloud_credits_from);
510 let usage_credits = self
511 .spend
512 .as_ref()
513 .and_then(|raw| raw_object_block::<SpendBlock>(raw))
514 .and_then(usage_credits_from);
515 AnthropicSnapshot {
516 plan: plan_label,
517 session,
518 weekly,
519 sonnet,
520 scoped,
521 extra,
522 reset_credits: reset_credits(self.cedar_ember),
523 cloud_credits,
524 usage_credits,
525 }
526 }
527}
528
529fn cloud_credits_from(block: CloudCreditsBlock) -> Option<CloudCredits> {
538 let limit = dollars_to_cents(block.limit_dollars.as_ref()?)?;
541 let remaining = dollars_to_cents(block.remaining_dollars.as_ref()?)?;
542 if limit <= 0 || remaining < 0 {
546 return None;
547 }
548 Some(CloudCredits {
549 limit: Cents(limit),
550 remaining: Cents(remaining),
551 expires_at: block
552 .resets_at
553 .as_deref()
554 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
555 .map(|dt| dt.with_timezone(&chrono::Utc)),
556 locked: block.locked_reason.is_some_and(|r| !r.is_empty()),
559 })
560}
561
562fn json_int(v: &serde_json::Value) -> Option<i64> {
567 let n = v.as_number()?;
568 if let Some(i) = n.as_i64() {
572 return Some(i);
573 }
574 if let Some(u) = n.as_u64() {
575 return i64::try_from(u).ok();
576 }
577 n.as_f64()
582 .filter(|f| f.is_finite() && f.fract() == 0.0 && f.abs() <= 2_f64.powi(53))
583 .map(|f| f as i64)
584}
585
586fn usage_credits_from(spend: SpendBlock) -> Option<UsageCredits> {
594 let balance_value = spend.balance?;
595 let balance = balance_value.as_object()?;
596 let currency = balance
597 .get("currency")
598 .and_then(|v| v.as_str())
599 .map(str::to_string);
600
601 let shaped = (|| {
609 let minor = balance.get("amount_minor").and_then(json_int)?;
610 let exponent = balance
611 .get("exponent")
612 .and_then(json_int)
613 .filter(|e| (0..=4).contains(e))?;
614 let currency = currency.as_ref()?;
615 Some((minor, exponent as u32, currency.clone()))
616 })()
617 .or_else(|| {
618 let amount = balance.get("amount").and_then(json_int)?;
619 let currency = currency.as_ref().filter(|c| *c == "USD")?;
620 Some((amount, 2, currency.clone()))
621 });
622 let (amount, decimal_places, currency) = shaped?;
623
624 let auto_reload = spend
630 .auto_reload
631 .as_ref()
632 .and_then(|v| v.get("enabled"))
633 .and_then(|v| v.as_bool())
634 .or_else(|| {
635 balance
636 .get("auto_reload_settings")
637 .and_then(|v| v.get("enabled"))
638 .and_then(|v| v.as_bool())
639 });
640
641 Some(UsageCredits {
642 balance: Cents(amount),
643 decimal_places,
644 currency: Some(currency),
645 auto_reload,
646 })
647}
648
649fn reset_credits(block: Option<ResetGrantsBlock>) -> ResetCredits {
662 let Some(block) = block.filter(|block| block.eligible) else {
663 return ResetCredits::default();
664 };
665 let usable = block
666 .grants
667 .into_iter()
668 .filter(|grant| grant.usable_now && !grant.paused && grant.resets_left > 0);
669 let mut credits = ResetCredits::default();
670 for grant in usable {
671 credits.available = credits.available.saturating_add(grant.resets_left);
672 credits.credits.push(ResetCredit {
673 title: checked_reset_title(grant.label),
674 expires_at: grant
675 .ends_at
676 .as_deref()
677 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
678 .map(|dt| dt.with_timezone(&chrono::Utc)),
679 });
680 }
681 credits
682}
683
684#[cfg(test)]
685mod tests {
686 use super::*;
687
688 #[test]
689 fn parses_full_response() {
690 let raw = r#"{
691 "five_hour": {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
692 "seven_day": {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
693 "seven_day_sonnet": {"utilization": 4.2, "resets_at": "2026-05-30T12:00:00Z"},
694 "extra_usage": {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
695 }"#;
696 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
697 let snap = resp.into_snapshot("Max 5x".into());
698 assert_eq!(snap.session.utilization_pct, 43); assert_eq!(snap.weekly.utilization_pct, 27);
700 assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
701 let extra = snap.extra.as_ref().unwrap();
702 assert_eq!(extra.limit, Some(Cents(5000)));
703 assert_eq!(extra.spent.0, 250);
704 assert!(snap.session.resets_at.is_some());
705 }
706
707 #[test]
708 fn parses_weekly_scoped_limits() {
709 let raw = r#"{
712 "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
713 "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
714 "limits": [
715 {"kind": "session", "group": "session", "percent": 10,
716 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
717 "scope": null, "is_active": false},
718 {"kind": "weekly_all", "group": "weekly", "percent": 55,
719 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
720 "scope": null, "is_active": false},
721 {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
722 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
723 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
724 "is_active": true}
725 ]
726 }"#;
727 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
728 let snap = resp.into_snapshot("Pro".into());
729 assert_eq!(snap.scoped.len(), 1);
730 assert_eq!(snap.scoped[0].label, "Fable");
731 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
732 assert!(snap.scoped[0].window.resets_at.is_some());
733 assert_eq!(snap.weekly.utilization_pct, 55);
735 }
736
737 #[test]
738 fn missing_limits_array_yields_empty_scoped() {
739 let raw = r#"{
740 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
741 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
742 }"#;
743 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
744 let snap = resp.into_snapshot("Pro".into());
745 assert!(snap.scoped.is_empty());
746 }
747
748 #[test]
749 fn missing_sonnet_and_extra_are_none() {
750 let raw = r#"{
751 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
752 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
753 }"#;
754 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
755 let snap = resp.into_snapshot("Pro".into());
756 assert!(snap.sonnet.is_none());
757 assert!(snap.extra.is_none());
758 }
759
760 #[test]
761 fn user_disabled_extra_usage_becomes_none() {
762 let raw = r#"{
763 "five_hour": {"utilization": 0},
764 "seven_day": {"utilization": 0},
765 "extra_usage": {"is_enabled": false, "user_disabled": true,
766 "monthly_limit": 5000, "used_credits": 0}
767 }"#;
768 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
769 let snap = resp.into_snapshot("Pro".into());
770 assert!(snap.extra.is_none());
771 }
772
773 #[test]
774 fn provider_disabled_extra_usage_keeps_spend_and_reason_visible() {
775 let raw = r#"{
776 "extra_usage": {
777 "is_enabled": false,
778 "monthly_limit": null,
779 "used_credits": 1234.0,
780 "currency": "USD",
781 "decimal_places": 2,
782 "disabled_reason": "out_of_credits",
783 "user_disabled": false,
784 "spend_limit_reached": false,
785 "credits_ever_enabled": true
786 }
787 }"#;
788 let extra = serde_json::from_str::<UsageResponse>(raw)
789 .unwrap()
790 .into_snapshot("Pro".into())
791 .extra
792 .unwrap();
793 assert_eq!(extra.fmt_spent(), "$12.34");
794 assert_eq!(extra.status, ExtraUsageStatus::OutOfCredits);
795 assert_eq!(extra.status_note(), Some("out of credits"));
796 }
797
798 #[test]
799 fn enabled_extra_usage_without_spend_is_dropped() {
800 for raw in [
804 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
805 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000,"used_credits":null}}"#,
806 ] {
807 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
808 assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
809 }
810 }
811
812 #[test]
813 fn uncapped_plan_keeps_real_spend_visible() {
814 let resp: UsageResponse = serde_json::from_str(
819 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,
820 "used_credits":14157.0,"currency":"BRL","decimal_places":2,
821 "disabled_reason":null}}"#,
822 )
823 .unwrap();
824 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
825 assert_eq!(extra.limit, None);
826 assert_eq!(extra.spent.0, 14157);
827 assert_eq!(extra.percent(), 0);
829 assert_eq!(extra.currency.as_deref(), Some("BRL"));
832 assert_eq!(extra.decimal_places, Some(2));
833 assert_eq!(extra.fmt_spent(), "R$141.57");
834
835 let resp: UsageResponse =
841 serde_json::from_str(r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#)
842 .unwrap();
843 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
844 assert_eq!(extra.limit, None);
845 assert_eq!(extra.spent.0, 250);
846 }
847
848 #[test]
849 fn implausible_decimal_places_is_schema_drift() {
850 for value in ["7", "-1", "100", "2.5"] {
853 let raw = format!(
854 r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"decimal_places":{value}}}}}"#
855 );
856 assert!(
857 serde_json::from_str::<UsageResponse>(&raw).is_err(),
858 "{raw}"
859 );
860 }
861 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":2.0}}"#;
867 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
868 assert_eq!(
869 resp.into_snapshot("Pro".into())
870 .extra
871 .unwrap()
872 .decimal_places,
873 Some(2)
874 );
875 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":null}}"#;
878 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
879 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
880 assert_eq!(extra.decimal_places, None);
881 assert_eq!(extra.fmt_spent(), "$2.50");
882
883 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW"}}"#;
887 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
888 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
889 assert_eq!(extra.decimal_places, None);
890 assert_eq!(extra.fmt_spent(), "500 minor units KRW");
891
892 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD"}}"#;
893 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
894 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
895 assert_eq!(extra.decimal_places, None);
896 assert_eq!(extra.fmt_spent(), "1234 minor units KWD");
897
898 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW","decimal_places":0}}"#;
900 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
901 assert_eq!(
902 resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
903 "500 KRW"
904 );
905 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD","decimal_places":3}}"#;
906 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
907 assert_eq!(
908 resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
909 "1.234 KWD"
910 );
911 }
912
913 #[test]
914 fn currency_must_be_an_iso_alpha_code() {
915 for value in [r#""brl""#, r#""""#, r#""R$""#, r#""USD;;0""#, r#""<b>""#] {
919 let raw = format!(
920 r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"currency":{value}}}}}"#
921 );
922 assert!(
923 serde_json::from_str::<UsageResponse>(&raw).is_err(),
924 "{raw}"
925 );
926 }
927 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"currency":null}}"#;
929 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
930 assert_eq!(
931 resp.into_snapshot("Pro".into()).extra.unwrap().currency,
932 None
933 );
934 }
935
936 #[test]
937 fn malformed_cent_values_are_schema_drift() {
938 for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
939 let raw = format!(
940 r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
941 );
942 assert!(
943 serde_json::from_str::<UsageResponse>(&raw).is_err(),
944 "{raw}"
945 );
946 }
947
948 let resp: UsageResponse = serde_json::from_str(
949 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
950 )
951 .unwrap();
952 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
953 assert_eq!(extra.limit, Some(Cents(5000)));
954 assert_eq!(extra.spent.0, 250);
955 }
956
957 #[test]
958 fn empty_object_yields_neutral_snapshot() {
959 let resp: UsageResponse = serde_json::from_str("{}").unwrap();
960 let snap = resp.into_snapshot("Unknown".into());
961 assert_eq!(snap.session.utilization_pct, 0);
962 assert_eq!(snap.weekly.utilization_pct, 0);
963 assert!(snap.session.resets_at.is_none());
964 }
965
966 #[test]
967 fn benign_overshoot_saturates_to_hundred() {
968 let raw = r#"{
971 "five_hour": {"utilization": 100.4},
972 "seven_day": {"utilization": 100.6}
973 }"#;
974 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
975 let snap = resp.into_snapshot("Pro".into());
976 assert_eq!(snap.session.utilization_pct, 100);
977 assert_eq!(snap.weekly.utilization_pct, 100); }
979
980 #[test]
981 fn out_of_range_utilization_is_rejected_not_clamped() {
982 for raw in [
983 r#"{"five_hour": {"utilization": 500}}"#,
984 r#"{"five_hour": {"utilization": -1}}"#,
985 r#"{"seven_day": {"utilization": 101.5}}"#,
986 ] {
987 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
988 assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
989 }
990 }
991
992 #[test]
993 fn out_of_range_scoped_percent_is_rejected() {
994 let raw = r#"{
996 "limits": [{"kind": "weekly_scoped", "percent": 420,
997 "scope": {"model": {"display_name": "Fable"}}}]
998 }"#;
999 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
1000 assert!(err.to_string().contains("outside 0..=100"), "{err}");
1001 }
1002
1003 #[test]
1004 fn non_finite_percentage_is_rejected() {
1005 for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1008 assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
1009 }
1010 }
1011
1012 #[test]
1013 fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
1014 let raw = r#"{
1018 "limits": [{"kind": "weekly_scoped", "percent": null,
1019 "scope": {"model": {"display_name": "Fable"}}}]
1020 }"#;
1021 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1022 let snap = resp.into_snapshot("Pro".into());
1023 assert!(
1024 snap.scoped.is_empty(),
1025 "a scoped limit with no percentage must not render a fabricated bar"
1026 );
1027
1028 let ok = r#"{
1030 "limits": [{"kind": "weekly_scoped", "percent": 84,
1031 "scope": {"model": {"display_name": "Fable"}}}]
1032 }"#;
1033 let resp: UsageResponse = serde_json::from_str(ok).unwrap();
1034 let snap = resp.into_snapshot("Pro".into());
1035 assert_eq!(snap.scoped[0].label, "Fable");
1036 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
1037 }
1038
1039 const LAUNCH_GRANT: &str = r#"{
1042 "five_hour": {"utilization": 2, "resets_at": "2026-09-24T17:49:59Z"},
1043 "seven_day": {"utilization": 63, "resets_at": "2026-09-25T08:59:59Z"},
1044 "cedar_ember": {
1045 "eligible": true,
1046 "ineligible_reason": null,
1047 "at_limit": false,
1048 "exhausted": [],
1049 "grants": [{
1050 "id": "a-redemption-handle",
1051 "label": "Claude Opus 5.5 launch: one usage-limit reset for Team members",
1052 "resets_total": 1,
1053 "resets_left": 1,
1054 "starts_at": "2026-09-22T16:00:00+00:00",
1055 "ends_at": "2026-10-22T16:00:00+00:00",
1056 "clears": ["five_hour", "seven_day", "seven_day_overage_included"],
1057 "paused": false,
1058 "usable_now": true,
1059 "use_requires_limit": false,
1060 "percent_used": {"five_hour": 2, "seven_day": 63},
1061 "blocking": [],
1062 "arm": null
1063 }],
1064 "next_grant_id": "a-redemption-handle",
1065 "weekly_resets_at": "2026-09-25T09:00:00+00:00",
1066 "cooldown_until": null,
1067 "event_props": null
1068 }
1069 }"#;
1070
1071 #[test]
1072 fn parses_banked_resets_from_the_cedar_ember_block() {
1073 let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
1074 let snap = resp.into_snapshot("Max 20x".into());
1075
1076 assert_eq!(snap.reset_credits.available, 1);
1077 assert_eq!(
1078 snap.reset_credits.credits[0].title.as_deref(),
1079 Some("Claude Opus 5.5 launch: one usage-limit reset for Team members")
1080 );
1081 assert_eq!(
1082 snap.reset_credits.next_expiry(),
1083 Some("2026-10-22T16:00:00Z".parse().unwrap())
1084 );
1085 assert_eq!(snap.session.utilization_pct, 2);
1087 assert_eq!(snap.weekly.utilization_pct, 63);
1088 }
1089
1090 #[test]
1093 fn the_redemption_grant_id_never_leaves_the_parser() {
1094 let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
1095 let snap = resp.into_snapshot("Max 20x".into());
1096 assert!(!format!("{snap:?}").contains("a-redemption-handle"));
1097 }
1098
1099 #[test]
1102 fn an_ineligible_block_reports_no_resets() {
1103 let raw = r#"{
1104 "cedar_ember": {
1105 "eligible": false,
1106 "ineligible_reason": "cli_version",
1107 "grants": [{"id": "g", "resets_left": 1, "usable_now": true,
1108 "ends_at": "2026-10-22T16:00:00Z"}]
1109 }
1110 }"#;
1111 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1112 assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
1113 }
1114
1115 #[test]
1119 fn only_redeemable_grants_are_counted() {
1120 let raw = r#"{
1121 "cedar_ember": {
1122 "eligible": true,
1123 "grants": [
1124 {"id": "a", "label": "not open yet", "resets_left": 1,
1125 "usable_now": false, "paused": false},
1126 {"id": "b", "label": "paused", "resets_left": 1,
1127 "usable_now": true, "paused": true},
1128 {"id": "c", "label": "spent", "resets_left": 0,
1129 "usable_now": true, "paused": false},
1130 {"id": "d", "label": "yours", "resets_left": 2,
1131 "usable_now": true, "paused": false,
1132 "ends_at": "2026-10-22T16:00:00Z"}
1133 ]
1134 }
1135 }"#;
1136 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1137 let credits = resp.into_snapshot("Pro".into()).reset_credits;
1138 assert_eq!(credits.available, 2, "only the redeemable grant counts");
1139 assert_eq!(credits.credits.len(), 1);
1140 assert_eq!(credits.credits[0].title.as_deref(), Some("yours"));
1141 }
1142
1143 #[test]
1146 fn an_absent_or_null_block_is_not_a_parse_failure() {
1147 for raw in [
1148 r#"{"five_hour": {"utilization": 5}}"#,
1149 r#"{"cedar_ember": null}"#,
1150 ] {
1151 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1152 assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
1153 }
1154 }
1155
1156 #[test]
1160 fn a_control_bearing_label_is_dropped_but_the_expiry_survives() {
1161 let raw = r#"{
1162 "cedar_ember": {
1163 "eligible": true,
1164 "grants": [{"id": "a", "label": "line\u0000break", "resets_left": 1,
1165 "usable_now": true, "ends_at": "2026-10-22T16:00:00Z"}]
1166 }
1167 }"#;
1168 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1169 let credits = resp.into_snapshot("Pro".into()).reset_credits;
1170 assert_eq!(credits.available, 1);
1171 assert!(credits.credits[0].title.is_none());
1172 assert!(credits.credits[0].expires_at.is_some());
1173 }
1174
1175 #[test]
1176 fn unparseable_reset_becomes_none() {
1177 let raw = r#"{
1178 "five_hour": {"utilization": 50, "resets_at": "not a date"},
1179 "seven_day": {"utilization": 0}
1180 }"#;
1181 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1182 let snap = resp.into_snapshot("Pro".into());
1183 assert!(snap.session.resets_at.is_none());
1184 assert_eq!(snap.session.utilization_pct, 50);
1185 }
1186
1187 const CREDITS_PAYLOAD: &str = r#"{
1192 "five_hour": {"utilization": 12, "resets_at": "2026-10-26T15:00:00Z"},
1193 "seven_day": {"utilization": 34, "resets_at": "2026-11-02T07:00:00Z"},
1194 "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},
1195 "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}
1196 }"#;
1197
1198 #[test]
1199 fn the_real_credits_payload_lifts_cloud_credits_and_no_usage_balance() {
1200 let snap = serde_json::from_str::<UsageResponse>(CREDITS_PAYLOAD)
1201 .unwrap()
1202 .into_snapshot("Max 5x".into());
1203 let cloud = snap
1204 .cloud_credits
1205 .as_ref()
1206 .expect("iguana_necktie must lift into cloud_credits");
1207 assert_eq!(cloud.limit, Cents(25000));
1208 assert_eq!(cloud.remaining, Cents(25000));
1209 assert_eq!(
1210 cloud.expires_at,
1211 Some("2026-11-05T07:59:00Z".parse().unwrap())
1212 );
1213 assert!(!cloud.locked);
1214 assert!(snap.usage_credits.is_none());
1217 assert_eq!(snap.session.utilization_pct, 12);
1219 assert_eq!(snap.weekly.utilization_pct, 34);
1220 }
1221
1222 #[test]
1223 fn cloud_credits_carry_the_remaining_after_spend() {
1224 let raw = r#"{
1227 "iguana_necktie": {"resets_at": "2026-11-05T07:59:00+00:00",
1228 "limit_dollars": 250, "used_dollars": 100,
1229 "remaining_dollars": 150, "locked_reason": null}
1230 }"#;
1231 let snap = serde_json::from_str::<UsageResponse>(raw)
1232 .unwrap()
1233 .into_snapshot("Max 5x".into());
1234 let cloud = snap.cloud_credits.as_ref().unwrap();
1235 assert_eq!(cloud.limit, Cents(25000));
1236 assert_eq!(cloud.remaining, Cents(15000));
1237 }
1238
1239 #[test]
1240 fn iguana_necktie_drift_degrades_to_none_not_an_error() {
1241 for raw in [
1247 r#"{"five_hour": {"utilization": 5}}"#,
1248 r#"{"five_hour": {"utilization": 5}, "iguana_necktie": null}"#,
1249 r#"{"iguana_necktie": {"limit_dollars": 0, "remaining_dollars": 250}}"#,
1250 r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": -5}}"#,
1251 r#"{"iguana_necktie": {"limit_dollars": "a lot", "remaining_dollars": 250}}"#,
1252 r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": null}}"#,
1253 r#"{"iguana_necktie": [1, 2]}"#,
1254 r#"{"iguana_necktie": "hostile"}"#,
1255 ] {
1256 let resp: UsageResponse =
1257 serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1258 assert!(
1259 resp.into_snapshot("Pro".into()).cloud_credits.is_none(),
1260 "{raw}"
1261 );
1262 }
1263 let resp: UsageResponse = serde_json::from_str(
1265 r#"{"five_hour": {"utilization": 5}, "iguana_necktie": "hostile"}"#,
1266 )
1267 .unwrap();
1268 assert_eq!(resp.into_snapshot("Pro".into()).session.utilization_pct, 5);
1269 }
1270
1271 #[test]
1272 fn a_non_empty_locked_reason_locks_the_credits_without_leaving_the_parser() {
1273 let raw = r#"{
1277 "iguana_necktie": {"resets_at": "2026-11-05T07:59:00Z",
1278 "limit_dollars": 250, "remaining_dollars": 250,
1279 "locked_reason": "<b>org_migrating</b>"}
1280 }"#;
1281 let snap = serde_json::from_str::<UsageResponse>(raw)
1282 .unwrap()
1283 .into_snapshot("Max 5x".into());
1284 let cloud = snap.cloud_credits.as_ref().unwrap();
1285 assert!(cloud.locked);
1286 assert!(!format!("{cloud:?}").contains("<b>"));
1287
1288 for (reason, locked) in [("\"\"", false), ("42", false), ("[1]", false)] {
1291 let raw = format!(
1292 r#"{{"iguana_necktie": {{"limit_dollars": 250, "remaining_dollars": 250, "locked_reason": {reason}}}}}"#
1293 );
1294 let snap = serde_json::from_str::<UsageResponse>(&raw)
1295 .unwrap()
1296 .into_snapshot("Max 5x".into());
1297 assert_eq!(snap.cloud_credits.as_ref().unwrap().locked, locked, "{raw}");
1298 }
1299
1300 let raw = r#"{"iguana_necktie": {"limit_dollars": 250, "remaining_dollars": 250,
1302 "resets_at": "not a date"}}"#;
1303 let snap = serde_json::from_str::<UsageResponse>(raw)
1304 .unwrap()
1305 .into_snapshot("Max 5x".into());
1306 let cloud = snap.cloud_credits.as_ref().unwrap();
1307 assert!(cloud.expires_at.is_none());
1308 assert_eq!(cloud.limit, Cents(25000));
1309 }
1310
1311 #[test]
1312 fn spend_balance_shape_a_lifts_minor_units_with_its_own_scale() {
1313 let raw = r#"{
1314 "spend": {"balance": {"amount_minor": 1500, "currency": "USD", "exponent": 2},
1315 "auto_reload": {"enabled": true}}
1316 }"#;
1317 let usage = serde_json::from_str::<UsageResponse>(raw)
1318 .unwrap()
1319 .into_snapshot("Pro".into())
1320 .usage_credits
1321 .expect("shape (a) must lift into usage_credits");
1322 assert_eq!(usage.balance, Cents(1500));
1323 assert_eq!(usage.decimal_places, 2);
1324 assert_eq!(usage.currency.as_deref(), Some("USD"));
1325 assert_eq!(usage.auto_reload, Some(true));
1327
1328 let raw = r#"{
1331 "spend": {"balance": {"amount_minor": 500.0, "currency": "KRW", "exponent": 0.0},
1332 "auto_reload": null}
1333 }"#;
1334 let usage = serde_json::from_str::<UsageResponse>(raw)
1335 .unwrap()
1336 .into_snapshot("Pro".into())
1337 .usage_credits
1338 .unwrap();
1339 assert_eq!(usage.balance, Cents(500));
1340 assert_eq!(usage.decimal_places, 0);
1341 assert_eq!(usage.currency.as_deref(), Some("KRW"));
1342 assert_eq!(usage.auto_reload, None);
1343 }
1344
1345 #[test]
1346 fn spend_balance_shape_b_lifts_usd_minor_units_and_nested_auto_reload() {
1347 let raw = r#"{
1352 "spend": {"balance": {"amount": 750, "currency": "USD",
1353 "auto_reload_settings": {"enabled": false}},
1354 "auto_reload": null}
1355 }"#;
1356 let usage = serde_json::from_str::<UsageResponse>(raw)
1357 .unwrap()
1358 .into_snapshot("Pro".into())
1359 .usage_credits
1360 .expect("shape (b) must lift into usage_credits");
1361 assert_eq!(usage.balance, Cents(750));
1362 assert_eq!(usage.decimal_places, 2);
1363 assert_eq!(usage.currency.as_deref(), Some("USD"));
1364 assert_eq!(usage.auto_reload, Some(false));
1365
1366 let raw = r#"{
1368 "spend": {"balance": {"amount": 750, "currency": "USD",
1369 "auto_reload_settings": {"enabled": false}},
1370 "auto_reload": {"enabled": true}}
1371 }"#;
1372 let usage = serde_json::from_str::<UsageResponse>(raw)
1373 .unwrap()
1374 .into_snapshot("Pro".into())
1375 .usage_credits
1376 .unwrap();
1377 assert_eq!(usage.auto_reload, Some(true));
1378
1379 let raw = r#"{"spend": {"balance": {"amount_minor": 5, "amount": 100,
1383 "currency": "USD"}}}"#;
1384 let usage = serde_json::from_str::<UsageResponse>(raw)
1385 .unwrap()
1386 .into_snapshot("Pro".into())
1387 .usage_credits
1388 .unwrap();
1389 assert_eq!(usage.balance, Cents(100));
1390 assert_eq!(usage.decimal_places, 2);
1391 }
1392
1393 #[test]
1394 fn spend_balances_we_cannot_express_are_absent_not_zero() {
1395 for raw in [
1398 r#"{"spend": {"balance": null}}"#,
1400 r#"{"spend": {"balance": {"amount": 500, "currency": "BRL"}}}"#,
1402 r#"{"spend": {"balance": {"amount_minor": 5, "currency": "USD", "exponent": 5}}}"#,
1404 r#"{"spend": {"balance": 42}}"#,
1406 r#"{"spend": {"balance": {"dollars": 4}}}"#,
1408 r#"{"spend": {"balance": {"amount_minor": 5, "exponent": 2}}}"#,
1410 r#"{"spend": {"balance": {"amount": 750, "currency": "USD",
1412 "auto_reload_settings": {"enabled": "on"}},
1413 "auto_reload": 7}}"#,
1414 ] {
1415 let resp: UsageResponse =
1416 serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1417 let snap = resp.into_snapshot("Pro".into());
1418 if raw.contains(r#""auto_reload": 7"#) {
1419 let usage = snap.usage_credits.as_ref().unwrap();
1421 assert_eq!(usage.auto_reload, None, "{raw}");
1422 } else {
1423 assert!(snap.usage_credits.is_none(), "{raw}");
1424 }
1425 }
1426 }
1427
1428 #[test]
1429 fn spend_drift_degrades_to_none_not_an_error() {
1430 for raw in [
1433 r#"{"five_hour": {"utilization": 5}}"#,
1434 r#"{"spend": null}"#,
1435 r#"{"spend": [1]}"#,
1436 r#"{"spend": "hostile"}"#,
1437 r#"{"spend": {"balance": "not a balance", "auto_reload": {"enabled": "on"}}}"#,
1438 ] {
1439 let resp: UsageResponse =
1440 serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1441 let snap = resp.into_snapshot("Pro".into());
1442 assert!(snap.usage_credits.is_none(), "{raw}");
1443 }
1444 let resp: UsageResponse =
1445 serde_json::from_str(r#"{"five_hour": {"utilization": 5}, "spend": "hostile"}"#)
1446 .unwrap();
1447 assert_eq!(resp.into_snapshot("Pro".into()).session.utilization_pct, 5);
1448 }
1449
1450 #[test]
1456 fn a_number_the_parser_cannot_materialize_loses_only_its_block() {
1457 let raw = r#"{"five_hour":{"utilization":5},"iguana_necktie":{"limit_dollars":1e400,"remaining_dollars":1},"spend":{"balance":{"amount_minor":1e400,"currency":"USD","exponent":2}}}"#;
1461 let resp: UsageResponse =
1462 serde_json::from_str(raw).unwrap_or_else(|e| panic!("{raw}: {e}"));
1463 let snap = resp.into_snapshot("Pro".into());
1464 assert_eq!(snap.session.utilization_pct, 5);
1465 assert!(snap.cloud_credits.is_none(), "1e400 dollars is not a limit");
1466 assert!(
1467 snap.usage_credits.is_none(),
1468 "1e400 minor units is not a balance"
1469 );
1470 }
1471
1472 #[test]
1473 fn a_huge_finite_float_inside_a_credit_block_degrades_the_block_only() {
1474 let raw = r#"{"five_hour":{"utilization":5},"iguana_necktie":{"limit_dollars":1e300,"remaining_dollars":1}}"#;
1477 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1478 let snap = resp.into_snapshot("Pro".into());
1479 assert_eq!(snap.session.utilization_pct, 5);
1480 assert!(snap.cloud_credits.is_none());
1481 }
1482
1483 #[test]
1488 fn dollars_to_cents_rounds_monetary_ties_up_in_exact_arithmetic() {
1489 fn cents(text: &str) -> i64 {
1492 let n: serde_json::Number = serde_json::from_str(text).unwrap();
1493 dollars_to_cents(&n).unwrap()
1494 }
1495 assert_eq!(cents("1.005"), 101);
1496 assert_eq!(cents("0.004"), 0);
1497 assert_eq!(cents("149.995"), 15000);
1498 assert_eq!(cents("250"), 25000);
1499 assert_eq!(cents("0.005"), 1);
1500 assert_eq!(cents("19.99"), 1999);
1501 }
1502
1503 #[test]
1504 fn dollars_to_cents_reads_negative_zero_as_an_exhausted_zero() {
1505 for text in ["-0", "-0.0"] {
1506 let n: serde_json::Number = serde_json::from_str(text).unwrap();
1507 assert_eq!(dollars_to_cents(&n), Some(0), "{text}");
1508 }
1509 let n: serde_json::Number = serde_json::from_str("-0.01").unwrap();
1510 assert_eq!(dollars_to_cents(&n), None);
1511 }
1512
1513 #[test]
1514 fn dollars_to_cents_rejects_what_cents_cannot_hold() {
1515 fn opt(text: &str) -> Option<i64> {
1516 serde_json::from_str::<serde_json::Number>(text)
1517 .ok()
1518 .and_then(|n| dollars_to_cents(&n))
1519 }
1520 assert_eq!(opt("-5"), None);
1522 assert_eq!(opt("18446744073709551615"), None);
1524 assert_eq!(opt("1e13"), Some(1_000_000_000_000_000));
1527 assert_eq!(opt("1e14"), None);
1528 }
1529
1530 #[test]
1531 fn a_positive_dollar_limit_that_rounds_to_zero_cents_does_not_stand() {
1532 let raw = r#"{"iguana_necktie":{"limit_dollars":0.004,"remaining_dollars":0.001,"resets_at":"2026-11-05T07:59:00Z"}}"#;
1536 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1537 assert!(
1538 resp.into_snapshot("Pro".into()).cloud_credits.is_none(),
1539 "a limit the conversion rounds to zero is absent, not a 0% meter"
1540 );
1541 }
1542
1543 #[test]
1544 fn json_int_rejects_integers_past_its_own_range() {
1545 assert_eq!(json_int(&serde_json::json!(18446744073709551615u64)), None);
1549 assert_eq!(json_int(&serde_json::json!(1500.0)), Some(1500));
1551 }
1552
1553 #[test]
1558 fn a_non_boolean_primary_auto_reload_falls_to_the_balance_setting() {
1559 let raw = r#"{"spend":{"balance":{"amount":750,"currency":"USD","auto_reload_settings":{"enabled":true}},"auto_reload":{"enabled":"unknown"}}}"#;
1562 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
1563 let snap = resp.into_snapshot("Pro".into());
1564 let credits = snap
1565 .usage_credits
1566 .expect("the balance is still shape (b) with a USD amount");
1567 assert_eq!(credits.balance, Cents(750));
1568 assert_eq!(credits.auto_reload, Some(true));
1569 }
1570}