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(&self.kind) == limit(&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 x.abs_diff(y) < SAME_RESET
87 )
88 }
89}
90
91fn limit(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(crate) fn recency(a: &Window, b: &Window, now: i64) -> Ordering {
134 match (a.resets_at, b.resets_at) {
135 (Some(x), Some(y)) if x.abs_diff(y) >= SAME_RESET => x.cmp(&y),
136 _ => a.used(now).total_cmp(&b.used(now)),
137 }
138}
139
140pub(crate) fn merge(
172 known: Option<&Snapshot>,
173 offered: Option<&Snapshot>,
174 now: i64,
175) -> Option<Snapshot> {
176 let Some(offered) = offered else {
177 return known.cloned();
178 };
179 let Some(known) = known else {
180 let mut first = offered.clone();
181 first.observed_at = first.observed_at.or(Some(now));
182 return Some(first);
183 };
184 let answered = offered.source == Source::Live && offered.observed_at.is_some();
185 let taken_since = offered.observed_at > known.observed_at;
186 let (mut advanced, mut confirmed) = (false, false);
187 let mut windows = Vec::new();
188 for had in &known.windows {
189 match offered.windows.iter().find(|w| w.same_limit(had)) {
190 Some(given) => match recency(given, had, now) {
191 Ordering::Less if taken_since => {
192 advanced = true;
193 windows.push(given.clone());
194 }
195 Ordering::Less => windows.push(had.clone()),
196 Ordering::Equal if had.resets_at.is_some_and(|at| at <= now) => {
197 confirmed = true;
198 windows.push(if given.resets_at.is_none_or(|at| at > now) {
199 given.clone()
200 } else {
201 Window {
202 percent: 0.0,
203 resets_at: None,
204 ..had.clone()
205 }
206 });
207 }
208 Ordering::Equal => {
209 confirmed = true;
210 windows.push(had.clone());
211 }
212 Ordering::Greater => {
213 advanced = true;
214 windows.push(given.clone());
215 }
216 },
217 None if answered && had.resets_at.is_some_and(|at| at <= now) => {}
218 None => windows.push(had.clone()),
219 }
220 }
221 for given in &offered.windows {
222 if !known.windows.iter().any(|w| w.same_limit(given)) {
223 advanced = true;
224 windows.push(given.clone());
225 }
226 }
227 let vouched = advanced || (confirmed && offered.observed_at.is_some());
228 Some(Snapshot {
229 windows,
230 observed_at: if advanced {
231 known
232 .observed_at
233 .max(Some(offered.observed_at.unwrap_or(now)))
234 } else if confirmed {
235 known.observed_at.max(offered.observed_at)
236 } else {
237 known.observed_at
238 },
239 account_uuid: known
240 .account_uuid
241 .clone()
242 .or_else(|| offered.account_uuid.clone()),
243 source: if vouched {
244 offered.source
245 } else {
246 known.source
247 },
248 })
249}
250
251fn percent(v: &Value) -> Option<f64> {
253 let p = v.as_f64()?;
254 (p.is_finite() && p >= 0.0).then_some(p)
255}
256
257fn window_from_limit(l: &Value) -> Option<Window> {
258 let named = |what: &str| {
261 l.get("scope")
262 .and_then(|s| s.get(what))
263 .and_then(|m| m.get("display_name"))
264 .and_then(Value::as_str)
265 .map(str::to_owned)
266 };
267 Some(Window {
268 kind: l.get("kind")?.as_str()?.to_string(),
269 scope: named("model").or_else(|| named("surface")),
270 severity: l.get("severity").and_then(Value::as_str).map(str::to_owned),
271 percent: percent(l.get("percent")?)?,
272 resets_at: l
273 .get("resets_at")
274 .and_then(Value::as_str)
275 .and_then(time::parse),
276 is_active: l.get("is_active").and_then(Value::as_bool).unwrap_or(false),
277 length_seconds: l
278 .get("kind")
279 .and_then(Value::as_str)
280 .and_then(anthropic_window_length),
281 })
282}
283
284fn window_from_named(kind: &str, v: &Value) -> Option<Window> {
285 Some(Window {
286 kind: kind.to_string(),
287 scope: None,
288 severity: None,
289 percent: percent(v.get("utilization")?)?,
290 resets_at: v
291 .get("resets_at")
292 .and_then(Value::as_str)
293 .and_then(time::parse),
294 is_active: false,
295 length_seconds: anthropic_window_length(kind),
296 })
297}
298
299fn windows_of(u: &Value) -> Vec<Window> {
301 let mut windows: Vec<Window> = u
302 .get("limits")
303 .and_then(Value::as_array)
304 .map(|ls| ls.iter().filter_map(window_from_limit).collect())
305 .unwrap_or_default();
306 if windows.is_empty() {
307 for kind in ["five_hour", "seven_day"] {
308 if let Some(w) = u.get(kind).and_then(|v| window_from_named(kind, v)) {
309 windows.push(w);
310 }
311 }
312 }
313 windows
314}
315
316pub fn from_usage_object(u: &Value, observed_at: i64) -> Snapshot {
318 Snapshot {
319 windows: windows_of(u),
320 observed_at: Some(observed_at),
321 account_uuid: None,
322 source: Source::Live,
323 }
324}
325
326pub fn from_config_cache(config: &Value) -> Option<Snapshot> {
329 let c = config.get("cachedUsageUtilization")?;
330 Some(Snapshot {
331 windows: windows_of(c.get("utilization")?),
332 observed_at: c
333 .get("fetchedAtMs")
334 .and_then(Value::as_i64)
335 .map(|ms| ms / 1000),
336 account_uuid: c
337 .get("accountUuid")
338 .and_then(Value::as_str)
339 .map(str::to_owned),
340 source: Source::ClaudeCodeCache,
341 })
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn real_config() -> Value {
350 serde_json::json!({"cachedUsageUtilization": {
351 "fetchedAtMs": 1789933772292i64,
352 "accountUuid": "1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
353 "utilization": {
354 "five_hour": {"utilization": 62, "resets_at": "2026-09-20T22:20:00.095287+00:00"},
355 "seven_day": {"utilization": 48, "resets_at": "2026-09-27T02:00:00.095306+00:00"},
356 "limits": [
357 {"kind": "session", "group": "session", "percent": 62,
358 "resets_at": "2026-09-20T22:20:00.095287+00:00", "scope": null, "is_active": true},
359 {"kind": "weekly_all", "group": "weekly", "percent": 48,
360 "resets_at": "2026-09-27T02:00:00.095306+00:00", "scope": null, "is_active": false},
361 {"kind": "weekly_scoped", "group": "weekly", "percent": 0,
362 "resets_at": "2026-09-27T02:00:00+00:00",
363 "scope": {"model": {"id": null, "display_name": "Fable"}}, "is_active": false}
364 ]}}})
365 }
366
367 #[test]
368 fn reads_the_real_cache_shape() {
369 let s = from_config_cache(&real_config()).expect("should parse");
370 assert_eq!(s.windows.len(), 3);
371 assert_eq!(
372 s.account_uuid.as_deref(),
373 Some("1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0")
374 );
375 assert_eq!(s.observed_at, Some(1789933772));
376 let scoped = s
377 .windows
378 .iter()
379 .find(|w| w.kind == "weekly_scoped")
380 .unwrap();
381 assert_eq!(scoped.scope.as_deref(), Some("Fable"));
382 }
383
384 #[test]
385 fn falls_back_to_the_named_windows_when_limits_is_missing() {
386 let mut c = real_config();
387 c["cachedUsageUtilization"]["utilization"]
388 .as_object_mut()
389 .unwrap()
390 .remove("limits");
391 let s = from_config_cache(&c).unwrap();
392 assert_eq!(s.windows.len(), 2);
393 assert_eq!(s.windows[0].kind, "five_hour");
394 assert_eq!(s.windows[0].percent, 62.0);
395 }
396
397 #[test]
398 fn a_nonsense_percentage_is_dropped_rather_than_drawn() {
399 for nonsense in [serde_json::json!(-5), serde_json::json!("75")] {
400 let mut c = real_config();
401 c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = nonsense;
402 assert_eq!(from_config_cache(&c).unwrap().windows.len(), 2);
403 }
404 }
405
406 #[test]
407 fn an_exceeded_limit_is_kept_not_dropped() {
408 let mut c = real_config();
409 c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = serde_json::json!(104);
410 let s = from_config_cache(&c).unwrap();
411 assert_eq!(s.windows.len(), 3);
412 assert_eq!(s.windows[0].percent, 104.0);
413 }
414
415 #[test]
416 fn missing_cache_is_not_an_error() {
417 assert!(from_config_cache(&serde_json::json!({})).is_none());
418 }
419
420 const NOW: i64 = 1_789_935_000;
421 const HOUR: i64 = 3_600;
422
423 fn measured(kind: &str, percent: f64, resets_at: Option<i64>) -> Window {
424 Window {
425 kind: kind.into(),
426 scope: None,
427 percent,
428 resets_at,
429 is_active: true,
430 severity: None,
431 length_seconds: anthropic_window_length(kind),
432 }
433 }
434
435 fn reading(windows: Vec<Window>, observed_at: Option<i64>) -> Snapshot {
436 Snapshot {
437 windows,
438 observed_at,
439 account_uuid: None,
440 source: Source::Live,
441 }
442 }
443
444 fn shares(reading: &Snapshot) -> Vec<(&str, f64)> {
445 reading
446 .windows
447 .iter()
448 .map(|w| (w.kind.as_str(), w.percent))
449 .collect()
450 }
451
452 #[test]
453 fn a_later_reset_is_a_newer_window_whatever_its_share() {
454 let full = measured("session", 90.0, Some(NOW + HOUR));
455 let next = measured("session", 2.0, Some(NOW + 6 * HOUR));
456 assert_eq!(recency(&next, &full, NOW), Ordering::Greater);
457 assert_eq!(recency(&full, &next, NOW), Ordering::Less);
458 }
459
460 #[test]
461 fn within_one_window_the_higher_share_is_the_newer() {
462 let earlier = measured("session", 20.0, Some(NOW + HOUR));
463 let later = measured("session", 22.0, Some(NOW + HOUR));
464 assert_eq!(recency(&later, &earlier, NOW), Ordering::Greater);
465 assert_eq!(recency(&earlier, &later, NOW), Ordering::Less);
466 }
467
468 #[test]
472 fn resets_a_second_apart_are_one_window() {
473 let answered = measured("session", 22.0, Some(NOW + HOUR));
474 let passed = measured("five_hour", 20.0, Some(NOW + HOUR + 1));
475 assert_eq!(recency(&passed, &answered, NOW), Ordering::Less);
476 assert_eq!(recency(&answered, &passed, NOW), Ordering::Greater);
477 }
478
479 #[test]
482 fn a_window_past_its_reset_counts_as_reset() {
483 let over = measured("session", 90.0, Some(NOW - 1));
484 let begun = measured("session", 5.0, None);
485 assert_eq!(recency(&begun, &over, NOW), Ordering::Greater);
486 assert_eq!(recency(&over, &begun, NOW), Ordering::Less);
487 }
488
489 #[test]
490 fn a_reading_that_says_no_time_never_moves_a_limit_backwards() {
491 let known = reading(vec![measured("session", 22.0, Some(NOW + HOUR))], Some(NOW));
492 for behind in [
493 measured("five_hour", 20.0, Some(NOW + HOUR)),
494 measured("five_hour", 95.0, Some(NOW - 4 * HOUR)),
495 ] {
496 let merged = merge(Some(&known), Some(&reading(vec![behind], None)), NOW).unwrap();
497 assert_eq!(shares(&merged), [("session", 22.0)]);
498 }
499 let ahead = reading(vec![measured("five_hour", 25.0, Some(NOW + HOUR))], None);
500 let merged = merge(Some(&known), Some(&ahead), NOW).unwrap();
501 assert_eq!(
502 shares(&merged),
503 [("five_hour", 25.0)],
504 "under the name it came with"
505 );
506 }
507
508 #[test]
514 fn an_answer_taken_after_everything_known_is_what_the_limits_are_now() {
515 let known = reading(
516 vec![
517 measured("session", 0.0, None),
518 measured("weekly_all", 100.0, Some(NOW + 33 * HOUR)),
519 ],
520 Some(NOW - HOUR),
521 );
522 let answered = reading(
523 vec![
524 measured("session", 5.0, Some(NOW + 5 * HOUR)),
525 measured("weekly_all", 14.0, Some(NOW + 33 * HOUR)),
526 ],
527 Some(NOW),
528 );
529 let mut cached = reading(answered.windows.clone(), Some(NOW - 5));
530 cached.source = Source::ClaudeCodeCache;
531 for offered in [answered, cached] {
532 let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
533 assert_eq!(
534 shares(&merged),
535 [("session", 5.0), ("weekly_all", 14.0)],
536 "{:?}",
537 offered.source
538 );
539 assert_eq!(merged.observed_at, offered.observed_at);
540 assert_eq!(merged.source, offered.source);
541 }
542
543 let unused = reading(vec![measured("weekly_all", 0.0, None)], Some(NOW));
544 let merged = merge(Some(&known), Some(&unused), NOW).unwrap();
545 assert_eq!(
546 shares(&merged),
547 [("session", 0.0), ("weekly_all", 0.0)],
548 "and one that finds nothing used and no window running"
549 );
550 }
551
552 #[test]
556 fn an_answer_taken_no_later_than_what_is_known_never_lowers_a_share() {
557 let known = reading(
558 vec![measured("weekly_all", 100.0, Some(NOW + 33 * HOUR))],
559 Some(NOW - 60),
560 );
561 for taken in [NOW - 60, NOW - HOUR] {
562 let answered = reading(
563 vec![measured("weekly_all", 14.0, Some(NOW + 33 * HOUR))],
564 Some(taken),
565 );
566 let merged = merge(Some(&known), Some(&answered), NOW).unwrap();
567 assert_eq!(shares(&merged), [("weekly_all", 100.0)], "taken at {taken}");
568 }
569 }
570
571 #[test]
574 fn a_limit_only_one_reading_measured_is_kept() {
575 let scoped = Window {
576 scope: Some("Fable".into()),
577 ..measured("weekly_scoped", 5.0, Some(NOW + 50 * HOUR))
578 };
579 let known = reading(
580 vec![measured("session", 22.0, Some(NOW + HOUR)), scoped],
581 Some(NOW - 60),
582 );
583 let offered = reading(
584 vec![
585 measured("five_hour", 25.0, Some(NOW + HOUR)),
586 measured("seven_day", 40.0, Some(NOW + 50 * HOUR)),
587 ],
588 None,
589 );
590 let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
591 assert_eq!(
592 shares(&merged),
593 [
594 ("five_hour", 25.0),
595 ("weekly_scoped", 5.0),
596 ("seven_day", 40.0)
597 ]
598 );
599 }
600
601 #[test]
605 fn a_limit_an_answer_leaves_out_goes_once_its_reset_has_passed() {
606 let known = reading(
607 vec![
608 measured("session", 22.0, Some(NOW + HOUR)),
609 measured("weekly_scoped", 5.0, Some(NOW - 1)),
610 ],
611 Some(NOW - 60),
612 );
613 let answered = reading(vec![measured("session", 25.0, Some(NOW + HOUR))], Some(NOW));
614 let merged = merge(Some(&known), Some(&answered), NOW).unwrap();
615 assert_eq!(shares(&merged), [("session", 25.0)]);
616 }
617
618 #[test]
623 fn a_reading_that_is_not_an_answer_just_now_never_takes_a_limit_away() {
624 let known = reading(
625 vec![
626 measured("session", 40.0, Some(NOW - 10)),
627 measured("weekly_all", 30.0, Some(NOW + 50 * HOUR)),
628 ],
629 Some(NOW - HOUR),
630 );
631 let passed = reading(
632 vec![measured("seven_day", 31.0, Some(NOW + 50 * HOUR))],
633 None,
634 );
635 let mut cached = reading(passed.windows.clone(), Some(NOW - 5));
636 cached.source = Source::ClaudeCodeCache;
637 for offered in [passed, cached] {
638 let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
639 assert_eq!(
640 shares(&merged),
641 [("session", 40.0), ("seven_day", 31.0)],
642 "{:?}",
643 offered.source
644 );
645 assert_eq!(merged.windows[0].used(NOW), 0.0);
646 }
647 }
648
649 #[test]
650 fn with_one_side_absent_the_other_stands() {
651 let known = reading(
652 vec![measured("session", 22.0, Some(NOW + HOUR))],
653 Some(NOW - 60),
654 );
655 assert_eq!(merge(Some(&known), None, NOW), Some(known.clone()));
656 assert_eq!(merge(None, None, NOW), None);
657
658 let first = merge(None, Some(&reading(known.windows.clone(), None)), NOW).unwrap();
659 assert_eq!(shares(&first), [("session", 22.0)]);
660 assert_eq!(first.observed_at, Some(NOW), "stamped when it arrived");
661 }
662
663 #[test]
667 fn a_reading_that_says_no_time_is_stamped_only_when_it_moves_something() {
668 let mut known = reading(
669 vec![measured("session", 22.0, Some(NOW + HOUR))],
670 Some(NOW - 600),
671 );
672 known.source = Source::Remembered;
673 let repeated = reading(vec![measured("five_hour", 22.0, Some(NOW + HOUR))], None);
674 let merged = merge(Some(&known), Some(&repeated), NOW).unwrap();
675 assert_eq!(
676 merged.windows, known.windows,
677 "a repeat changes nothing, name and all"
678 );
679 assert_eq!(merged.observed_at, Some(NOW - 600));
680 assert_eq!(
681 merged.source,
682 Source::Remembered,
683 "and it is still what was known"
684 );
685
686 let moved = reading(vec![measured("five_hour", 23.0, Some(NOW + HOUR))], None);
687 assert_eq!(
688 merge(Some(&known), Some(&moved), NOW).unwrap().observed_at,
689 Some(NOW)
690 );
691
692 let mut confirmed = reading(known.windows.clone(), Some(NOW - 5));
693 confirmed.source = Source::ClaudeCodeCache;
694 let merged = merge(Some(&known), Some(&confirmed), NOW).unwrap();
695 assert_eq!(
696 merged.observed_at,
697 Some(NOW - 5),
698 "a measured repeat confirms it"
699 );
700 assert_eq!(merged.source, Source::ClaudeCodeCache);
701 }
702
703 #[test]
708 fn a_window_past_its_reset_is_reset_by_a_reading_that_finds_nothing_used() {
709 let known = reading(
710 vec![measured("session", 100.0, Some(NOW - HOUR))],
711 Some(NOW - 2 * HOUR),
712 );
713 for (said, source) in [
714 (measured("session", 0.0, None), Source::Live),
715 (measured("session", 0.0, Some(NOW - HOUR)), Source::Live),
716 (measured("five_hour", 0.0, None), Source::ClaudeCodeCache),
717 ] {
718 let mut offered = reading(vec![said], Some(NOW - 5));
719 offered.source = source;
720 let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
721 assert_eq!(merged.windows[0].percent, 0.0, "{source:?}");
722 assert_eq!(merged.windows[0].resets_at, None, "{source:?}");
723 assert_eq!(merged.source, source, "and it is what said so");
724 assert_eq!(
725 merge(Some(&merged), Some(&offered), NOW).as_ref(),
726 Some(&merged),
727 "a repeat changes nothing"
728 );
729 }
730
731 let untimed = reading(vec![measured("five_hour", 0.0, None)], None);
732 let merged = merge(Some(&known), Some(&untimed), NOW).unwrap();
733 assert_eq!(merged.windows[0].percent, 0.0);
734 assert_eq!(
735 merged.observed_at,
736 Some(NOW - 2 * HOUR),
737 "a reading that says no time moved nothing forward"
738 );
739 }
740}