1use serde::{Deserialize, Serialize};
9
10use crate::usage::{
11 AnthropicSnapshot, Cents, ExtraUsage, ExtraUsageStatus, ResetCredit, ResetCredits,
12 ScopedWindow, UsageWindow, checked_reset_title,
13};
14
15#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
17pub struct UsageResponse {
18 #[serde(default)]
19 pub five_hour: Option<Window>,
20 #[serde(default)]
21 pub seven_day: Option<Window>,
22 #[serde(default)]
23 pub seven_day_sonnet: Option<Window>,
24 #[serde(default)]
25 pub extra_usage: Option<ExtraUsageBlock>,
26 #[serde(default)]
30 pub limits: Vec<LimitEntry>,
31 #[serde(default)]
36 pub cedar_ember: Option<ResetGrantsBlock>,
37}
38
39#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
45pub struct ResetGrantsBlock {
46 #[serde(default)]
47 pub eligible: bool,
48 #[serde(default)]
49 pub grants: Vec<ResetGrant>,
50}
51
52#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
56pub struct ResetGrant {
57 #[serde(default)]
59 pub label: Option<String>,
60 #[serde(default)]
63 pub resets_left: u32,
64 #[serde(default)]
66 pub ends_at: Option<String>,
67 #[serde(default)]
72 pub usable_now: bool,
73 #[serde(default)]
74 pub paused: bool,
75}
76
77#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
81pub struct LimitEntry {
82 #[serde(default)]
83 pub kind: Option<String>,
84 #[serde(default, deserialize_with = "de_percent_opt")]
85 pub percent: Option<f64>,
86 #[serde(default)]
87 pub resets_at: Option<String>,
88 #[serde(default)]
89 pub scope: Option<LimitScope>,
90}
91
92#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
93pub struct LimitScope {
94 #[serde(default)]
95 pub model: Option<LimitModel>,
96}
97
98#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
99pub struct LimitModel {
100 #[serde(default)]
101 pub display_name: Option<String>,
102}
103
104#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
106pub struct Window {
107 #[serde(default, deserialize_with = "de_percent")]
108 pub utilization: f64,
109 #[serde(default)]
110 pub resets_at: Option<String>,
111}
112
113#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq)]
118pub struct ExtraUsageBlock {
119 #[serde(default)]
120 pub is_enabled: bool,
121 #[serde(default, deserialize_with = "de_opt_cents")]
122 pub monthly_limit: Option<i64>,
123 #[serde(default, deserialize_with = "de_opt_cents")]
124 pub used_credits: Option<i64>,
125 #[serde(default, deserialize_with = "de_opt_currency")]
128 pub currency: Option<String>,
129 #[serde(default, deserialize_with = "de_opt_decimal_places")]
132 pub decimal_places: Option<u32>,
133 #[serde(default)]
134 pub user_disabled: bool,
135 #[serde(default)]
136 pub spend_limit_reached: bool,
137 #[serde(default)]
138 pub credits_ever_enabled: bool,
139 #[serde(default)]
140 pub disabled_reason: Option<String>,
141}
142
143fn de_opt_decimal_places<'de, D>(d: D) -> std::result::Result<Option<u32>, D::Error>
152where
153 D: serde::Deserializer<'de>,
154{
155 let n = match serde_json::Value::deserialize(d)? {
156 serde_json::Value::Null => return Ok(None),
157 serde_json::Value::Number(n) => n
158 .as_i64()
159 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
160 _ => None,
161 };
162 match n {
163 Some(n) if (0..=6).contains(&n) => Ok(Some(n as u32)),
164 _ => Err(serde::de::Error::custom(
165 "decimal_places must be an integer in 0..=6",
166 )),
167 }
168}
169
170fn de_opt_currency<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
175where
176 D: serde::Deserializer<'de>,
177{
178 match Option::<String>::deserialize(d)? {
179 None => Ok(None),
180 Some(s) if s.len() == 3 && s.bytes().all(|b| b.is_ascii_uppercase()) => Ok(Some(s)),
181 Some(s) => Err(serde::de::Error::custom(format!(
182 "currency {s:?} is not an ISO 4217 alpha code"
183 ))),
184 }
185}
186
187fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
188where
189 D: serde::Deserializer<'de>,
190{
191 let v = serde_json::Value::deserialize(d)?;
192 match v {
193 serde_json::Value::Null => Ok(None),
194 serde_json::Value::Number(n) => {
195 if let Some(i) = n.as_i64() {
196 if i >= 0 {
197 Ok(Some(i))
198 } else {
199 Err(serde::de::Error::custom("cents cannot be negative"))
200 }
201 } else if let Some(f) = n.as_f64() {
202 const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
203 if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
204 Ok(Some(f as i64))
205 } else {
206 Err(serde::de::Error::custom(
207 "cents must be a non-negative integer in range",
208 ))
209 }
210 } else {
211 Err(serde::de::Error::custom("number out of i64 range"))
212 }
213 }
214 other => Err(serde::de::Error::custom(format!(
215 "expected number or null, got {other:?}"
216 ))),
217 }
218}
219
220const PCT_SLACK: f64 = 1.0;
224
225fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
236 if !v.is_finite() {
237 return Err(E::custom(format!("percentage {v} is not finite")));
238 }
239 if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
240 return Err(E::custom(format!("percentage {v} outside 0..=100")));
241 }
242 Ok(v)
243}
244
245fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
246where
247 D: serde::Deserializer<'de>,
248{
249 checked_percent(f64::deserialize(d)?)
250}
251
252fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
253where
254 D: serde::Deserializer<'de>,
255{
256 Option::<f64>::deserialize(d)?
257 .map(checked_percent)
258 .transpose()
259}
260
261impl UsageResponse {
262 pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
267 const SESSION: chrono::Duration = chrono::Duration::hours(5);
269 const WEEKLY: chrono::Duration = chrono::Duration::days(7);
270
271 fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
272 let Some(w) = w else {
273 return UsageWindow {
274 utilization_pct: 0,
275 resets_at: None,
276 window_duration: dur,
277 };
278 };
279 UsageWindow {
280 utilization_pct: i32::from(crate::format::clamp_pct(w.utilization)),
284 resets_at: w
285 .resets_at
286 .as_deref()
287 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
288 .map(|dt| dt.with_timezone(&chrono::Utc)),
289 window_duration: dur,
290 }
291 }
292
293 let session = to_window(self.five_hour, SESSION);
294 let weekly = to_window(self.seven_day, WEEKLY);
295 let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
296 let extra = self.extra_usage.and_then(|e| {
297 let status = if e.is_enabled {
298 ExtraUsageStatus::Active
299 } else if e.user_disabled {
300 return None;
301 } else {
302 match e.disabled_reason.as_deref() {
303 Some("out_of_credits") => ExtraUsageStatus::OutOfCredits,
304 Some("monthly_limit_reached" | "spend_limit_reached") => {
305 ExtraUsageStatus::MonthlyLimitReached
306 }
307 _ if e.spend_limit_reached => ExtraUsageStatus::MonthlyLimitReached,
308 _ if e.credits_ever_enabled && e.used_credits.unwrap_or_default() > 0 => {
309 ExtraUsageStatus::Unavailable
310 }
311 _ => return None,
312 }
313 };
314 Some(ExtraUsage {
315 limit: e.monthly_limit.map(Cents),
321 spent: Cents(e.used_credits?),
322 decimal_places: e.decimal_places,
326 currency: e.currency,
327 status,
328 })
329 });
330 let scoped = self
331 .limits
332 .into_iter()
333 .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
334 .filter_map(|l| {
335 let label = l.scope?.model?.display_name?;
336 let utilization = l.percent?;
341 let window = to_window(
342 Some(Window {
343 utilization,
344 resets_at: l.resets_at,
345 }),
346 WEEKLY,
347 );
348 Some(ScopedWindow { label, window })
349 })
350 .collect();
351
352 AnthropicSnapshot {
353 plan: plan_label,
354 session,
355 weekly,
356 sonnet,
357 scoped,
358 extra,
359 reset_credits: reset_credits(self.cedar_ember),
360 }
361 }
362}
363
364fn reset_credits(block: Option<ResetGrantsBlock>) -> ResetCredits {
377 let Some(block) = block.filter(|block| block.eligible) else {
378 return ResetCredits::default();
379 };
380 let usable = block
381 .grants
382 .into_iter()
383 .filter(|grant| grant.usable_now && !grant.paused && grant.resets_left > 0);
384 let mut credits = ResetCredits::default();
385 for grant in usable {
386 credits.available = credits.available.saturating_add(grant.resets_left);
387 credits.credits.push(ResetCredit {
388 title: checked_reset_title(grant.label),
389 expires_at: grant
390 .ends_at
391 .as_deref()
392 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
393 .map(|dt| dt.with_timezone(&chrono::Utc)),
394 });
395 }
396 credits
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn parses_full_response() {
405 let raw = r#"{
406 "five_hour": {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
407 "seven_day": {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
408 "seven_day_sonnet": {"utilization": 4.2, "resets_at": "2026-05-30T12:00:00Z"},
409 "extra_usage": {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
410 }"#;
411 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
412 let snap = resp.into_snapshot("Max 5x".into());
413 assert_eq!(snap.session.utilization_pct, 43); assert_eq!(snap.weekly.utilization_pct, 27);
415 assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
416 let extra = snap.extra.as_ref().unwrap();
417 assert_eq!(extra.limit, Some(Cents(5000)));
418 assert_eq!(extra.spent.0, 250);
419 assert!(snap.session.resets_at.is_some());
420 }
421
422 #[test]
423 fn parses_weekly_scoped_limits() {
424 let raw = r#"{
427 "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
428 "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
429 "limits": [
430 {"kind": "session", "group": "session", "percent": 10,
431 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
432 "scope": null, "is_active": false},
433 {"kind": "weekly_all", "group": "weekly", "percent": 55,
434 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
435 "scope": null, "is_active": false},
436 {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
437 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
438 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
439 "is_active": true}
440 ]
441 }"#;
442 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
443 let snap = resp.into_snapshot("Pro".into());
444 assert_eq!(snap.scoped.len(), 1);
445 assert_eq!(snap.scoped[0].label, "Fable");
446 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
447 assert!(snap.scoped[0].window.resets_at.is_some());
448 assert_eq!(snap.weekly.utilization_pct, 55);
450 }
451
452 #[test]
453 fn missing_limits_array_yields_empty_scoped() {
454 let raw = r#"{
455 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
456 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
457 }"#;
458 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
459 let snap = resp.into_snapshot("Pro".into());
460 assert!(snap.scoped.is_empty());
461 }
462
463 #[test]
464 fn missing_sonnet_and_extra_are_none() {
465 let raw = r#"{
466 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
467 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
468 }"#;
469 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
470 let snap = resp.into_snapshot("Pro".into());
471 assert!(snap.sonnet.is_none());
472 assert!(snap.extra.is_none());
473 }
474
475 #[test]
476 fn user_disabled_extra_usage_becomes_none() {
477 let raw = r#"{
478 "five_hour": {"utilization": 0},
479 "seven_day": {"utilization": 0},
480 "extra_usage": {"is_enabled": false, "user_disabled": true,
481 "monthly_limit": 5000, "used_credits": 0}
482 }"#;
483 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
484 let snap = resp.into_snapshot("Pro".into());
485 assert!(snap.extra.is_none());
486 }
487
488 #[test]
489 fn provider_disabled_extra_usage_keeps_spend_and_reason_visible() {
490 let raw = r#"{
491 "extra_usage": {
492 "is_enabled": false,
493 "monthly_limit": null,
494 "used_credits": 1234.0,
495 "currency": "USD",
496 "decimal_places": 2,
497 "disabled_reason": "out_of_credits",
498 "user_disabled": false,
499 "spend_limit_reached": false,
500 "credits_ever_enabled": true
501 }
502 }"#;
503 let extra = serde_json::from_str::<UsageResponse>(raw)
504 .unwrap()
505 .into_snapshot("Pro".into())
506 .extra
507 .unwrap();
508 assert_eq!(extra.fmt_spent(), "$12.34");
509 assert_eq!(extra.status, ExtraUsageStatus::OutOfCredits);
510 assert_eq!(extra.status_note(), Some("out of credits"));
511 }
512
513 #[test]
514 fn enabled_extra_usage_without_spend_is_dropped() {
515 for raw in [
519 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
520 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000,"used_credits":null}}"#,
521 ] {
522 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
523 assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
524 }
525 }
526
527 #[test]
528 fn uncapped_plan_keeps_real_spend_visible() {
529 let resp: UsageResponse = serde_json::from_str(
534 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,
535 "used_credits":14157.0,"currency":"BRL","decimal_places":2,
536 "disabled_reason":null}}"#,
537 )
538 .unwrap();
539 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
540 assert_eq!(extra.limit, None);
541 assert_eq!(extra.spent.0, 14157);
542 assert_eq!(extra.percent(), 0);
544 assert_eq!(extra.currency.as_deref(), Some("BRL"));
547 assert_eq!(extra.decimal_places, Some(2));
548 assert_eq!(extra.fmt_spent(), "R$141.57");
549
550 let resp: UsageResponse =
556 serde_json::from_str(r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#)
557 .unwrap();
558 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
559 assert_eq!(extra.limit, None);
560 assert_eq!(extra.spent.0, 250);
561 }
562
563 #[test]
564 fn implausible_decimal_places_is_schema_drift() {
565 for value in ["7", "-1", "100", "2.5"] {
568 let raw = format!(
569 r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"decimal_places":{value}}}}}"#
570 );
571 assert!(
572 serde_json::from_str::<UsageResponse>(&raw).is_err(),
573 "{raw}"
574 );
575 }
576 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":2.0}}"#;
582 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
583 assert_eq!(
584 resp.into_snapshot("Pro".into())
585 .extra
586 .unwrap()
587 .decimal_places,
588 Some(2)
589 );
590 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":null}}"#;
593 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
594 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
595 assert_eq!(extra.decimal_places, None);
596 assert_eq!(extra.fmt_spent(), "$2.50");
597
598 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW"}}"#;
602 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
603 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
604 assert_eq!(extra.decimal_places, None);
605 assert_eq!(extra.fmt_spent(), "500 minor units KRW");
606
607 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD"}}"#;
608 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
609 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
610 assert_eq!(extra.decimal_places, None);
611 assert_eq!(extra.fmt_spent(), "1234 minor units KWD");
612
613 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW","decimal_places":0}}"#;
615 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
616 assert_eq!(
617 resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
618 "500 KRW"
619 );
620 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD","decimal_places":3}}"#;
621 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
622 assert_eq!(
623 resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
624 "1.234 KWD"
625 );
626 }
627
628 #[test]
629 fn currency_must_be_an_iso_alpha_code() {
630 for value in [r#""brl""#, r#""""#, r#""R$""#, r#""USD;;0""#, r#""<b>""#] {
634 let raw = format!(
635 r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"currency":{value}}}}}"#
636 );
637 assert!(
638 serde_json::from_str::<UsageResponse>(&raw).is_err(),
639 "{raw}"
640 );
641 }
642 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"currency":null}}"#;
644 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
645 assert_eq!(
646 resp.into_snapshot("Pro".into()).extra.unwrap().currency,
647 None
648 );
649 }
650
651 #[test]
652 fn malformed_cent_values_are_schema_drift() {
653 for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
654 let raw = format!(
655 r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
656 );
657 assert!(
658 serde_json::from_str::<UsageResponse>(&raw).is_err(),
659 "{raw}"
660 );
661 }
662
663 let resp: UsageResponse = serde_json::from_str(
664 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
665 )
666 .unwrap();
667 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
668 assert_eq!(extra.limit, Some(Cents(5000)));
669 assert_eq!(extra.spent.0, 250);
670 }
671
672 #[test]
673 fn empty_object_yields_neutral_snapshot() {
674 let resp: UsageResponse = serde_json::from_str("{}").unwrap();
675 let snap = resp.into_snapshot("Unknown".into());
676 assert_eq!(snap.session.utilization_pct, 0);
677 assert_eq!(snap.weekly.utilization_pct, 0);
678 assert!(snap.session.resets_at.is_none());
679 }
680
681 #[test]
682 fn benign_overshoot_saturates_to_hundred() {
683 let raw = r#"{
686 "five_hour": {"utilization": 100.4},
687 "seven_day": {"utilization": 100.6}
688 }"#;
689 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
690 let snap = resp.into_snapshot("Pro".into());
691 assert_eq!(snap.session.utilization_pct, 100);
692 assert_eq!(snap.weekly.utilization_pct, 100); }
694
695 #[test]
696 fn out_of_range_utilization_is_rejected_not_clamped() {
697 for raw in [
698 r#"{"five_hour": {"utilization": 500}}"#,
699 r#"{"five_hour": {"utilization": -1}}"#,
700 r#"{"seven_day": {"utilization": 101.5}}"#,
701 ] {
702 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
703 assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
704 }
705 }
706
707 #[test]
708 fn out_of_range_scoped_percent_is_rejected() {
709 let raw = r#"{
711 "limits": [{"kind": "weekly_scoped", "percent": 420,
712 "scope": {"model": {"display_name": "Fable"}}}]
713 }"#;
714 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
715 assert!(err.to_string().contains("outside 0..=100"), "{err}");
716 }
717
718 #[test]
719 fn non_finite_percentage_is_rejected() {
720 for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
723 assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
724 }
725 }
726
727 #[test]
728 fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
729 let raw = r#"{
733 "limits": [{"kind": "weekly_scoped", "percent": null,
734 "scope": {"model": {"display_name": "Fable"}}}]
735 }"#;
736 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
737 let snap = resp.into_snapshot("Pro".into());
738 assert!(
739 snap.scoped.is_empty(),
740 "a scoped limit with no percentage must not render a fabricated bar"
741 );
742
743 let ok = r#"{
745 "limits": [{"kind": "weekly_scoped", "percent": 84,
746 "scope": {"model": {"display_name": "Fable"}}}]
747 }"#;
748 let resp: UsageResponse = serde_json::from_str(ok).unwrap();
749 let snap = resp.into_snapshot("Pro".into());
750 assert_eq!(snap.scoped[0].label, "Fable");
751 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
752 }
753
754 const LAUNCH_GRANT: &str = r#"{
757 "five_hour": {"utilization": 2, "resets_at": "2026-09-24T17:49:59Z"},
758 "seven_day": {"utilization": 63, "resets_at": "2026-09-25T08:59:59Z"},
759 "cedar_ember": {
760 "eligible": true,
761 "ineligible_reason": null,
762 "at_limit": false,
763 "exhausted": [],
764 "grants": [{
765 "id": "a-redemption-handle",
766 "label": "Claude Opus 5.5 launch: one usage-limit reset for Team members",
767 "resets_total": 1,
768 "resets_left": 1,
769 "starts_at": "2026-09-22T16:00:00+00:00",
770 "ends_at": "2026-10-22T16:00:00+00:00",
771 "clears": ["five_hour", "seven_day", "seven_day_overage_included"],
772 "paused": false,
773 "usable_now": true,
774 "use_requires_limit": false,
775 "percent_used": {"five_hour": 2, "seven_day": 63},
776 "blocking": [],
777 "arm": null
778 }],
779 "next_grant_id": "a-redemption-handle",
780 "weekly_resets_at": "2026-09-25T09:00:00+00:00",
781 "cooldown_until": null,
782 "event_props": null
783 }
784 }"#;
785
786 #[test]
787 fn parses_banked_resets_from_the_cedar_ember_block() {
788 let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
789 let snap = resp.into_snapshot("Max 20x".into());
790
791 assert_eq!(snap.reset_credits.available, 1);
792 assert_eq!(
793 snap.reset_credits.credits[0].title.as_deref(),
794 Some("Claude Opus 5.5 launch: one usage-limit reset for Team members")
795 );
796 assert_eq!(
797 snap.reset_credits.next_expiry(),
798 Some("2026-10-22T16:00:00Z".parse().unwrap())
799 );
800 assert_eq!(snap.session.utilization_pct, 2);
802 assert_eq!(snap.weekly.utilization_pct, 63);
803 }
804
805 #[test]
808 fn the_redemption_grant_id_never_leaves_the_parser() {
809 let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
810 let snap = resp.into_snapshot("Max 20x".into());
811 assert!(!format!("{snap:?}").contains("a-redemption-handle"));
812 }
813
814 #[test]
817 fn an_ineligible_block_reports_no_resets() {
818 let raw = r#"{
819 "cedar_ember": {
820 "eligible": false,
821 "ineligible_reason": "cli_version",
822 "grants": [{"id": "g", "resets_left": 1, "usable_now": true,
823 "ends_at": "2026-10-22T16:00:00Z"}]
824 }
825 }"#;
826 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
827 assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
828 }
829
830 #[test]
834 fn only_redeemable_grants_are_counted() {
835 let raw = r#"{
836 "cedar_ember": {
837 "eligible": true,
838 "grants": [
839 {"id": "a", "label": "not open yet", "resets_left": 1,
840 "usable_now": false, "paused": false},
841 {"id": "b", "label": "paused", "resets_left": 1,
842 "usable_now": true, "paused": true},
843 {"id": "c", "label": "spent", "resets_left": 0,
844 "usable_now": true, "paused": false},
845 {"id": "d", "label": "yours", "resets_left": 2,
846 "usable_now": true, "paused": false,
847 "ends_at": "2026-10-22T16:00:00Z"}
848 ]
849 }
850 }"#;
851 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
852 let credits = resp.into_snapshot("Pro".into()).reset_credits;
853 assert_eq!(credits.available, 2, "only the redeemable grant counts");
854 assert_eq!(credits.credits.len(), 1);
855 assert_eq!(credits.credits[0].title.as_deref(), Some("yours"));
856 }
857
858 #[test]
861 fn an_absent_or_null_block_is_not_a_parse_failure() {
862 for raw in [
863 r#"{"five_hour": {"utilization": 5}}"#,
864 r#"{"cedar_ember": null}"#,
865 ] {
866 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
867 assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
868 }
869 }
870
871 #[test]
875 fn a_control_bearing_label_is_dropped_but_the_expiry_survives() {
876 let raw = r#"{
877 "cedar_ember": {
878 "eligible": true,
879 "grants": [{"id": "a", "label": "line\u0000break", "resets_left": 1,
880 "usable_now": true, "ends_at": "2026-10-22T16:00:00Z"}]
881 }
882 }"#;
883 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
884 let credits = resp.into_snapshot("Pro".into()).reset_credits;
885 assert_eq!(credits.available, 1);
886 assert!(credits.credits[0].title.is_none());
887 assert!(credits.credits[0].expires_at.is_some());
888 }
889
890 #[test]
891 fn unparseable_reset_becomes_none() {
892 let raw = r#"{
893 "five_hour": {"utilization": 50, "resets_at": "not a date"},
894 "seven_day": {"utilization": 0}
895 }"#;
896 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
897 let snap = resp.into_snapshot("Pro".into());
898 assert!(snap.session.resets_at.is_none());
899 assert_eq!(snap.session.utilization_pct, 50);
900 }
901}