1use crate::time;
6use serde_json::Value;
7use std::cmp::Ordering;
8
9#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
10pub struct Window {
11 pub kind: String,
12 pub scope: Option<String>,
13 pub percent: f64,
14 pub resets_at: Option<i64>,
15 pub is_active: bool,
16 #[serde(default)]
19 pub severity: Option<String>,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub length_seconds: Option<i64>,
28}
29
30pub fn anthropic_window_length(kind: &str) -> Option<i64> {
36 match kind {
37 "session" | "five_hour" => Some(5 * 3600),
38 "seven_day" => Some(7 * 86_400),
39 weekly if weekly.starts_with("weekly_") => Some(7 * 86_400),
40 _ => None,
41 }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum Source {
48 Live,
50 ClaudeCodeCache,
52 Remembered,
54}
55
56#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
57pub struct Snapshot {
58 pub windows: Vec<Window>,
59 pub observed_at: Option<i64>,
60 pub account_uuid: Option<String>,
61 pub source: Source,
62}
63
64impl Window {
65 pub(crate) fn used(&self, now: i64) -> f64 {
68 if self.resets_at.is_some_and(|at| at <= now) {
69 0.0
70 } else {
71 self.percent
72 }
73 }
74
75 pub(crate) fn same_limit(&self, other: &Window) -> bool {
77 limit_name(&self.kind) == limit_name(&other.kind) && self.scope == other.scope
78 }
79
80 pub(crate) fn same_window(&self, other: &Window) -> bool {
83 self.same_limit(other)
84 && matches!(
85 (self.resets_at, other.resets_at),
86 (Some(x), Some(y)) if same_reset(x, y)
87 )
88 }
89}
90
91pub(crate) fn limit_name(kind: &str) -> &str {
96 match kind {
97 "five_hour" => "session",
98 "seven_day" => "weekly_all",
99 other => other,
100 }
101}
102
103const SAME_RESET: u64 = 60;
111
112pub fn same_reset(a: i64, b: i64) -> bool {
116 a.abs_diff(b) < SAME_RESET
117}
118
119pub(crate) fn recency(a: &Window, b: &Window, now: i64) -> Ordering {
141 match (a.resets_at, b.resets_at) {
142 (Some(x), Some(y)) if !same_reset(x, y) => x.cmp(&y),
143 _ => a.used(now).total_cmp(&b.used(now)),
144 }
145}
146
147pub(crate) fn merge(
179 known: Option<&Snapshot>,
180 offered: Option<&Snapshot>,
181 now: i64,
182) -> Option<Snapshot> {
183 let Some(offered) = offered else {
184 return known.cloned();
185 };
186 let Some(known) = known else {
187 let mut first = offered.clone();
188 first.observed_at = first.observed_at.or(Some(now));
189 return Some(first);
190 };
191 let answered = offered.source == Source::Live && offered.observed_at.is_some();
192 let taken_since = offered.observed_at > known.observed_at;
193 let (mut advanced, mut confirmed) = (false, false);
194 let mut windows = Vec::new();
195 for had in &known.windows {
196 match offered.windows.iter().find(|w| w.same_limit(had)) {
197 Some(given) => match recency(given, had, now) {
198 Ordering::Less if taken_since => {
199 advanced = true;
200 windows.push(given.clone());
201 }
202 Ordering::Less => windows.push(had.clone()),
203 Ordering::Equal if had.resets_at.is_some_and(|at| at <= now) => {
204 confirmed = true;
205 windows.push(if given.resets_at.is_none_or(|at| at > now) {
206 given.clone()
207 } else {
208 Window {
209 percent: 0.0,
210 resets_at: None,
211 ..had.clone()
212 }
213 });
214 }
215 Ordering::Equal => {
216 confirmed = true;
217 windows.push(had.clone());
218 }
219 Ordering::Greater => {
220 advanced = true;
221 windows.push(given.clone());
222 }
223 },
224 None if answered && had.resets_at.is_some_and(|at| at <= now) => {}
225 None => windows.push(had.clone()),
226 }
227 }
228 for given in &offered.windows {
229 if !known.windows.iter().any(|w| w.same_limit(given)) {
230 advanced = true;
231 windows.push(given.clone());
232 }
233 }
234 let vouched = advanced || (confirmed && offered.observed_at.is_some());
235 Some(Snapshot {
236 windows,
237 observed_at: if advanced {
238 known
239 .observed_at
240 .max(Some(offered.observed_at.unwrap_or(now)))
241 } else if confirmed {
242 known.observed_at.max(offered.observed_at)
243 } else {
244 known.observed_at
245 },
246 account_uuid: known
247 .account_uuid
248 .clone()
249 .or_else(|| offered.account_uuid.clone()),
250 source: if vouched {
251 offered.source
252 } else {
253 known.source
254 },
255 })
256}
257
258fn percent(v: &Value) -> Option<f64> {
260 let p = v.as_f64()?;
261 (p.is_finite() && p >= 0.0).then_some(p)
262}
263
264fn window_from_limit(l: &Value) -> Option<Window> {
265 let named = |what: &str| {
268 l.get("scope")
269 .and_then(|s| s.get(what))
270 .and_then(|m| m.get("display_name"))
271 .and_then(Value::as_str)
272 .map(str::to_owned)
273 };
274 Some(Window {
275 kind: l.get("kind")?.as_str()?.to_string(),
276 scope: named("model").or_else(|| named("surface")),
277 severity: l.get("severity").and_then(Value::as_str).map(str::to_owned),
278 percent: percent(l.get("percent")?)?,
279 resets_at: l
280 .get("resets_at")
281 .and_then(Value::as_str)
282 .and_then(time::parse),
283 is_active: l.get("is_active").and_then(Value::as_bool).unwrap_or(false),
284 length_seconds: l
285 .get("kind")
286 .and_then(Value::as_str)
287 .and_then(anthropic_window_length),
288 })
289}
290
291fn window_from_named(kind: &str, v: &Value) -> Option<Window> {
292 Some(Window {
293 kind: kind.to_string(),
294 scope: None,
295 severity: None,
296 percent: percent(v.get("utilization")?)?,
297 resets_at: v
298 .get("resets_at")
299 .and_then(Value::as_str)
300 .and_then(time::parse),
301 is_active: false,
302 length_seconds: anthropic_window_length(kind),
303 })
304}
305
306fn windows_of(u: &Value) -> Vec<Window> {
308 let mut windows: Vec<Window> = u
309 .get("limits")
310 .and_then(Value::as_array)
311 .map(|ls| ls.iter().filter_map(window_from_limit).collect())
312 .unwrap_or_default();
313 if windows.is_empty() {
314 for kind in ["five_hour", "seven_day"] {
315 if let Some(w) = u.get(kind).and_then(|v| window_from_named(kind, v)) {
316 windows.push(w);
317 }
318 }
319 }
320 windows
321}
322
323pub fn from_usage_object(u: &Value, observed_at: i64) -> Snapshot {
325 Snapshot {
326 windows: windows_of(u),
327 observed_at: Some(observed_at),
328 account_uuid: None,
329 source: Source::Live,
330 }
331}
332
333pub fn from_config_cache(config: &Value) -> Option<Snapshot> {
336 let c = config.get("cachedUsageUtilization")?;
337 Some(Snapshot {
338 windows: windows_of(c.get("utilization")?),
339 observed_at: c
340 .get("fetchedAtMs")
341 .and_then(Value::as_i64)
342 .map(|ms| ms / 1000),
343 account_uuid: c
344 .get("accountUuid")
345 .and_then(Value::as_str)
346 .map(str::to_owned),
347 source: Source::ClaudeCodeCache,
348 })
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354
355 fn real_config() -> Value {
357 serde_json::json!({"cachedUsageUtilization": {
358 "fetchedAtMs": 1789933772292i64,
359 "accountUuid": "1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
360 "utilization": {
361 "five_hour": {"utilization": 62, "resets_at": "2026-09-20T22:20:00.095287+00:00"},
362 "seven_day": {"utilization": 48, "resets_at": "2026-09-27T02:00:00.095306+00:00"},
363 "limits": [
364 {"kind": "session", "group": "session", "percent": 62,
365 "resets_at": "2026-09-20T22:20:00.095287+00:00", "scope": null, "is_active": true},
366 {"kind": "weekly_all", "group": "weekly", "percent": 48,
367 "resets_at": "2026-09-27T02:00:00.095306+00:00", "scope": null, "is_active": false},
368 {"kind": "weekly_scoped", "group": "weekly", "percent": 0,
369 "resets_at": "2026-09-27T02:00:00+00:00",
370 "scope": {"model": {"id": null, "display_name": "Fable"}}, "is_active": false}
371 ]}}})
372 }
373
374 #[test]
375 fn reads_the_real_cache_shape() {
376 let s = from_config_cache(&real_config()).expect("should parse");
377 assert_eq!(s.windows.len(), 3);
378 assert_eq!(
379 s.account_uuid.as_deref(),
380 Some("1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0")
381 );
382 assert_eq!(s.observed_at, Some(1789933772));
383 let scoped = s
384 .windows
385 .iter()
386 .find(|w| w.kind == "weekly_scoped")
387 .unwrap();
388 assert_eq!(scoped.scope.as_deref(), Some("Fable"));
389 }
390
391 #[test]
392 fn falls_back_to_the_named_windows_when_limits_is_missing() {
393 let mut c = real_config();
394 c["cachedUsageUtilization"]["utilization"]
395 .as_object_mut()
396 .unwrap()
397 .remove("limits");
398 let s = from_config_cache(&c).unwrap();
399 assert_eq!(s.windows.len(), 2);
400 assert_eq!(s.windows[0].kind, "five_hour");
401 assert_eq!(s.windows[0].percent, 62.0);
402 }
403
404 #[test]
405 fn a_nonsense_percentage_is_dropped_rather_than_drawn() {
406 for nonsense in [serde_json::json!(-5), serde_json::json!("75")] {
407 let mut c = real_config();
408 c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = nonsense;
409 assert_eq!(from_config_cache(&c).unwrap().windows.len(), 2);
410 }
411 }
412
413 #[test]
414 fn an_exceeded_limit_is_kept_not_dropped() {
415 let mut c = real_config();
416 c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = serde_json::json!(104);
417 let s = from_config_cache(&c).unwrap();
418 assert_eq!(s.windows.len(), 3);
419 assert_eq!(s.windows[0].percent, 104.0);
420 }
421
422 #[test]
423 fn missing_cache_is_not_an_error() {
424 assert!(from_config_cache(&serde_json::json!({})).is_none());
425 }
426
427 const NOW: i64 = 1_789_935_000;
428 const HOUR: i64 = 3_600;
429
430 fn measured(kind: &str, percent: f64, resets_at: Option<i64>) -> Window {
431 Window {
432 kind: kind.into(),
433 scope: None,
434 percent,
435 resets_at,
436 is_active: true,
437 severity: None,
438 length_seconds: anthropic_window_length(kind),
439 }
440 }
441
442 fn reading(windows: Vec<Window>, observed_at: Option<i64>) -> Snapshot {
443 Snapshot {
444 windows,
445 observed_at,
446 account_uuid: None,
447 source: Source::Live,
448 }
449 }
450
451 fn shares(reading: &Snapshot) -> Vec<(&str, f64)> {
452 reading
453 .windows
454 .iter()
455 .map(|w| (w.kind.as_str(), w.percent))
456 .collect()
457 }
458
459 #[test]
460 fn a_later_reset_is_a_newer_window_whatever_its_share() {
461 let full = measured("session", 90.0, Some(NOW + HOUR));
462 let next = measured("session", 2.0, Some(NOW + 6 * HOUR));
463 assert_eq!(recency(&next, &full, NOW), Ordering::Greater);
464 assert_eq!(recency(&full, &next, NOW), Ordering::Less);
465 }
466
467 #[test]
468 fn within_one_window_the_higher_share_is_the_newer() {
469 let earlier = measured("session", 20.0, Some(NOW + HOUR));
470 let later = measured("session", 22.0, Some(NOW + HOUR));
471 assert_eq!(recency(&later, &earlier, NOW), Ordering::Greater);
472 assert_eq!(recency(&earlier, &later, NOW), Ordering::Less);
473 }
474
475 #[test]
479 fn resets_a_second_apart_are_one_window() {
480 let answered = measured("session", 22.0, Some(NOW + HOUR));
481 let passed = measured("five_hour", 20.0, Some(NOW + HOUR + 1));
482 assert_eq!(recency(&passed, &answered, NOW), Ordering::Less);
483 assert_eq!(recency(&answered, &passed, NOW), Ordering::Greater);
484 }
485
486 #[test]
489 fn resets_under_a_minute_apart_are_one_reset() {
490 assert!(same_reset(NOW, NOW));
491 assert!(same_reset(NOW, NOW + 59));
492 assert!(same_reset(NOW + 59, NOW));
493 assert!(!same_reset(NOW, NOW + 60));
494 assert!(!same_reset(NOW + 60, NOW));
495 assert!(
496 !same_reset(0, NOW),
497 "no reset known is no reset in particular"
498 );
499 }
500
501 #[test]
504 fn a_window_past_its_reset_counts_as_reset() {
505 let over = measured("session", 90.0, Some(NOW - 1));
506 let begun = measured("session", 5.0, None);
507 assert_eq!(recency(&begun, &over, NOW), Ordering::Greater);
508 assert_eq!(recency(&over, &begun, NOW), Ordering::Less);
509 }
510
511 #[test]
512 fn a_reading_that_says_no_time_never_moves_a_limit_backwards() {
513 let known = reading(vec![measured("session", 22.0, Some(NOW + HOUR))], Some(NOW));
514 for behind in [
515 measured("five_hour", 20.0, Some(NOW + HOUR)),
516 measured("five_hour", 95.0, Some(NOW - 4 * HOUR)),
517 ] {
518 let merged = merge(Some(&known), Some(&reading(vec![behind], None)), NOW).unwrap();
519 assert_eq!(shares(&merged), [("session", 22.0)]);
520 }
521 let ahead = reading(vec![measured("five_hour", 25.0, Some(NOW + HOUR))], None);
522 let merged = merge(Some(&known), Some(&ahead), NOW).unwrap();
523 assert_eq!(
524 shares(&merged),
525 [("five_hour", 25.0)],
526 "under the name it came with"
527 );
528 }
529
530 #[test]
536 fn an_answer_taken_after_everything_known_is_what_the_limits_are_now() {
537 let known = reading(
538 vec![
539 measured("session", 0.0, None),
540 measured("weekly_all", 100.0, Some(NOW + 33 * HOUR)),
541 ],
542 Some(NOW - HOUR),
543 );
544 let answered = reading(
545 vec![
546 measured("session", 5.0, Some(NOW + 5 * HOUR)),
547 measured("weekly_all", 14.0, Some(NOW + 33 * HOUR)),
548 ],
549 Some(NOW),
550 );
551 let mut cached = reading(answered.windows.clone(), Some(NOW - 5));
552 cached.source = Source::ClaudeCodeCache;
553 for offered in [answered, cached] {
554 let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
555 assert_eq!(
556 shares(&merged),
557 [("session", 5.0), ("weekly_all", 14.0)],
558 "{:?}",
559 offered.source
560 );
561 assert_eq!(merged.observed_at, offered.observed_at);
562 assert_eq!(merged.source, offered.source);
563 }
564
565 let unused = reading(vec![measured("weekly_all", 0.0, None)], Some(NOW));
566 let merged = merge(Some(&known), Some(&unused), NOW).unwrap();
567 assert_eq!(
568 shares(&merged),
569 [("session", 0.0), ("weekly_all", 0.0)],
570 "and one that finds nothing used and no window running"
571 );
572 }
573
574 #[test]
578 fn an_answer_taken_no_later_than_what_is_known_never_lowers_a_share() {
579 let known = reading(
580 vec![measured("weekly_all", 100.0, Some(NOW + 33 * HOUR))],
581 Some(NOW - 60),
582 );
583 for taken in [NOW - 60, NOW - HOUR] {
584 let answered = reading(
585 vec![measured("weekly_all", 14.0, Some(NOW + 33 * HOUR))],
586 Some(taken),
587 );
588 let merged = merge(Some(&known), Some(&answered), NOW).unwrap();
589 assert_eq!(shares(&merged), [("weekly_all", 100.0)], "taken at {taken}");
590 }
591 }
592
593 #[test]
596 fn a_limit_only_one_reading_measured_is_kept() {
597 let scoped = Window {
598 scope: Some("Fable".into()),
599 ..measured("weekly_scoped", 5.0, Some(NOW + 50 * HOUR))
600 };
601 let known = reading(
602 vec![measured("session", 22.0, Some(NOW + HOUR)), scoped],
603 Some(NOW - 60),
604 );
605 let offered = reading(
606 vec![
607 measured("five_hour", 25.0, Some(NOW + HOUR)),
608 measured("seven_day", 40.0, Some(NOW + 50 * HOUR)),
609 ],
610 None,
611 );
612 let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
613 assert_eq!(
614 shares(&merged),
615 [
616 ("five_hour", 25.0),
617 ("weekly_scoped", 5.0),
618 ("seven_day", 40.0)
619 ]
620 );
621 }
622
623 #[test]
627 fn a_limit_an_answer_leaves_out_goes_once_its_reset_has_passed() {
628 let known = reading(
629 vec![
630 measured("session", 22.0, Some(NOW + HOUR)),
631 measured("weekly_scoped", 5.0, Some(NOW - 1)),
632 ],
633 Some(NOW - 60),
634 );
635 let answered = reading(vec![measured("session", 25.0, Some(NOW + HOUR))], Some(NOW));
636 let merged = merge(Some(&known), Some(&answered), NOW).unwrap();
637 assert_eq!(shares(&merged), [("session", 25.0)]);
638 }
639
640 #[test]
645 fn a_reading_that_is_not_an_answer_just_now_never_takes_a_limit_away() {
646 let known = reading(
647 vec![
648 measured("session", 40.0, Some(NOW - 10)),
649 measured("weekly_all", 30.0, Some(NOW + 50 * HOUR)),
650 ],
651 Some(NOW - HOUR),
652 );
653 let passed = reading(
654 vec![measured("seven_day", 31.0, Some(NOW + 50 * HOUR))],
655 None,
656 );
657 let mut cached = reading(passed.windows.clone(), Some(NOW - 5));
658 cached.source = Source::ClaudeCodeCache;
659 for offered in [passed, cached] {
660 let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
661 assert_eq!(
662 shares(&merged),
663 [("session", 40.0), ("seven_day", 31.0)],
664 "{:?}",
665 offered.source
666 );
667 assert_eq!(merged.windows[0].used(NOW), 0.0);
668 }
669 }
670
671 #[test]
672 fn with_one_side_absent_the_other_stands() {
673 let known = reading(
674 vec![measured("session", 22.0, Some(NOW + HOUR))],
675 Some(NOW - 60),
676 );
677 assert_eq!(merge(Some(&known), None, NOW), Some(known.clone()));
678 assert_eq!(merge(None, None, NOW), None);
679
680 let first = merge(None, Some(&reading(known.windows.clone(), None)), NOW).unwrap();
681 assert_eq!(shares(&first), [("session", 22.0)]);
682 assert_eq!(first.observed_at, Some(NOW), "stamped when it arrived");
683 }
684
685 #[test]
689 fn a_reading_that_says_no_time_is_stamped_only_when_it_moves_something() {
690 let mut known = reading(
691 vec![measured("session", 22.0, Some(NOW + HOUR))],
692 Some(NOW - 600),
693 );
694 known.source = Source::Remembered;
695 let repeated = reading(vec![measured("five_hour", 22.0, Some(NOW + HOUR))], None);
696 let merged = merge(Some(&known), Some(&repeated), NOW).unwrap();
697 assert_eq!(
698 merged.windows, known.windows,
699 "a repeat changes nothing, name and all"
700 );
701 assert_eq!(merged.observed_at, Some(NOW - 600));
702 assert_eq!(
703 merged.source,
704 Source::Remembered,
705 "and it is still what was known"
706 );
707
708 let moved = reading(vec![measured("five_hour", 23.0, Some(NOW + HOUR))], None);
709 assert_eq!(
710 merge(Some(&known), Some(&moved), NOW).unwrap().observed_at,
711 Some(NOW)
712 );
713
714 let mut confirmed = reading(known.windows.clone(), Some(NOW - 5));
715 confirmed.source = Source::ClaudeCodeCache;
716 let merged = merge(Some(&known), Some(&confirmed), NOW).unwrap();
717 assert_eq!(
718 merged.observed_at,
719 Some(NOW - 5),
720 "a measured repeat confirms it"
721 );
722 assert_eq!(merged.source, Source::ClaudeCodeCache);
723 }
724
725 #[test]
730 fn a_window_past_its_reset_is_reset_by_a_reading_that_finds_nothing_used() {
731 let known = reading(
732 vec![measured("session", 100.0, Some(NOW - HOUR))],
733 Some(NOW - 2 * HOUR),
734 );
735 for (said, source) in [
736 (measured("session", 0.0, None), Source::Live),
737 (measured("session", 0.0, Some(NOW - HOUR)), Source::Live),
738 (measured("five_hour", 0.0, None), Source::ClaudeCodeCache),
739 ] {
740 let mut offered = reading(vec![said], Some(NOW - 5));
741 offered.source = source;
742 let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
743 assert_eq!(merged.windows[0].percent, 0.0, "{source:?}");
744 assert_eq!(merged.windows[0].resets_at, None, "{source:?}");
745 assert_eq!(merged.source, source, "and it is what said so");
746 assert_eq!(
747 merge(Some(&merged), Some(&offered), NOW).as_ref(),
748 Some(&merged),
749 "a repeat changes nothing"
750 );
751 }
752
753 let untimed = reading(vec![measured("five_hour", 0.0, None)], None);
754 let merged = merge(Some(&known), Some(&untimed), NOW).unwrap();
755 assert_eq!(merged.windows[0].percent, 0.0);
756 assert_eq!(
757 merged.observed_at,
758 Some(NOW - 2 * HOUR),
759 "a reading that says no time moved nothing forward"
760 );
761 }
762}