1use serde::{Deserialize, Serialize};
9
10use crate::usage::{
11 AnthropicSnapshot, Cents, ExtraUsage, ResetCredit, ResetCredits, ScopedWindow, UsageWindow,
12 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}
134
135fn de_opt_decimal_places<'de, D>(d: D) -> std::result::Result<Option<u32>, D::Error>
144where
145 D: serde::Deserializer<'de>,
146{
147 let n = match serde_json::Value::deserialize(d)? {
148 serde_json::Value::Null => return Ok(None),
149 serde_json::Value::Number(n) => n
150 .as_i64()
151 .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)),
152 _ => None,
153 };
154 match n {
155 Some(n) if (0..=6).contains(&n) => Ok(Some(n as u32)),
156 _ => Err(serde::de::Error::custom(
157 "decimal_places must be an integer in 0..=6",
158 )),
159 }
160}
161
162fn de_opt_currency<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
167where
168 D: serde::Deserializer<'de>,
169{
170 match Option::<String>::deserialize(d)? {
171 None => Ok(None),
172 Some(s) if s.len() == 3 && s.bytes().all(|b| b.is_ascii_uppercase()) => Ok(Some(s)),
173 Some(s) => Err(serde::de::Error::custom(format!(
174 "currency {s:?} is not an ISO 4217 alpha code"
175 ))),
176 }
177}
178
179fn de_opt_cents<'de, D>(d: D) -> std::result::Result<Option<i64>, D::Error>
180where
181 D: serde::Deserializer<'de>,
182{
183 let v = serde_json::Value::deserialize(d)?;
184 match v {
185 serde_json::Value::Null => Ok(None),
186 serde_json::Value::Number(n) => {
187 if let Some(i) = n.as_i64() {
188 if i >= 0 {
189 Ok(Some(i))
190 } else {
191 Err(serde::de::Error::custom("cents cannot be negative"))
192 }
193 } else if let Some(f) = n.as_f64() {
194 const MAX_EXACT_F64_INT: f64 = (1_u64 << 53) as f64;
195 if f.is_finite() && f.fract() == 0.0 && (0.0..=MAX_EXACT_F64_INT).contains(&f) {
196 Ok(Some(f as i64))
197 } else {
198 Err(serde::de::Error::custom(
199 "cents must be a non-negative integer in range",
200 ))
201 }
202 } else {
203 Err(serde::de::Error::custom("number out of i64 range"))
204 }
205 }
206 other => Err(serde::de::Error::custom(format!(
207 "expected number or null, got {other:?}"
208 ))),
209 }
210}
211
212const PCT_SLACK: f64 = 1.0;
216
217fn checked_percent<E: serde::de::Error>(v: f64) -> std::result::Result<f64, E> {
228 if !v.is_finite() {
229 return Err(E::custom(format!("percentage {v} is not finite")));
230 }
231 if !(0.0..=100.0 + PCT_SLACK).contains(&v) {
232 return Err(E::custom(format!("percentage {v} outside 0..=100")));
233 }
234 Ok(v)
235}
236
237fn de_percent<'de, D>(d: D) -> std::result::Result<f64, D::Error>
238where
239 D: serde::Deserializer<'de>,
240{
241 checked_percent(f64::deserialize(d)?)
242}
243
244fn de_percent_opt<'de, D>(d: D) -> std::result::Result<Option<f64>, D::Error>
245where
246 D: serde::Deserializer<'de>,
247{
248 Option::<f64>::deserialize(d)?
249 .map(checked_percent)
250 .transpose()
251}
252
253impl UsageResponse {
254 pub fn into_snapshot(self, plan_label: String) -> AnthropicSnapshot {
259 const SESSION: chrono::Duration = chrono::Duration::hours(5);
261 const WEEKLY: chrono::Duration = chrono::Duration::days(7);
262
263 fn to_window(w: Option<Window>, dur: chrono::Duration) -> UsageWindow {
264 let Some(w) = w else {
265 return UsageWindow {
266 utilization_pct: 0,
267 resets_at: None,
268 window_duration: dur,
269 };
270 };
271 UsageWindow {
272 utilization_pct: i32::from(crate::format::clamp_pct(w.utilization)),
276 resets_at: w
277 .resets_at
278 .as_deref()
279 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
280 .map(|dt| dt.with_timezone(&chrono::Utc)),
281 window_duration: dur,
282 }
283 }
284
285 let session = to_window(self.five_hour, SESSION);
286 let weekly = to_window(self.seven_day, WEEKLY);
287 let sonnet = self.seven_day_sonnet.map(|w| to_window(Some(w), WEEKLY));
288 let extra = self.extra_usage.filter(|e| e.is_enabled).and_then(|e| {
289 Some(ExtraUsage {
290 limit: e.monthly_limit.map(Cents),
296 spent: Cents(e.used_credits?),
297 decimal_places: e.decimal_places,
301 currency: e.currency,
302 })
303 });
304 let scoped = self
305 .limits
306 .into_iter()
307 .filter(|l| l.kind.as_deref() == Some("weekly_scoped"))
308 .filter_map(|l| {
309 let label = l.scope?.model?.display_name?;
310 let utilization = l.percent?;
315 let window = to_window(
316 Some(Window {
317 utilization,
318 resets_at: l.resets_at,
319 }),
320 WEEKLY,
321 );
322 Some(ScopedWindow { label, window })
323 })
324 .collect();
325
326 AnthropicSnapshot {
327 plan: plan_label,
328 session,
329 weekly,
330 sonnet,
331 scoped,
332 extra,
333 reset_credits: reset_credits(self.cedar_ember),
334 }
335 }
336}
337
338fn reset_credits(block: Option<ResetGrantsBlock>) -> ResetCredits {
351 let Some(block) = block.filter(|block| block.eligible) else {
352 return ResetCredits::default();
353 };
354 let usable = block
355 .grants
356 .into_iter()
357 .filter(|grant| grant.usable_now && !grant.paused && grant.resets_left > 0);
358 let mut credits = ResetCredits::default();
359 for grant in usable {
360 credits.available = credits.available.saturating_add(grant.resets_left);
361 credits.credits.push(ResetCredit {
362 title: checked_reset_title(grant.label),
363 expires_at: grant
364 .ends_at
365 .as_deref()
366 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
367 .map(|dt| dt.with_timezone(&chrono::Utc)),
368 });
369 }
370 credits
371}
372
373#[cfg(test)]
374mod tests {
375 use super::*;
376
377 #[test]
378 fn parses_full_response() {
379 let raw = r#"{
380 "five_hour": {"utilization": 42.7, "resets_at": "2026-05-23T17:30:00Z"},
381 "seven_day": {"utilization": 27.0, "resets_at": "2026-05-30T12:00:00Z"},
382 "seven_day_sonnet": {"utilization": 4.2, "resets_at": "2026-05-30T12:00:00Z"},
383 "extra_usage": {"is_enabled": true, "monthly_limit": 5000, "used_credits": 250}
384 }"#;
385 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
386 let snap = resp.into_snapshot("Max 5x".into());
387 assert_eq!(snap.session.utilization_pct, 43); assert_eq!(snap.weekly.utilization_pct, 27);
389 assert_eq!(snap.sonnet.as_ref().unwrap().utilization_pct, 4);
390 let extra = snap.extra.as_ref().unwrap();
391 assert_eq!(extra.limit, Some(Cents(5000)));
392 assert_eq!(extra.spent.0, 250);
393 assert!(snap.session.resets_at.is_some());
394 }
395
396 #[test]
397 fn parses_weekly_scoped_limits() {
398 let raw = r#"{
401 "five_hour": {"utilization": 10.0, "resets_at": "2026-07-08T22:59:59Z"},
402 "seven_day": {"utilization": 55.0, "resets_at": "2026-07-10T10:59:59Z"},
403 "limits": [
404 {"kind": "session", "group": "session", "percent": 10,
405 "severity": "normal", "resets_at": "2026-07-08T22:59:59Z",
406 "scope": null, "is_active": false},
407 {"kind": "weekly_all", "group": "weekly", "percent": 55,
408 "severity": "normal", "resets_at": "2026-07-10T10:59:59Z",
409 "scope": null, "is_active": false},
410 {"kind": "weekly_scoped", "group": "weekly", "percent": 84,
411 "severity": "warning", "resets_at": "2026-07-10T10:59:59Z",
412 "scope": {"model": {"id": null, "display_name": "Fable"}, "surface": null},
413 "is_active": true}
414 ]
415 }"#;
416 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
417 let snap = resp.into_snapshot("Pro".into());
418 assert_eq!(snap.scoped.len(), 1);
419 assert_eq!(snap.scoped[0].label, "Fable");
420 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
421 assert!(snap.scoped[0].window.resets_at.is_some());
422 assert_eq!(snap.weekly.utilization_pct, 55);
424 }
425
426 #[test]
427 fn missing_limits_array_yields_empty_scoped() {
428 let raw = r#"{
429 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
430 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
431 }"#;
432 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
433 let snap = resp.into_snapshot("Pro".into());
434 assert!(snap.scoped.is_empty());
435 }
436
437 #[test]
438 fn missing_sonnet_and_extra_are_none() {
439 let raw = r#"{
440 "five_hour": {"utilization": 0, "resets_at": "2026-05-23T17:30:00Z"},
441 "seven_day": {"utilization": 0, "resets_at": "2026-05-30T12:00:00Z"}
442 }"#;
443 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
444 let snap = resp.into_snapshot("Pro".into());
445 assert!(snap.sonnet.is_none());
446 assert!(snap.extra.is_none());
447 }
448
449 #[test]
450 fn disabled_extra_usage_becomes_none() {
451 let raw = r#"{
452 "five_hour": {"utilization": 0},
453 "seven_day": {"utilization": 0},
454 "extra_usage": {"is_enabled": false, "monthly_limit": 5000, "used_credits": 0}
455 }"#;
456 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
457 let snap = resp.into_snapshot("Pro".into());
458 assert!(snap.extra.is_none());
459 }
460
461 #[test]
462 fn enabled_extra_usage_without_spend_is_dropped() {
463 for raw in [
467 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000}}"#,
468 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000,"used_credits":null}}"#,
469 ] {
470 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
471 assert!(resp.into_snapshot("Pro".into()).extra.is_none(), "{raw}");
472 }
473 }
474
475 #[test]
476 fn uncapped_plan_keeps_real_spend_visible() {
477 let resp: UsageResponse = serde_json::from_str(
482 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":null,
483 "used_credits":14157.0,"currency":"BRL","decimal_places":2,
484 "disabled_reason":null}}"#,
485 )
486 .unwrap();
487 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
488 assert_eq!(extra.limit, None);
489 assert_eq!(extra.spent.0, 14157);
490 assert_eq!(extra.percent(), 0);
492 assert_eq!(extra.currency.as_deref(), Some("BRL"));
495 assert_eq!(extra.decimal_places, Some(2));
496 assert_eq!(extra.fmt_spent(), "R$141.57");
497
498 let resp: UsageResponse =
504 serde_json::from_str(r#"{"extra_usage":{"is_enabled":true,"used_credits":250}}"#)
505 .unwrap();
506 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
507 assert_eq!(extra.limit, None);
508 assert_eq!(extra.spent.0, 250);
509 }
510
511 #[test]
512 fn implausible_decimal_places_is_schema_drift() {
513 for value in ["7", "-1", "100", "2.5"] {
516 let raw = format!(
517 r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"decimal_places":{value}}}}}"#
518 );
519 assert!(
520 serde_json::from_str::<UsageResponse>(&raw).is_err(),
521 "{raw}"
522 );
523 }
524 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":2.0}}"#;
530 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
531 assert_eq!(
532 resp.into_snapshot("Pro".into())
533 .extra
534 .unwrap()
535 .decimal_places,
536 Some(2)
537 );
538 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"decimal_places":null}}"#;
541 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
542 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
543 assert_eq!(extra.decimal_places, None);
544 assert_eq!(extra.fmt_spent(), "$2.50");
545
546 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW"}}"#;
550 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
551 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
552 assert_eq!(extra.decimal_places, None);
553 assert_eq!(extra.fmt_spent(), "500 minor units KRW");
554
555 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD"}}"#;
556 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
557 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
558 assert_eq!(extra.decimal_places, None);
559 assert_eq!(extra.fmt_spent(), "1234 minor units KWD");
560
561 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":500,"currency":"KRW","decimal_places":0}}"#;
563 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
564 assert_eq!(
565 resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
566 "500 KRW"
567 );
568 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":1234,"currency":"KWD","decimal_places":3}}"#;
569 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
570 assert_eq!(
571 resp.into_snapshot("Pro".into()).extra.unwrap().fmt_spent(),
572 "1.234 KWD"
573 );
574 }
575
576 #[test]
577 fn currency_must_be_an_iso_alpha_code() {
578 for value in [r#""brl""#, r#""""#, r#""R$""#, r#""USD;;0""#, r#""<b>""#] {
582 let raw = format!(
583 r#"{{"extra_usage":{{"is_enabled":true,"used_credits":250,"currency":{value}}}}}"#
584 );
585 assert!(
586 serde_json::from_str::<UsageResponse>(&raw).is_err(),
587 "{raw}"
588 );
589 }
590 let raw = r#"{"extra_usage":{"is_enabled":true,"used_credits":250,"currency":null}}"#;
592 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
593 assert_eq!(
594 resp.into_snapshot("Pro".into()).extra.unwrap().currency,
595 None
596 );
597 }
598
599 #[test]
600 fn malformed_cent_values_are_schema_drift() {
601 for value in ["-1", "1.5", "1e300", "true", r#""lots""#] {
602 let raw = format!(
603 r#"{{"extra_usage":{{"is_enabled":true,"monthly_limit":{value},"used_credits":0}}}}"#
604 );
605 assert!(
606 serde_json::from_str::<UsageResponse>(&raw).is_err(),
607 "{raw}"
608 );
609 }
610
611 let resp: UsageResponse = serde_json::from_str(
612 r#"{"extra_usage":{"is_enabled":true,"monthly_limit":5000.0,"used_credits":250.0}}"#,
613 )
614 .unwrap();
615 let extra = resp.into_snapshot("Pro".into()).extra.unwrap();
616 assert_eq!(extra.limit, Some(Cents(5000)));
617 assert_eq!(extra.spent.0, 250);
618 }
619
620 #[test]
621 fn empty_object_yields_neutral_snapshot() {
622 let resp: UsageResponse = serde_json::from_str("{}").unwrap();
623 let snap = resp.into_snapshot("Unknown".into());
624 assert_eq!(snap.session.utilization_pct, 0);
625 assert_eq!(snap.weekly.utilization_pct, 0);
626 assert!(snap.session.resets_at.is_none());
627 }
628
629 #[test]
630 fn benign_overshoot_saturates_to_hundred() {
631 let raw = r#"{
634 "five_hour": {"utilization": 100.4},
635 "seven_day": {"utilization": 100.6}
636 }"#;
637 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
638 let snap = resp.into_snapshot("Pro".into());
639 assert_eq!(snap.session.utilization_pct, 100);
640 assert_eq!(snap.weekly.utilization_pct, 100); }
642
643 #[test]
644 fn out_of_range_utilization_is_rejected_not_clamped() {
645 for raw in [
646 r#"{"five_hour": {"utilization": 500}}"#,
647 r#"{"five_hour": {"utilization": -1}}"#,
648 r#"{"seven_day": {"utilization": 101.5}}"#,
649 ] {
650 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
651 assert!(err.to_string().contains("outside 0..=100"), "{raw}: {err}");
652 }
653 }
654
655 #[test]
656 fn out_of_range_scoped_percent_is_rejected() {
657 let raw = r#"{
659 "limits": [{"kind": "weekly_scoped", "percent": 420,
660 "scope": {"model": {"display_name": "Fable"}}}]
661 }"#;
662 let err = serde_json::from_str::<UsageResponse>(raw).unwrap_err();
663 assert!(err.to_string().contains("outside 0..=100"), "{err}");
664 }
665
666 #[test]
667 fn non_finite_percentage_is_rejected() {
668 for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
671 assert!(checked_percent::<serde_json::Error>(v).is_err(), "{v}");
672 }
673 }
674
675 #[test]
676 fn scoped_limit_without_a_percentage_is_dropped_not_zeroed() {
677 let raw = r#"{
681 "limits": [{"kind": "weekly_scoped", "percent": null,
682 "scope": {"model": {"display_name": "Fable"}}}]
683 }"#;
684 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
685 let snap = resp.into_snapshot("Pro".into());
686 assert!(
687 snap.scoped.is_empty(),
688 "a scoped limit with no percentage must not render a fabricated bar"
689 );
690
691 let ok = r#"{
693 "limits": [{"kind": "weekly_scoped", "percent": 84,
694 "scope": {"model": {"display_name": "Fable"}}}]
695 }"#;
696 let resp: UsageResponse = serde_json::from_str(ok).unwrap();
697 let snap = resp.into_snapshot("Pro".into());
698 assert_eq!(snap.scoped[0].label, "Fable");
699 assert_eq!(snap.scoped[0].window.utilization_pct, 84);
700 }
701
702 const LAUNCH_GRANT: &str = r#"{
705 "five_hour": {"utilization": 2, "resets_at": "2026-09-24T17:49:59Z"},
706 "seven_day": {"utilization": 63, "resets_at": "2026-09-25T08:59:59Z"},
707 "cedar_ember": {
708 "eligible": true,
709 "ineligible_reason": null,
710 "at_limit": false,
711 "exhausted": [],
712 "grants": [{
713 "id": "a-redemption-handle",
714 "label": "Claude Opus 5.5 launch: one usage-limit reset for Team members",
715 "resets_total": 1,
716 "resets_left": 1,
717 "starts_at": "2026-09-22T16:00:00+00:00",
718 "ends_at": "2026-10-22T16:00:00+00:00",
719 "clears": ["five_hour", "seven_day", "seven_day_overage_included"],
720 "paused": false,
721 "usable_now": true,
722 "use_requires_limit": false,
723 "percent_used": {"five_hour": 2, "seven_day": 63},
724 "blocking": [],
725 "arm": null
726 }],
727 "next_grant_id": "a-redemption-handle",
728 "weekly_resets_at": "2026-09-25T09:00:00+00:00",
729 "cooldown_until": null,
730 "event_props": null
731 }
732 }"#;
733
734 #[test]
735 fn parses_banked_resets_from_the_cedar_ember_block() {
736 let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
737 let snap = resp.into_snapshot("Max 20x".into());
738
739 assert_eq!(snap.reset_credits.available, 1);
740 assert_eq!(
741 snap.reset_credits.credits[0].title.as_deref(),
742 Some("Claude Opus 5.5 launch: one usage-limit reset for Team members")
743 );
744 assert_eq!(
745 snap.reset_credits.next_expiry(),
746 Some("2026-10-22T16:00:00Z".parse().unwrap())
747 );
748 assert_eq!(snap.session.utilization_pct, 2);
750 assert_eq!(snap.weekly.utilization_pct, 63);
751 }
752
753 #[test]
756 fn the_redemption_grant_id_never_leaves_the_parser() {
757 let resp: UsageResponse = serde_json::from_str(LAUNCH_GRANT).unwrap();
758 let snap = resp.into_snapshot("Max 20x".into());
759 assert!(!format!("{snap:?}").contains("a-redemption-handle"));
760 }
761
762 #[test]
765 fn an_ineligible_block_reports_no_resets() {
766 let raw = r#"{
767 "cedar_ember": {
768 "eligible": false,
769 "ineligible_reason": "cli_version",
770 "grants": [{"id": "g", "resets_left": 1, "usable_now": true,
771 "ends_at": "2026-10-22T16:00:00Z"}]
772 }
773 }"#;
774 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
775 assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
776 }
777
778 #[test]
782 fn only_redeemable_grants_are_counted() {
783 let raw = r#"{
784 "cedar_ember": {
785 "eligible": true,
786 "grants": [
787 {"id": "a", "label": "not open yet", "resets_left": 1,
788 "usable_now": false, "paused": false},
789 {"id": "b", "label": "paused", "resets_left": 1,
790 "usable_now": true, "paused": true},
791 {"id": "c", "label": "spent", "resets_left": 0,
792 "usable_now": true, "paused": false},
793 {"id": "d", "label": "yours", "resets_left": 2,
794 "usable_now": true, "paused": false,
795 "ends_at": "2026-10-22T16:00:00Z"}
796 ]
797 }
798 }"#;
799 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
800 let credits = resp.into_snapshot("Pro".into()).reset_credits;
801 assert_eq!(credits.available, 2, "only the redeemable grant counts");
802 assert_eq!(credits.credits.len(), 1);
803 assert_eq!(credits.credits[0].title.as_deref(), Some("yours"));
804 }
805
806 #[test]
809 fn an_absent_or_null_block_is_not_a_parse_failure() {
810 for raw in [
811 r#"{"five_hour": {"utilization": 5}}"#,
812 r#"{"cedar_ember": null}"#,
813 ] {
814 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
815 assert!(resp.into_snapshot("Pro".into()).reset_credits.is_empty());
816 }
817 }
818
819 #[test]
823 fn a_control_bearing_label_is_dropped_but_the_expiry_survives() {
824 let raw = r#"{
825 "cedar_ember": {
826 "eligible": true,
827 "grants": [{"id": "a", "label": "line\u0000break", "resets_left": 1,
828 "usable_now": true, "ends_at": "2026-10-22T16:00:00Z"}]
829 }
830 }"#;
831 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
832 let credits = resp.into_snapshot("Pro".into()).reset_credits;
833 assert_eq!(credits.available, 1);
834 assert!(credits.credits[0].title.is_none());
835 assert!(credits.credits[0].expires_at.is_some());
836 }
837
838 #[test]
839 fn unparseable_reset_becomes_none() {
840 let raw = r#"{
841 "five_hour": {"utilization": 50, "resets_at": "not a date"},
842 "seven_day": {"utilization": 0}
843 }"#;
844 let resp: UsageResponse = serde_json::from_str(raw).unwrap();
845 let snap = resp.into_snapshot("Pro".into());
846 assert!(snap.session.resets_at.is_none());
847 assert_eq!(snap.session.utilization_pct, 50);
848 }
849}