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_name(&self.kind) == limit_name(&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 same_reset(x, y)
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.
95pub(crate) fn limit_name(kind: &str) -> &str {
96    match kind {
97        "five_hour" => "session",
98        "seven_day" => "weekly_all",
99        other => other,
100    }
101}
102
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/// Whether two resets of a limit are one: less than `SAME_RESET` apart. Public so that
113/// whatever else tells one window of a limit from its next, such as the app deciding
114/// whether a spent limit was already mentioned, counts them the way readings are merged.
115pub fn same_reset(a: i64, b: i64) -> bool {
116    a.abs_diff(b) < SAME_RESET
117}
118
119/// Which of two measurements of one account's limit is the newer: `Greater` when `a` is.
120///
121/// A later reset is a later window, whatever its share. Within one window use only rises,
122/// so while the limit stays the same the higher share was measured later. A window whose
123/// reset has passed counts as reset, with nothing used, and one with no reset time is
124/// compared by its share alone.
125///
126/// No timestamp is needed, which is the point: a Claude Code session passes its limits with
127/// none. They are what its last response said, however long ago that was, and a session
128/// left open passes the same old numbers every time its status line runs. The reset time is
129/// the service's own, and use within a window only rises, so the numbers order themselves.
130///
131/// Both must be the account's own: another account's windows order against its own just as
132/// readily. A session's numbers do not say whose they are, so the status line offers only
133/// what moved between two of a session's runs with the same account named both times, and
134/// leaves out any whose reset shows them to be another account's.
135///
136/// The service can lower a share within a window, as a banked reset does, or a plan upgraded
137/// in the middle of one by raising the limit. Shares cannot show that: the lower share reads
138/// as the older. Only a reading that says when it was taken can, which [`merge`] looks at
139/// before this.
140pub(crate) fn recency(a: &Window, b: &Window, now: i64) -> Ordering {
141    match (a.resets_at, b.resets_at) {
142        (Some(x), Some(y)) if !same_reset(x, y) => x.cmp(&y),
143        _ => a.used(now).total_cmp(&b.used(now)),
144    }
145}
146
147/// One account's reading with `offered` folded in, limit by limit: each limit keeps the
148/// newer measurement by [`recency`], taken whole, and on a tie the one already known, so a
149/// repeat changes nothing, whatever name or rounding it came with.
150///
151/// Unless `offered` was taken after everything `known` holds. An answer from the
152/// service, or Claude Code's cache of one, says when it was taken, and one taken later than
153/// anything that advanced or confirmed `known` is what the limits were at that time. Where
154/// it finds less used, the service lowered the share, and the lower share is taken. A
155/// session's numbers say no time, so they only ever move a limit forward. The time is the
156/// whole reading's, so a session that moved any limit since the answer was taken, or in the
157/// same second, leaves the lower share to the next answer.
158///
159/// Except where the known window's reset has passed. A tie there is a reading that finds
160/// nothing used since, which is what an account nobody has used since says, with no reset or
161/// with the one that passed. Kept, the old share stood for as long as the account went
162/// unused. So the offered window is taken, or where its reset has passed too, the limit is
163/// recorded as nothing used and no reset, which a repeat then ties with and leaves alone.
164/// Nothing new was measured, so this confirms the reading rather than advancing it.
165///
166/// A limit only one of them measured is kept, because a reading can speak for fewer limits
167/// than there are: a session knows the five-hour and weekly limits and nothing scoped to a
168/// model. One that only `known` has goes once its reset has passed and an answer the
169/// service has just given leaves it out, so a limit the service stops reporting is not shown
170/// for ever. Nothing else takes a limit away: a session leaves out a window whose reset has
171/// passed, and taken for the service no longer reporting it, every reset took the five-hour
172/// limit off the status line, `pitboard status` and the menu bar until the next answer.
173/// Kept, the status line reads it as nothing used.
174///
175/// `observed_at` is the latest time anything confirmed or advanced the reading. One offered
176/// without a time, which is what a session passes, is stamped `now` when it moves something
177/// forward, and vouches for nothing when it only repeats what is known.
178pub(crate) fn merge(
179    known: Option<&Snapshot>,
180    offered: Option<&Snapshot>,
181    now: i64,
182) -> Option<Snapshot> {
183    let Some(offered) = offered else {
184        return known.cloned();
185    };
186    let Some(known) = known else {
187        let mut first = offered.clone();
188        first.observed_at = first.observed_at.or(Some(now));
189        return Some(first);
190    };
191    let answered = offered.source == Source::Live && offered.observed_at.is_some();
192    let taken_since = offered.observed_at > known.observed_at;
193    let (mut advanced, mut confirmed) = (false, false);
194    let mut windows = Vec::new();
195    for had in &known.windows {
196        match offered.windows.iter().find(|w| w.same_limit(had)) {
197            Some(given) => match recency(given, had, now) {
198                Ordering::Less if taken_since => {
199                    advanced = true;
200                    windows.push(given.clone());
201                }
202                Ordering::Less => windows.push(had.clone()),
203                Ordering::Equal if had.resets_at.is_some_and(|at| at <= now) => {
204                    confirmed = true;
205                    windows.push(if given.resets_at.is_none_or(|at| at > now) {
206                        given.clone()
207                    } else {
208                        Window {
209                            percent: 0.0,
210                            resets_at: None,
211                            ..had.clone()
212                        }
213                    });
214                }
215                Ordering::Equal => {
216                    confirmed = true;
217                    windows.push(had.clone());
218                }
219                Ordering::Greater => {
220                    advanced = true;
221                    windows.push(given.clone());
222                }
223            },
224            None if answered && had.resets_at.is_some_and(|at| at <= now) => {}
225            None => windows.push(had.clone()),
226        }
227    }
228    for given in &offered.windows {
229        if !known.windows.iter().any(|w| w.same_limit(given)) {
230            advanced = true;
231            windows.push(given.clone());
232        }
233    }
234    let vouched = advanced || (confirmed && offered.observed_at.is_some());
235    Some(Snapshot {
236        windows,
237        observed_at: if advanced {
238            known
239                .observed_at
240                .max(Some(offered.observed_at.unwrap_or(now)))
241        } else if confirmed {
242            known.observed_at.max(offered.observed_at)
243        } else {
244            known.observed_at
245        },
246        account_uuid: known
247            .account_uuid
248            .clone()
249            .or_else(|| offered.account_uuid.clone()),
250        source: if vouched {
251            offered.source
252        } else {
253            known.source
254        },
255    })
256}
257
258/// A share of a limit. Past 100 is real, once a limit is exceeded; below zero is not.
259fn percent(v: &Value) -> Option<f64> {
260    let p = v.as_f64()?;
261    (p.is_finite() && p >= 0.0).then_some(p)
262}
263
264fn window_from_limit(l: &Value) -> Option<Window> {
265    // A row is scoped to a model or to a surface; either way the scope is what makes it
266    // narrower than the account's own limit.
267    let named = |what: &str| {
268        l.get("scope")
269            .and_then(|s| s.get(what))
270            .and_then(|m| m.get("display_name"))
271            .and_then(Value::as_str)
272            .map(str::to_owned)
273    };
274    Some(Window {
275        kind: l.get("kind")?.as_str()?.to_string(),
276        scope: named("model").or_else(|| named("surface")),
277        severity: l.get("severity").and_then(Value::as_str).map(str::to_owned),
278        percent: percent(l.get("percent")?)?,
279        resets_at: l
280            .get("resets_at")
281            .and_then(Value::as_str)
282            .and_then(time::parse),
283        is_active: l.get("is_active").and_then(Value::as_bool).unwrap_or(false),
284        length_seconds: l
285            .get("kind")
286            .and_then(Value::as_str)
287            .and_then(anthropic_window_length),
288    })
289}
290
291fn window_from_named(kind: &str, v: &Value) -> Option<Window> {
292    Some(Window {
293        kind: kind.to_string(),
294        scope: None,
295        severity: None,
296        percent: percent(v.get("utilization")?)?,
297        resets_at: v
298            .get("resets_at")
299            .and_then(Value::as_str)
300            .and_then(time::parse),
301        is_active: false,
302        length_seconds: anthropic_window_length(kind),
303    })
304}
305
306/// The API answer and Claude Code's cached copy of it share this shape.
307fn windows_of(u: &Value) -> Vec<Window> {
308    let mut windows: Vec<Window> = u
309        .get("limits")
310        .and_then(Value::as_array)
311        .map(|ls| ls.iter().filter_map(window_from_limit).collect())
312        .unwrap_or_default();
313    if windows.is_empty() {
314        for kind in ["five_hour", "seven_day"] {
315            if let Some(w) = u.get(kind).and_then(|v| window_from_named(kind, v)) {
316                windows.push(w);
317            }
318        }
319    }
320    windows
321}
322
323/// A reading taken from Anthropic's usage endpoint just now.
324pub fn from_usage_object(u: &Value, observed_at: i64) -> Snapshot {
325    Snapshot {
326        windows: windows_of(u),
327        observed_at: Some(observed_at),
328        account_uuid: None,
329        source: Source::Live,
330    }
331}
332
333/// Claude Code's own cache. It records the account it was measured for, so a reading for
334/// another account can be told apart and ignored.
335pub fn from_config_cache(config: &Value) -> Option<Snapshot> {
336    let c = config.get("cachedUsageUtilization")?;
337    Some(Snapshot {
338        windows: windows_of(c.get("utilization")?),
339        observed_at: c
340            .get("fetchedAtMs")
341            .and_then(Value::as_i64)
342            .map(|ms| ms / 1000),
343        account_uuid: c
344            .get("accountUuid")
345            .and_then(Value::as_str)
346            .map(str::to_owned),
347        source: Source::ClaudeCodeCache,
348    })
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    /// Trimmed from this machine's real `~/.claude.json`.
356    fn real_config() -> Value {
357        serde_json::json!({"cachedUsageUtilization": {
358        "fetchedAtMs": 1789933772292i64,
359        "accountUuid": "1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
360        "utilization": {
361            "five_hour": {"utilization": 62, "resets_at": "2026-09-20T22:20:00.095287+00:00"},
362            "seven_day": {"utilization": 48, "resets_at": "2026-09-27T02:00:00.095306+00:00"},
363            "limits": [
364                {"kind": "session", "group": "session", "percent": 62,
365                 "resets_at": "2026-09-20T22:20:00.095287+00:00", "scope": null, "is_active": true},
366                {"kind": "weekly_all", "group": "weekly", "percent": 48,
367                 "resets_at": "2026-09-27T02:00:00.095306+00:00", "scope": null, "is_active": false},
368                {"kind": "weekly_scoped", "group": "weekly", "percent": 0,
369                 "resets_at": "2026-09-27T02:00:00+00:00",
370                 "scope": {"model": {"id": null, "display_name": "Fable"}}, "is_active": false}
371            ]}}})
372    }
373
374    #[test]
375    fn reads_the_real_cache_shape() {
376        let s = from_config_cache(&real_config()).expect("should parse");
377        assert_eq!(s.windows.len(), 3);
378        assert_eq!(
379            s.account_uuid.as_deref(),
380            Some("1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0")
381        );
382        assert_eq!(s.observed_at, Some(1789933772));
383        let scoped = s
384            .windows
385            .iter()
386            .find(|w| w.kind == "weekly_scoped")
387            .unwrap();
388        assert_eq!(scoped.scope.as_deref(), Some("Fable"));
389    }
390
391    #[test]
392    fn falls_back_to_the_named_windows_when_limits_is_missing() {
393        let mut c = real_config();
394        c["cachedUsageUtilization"]["utilization"]
395            .as_object_mut()
396            .unwrap()
397            .remove("limits");
398        let s = from_config_cache(&c).unwrap();
399        assert_eq!(s.windows.len(), 2);
400        assert_eq!(s.windows[0].kind, "five_hour");
401        assert_eq!(s.windows[0].percent, 62.0);
402    }
403
404    #[test]
405    fn a_nonsense_percentage_is_dropped_rather_than_drawn() {
406        for nonsense in [serde_json::json!(-5), serde_json::json!("75")] {
407            let mut c = real_config();
408            c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = nonsense;
409            assert_eq!(from_config_cache(&c).unwrap().windows.len(), 2);
410        }
411    }
412
413    #[test]
414    fn an_exceeded_limit_is_kept_not_dropped() {
415        let mut c = real_config();
416        c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = serde_json::json!(104);
417        let s = from_config_cache(&c).unwrap();
418        assert_eq!(s.windows.len(), 3);
419        assert_eq!(s.windows[0].percent, 104.0);
420    }
421
422    #[test]
423    fn missing_cache_is_not_an_error() {
424        assert!(from_config_cache(&serde_json::json!({})).is_none());
425    }
426
427    const NOW: i64 = 1_789_935_000;
428    const HOUR: i64 = 3_600;
429
430    fn measured(kind: &str, percent: f64, resets_at: Option<i64>) -> Window {
431        Window {
432            kind: kind.into(),
433            scope: None,
434            percent,
435            resets_at,
436            is_active: true,
437            severity: None,
438            length_seconds: anthropic_window_length(kind),
439        }
440    }
441
442    fn reading(windows: Vec<Window>, observed_at: Option<i64>) -> Snapshot {
443        Snapshot {
444            windows,
445            observed_at,
446            account_uuid: None,
447            source: Source::Live,
448        }
449    }
450
451    fn shares(reading: &Snapshot) -> Vec<(&str, f64)> {
452        reading
453            .windows
454            .iter()
455            .map(|w| (w.kind.as_str(), w.percent))
456            .collect()
457    }
458
459    #[test]
460    fn a_later_reset_is_a_newer_window_whatever_its_share() {
461        let full = measured("session", 90.0, Some(NOW + HOUR));
462        let next = measured("session", 2.0, Some(NOW + 6 * HOUR));
463        assert_eq!(recency(&next, &full, NOW), Ordering::Greater);
464        assert_eq!(recency(&full, &next, NOW), Ordering::Less);
465    }
466
467    #[test]
468    fn within_one_window_the_higher_share_is_the_newer() {
469        let earlier = measured("session", 20.0, Some(NOW + HOUR));
470        let later = measured("session", 22.0, Some(NOW + HOUR));
471        assert_eq!(recency(&later, &earlier, NOW), Ordering::Greater);
472        assert_eq!(recency(&earlier, &later, NOW), Ordering::Less);
473    }
474
475    /// Anthropic's answer gives a reset to a fraction of a second, which is dropped, and a
476    /// session is given whole seconds. Taken for a newer window, the session's older 20%
477    /// would win over the 22% the service has just measured.
478    #[test]
479    fn resets_a_second_apart_are_one_window() {
480        let answered = measured("session", 22.0, Some(NOW + HOUR));
481        let passed = measured("five_hour", 20.0, Some(NOW + HOUR + 1));
482        assert_eq!(recency(&passed, &answered, NOW), Ordering::Less);
483        assert_eq!(recency(&answered, &passed, NOW), Ordering::Greater);
484    }
485
486    /// The rule the app keys what it has told somebody by: resets under a minute apart, in
487    /// either order, are one, and a minute apart are two.
488    #[test]
489    fn resets_under_a_minute_apart_are_one_reset() {
490        assert!(same_reset(NOW, NOW));
491        assert!(same_reset(NOW, NOW + 59));
492        assert!(same_reset(NOW + 59, NOW));
493        assert!(!same_reset(NOW, NOW + 60));
494        assert!(!same_reset(NOW + 60, NOW));
495        assert!(
496            !same_reset(0, NOW),
497            "no reset known is no reset in particular"
498        );
499    }
500
501    /// A window that has reset has nothing used, however full it was, so any use of the
502    /// limit since is newer, even from a reading that does not say when it resets.
503    #[test]
504    fn a_window_past_its_reset_counts_as_reset() {
505        let over = measured("session", 90.0, Some(NOW - 1));
506        let begun = measured("session", 5.0, None);
507        assert_eq!(recency(&begun, &over, NOW), Ordering::Greater);
508        assert_eq!(recency(&over, &begun, NOW), Ordering::Less);
509    }
510
511    #[test]
512    fn a_reading_that_says_no_time_never_moves_a_limit_backwards() {
513        let known = reading(vec![measured("session", 22.0, Some(NOW + HOUR))], Some(NOW));
514        for behind in [
515            measured("five_hour", 20.0, Some(NOW + HOUR)),
516            measured("five_hour", 95.0, Some(NOW - 4 * HOUR)),
517        ] {
518            let merged = merge(Some(&known), Some(&reading(vec![behind], None)), NOW).unwrap();
519            assert_eq!(shares(&merged), [("session", 22.0)]);
520        }
521        let ahead = reading(vec![measured("five_hour", 25.0, Some(NOW + HOUR))], None);
522        let merged = merge(Some(&known), Some(&ahead), NOW).unwrap();
523        assert_eq!(
524            shares(&merged),
525            [("five_hour", 25.0)],
526            "under the name it came with"
527        );
528    }
529
530    /// Measured on this machine on 2026-09-29: an account at 100% of its weekly limit,
531    /// resetting at 20:00 UTC the next day, had a banked reset used on claude.ai. Its
532    /// sessions then passed 1%, 13% and 14% of the same limit, with the same reset. Ordered
533    /// by share, every lower answer lost to the 100%, and the account read as out for the
534    /// day and a half until the reset.
535    #[test]
536    fn an_answer_taken_after_everything_known_is_what_the_limits_are_now() {
537        let known = reading(
538            vec![
539                measured("session", 0.0, None),
540                measured("weekly_all", 100.0, Some(NOW + 33 * HOUR)),
541            ],
542            Some(NOW - HOUR),
543        );
544        let answered = reading(
545            vec![
546                measured("session", 5.0, Some(NOW + 5 * HOUR)),
547                measured("weekly_all", 14.0, Some(NOW + 33 * HOUR)),
548            ],
549            Some(NOW),
550        );
551        let mut cached = reading(answered.windows.clone(), Some(NOW - 5));
552        cached.source = Source::ClaudeCodeCache;
553        for offered in [answered, cached] {
554            let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
555            assert_eq!(
556                shares(&merged),
557                [("session", 5.0), ("weekly_all", 14.0)],
558                "{:?}",
559                offered.source
560            );
561            assert_eq!(merged.observed_at, offered.observed_at);
562            assert_eq!(merged.source, offered.source);
563        }
564
565        let unused = reading(vec![measured("weekly_all", 0.0, None)], Some(NOW));
566        let merged = merge(Some(&known), Some(&unused), NOW).unwrap();
567        assert_eq!(
568            shares(&merged),
569            [("session", 0.0), ("weekly_all", 0.0)],
570            "and one that finds nothing used and no window running"
571        );
572    }
573
574    /// An answer is what the limits were when it was taken. Whatever was recorded since,
575    /// or in the same second, may have come with a later response, so an answer only moves
576    /// such a limit forward.
577    #[test]
578    fn an_answer_taken_no_later_than_what_is_known_never_lowers_a_share() {
579        let known = reading(
580            vec![measured("weekly_all", 100.0, Some(NOW + 33 * HOUR))],
581            Some(NOW - 60),
582        );
583        for taken in [NOW - 60, NOW - HOUR] {
584            let answered = reading(
585                vec![measured("weekly_all", 14.0, Some(NOW + 33 * HOUR))],
586                Some(taken),
587            );
588            let merged = merge(Some(&known), Some(&answered), NOW).unwrap();
589            assert_eq!(shares(&merged), [("weekly_all", 100.0)], "taken at {taken}");
590        }
591    }
592
593    /// A session knows the five-hour and weekly limits and nothing scoped to a model, so a
594    /// limit one reading leaves out is not a limit that has gone.
595    #[test]
596    fn a_limit_only_one_reading_measured_is_kept() {
597        let scoped = Window {
598            scope: Some("Fable".into()),
599            ..measured("weekly_scoped", 5.0, Some(NOW + 50 * HOUR))
600        };
601        let known = reading(
602            vec![measured("session", 22.0, Some(NOW + HOUR)), scoped],
603            Some(NOW - 60),
604        );
605        let offered = reading(
606            vec![
607                measured("five_hour", 25.0, Some(NOW + HOUR)),
608                measured("seven_day", 40.0, Some(NOW + 50 * HOUR)),
609            ],
610            None,
611        );
612        let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
613        assert_eq!(
614            shares(&merged),
615            [
616                ("five_hour", 25.0),
617                ("weekly_scoped", 5.0),
618                ("seven_day", 40.0)
619            ]
620        );
621    }
622
623    /// Kept for ever, a limit the service stopped reporting would be shown for ever. Once
624    /// its window is over, an answer that leaves it out is the service no longer reporting
625    /// it, and there is nothing left in it to show.
626    #[test]
627    fn a_limit_an_answer_leaves_out_goes_once_its_reset_has_passed() {
628        let known = reading(
629            vec![
630                measured("session", 22.0, Some(NOW + HOUR)),
631                measured("weekly_scoped", 5.0, Some(NOW - 1)),
632            ],
633            Some(NOW - 60),
634        );
635        let answered = reading(vec![measured("session", 25.0, Some(NOW + HOUR))], Some(NOW));
636        let merged = merge(Some(&known), Some(&answered), NOW).unwrap();
637        assert_eq!(shares(&merged), [("session", 25.0)]);
638    }
639
640    /// A session says no time and leaves out a window whose reset has passed. Taken for the
641    /// service no longer reporting it, every reset took the five-hour limit away until the
642    /// next answer, where it reads as nothing used. Claude Code's cache is an answer as of
643    /// whenever Claude Code last asked, so it takes nothing away either.
644    #[test]
645    fn a_reading_that_is_not_an_answer_just_now_never_takes_a_limit_away() {
646        let known = reading(
647            vec![
648                measured("session", 40.0, Some(NOW - 10)),
649                measured("weekly_all", 30.0, Some(NOW + 50 * HOUR)),
650            ],
651            Some(NOW - HOUR),
652        );
653        let passed = reading(
654            vec![measured("seven_day", 31.0, Some(NOW + 50 * HOUR))],
655            None,
656        );
657        let mut cached = reading(passed.windows.clone(), Some(NOW - 5));
658        cached.source = Source::ClaudeCodeCache;
659        for offered in [passed, cached] {
660            let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
661            assert_eq!(
662                shares(&merged),
663                [("session", 40.0), ("seven_day", 31.0)],
664                "{:?}",
665                offered.source
666            );
667            assert_eq!(merged.windows[0].used(NOW), 0.0);
668        }
669    }
670
671    #[test]
672    fn with_one_side_absent_the_other_stands() {
673        let known = reading(
674            vec![measured("session", 22.0, Some(NOW + HOUR))],
675            Some(NOW - 60),
676        );
677        assert_eq!(merge(Some(&known), None, NOW), Some(known.clone()));
678        assert_eq!(merge(None, None, NOW), None);
679
680        let first = merge(None, Some(&reading(known.windows.clone(), None)), NOW).unwrap();
681        assert_eq!(shares(&first), [("session", 22.0)]);
682        assert_eq!(first.observed_at, Some(NOW), "stamped when it arrived");
683    }
684
685    /// A session passes the numbers of its last response, however long ago that was, and
686    /// says nothing about when. Repeating them vouches for nothing; moving one forward means
687    /// the response that did it has only just come.
688    #[test]
689    fn a_reading_that_says_no_time_is_stamped_only_when_it_moves_something() {
690        let mut known = reading(
691            vec![measured("session", 22.0, Some(NOW + HOUR))],
692            Some(NOW - 600),
693        );
694        known.source = Source::Remembered;
695        let repeated = reading(vec![measured("five_hour", 22.0, Some(NOW + HOUR))], None);
696        let merged = merge(Some(&known), Some(&repeated), NOW).unwrap();
697        assert_eq!(
698            merged.windows, known.windows,
699            "a repeat changes nothing, name and all"
700        );
701        assert_eq!(merged.observed_at, Some(NOW - 600));
702        assert_eq!(
703            merged.source,
704            Source::Remembered,
705            "and it is still what was known"
706        );
707
708        let moved = reading(vec![measured("five_hour", 23.0, Some(NOW + HOUR))], None);
709        assert_eq!(
710            merge(Some(&known), Some(&moved), NOW).unwrap().observed_at,
711            Some(NOW)
712        );
713
714        let mut confirmed = reading(known.windows.clone(), Some(NOW - 5));
715        confirmed.source = Source::ClaudeCodeCache;
716        let merged = merge(Some(&known), Some(&confirmed), NOW).unwrap();
717        assert_eq!(
718            merged.observed_at,
719            Some(NOW - 5),
720            "a measured repeat confirms it"
721        );
722        assert_eq!(merged.source, Source::ClaudeCodeCache);
723    }
724
725    /// Asked about an account that has done nothing since its window reset, Anthropic finds
726    /// nothing used and gives no reset, or the one that passed, and Claude Code's cache says
727    /// the same offline. Kept on that tie, a parked account that had run out read as full,
728    /// marked live, until somebody used it again.
729    #[test]
730    fn a_window_past_its_reset_is_reset_by_a_reading_that_finds_nothing_used() {
731        let known = reading(
732            vec![measured("session", 100.0, Some(NOW - HOUR))],
733            Some(NOW - 2 * HOUR),
734        );
735        for (said, source) in [
736            (measured("session", 0.0, None), Source::Live),
737            (measured("session", 0.0, Some(NOW - HOUR)), Source::Live),
738            (measured("five_hour", 0.0, None), Source::ClaudeCodeCache),
739        ] {
740            let mut offered = reading(vec![said], Some(NOW - 5));
741            offered.source = source;
742            let merged = merge(Some(&known), Some(&offered), NOW).unwrap();
743            assert_eq!(merged.windows[0].percent, 0.0, "{source:?}");
744            assert_eq!(merged.windows[0].resets_at, None, "{source:?}");
745            assert_eq!(merged.source, source, "and it is what said so");
746            assert_eq!(
747                merge(Some(&merged), Some(&offered), NOW).as_ref(),
748                Some(&merged),
749                "a repeat changes nothing"
750            );
751        }
752
753        let untimed = reading(vec![measured("five_hour", 0.0, None)], None);
754        let merged = merge(Some(&known), Some(&untimed), NOW).unwrap();
755        assert_eq!(merged.windows[0].percent, 0.0);
756        assert_eq!(
757            merged.observed_at,
758            Some(NOW - 2 * HOUR),
759            "a reading that says no time moved nothing forward"
760        );
761    }
762}