1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum Source {
21 Live,
23 ClaudeCodeCache,
25 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
37fn 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
74fn 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
91pub 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
101pub 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 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}