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.
128///
129/// The service can lower a share within a window, as a banked reset does, or a plan upgraded
130/// in the middle of one by raising the limit. Shares cannot show that: the lower share reads
131/// as the older. Only a reading that says when it was taken can, which [`merge`] looks at
132/// before this.
133pub(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
140/// One account's reading with `offered` folded in, limit by limit: each limit keeps the
141/// newer measurement by [`recency`], taken whole, and on a tie the one already known, so a
142/// repeat changes nothing, whatever name or rounding it came with.
143///
144/// Unless `offered` was taken after everything `known` holds. An answer from the
145/// service, or Claude Code's cache of one, says when it was taken, and one taken later than
146/// anything that advanced or confirmed `known` is what the limits were at that time. Where
147/// it finds less used, the service lowered the share, and the lower share is taken. A
148/// session's numbers say no time, so they only ever move a limit forward. The time is the
149/// whole reading's, so a session that moved any limit since the answer was taken, or in the
150/// same second, leaves the lower share to the next answer.
151///
152/// Except where the known window's reset has passed. A tie there is a reading that finds
153/// nothing used since, which is what an account nobody has used since says, with no reset or
154/// with the one that passed. Kept, the old share stood for as long as the account went
155/// unused. So the offered window is taken, or where its reset has passed too, the limit is
156/// recorded as nothing used and no reset, which a repeat then ties with and leaves alone.
157/// Nothing new was measured, so this confirms the reading rather than advancing it.
158///
159/// A limit only one of them measured is kept, because a reading can speak for fewer limits
160/// than there are: a session knows the five-hour and weekly limits and nothing scoped to a
161/// model. One that only `known` has goes once its reset has passed and an answer the
162/// service has just given leaves it out, so a limit the service stops reporting is not shown
163/// for ever. Nothing else takes a limit away: a session leaves out a window whose reset has
164/// passed, and taken for the service no longer reporting it, every reset took the five-hour
165/// limit off the status line, `pitboard status` and the menu bar until the next answer.
166/// Kept, the status line reads it as nothing used.
167///
168/// `observed_at` is the latest time anything confirmed or advanced the reading. One offered
169/// without a time, which is what a session passes, is stamped `now` when it moves something
170/// forward, and vouches for nothing when it only repeats what is known.
171pub(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
251/// A share of a limit. Past 100 is real, once a limit is exceeded; below zero is not.
252fn 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    // A row is scoped to a model or to a surface; either way the scope is what makes it
259    // narrower than the account's own limit.
260    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
299/// The API answer and Claude Code's cached copy of it share this shape.
300fn 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
316/// A reading taken from Anthropic's usage endpoint just now.
317pub 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
326/// Claude Code's own cache. It records the account it was measured for, so a reading for
327/// another account can be told apart and ignored.
328pub 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    /// Trimmed from this machine's real `~/.claude.json`.
349    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    /// Anthropic's answer gives a reset to a fraction of a second, which is dropped, and a
469    /// session is given whole seconds. Taken for a newer window, the session's older 20%
470    /// would win over the 22% the service has just measured.
471    #[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    /// A window that has reset has nothing used, however full it was, so any use of the
480    /// limit since is newer, even from a reading that does not say when it resets.
481    #[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    /// Measured on this machine on 2026-09-29: an account at 100% of its weekly limit,
509    /// resetting at 20:00 UTC the next day, had a banked reset used on claude.ai. Its
510    /// sessions then passed 1%, 13% and 14% of the same limit, with the same reset. Ordered
511    /// by share, every lower answer lost to the 100%, and the account read as out for the
512    /// day and a half until the reset.
513    #[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    /// An answer is what the limits were when it was taken. Whatever was recorded since,
553    /// or in the same second, may have come with a later response, so an answer only moves
554    /// such a limit forward.
555    #[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    /// A session knows the five-hour and weekly limits and nothing scoped to a model, so a
572    /// limit one reading leaves out is not a limit that has gone.
573    #[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    /// Kept for ever, a limit the service stopped reporting would be shown for ever. Once
602    /// its window is over, an answer that leaves it out is the service no longer reporting
603    /// it, and there is nothing left in it to show.
604    #[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    /// A session says no time and leaves out a window whose reset has passed. Taken for the
619    /// service no longer reporting it, every reset took the five-hour limit away until the
620    /// next answer, where it reads as nothing used. Claude Code's cache is an answer as of
621    /// whenever Claude Code last asked, so it takes nothing away either.
622    #[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    /// A session passes the numbers of its last response, however long ago that was, and
664    /// says nothing about when. Repeating them vouches for nothing; moving one forward means
665    /// the response that did it has only just come.
666    #[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    /// Asked about an account that has done nothing since its window reset, Anthropic finds
704    /// nothing used and gives no reset, or the one that passed, and Claude Code's cache says
705    /// the same offline. Kept on that tie, a parked account that had run out read as full,
706    /// marked live, until somebody used it again.
707    #[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}