Skip to main content

ai_usagebar/kimi/
types.rs

1//! Wire types for Kimi's `/coding/v1/usages` endpoint.
2
3use 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    // Kimi omits this for accounts without a rolling quota and has also
15    // returned `null`; both mean no rolling window is available.
16    limits: Option<Vec<Limit>>,
17    // The newer response shape: quota ratios keyed by name, *replacing* the
18    // top-level `usage` block. Only `limit_month_total` — the combined
19    // monthly pool — is read. `limit_month_code` is the Code slice inside
20    // that pool, never its own allowance, and `limit_5h` disagrees with
21    // `limits[]`, which stays the one source for the rolling window.
22    usages: Option<UsagesMap>,
23}
24
25/// Quota ratios keyed by quota name on the newer response shape.
26pub type UsagesMap = std::collections::HashMap<String, UsageRatio>;
27
28#[derive(Debug, Clone, Deserialize, Default)]
29#[serde(default)]
30pub struct UsageRatio {
31    /// Fraction of the pool consumed (0.0..=1.0); ×100 is the percent.
32    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        // The raw membership enum ("LEVEL_INTERMEDIATE") is a wire value, not
98        // something to put on a status bar. `fetch` overwrites this with the
99        // vendor's own tier name from `/me` when that call succeeds.
100        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        // `limits` is absent for accounts where Kimi does not expose the
107        // rolling quota. Once it is present, a 5h window is required: silently
108        // treating an unfamiliar advertised window as zero usage masks drift.
109        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            // The legacy shape: weekly counters in the top-level `usage`
128            // block. A `usages` map alongside it is ignored.
129            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            // The newer shape: no weekly bucket at all. The monthly pool is
148            // the only long window, and nothing fabricates weekly counts.
149            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
176/// `used_ratio` is a fraction of the monthly pool (validated against the
177/// vendor's own website); the bar speaks percent. Anything outside
178/// 0.0..=1.0 — or not a finite number at all — is schema drift, not a quota.
179fn 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/// The profile response from `/coding/v1/me`, read for exactly one field.
212///
213/// That endpoint also returns the account's email, phone, nickname, avatar and
214/// ids. **None of them are deserialized here** — serde drops unknown fields, so
215/// the personal data never enters a snapshot, the cache, or an error message.
216/// Keep it that way: the plan label is the only thing this vendor needs.
217#[derive(Debug, Clone, Deserialize, Default)]
218#[serde(default)]
219pub struct UserInfoResponse {
220    /// The subscription tier's own name — "Andante", "Moderato",
221    /// "Allegretto", "Allegro". Kimi names its plans after tempo markings, so
222    /// this reads as a product name and not as a gamification badge.
223    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
236/// Make a raw membership enum readable when the vendor's own label is
237/// unavailable (`/me` unreachable, or an API key whose account has no coding
238/// profile). `LEVEL_INTERMEDIATE` → `Intermediate`.
239///
240/// Deliberately *not* a table mapping levels onto tier names: the enum-to-tier
241/// correspondence is not published anywhere, and inventing "Allegretto" for a
242/// level that might mean something else would put a wrong plan on screen with
243/// full confidence. Prettifying what the vendor said is the honest fallback.
244pub 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
265/// Kimi documents the rolling window as 300 minutes. Accept only equivalent
266/// spellings used by protobuf/JSON gateways, not arbitrary duration units.
267fn 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        // No LEVEL_ prefix, mixed case, and surrounding space still read well.
308        assert_eq!(humanize_membership_level("  basic  "), "Basic");
309        // Degenerate inputs are returned rather than turned into an empty label.
310        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        // The struct has no field to hold the personal data, so nothing else
325        // can leak into a snapshot or a Debug line.
326        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        // Deserialization itself must fail because -1 is not a valid u64.
505        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    /// Verbatim redacted capture from an account on the newer response shape
679    /// (issue #199): no top-level `usage` block, a `usages` map instead.
680    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        // No weekly bucket on this shape, and nothing fabricates counters.
701        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        // The monthly pool comes from limit_month_total, percent + reset.
707        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        // The 5h window still comes from limits[], not usages.limit_5h.
713        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        // JSON cannot spell NaN or infinity, so those go through the
791        // validator directly.
792        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    /// `limit_month_code` is the Code slice *inside* the combined monthly
805    /// pool: never its own allowance, and never added to the total.
806    #[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        // No field on the snapshot can carry the code slice.
820        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        // limit_5h disagrees with limits[] on real accounts, and the website
827        // matches limits[] — so the rolling window ignores the map entirely.
828        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}