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