Skip to main content

pitboard_core/
usage.rs

1//! One view of "how much is left", whatever shape it arrived in. Usage comes as a
2//! `limits[]` array or as named `five_hour`/`seven_day` objects; both are normalised at the
3//! boundary, and a value that fails to normalise is dropped rather than drawn.
4
5use 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    /// How Anthropic grades this row, when it grades it. Its word, not a threshold of
17    /// pitboard's own, and absent in a reading taken before pitboard read this field.
18    #[serde(default)]
19    pub severity: Option<String>,
20    /// How long the window runs, in seconds, where that is known.
21    ///
22    /// What makes a limit comparable to itself over time: a reset time alone cannot say
23    /// how long a window is, because the time left shrinks as the window runs out. Stated
24    /// outright by OpenAI, implied by the kind for Anthropic, and absent from a reading
25    /// taken before pitboard kept it.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub length_seconds: Option<i64>,
28}
29
30/// How long one of Anthropic's windows runs, from its kind.
31///
32/// Anthropic names its windows rather than timing them: `session` and the older
33/// `five_hour` are the five-hour limit, and every `weekly_` kind, like the older
34/// `seven_day`, runs a week. A kind not listed here has no length pitboard can vouch for.
35pub 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/// Where a measurement came from, so a stale number is never shown as a live one.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum Source {
48    /// Asked of Anthropic just now.
49    Live,
50    /// Copied from Claude Code's own cache, which it refreshes only when it asks.
51    ClaudeCodeCache,
52    /// The last live reading pitboard took itself.
53    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    /// The share used as of `now`. A window whose reset has passed counts as reset, though
66    /// no reading has said so yet.
67    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    /// Whether `other` measures the same limit, whichever name its source gave it.
76    pub(crate) fn same_limit(&self, other: &Window) -> bool {
77        limit(&self.kind) == limit(&other.kind) && self.scope == other.scope
78    }
79
80    /// Whether `other` is this very window: the same limit, resetting at the same time as
81    /// far as sources agree on one. A window with no reset time is no window in particular.
82    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
91/// A limit by one name. A Claude Code session, and Anthropic's answer when it has no
92/// `limits`, use the older `five_hour` and `seven_day` for the limits `limits` calls
93/// `session` and `weekly_all`. Codex's windows borrow the older names for their lengths,
94/// which is harmless: one account's readings are only ever compared with each other.
95fn limit(kind: &str) -> &str {
96    match kind {
97        "five_hour" => "session",
98        "seven_day" => "weekly_all",
99        other => other,
100    }
101}
102
103/// Resets closer together than this are one reset.
104///
105/// Sources do not agree to the second on when a window resets: Anthropic's answer gives a
106/// fraction of a second, which is dropped, and a Claude Code session is given whole seconds.
107/// A limit's next window starts only once the last has reset, and the shortest window any
108/// service has shown runs five hours, so resets a minute apart are rounding and never two
109/// windows.
110const SAME_RESET: u64 = 60;
111
112/// Which of two measurements of one account's limit is the newer: `Greater` when `a` is.
113///
114/// A later reset is a later window, whatever its share. Within one window use only rises,
115/// so while the limit stays the same the higher share was measured later. A window whose
116/// reset has passed counts as reset, with nothing used, and one with no reset time is
117/// compared by its share alone.
118///
119/// No timestamp is needed, which is the point: a Claude Code session passes its limits with
120/// none. They are what its last response said, however long ago that was, and a session
121/// left open passes the same old numbers every time its status line runs. The reset time is
122/// the service's own, and use within a window only rises, so the numbers order themselves.
123///
124/// Both must be the account's own: another account's windows order against its own just as
125/// readily. A session's numbers do not say whose they are, so the status line offers only
126/// what moved between two of a session's runs with the same account named both times, and
127/// leaves out any whose reset shows them to be another account's. And where the service
128/// lowers a share within a window, as a plan upgraded in the middle of one does by raising
129/// the limit, the old, higher share stands until the window resets.
130pub(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
137/// One account's reading with `offered` folded in, limit by limit: each limit keeps the
138/// newer measurement by [`recency`], taken whole, and on a tie the one already known, so a
139/// repeat changes nothing, whatever name or rounding it came with.
140///
141/// Except where the known window's reset has passed. A tie there is a reading that finds
142/// nothing used since, which is what an account nobody has used since says, with no reset or
143/// with the one that passed. Kept, the old share stood for as long as the account went
144/// unused. So the offered window is taken, or where its reset has passed too, the limit is
145/// recorded as nothing used and no reset, which a repeat then ties with and leaves alone.
146/// Nothing new was measured, so this confirms the reading rather than advancing it.
147///
148/// A limit only one of them measured is kept, because a reading can speak for fewer limits
149/// than there are: a session knows the five-hour and weekly limits and nothing scoped to a
150/// model. One that only `known` has goes once its reset has passed and an answer the
151/// service has just given leaves it out, so a limit the service stops reporting is not shown
152/// for ever. Nothing else takes a limit away: a session leaves out a window whose reset has
153/// passed, and taken for the service no longer reporting it, every reset took the five-hour
154/// limit off the status line, `pitboard status` and the menu bar until the next answer.
155/// Kept, the status line reads it as nothing used.
156///
157/// `observed_at` is the latest time anything confirmed or advanced the reading. One offered
158/// without a time, which is what a session passes, is stamped `now` when it moves something
159/// forward, and vouches for nothing when it only repeats what is known.
160pub(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
235/// A share of a limit. Past 100 is real, once a limit is exceeded; below zero is not.
236fn 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    // A row is scoped to a model or to a surface; either way the scope is what makes it
243    // narrower than the account's own limit.
244    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
283/// The API answer and Claude Code's cached copy of it share this shape.
284fn 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
300/// A reading taken from Anthropic's usage endpoint just now.
301pub 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
310/// Claude Code's own cache. It records the account it was measured for, so a reading for
311/// another account can be told apart and ignored.
312pub 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    /// Trimmed from this machine's real `~/.claude.json`.
333    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    /// Anthropic's answer gives a reset to a fraction of a second, which is dropped, and a
453    /// session is given whole seconds. Taken for a newer window, the session's older 20%
454    /// would win over the 22% the service has just measured.
455    #[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    /// A window that has reset has nothing used, however full it was, so any use of the
464    /// limit since is newer, even from a reading that does not say when it resets.
465    #[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    /// A session knows the five-hour and weekly limits and nothing scoped to a model, so a
493    /// limit one reading leaves out is not a limit that has gone.
494    #[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    /// Kept for ever, a limit the service stopped reporting would be shown for ever. Once
523    /// its window is over, an answer that leaves it out is the service no longer reporting
524    /// it, and there is nothing left in it to show.
525    #[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    /// A session says no time and leaves out a window whose reset has passed. Taken for the
540    /// service no longer reporting it, every reset took the five-hour limit away until the
541    /// next answer, where it reads as nothing used. Claude Code's cache is an answer as of
542    /// whenever Claude Code last asked, so it takes nothing away either.
543    #[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    /// A session passes the numbers of its last response, however long ago that was, and
585    /// says nothing about when. Repeating them vouches for nothing; moving one forward means
586    /// the response that did it has only just come.
587    #[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    /// Asked about an account that has done nothing since its window reset, Anthropic finds
625    /// nothing used and gives no reset, or the one that passed, and Claude Code's cache says
626    /// the same offline. Kept on that tie, a parked account that had run out read as full,
627    /// marked live, until somebody used it again.
628    #[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}