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;
7
8#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
9pub struct Window {
10    pub kind: String,
11    pub scope: Option<String>,
12    pub percent: f64,
13    pub resets_at: Option<i64>,
14    pub is_active: bool,
15}
16
17/// Where a measurement came from, so a stale number is never shown as a live one.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Source {
21    /// Asked of Anthropic just now.
22    Live,
23    /// Copied from Claude Code's own cache, which it refreshes only when it asks.
24    ClaudeCodeCache,
25    /// The last live reading pitboard took itself.
26    Remembered,
27}
28
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
30pub struct Snapshot {
31    pub windows: Vec<Window>,
32    pub observed_at: Option<i64>,
33    pub account_uuid: Option<String>,
34    pub source: Source,
35}
36
37/// A share of a limit. Past 100 is real, once a limit is exceeded; below zero is not.
38fn percent(v: &Value) -> Option<f64> {
39    let p = v.as_f64()?;
40    (p.is_finite() && p >= 0.0).then_some(p)
41}
42
43fn window_from_limit(l: &Value) -> Option<Window> {
44    Some(Window {
45        kind: l.get("kind")?.as_str()?.to_string(),
46        scope: l
47            .get("scope")
48            .and_then(|s| s.get("model"))
49            .and_then(|m| m.get("display_name"))
50            .and_then(Value::as_str)
51            .map(str::to_owned),
52        percent: percent(l.get("percent")?)?,
53        resets_at: l
54            .get("resets_at")
55            .and_then(Value::as_str)
56            .and_then(time::parse),
57        is_active: l.get("is_active").and_then(Value::as_bool).unwrap_or(false),
58    })
59}
60
61fn window_from_named(kind: &str, v: &Value) -> Option<Window> {
62    Some(Window {
63        kind: kind.to_string(),
64        scope: None,
65        percent: percent(v.get("utilization")?)?,
66        resets_at: v
67            .get("resets_at")
68            .and_then(Value::as_str)
69            .and_then(time::parse),
70        is_active: false,
71    })
72}
73
74/// The API answer and Claude Code's cached copy of it share this shape.
75fn windows_of(u: &Value) -> Vec<Window> {
76    let mut windows: Vec<Window> = u
77        .get("limits")
78        .and_then(Value::as_array)
79        .map(|ls| ls.iter().filter_map(window_from_limit).collect())
80        .unwrap_or_default();
81    if windows.is_empty() {
82        for kind in ["five_hour", "seven_day"] {
83            if let Some(w) = u.get(kind).and_then(|v| window_from_named(kind, v)) {
84                windows.push(w);
85            }
86        }
87    }
88    windows
89}
90
91/// A reading taken from Anthropic's usage endpoint just now.
92pub fn from_usage_object(u: &Value, observed_at: i64) -> Snapshot {
93    Snapshot {
94        windows: windows_of(u),
95        observed_at: Some(observed_at),
96        account_uuid: None,
97        source: Source::Live,
98    }
99}
100
101/// Claude Code's own cache. It records the account it was measured for, so a reading for
102/// another account can be told apart and ignored.
103pub fn from_config_cache(config: &Value) -> Option<Snapshot> {
104    let c = config.get("cachedUsageUtilization")?;
105    Some(Snapshot {
106        windows: windows_of(c.get("utilization")?),
107        observed_at: c
108            .get("fetchedAtMs")
109            .and_then(Value::as_i64)
110            .map(|ms| ms / 1000),
111        account_uuid: c
112            .get("accountUuid")
113            .and_then(Value::as_str)
114            .map(str::to_owned),
115        source: Source::ClaudeCodeCache,
116    })
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    /// Trimmed from this machine's real `~/.claude.json`.
124    fn real_config() -> Value {
125        serde_json::json!({"cachedUsageUtilization": {
126        "fetchedAtMs": 1789933772292i64,
127        "accountUuid": "9aeb9c89-316c-4344-84c5-603d71dc5c9a",
128        "utilization": {
129            "five_hour": {"utilization": 62, "resets_at": "2026-09-20T22:20:00.095287+00:00"},
130            "seven_day": {"utilization": 48, "resets_at": "2026-09-27T02:00:00.095306+00:00"},
131            "limits": [
132                {"kind": "session", "group": "session", "percent": 62,
133                 "resets_at": "2026-09-20T22:20:00.095287+00:00", "scope": null, "is_active": true},
134                {"kind": "weekly_all", "group": "weekly", "percent": 48,
135                 "resets_at": "2026-09-27T02:00:00.095306+00:00", "scope": null, "is_active": false},
136                {"kind": "weekly_scoped", "group": "weekly", "percent": 0,
137                 "resets_at": "2026-09-27T02:00:00+00:00",
138                 "scope": {"model": {"id": null, "display_name": "Fable"}}, "is_active": false}
139            ]}}})
140    }
141
142    #[test]
143    fn reads_the_real_cache_shape() {
144        let s = from_config_cache(&real_config()).expect("should parse");
145        assert_eq!(s.windows.len(), 3);
146        assert_eq!(
147            s.account_uuid.as_deref(),
148            Some("9aeb9c89-316c-4344-84c5-603d71dc5c9a")
149        );
150        assert_eq!(s.observed_at, Some(1789933772));
151        let scoped = s
152            .windows
153            .iter()
154            .find(|w| w.kind == "weekly_scoped")
155            .unwrap();
156        assert_eq!(scoped.scope.as_deref(), Some("Fable"));
157    }
158
159    #[test]
160    fn falls_back_to_the_named_windows_when_limits_is_missing() {
161        let mut c = real_config();
162        c["cachedUsageUtilization"]["utilization"]
163            .as_object_mut()
164            .unwrap()
165            .remove("limits");
166        let s = from_config_cache(&c).unwrap();
167        assert_eq!(s.windows.len(), 2);
168        assert_eq!(s.windows[0].kind, "five_hour");
169        assert_eq!(s.windows[0].percent, 62.0);
170    }
171
172    #[test]
173    fn a_nonsense_percentage_is_dropped_rather_than_drawn() {
174        for nonsense in [serde_json::json!(-5), serde_json::json!("75")] {
175            let mut c = real_config();
176            c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = nonsense;
177            assert_eq!(from_config_cache(&c).unwrap().windows.len(), 2);
178        }
179    }
180
181    #[test]
182    fn an_exceeded_limit_is_kept_not_dropped() {
183        let mut c = real_config();
184        c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = serde_json::json!(104);
185        let s = from_config_cache(&c).unwrap();
186        assert_eq!(s.windows.len(), 3);
187        assert_eq!(s.windows[0].percent, 104.0);
188    }
189
190    #[test]
191    fn missing_cache_is_not_an_error() {
192        assert!(from_config_cache(&serde_json::json!({})).is_none());
193    }
194}