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