1use chrono::{DateTime, Utc};
4use serde::Deserialize;
5
6use crate::error::{AppError, Result};
7use crate::usage::KimiSnapshot;
8
9#[derive(Debug, Clone, Deserialize, Default)]
10#[serde(default)]
11pub struct UsagesResponse {
12 user: Option<User>,
13 usage: Option<UsageBlock>,
14 limits: Option<Vec<Limit>>,
17 usages: Option<UsagesMap>,
23}
24
25pub type UsagesMap = std::collections::HashMap<String, UsageRatio>;
27
28#[derive(Debug, Clone, Deserialize, Default)]
29#[serde(default)]
30pub struct UsageRatio {
31 used_ratio: Option<f64>,
33 #[serde(alias = "resetTime", alias = "resetAt", alias = "reset_at")]
34 reset_time: Option<String>,
35}
36
37#[derive(Debug, Clone, Deserialize, Default)]
38#[serde(default)]
39struct User {
40 membership: Option<Membership>,
41}
42
43#[derive(Debug, Clone, Deserialize, Default)]
44#[serde(default)]
45struct Membership {
46 level: Option<String>,
47}
48
49#[derive(Debug, Clone, Deserialize, Default)]
50#[serde(default)]
51struct UsageBlock {
52 limit: Option<NumericOrString>,
53 used: Option<NumericOrString>,
54 remaining: Option<NumericOrString>,
55 #[serde(
56 rename = "resetTime",
57 alias = "resetAt",
58 alias = "reset_at",
59 alias = "reset_time"
60 )]
61 reset_time: Option<String>,
62}
63
64#[derive(Debug, Clone, Deserialize, Default)]
65#[serde(default)]
66struct Limit {
67 window: Option<Window>,
68 detail: Option<UsageBlock>,
69}
70
71#[derive(Debug, Clone, Deserialize, Default)]
72#[serde(default)]
73struct Window {
74 duration: u64,
75 #[serde(rename = "timeUnit", alias = "time_unit")]
76 time_unit: String,
77}
78
79#[derive(Debug, Clone, Deserialize)]
80#[serde(untagged)]
81enum NumericOrString {
82 Number(u64),
83 String(String),
84}
85
86impl NumericOrString {
87 fn as_u64(&self) -> Option<u64> {
88 match self {
89 NumericOrString::Number(n) => Some(*n),
90 NumericOrString::String(s) => s.trim().parse::<u64>().ok(),
91 }
92 }
93}
94
95impl UsagesResponse {
96 pub fn into_snapshot(self) -> Result<KimiSnapshot> {
97 let plan = self
101 .user
102 .and_then(|u| u.membership)
103 .and_then(|m| m.level)
104 .map(|level| humanize_membership_level(&level));
105
106 let limits = self.limits.unwrap_or_default();
110 let (window_limit, window_used, window_remaining, window_reset) = if limits.is_empty() {
111 (0, 0, 0, None)
112 } else {
113 let detail = limits
114 .into_iter()
115 .find_map(|l| {
116 (l.window.as_ref().is_some_and(is_five_hour_window))
117 .then_some(l.detail)
118 .flatten()
119 })
120 .ok_or_else(|| {
121 AppError::Schema("kimi: missing recognized 5h usage window".into())
122 })?;
123 extract_block(detail)?
124 };
125
126 match self.usage {
127 Some(usage) => {
130 let (weekly_limit, weekly_used, weekly_remaining, weekly_reset) =
131 extract_block(usage)?;
132 Ok(KimiSnapshot {
133 plan,
134 weekly_limit,
135 weekly_used,
136 weekly_remaining,
137 weekly_reset_at: weekly_reset,
138 has_weekly: true,
139 monthly_pct: None,
140 monthly_reset_at: None,
141 window_limit,
142 window_used,
143 window_remaining,
144 window_reset_at: window_reset,
145 })
146 }
147 None => {
150 let monthly = self
151 .usages
152 .as_ref()
153 .and_then(|usages| usages.get("limit_month_total"))
154 .ok_or_else(|| {
155 AppError::Schema("kimi: missing top-level usage block".into())
156 })?;
157 Ok(KimiSnapshot {
158 plan,
159 weekly_limit: 0,
160 weekly_used: 0,
161 weekly_remaining: 0,
162 weekly_reset_at: None,
163 has_weekly: false,
164 monthly_pct: Some(monthly_pct(monthly.used_ratio)?),
165 monthly_reset_at: parse_reset(monthly.reset_time.as_deref())?,
166 window_limit,
167 window_used,
168 window_remaining,
169 window_reset_at: window_reset,
170 })
171 }
172 }
173 }
174}
175
176fn monthly_pct(ratio: Option<f64>) -> Result<i32> {
180 let ratio = ratio
181 .ok_or_else(|| AppError::Schema("kimi: limit_month_total is missing used_ratio".into()))?;
182 if !ratio.is_finite() || !(0.0..=1.0).contains(&ratio) {
183 return Err(AppError::Schema(format!(
184 "kimi: limit_month_total used_ratio out of range: {ratio}"
185 )));
186 }
187 Ok((ratio * 100.0).round() as i32)
188}
189
190fn extract_block(block: UsageBlock) -> Result<(u64, u64, u64, Option<DateTime<Utc>>)> {
191 let limit = parse_count(&block.limit, "limit")?
192 .ok_or_else(|| AppError::Schema("kimi: missing limit in usage block".into()))?;
193 let used = parse_count(&block.used, "used")?;
194 let remaining = parse_count(&block.remaining, "remaining")?;
195 let reset = parse_reset(block.reset_time.as_deref())?;
196
197 let (used, remaining) = match (used, remaining) {
198 (Some(u), Some(r)) => (u, r),
199 (Some(u), None) => (u, limit.saturating_sub(u)),
200 (None, Some(r)) => (limit.saturating_sub(r), r),
201 (None, None) => {
202 return Err(AppError::Schema(
203 "kimi: usage block is missing both used and remaining".into(),
204 ));
205 }
206 };
207
208 Ok((limit, used, remaining, reset))
209}
210
211#[derive(Debug, Clone, Deserialize, Default)]
218#[serde(default)]
219pub struct UserInfoResponse {
220 user_level_name: Option<String>,
224}
225
226impl UserInfoResponse {
227 pub fn plan_label(&self) -> Option<String> {
228 self.user_level_name
229 .as_deref()
230 .map(str::trim)
231 .filter(|name| !name.is_empty())
232 .map(str::to_string)
233 }
234}
235
236pub fn humanize_membership_level(level: &str) -> String {
245 let trimmed = level.trim();
246 let body = trimmed.strip_prefix("LEVEL_").unwrap_or(trimmed);
247 if body.is_empty() {
248 return trimmed.to_string();
249 }
250 body.split('_')
251 .filter(|word| !word.is_empty())
252 .map(|word| {
253 let mut chars = word.chars();
254 match chars.next() {
255 Some(first) => {
256 first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
257 }
258 None => String::new(),
259 }
260 })
261 .collect::<Vec<_>>()
262 .join(" ")
263}
264
265fn is_five_hour_window(window: &Window) -> bool {
268 matches!(
269 (window.duration, window.time_unit.as_str()),
270 (300, "TIME_UNIT_MINUTE" | "MINUTE" | "MINUTES") | (5, "TIME_UNIT_HOUR" | "HOUR" | "HOURS")
271 )
272}
273
274fn parse_count(field: &Option<NumericOrString>, name: &str) -> Result<Option<u64>> {
275 match field {
276 None => Ok(None),
277 Some(n) => n
278 .as_u64()
279 .map(Some)
280 .ok_or_else(|| AppError::Schema(format!("kimi: invalid numeric value for {name}"))),
281 }
282}
283
284fn parse_reset(s: Option<&str>) -> Result<Option<DateTime<Utc>>> {
285 match s {
286 None | Some("") => Ok(None),
287 Some(s) => DateTime::parse_from_rfc3339(s)
288 .map(|dt| Some(dt.into()))
289 .map_err(|e| AppError::Schema(format!("kimi: unparseable resetTime: {e}"))),
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn membership_levels_are_humanized_not_invented() {
299 assert_eq!(
300 humanize_membership_level("LEVEL_INTERMEDIATE"),
301 "Intermediate"
302 );
303 assert_eq!(
304 humanize_membership_level("LEVEL_SUPER_ADVANCED"),
305 "Super Advanced"
306 );
307 assert_eq!(humanize_membership_level(" basic "), "Basic");
309 assert_eq!(humanize_membership_level("LEVEL_"), "LEVEL_");
311 assert_eq!(humanize_membership_level(""), "");
312 }
313
314 #[test]
315 fn the_profile_response_yields_only_the_tier_name() {
316 let raw = r#"{
317 "user_id": "u-1", "nickname": "someone", "email": "someone@example.com",
318 "phone": {"country_code": "55", "number": "999999999"},
319 "user_level": 25, "user_level_name": "Allegretto",
320 "domain_name": "DOMAIN_NEXUS"
321 }"#;
322 let me: UserInfoResponse = serde_json::from_str(raw).unwrap();
323 assert_eq!(me.plan_label(), Some("Allegretto".into()));
324 let rendered = format!("{me:?}");
327 assert!(!rendered.contains("example.com"), "{rendered}");
328 assert!(!rendered.contains("999999999"), "{rendered}");
329 }
330
331 #[test]
332 fn a_blank_or_absent_tier_name_is_no_label() {
333 for raw in [
334 r#"{"user_level_name": ""}"#,
335 r#"{"user_level_name": " "}"#,
336 "{}",
337 ] {
338 let me: UserInfoResponse = serde_json::from_str(raw).unwrap();
339 assert_eq!(me.plan_label(), None, "{raw}");
340 }
341 }
342
343 #[test]
344 fn parses_representative_json_with_string_numbers() {
345 let raw = r#"{
346 "user": { "membership": { "level": "LEVEL_INTERMEDIATE" } },
347 "usage": { "limit": "100", "used": "26", "remaining": "74", "resetTime": "2026-02-11T17:32:50.757941Z" },
348 "limits": [
349 {
350 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
351 "detail": { "limit": "100", "used": "15", "remaining": "85", "resetTime": "2026-02-07T12:32:50.757941Z" }
352 }
353 ]
354 }"#;
355 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
356 .unwrap()
357 .into_snapshot()
358 .unwrap();
359 assert_eq!(snap.plan, Some("Intermediate".into()));
360 assert_eq!(snap.weekly_limit, 100);
361 assert_eq!(snap.weekly_used, 26);
362 assert_eq!(snap.weekly_remaining, 74);
363 assert!(snap.weekly_reset_at.is_some());
364 assert_eq!(snap.window_limit, 100);
365 assert_eq!(snap.window_used, 15);
366 assert_eq!(snap.window_remaining, 85);
367 assert!(snap.window_reset_at.is_some());
368 assert_eq!(snap.weekly_pct(), 26);
369 assert_eq!(snap.window_pct(), 15);
370 }
371
372 #[test]
373 fn parses_numeric_json_numbers() {
374 let raw = r#"{
375 "user": { "membership": { "level": "LEVEL_ADVANCED" } },
376 "usage": { "limit": 500, "used": 123, "remaining": 377, "resetTime": "2026-02-11T17:32:50Z" },
377 "limits": [
378 {
379 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
380 "detail": { "limit": 200, "used": 50, "remaining": 150, "resetTime": "2026-02-07T12:32:50Z" }
381 }
382 ]
383 }"#;
384 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
385 .unwrap()
386 .into_snapshot()
387 .unwrap();
388 assert_eq!(snap.plan, Some("Advanced".into()));
389 assert_eq!(snap.weekly_limit, 500);
390 assert_eq!(snap.weekly_used, 123);
391 assert_eq!(snap.weekly_remaining, 377);
392 assert_eq!(snap.window_limit, 200);
393 assert_eq!(snap.window_used, 50);
394 assert_eq!(snap.window_remaining, 150);
395 }
396
397 #[test]
398 fn parses_missing_user_and_limits() {
399 let raw = r#"{
400 "usage": { "limit": "100", "used": "26", "remaining": "74" }
401 }"#;
402 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
403 .unwrap()
404 .into_snapshot()
405 .unwrap();
406 assert_eq!(snap.plan, None);
407 assert_eq!(snap.weekly_limit, 100);
408 assert_eq!(snap.weekly_used, 26);
409 assert_eq!(snap.weekly_remaining, 74);
410 assert_eq!(snap.weekly_reset_at, None);
411 assert_eq!(snap.window_limit, 0);
412 assert_eq!(snap.window_used, 0);
413 assert_eq!(snap.window_remaining, 0);
414 assert_eq!(snap.window_reset_at, None);
415 }
416
417 #[test]
418 fn computes_used_when_missing() {
419 let raw = r#"{
420 "usage": { "limit": "100", "remaining": "74" }
421 }"#;
422 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
423 .unwrap()
424 .into_snapshot()
425 .unwrap();
426 assert_eq!(snap.weekly_used, 26);
427 assert_eq!(snap.weekly_remaining, 74);
428 }
429
430 #[test]
431 fn computes_remaining_when_missing() {
432 let raw = r#"{
433 "usage": { "limit": "100", "used": "26" }
434 }"#;
435 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
436 .unwrap()
437 .into_snapshot()
438 .unwrap();
439 assert_eq!(snap.weekly_used, 26);
440 assert_eq!(snap.weekly_remaining, 74);
441 }
442
443 #[test]
444 fn zero_strings_are_valid() {
445 let raw = r#"{
446 "usage": { "limit": "100", "used": "0", "remaining": "100" }
447 }"#;
448 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
449 .unwrap()
450 .into_snapshot()
451 .unwrap();
452 assert_eq!(snap.weekly_used, 0);
453 assert_eq!(snap.weekly_remaining, 100);
454 assert_eq!(snap.weekly_pct(), 0);
455 }
456
457 #[test]
458 fn both_counts_missing_is_schema_drift() {
459 let raw = r#"{
460 "usage": { "limit": "100" }
461 }"#;
462 let err = serde_json::from_str::<UsagesResponse>(raw)
463 .unwrap()
464 .into_snapshot()
465 .unwrap_err();
466 assert!(err.to_string().contains("both used and remaining"));
467 }
468
469 #[test]
470 fn malformed_numeric_string_rejected() {
471 let raw = r#"{
472 "usage": { "limit": "100", "used": "garbage" }
473 }"#;
474 let err = serde_json::from_str::<UsagesResponse>(raw)
475 .unwrap()
476 .into_snapshot()
477 .unwrap_err();
478 assert!(
479 err.to_string().contains("used"),
480 "expected used parse error, got {err}"
481 );
482 }
483
484 #[test]
485 fn overflow_string_rejected() {
486 let raw = r#"{
487 "usage": { "limit": "18446744073709551616", "used": "0" }
488 }"#;
489 let err = serde_json::from_str::<UsagesResponse>(raw)
490 .unwrap()
491 .into_snapshot()
492 .unwrap_err();
493 assert!(
494 err.to_string().contains("limit"),
495 "expected limit overflow error, got {err}"
496 );
497 }
498
499 #[test]
500 fn negative_json_number_rejected_without_panic() {
501 let raw = r#"{
502 "usage": { "limit": 100, "used": -1 }
503 }"#;
504 let res = serde_json::from_str::<UsagesResponse>(raw);
506 assert!(
507 res.is_err(),
508 "negative u64 should not deserialize without panic"
509 );
510 }
511
512 #[test]
513 fn selects_300_min_window() {
514 let raw = r#"{
515 "usage": { "limit": "100", "used": "26", "remaining": "74" },
516 "limits": [
517 {
518 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
519 "detail": { "limit": "100", "used": "15", "remaining": "85" }
520 }
521 ]
522 }"#;
523 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
524 .unwrap()
525 .into_snapshot()
526 .unwrap();
527 assert_eq!(snap.window_limit, 100);
528 assert_eq!(snap.window_used, 15);
529 }
530
531 #[test]
532 fn empty_limits_yield_no_window() {
533 let raw = r#"{
534 "usage": { "limit": "100", "used": "26", "remaining": "74" },
535 "limits": []
536 }"#;
537 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
538 .unwrap()
539 .into_snapshot()
540 .unwrap();
541 assert_eq!(snap.window_limit, 0);
542 assert_eq!(snap.window_used, 0);
543 assert_eq!(snap.window_remaining, 0);
544 }
545
546 #[test]
547 fn null_limits_yield_no_window() {
548 let raw = r#"{
549 "usage": { "limit": "100", "used": "26", "remaining": "74" },
550 "limits": null
551 }"#;
552 let snap = serde_json::from_str::<UsagesResponse>(raw)
553 .unwrap()
554 .into_snapshot()
555 .unwrap();
556 assert_eq!(snap.window_limit, 0);
557 assert_eq!(snap.window_used, 0);
558 }
559
560 #[test]
561 fn unrecognized_window_is_schema_drift() {
562 let raw = r#"{
563 "usage": { "limit": "100", "used": "26", "remaining": "74" },
564 "limits": [
565 {
566 "window": { "duration": 60, "timeUnit": "TIME_UNIT_MINUTE" },
567 "detail": { "limit": "100", "used": "1", "remaining": "99" }
568 }
569 ]
570 }"#;
571 let err = serde_json::from_str::<UsagesResponse>(raw)
572 .unwrap()
573 .into_snapshot()
574 .unwrap_err();
575 assert!(err.to_string().contains("recognized 5h"));
576 }
577
578 #[test]
579 fn selects_second_300_min_window_when_first_lacks_detail() {
580 let raw = r#"{
581 "usage": { "limit": "100", "used": "10", "remaining": "90" },
582 "limits": [
583 {
584 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }
585 },
586 {
587 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
588 "detail": { "limit": "100", "used": "25", "remaining": "75" }
589 }
590 ]
591 }"#;
592 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
593 .unwrap()
594 .into_snapshot()
595 .unwrap();
596 assert_eq!(snap.window_limit, 100);
597 assert_eq!(snap.window_used, 25);
598 assert_eq!(snap.window_remaining, 75);
599 }
600
601 #[test]
602 fn selects_first_300_min_window_among_multiple() {
603 let raw = r#"{
604 "usage": { "limit": "100", "used": "10", "remaining": "90" },
605 "limits": [
606 {
607 "window": { "duration": 60, "timeUnit": "TIME_UNIT_MINUTE" },
608 "detail": { "limit": "100", "used": "1", "remaining": "99" }
609 },
610 {
611 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
612 "detail": { "limit": "100", "used": "25", "remaining": "75" }
613 },
614 {
615 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
616 "detail": { "limit": "100", "used": "50", "remaining": "50" }
617 }
618 ]
619 }"#;
620 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
621 .unwrap()
622 .into_snapshot()
623 .unwrap();
624 assert_eq!(snap.window_used, 25);
625 }
626
627 #[test]
628 fn used_greater_than_limit_clamps_pct() {
629 let snap = KimiSnapshot {
630 plan: None,
631 weekly_limit: 100,
632 weekly_used: 150,
633 weekly_remaining: 0,
634 weekly_reset_at: None,
635 has_weekly: true,
636 monthly_pct: None,
637 monthly_reset_at: None,
638 window_limit: 0,
639 window_used: 0,
640 window_remaining: 0,
641 window_reset_at: None,
642 };
643 assert_eq!(snap.weekly_pct(), 100);
644 }
645
646 #[test]
647 fn u64_max_round_trip() {
648 let raw = r#"{
649 "usage": { "limit": "18446744073709551615", "used": "0", "remaining": "18446744073709551615" }
650 }"#;
651 let snap: KimiSnapshot = serde_json::from_str::<UsagesResponse>(raw)
652 .unwrap()
653 .into_snapshot()
654 .unwrap();
655 assert_eq!(snap.weekly_limit, u64::MAX);
656 assert_eq!(snap.weekly_remaining, u64::MAX);
657 }
658
659 #[test]
660 fn accepts_reset_and_duration_aliases() {
661 let raw = r#"{
662 "usage": { "limit": 100, "used": 20, "resetAt": "2026-02-11T17:32:50Z" },
663 "limits": [{
664 "window": { "duration": 5, "time_unit": "TIME_UNIT_HOUR" },
665 "detail": { "limit": 100, "remaining": 75, "reset_at": "2026-02-07T12:32:50Z" }
666 }]
667 }"#;
668 let snap = serde_json::from_str::<UsagesResponse>(raw)
669 .unwrap()
670 .into_snapshot()
671 .unwrap();
672 assert_eq!(snap.weekly_used, 20);
673 assert_eq!(snap.window_used, 25);
674 assert!(snap.weekly_reset_at.is_some());
675 assert!(snap.window_reset_at.is_some());
676 }
677
678 const NEWER_SHAPE_CAPTURE: &str = r#"{
681 "limits": [
682 {
683 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
684 "detail": { "limit": "0", "used": "0", "remaining": "0", "resetTime": "2026-09-16T20:11:32.979529Z" }
685 }
686 ],
687 "usages": {
688 "limit_5h": { "used_ratio": 0, "reset_time": "2026-09-16T20:11:32Z" },
689 "limit_month_total": { "used_ratio": 0.0, "reset_time": "2026-10-16T00:00:00Z" },
690 "limit_month_code": { "used_ratio": 0, "reset_time": "2026-10-16T00:00:00Z" }
691 }
692 }"#;
693
694 #[test]
695 fn the_newer_usages_map_shape_parses_into_a_monthly_snapshot() {
696 let snap = serde_json::from_str::<UsagesResponse>(NEWER_SHAPE_CAPTURE)
697 .unwrap()
698 .into_snapshot()
699 .unwrap();
700 assert!(!snap.has_weekly);
702 assert_eq!(snap.weekly_limit, 0);
703 assert_eq!(snap.weekly_used, 0);
704 assert_eq!(snap.weekly_remaining, 0);
705 assert_eq!(snap.weekly_reset_at, None);
706 assert_eq!(snap.monthly_pct, Some(0));
708 assert_eq!(
709 snap.monthly_reset_at.map(|dt| dt.to_rfc3339()),
710 Some("2026-10-16T00:00:00+00:00".to_string())
711 );
712 assert_eq!(snap.window_limit, 0);
714 assert_eq!(snap.window_used, 0);
715 assert_eq!(snap.window_remaining, 0);
716 assert_eq!(
717 snap.window_reset_at.map(|dt| dt.to_rfc3339()),
718 Some("2026-09-16T20:11:32.979529+00:00".to_string())
719 );
720 }
721
722 #[test]
723 fn the_legacy_usage_block_shape_still_has_a_weekly_bucket_and_no_monthly() {
724 let raw = r#"{
725 "usage": { "limit": "100", "used": "26", "remaining": "74", "resetTime": "2026-02-11T17:32:50Z" }
726 }"#;
727 let snap = serde_json::from_str::<UsagesResponse>(raw)
728 .unwrap()
729 .into_snapshot()
730 .unwrap();
731 assert!(snap.has_weekly);
732 assert_eq!(snap.weekly_used, 26);
733 assert_eq!(snap.monthly_pct, None);
734 assert_eq!(snap.monthly_reset_at, None);
735 }
736
737 #[test]
738 fn a_usages_map_alongside_the_legacy_block_is_ignored() {
739 let raw = r#"{
740 "usage": { "limit": "100", "used": "26", "remaining": "74" },
741 "usages": { "limit_month_total": { "used_ratio": 0.9, "reset_time": "2026-10-16T00:00:00Z" } }
742 }"#;
743 let snap = serde_json::from_str::<UsagesResponse>(raw)
744 .unwrap()
745 .into_snapshot()
746 .unwrap();
747 assert!(snap.has_weekly);
748 assert_eq!(snap.weekly_used, 26);
749 assert_eq!(snap.monthly_pct, None);
750 }
751
752 #[test]
753 fn neither_usage_block_nor_month_total_is_schema_drift() {
754 for raw in [
755 r#"{"limits": []}"#,
756 r#"{"usages": {}}"#,
757 r#"{"usages": {"limit_5h": {"used_ratio": 0.5}}}"#,
758 ] {
759 let err = serde_json::from_str::<UsagesResponse>(raw)
760 .unwrap()
761 .into_snapshot()
762 .unwrap_err();
763 assert!(err.to_string().contains("usage block"), "{raw}: {err}");
764 }
765 }
766
767 #[test]
768 fn a_month_total_without_a_ratio_is_schema_drift() {
769 let raw = r#"{
770 "usages": { "limit_month_total": { "reset_time": "2026-10-16T00:00:00Z" } }
771 }"#;
772 let err = serde_json::from_str::<UsagesResponse>(raw)
773 .unwrap()
774 .into_snapshot()
775 .unwrap_err();
776 assert!(err.to_string().contains("used_ratio"), "{err}");
777 }
778
779 #[test]
780 fn out_of_range_ratios_are_schema_drift() {
781 for ratio in [-0.5, 1.5] {
782 let raw =
783 format!(r#"{{"usages": {{"limit_month_total": {{"used_ratio": {ratio}}}}}}}"#);
784 let err = serde_json::from_str::<UsagesResponse>(&raw)
785 .unwrap()
786 .into_snapshot()
787 .unwrap_err();
788 assert!(err.to_string().contains("used_ratio"), "{raw}: {err}");
789 }
790 for ratio in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
793 assert!(monthly_pct(Some(ratio)).is_err(), "{ratio}");
794 }
795 }
796
797 #[test]
798 fn the_ratio_rounds_to_a_percent() {
799 assert_eq!(monthly_pct(Some(0.0)).unwrap(), 0);
800 assert_eq!(monthly_pct(Some(0.424)).unwrap(), 42);
801 assert_eq!(monthly_pct(Some(1.0)).unwrap(), 100);
802 }
803
804 #[test]
807 fn the_code_slice_is_never_added_to_the_monthly_pool() {
808 let raw = r#"{
809 "usages": {
810 "limit_month_total": { "used_ratio": 0.4, "reset_time": "2026-10-16T00:00:00Z" },
811 "limit_month_code": { "used_ratio": 0.9, "reset_time": "2026-10-16T00:00:00Z" }
812 }
813 }"#;
814 let snap = serde_json::from_str::<UsagesResponse>(raw)
815 .unwrap()
816 .into_snapshot()
817 .unwrap();
818 assert_eq!(snap.monthly_pct, Some(40));
819 let rendered = format!("{snap:?}");
821 assert!(!rendered.contains("90"), "{rendered}");
822 }
823
824 #[test]
825 fn the_usages_map_limit_5h_is_never_read() {
826 let raw = r#"{
829 "limits": [
830 {
831 "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" },
832 "detail": { "limit": "100", "used": "15", "remaining": "85", "resetTime": "2026-09-16T20:11:32Z" }
833 }
834 ],
835 "usages": {
836 "limit_5h": { "used_ratio": 0.99, "reset_time": "2026-09-16T22:00:00Z" },
837 "limit_month_total": { "used_ratio": 0.4 }
838 }
839 }"#;
840 let snap = serde_json::from_str::<UsagesResponse>(raw)
841 .unwrap()
842 .into_snapshot()
843 .unwrap();
844 assert_eq!(snap.window_used, 15);
845 assert_eq!(
846 snap.window_reset_at.map(|dt| dt.to_rfc3339()),
847 Some("2026-09-16T20:11:32+00:00".to_string())
848 );
849 }
850}