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 #[serde(default)]
18 pub severity: Option<String>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub length_seconds: Option<i64>,
27}
28
29pub fn anthropic_window_length(kind: &str) -> Option<i64> {
35 match kind {
36 "session" | "five_hour" => Some(5 * 3600),
37 "seven_day" => Some(7 * 86_400),
38 weekly if weekly.starts_with("weekly_") => Some(7 * 86_400),
39 _ => None,
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum Source {
47 Live,
49 ClaudeCodeCache,
51 Remembered,
53}
54
55#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
56pub struct Snapshot {
57 pub windows: Vec<Window>,
58 pub observed_at: Option<i64>,
59 pub account_uuid: Option<String>,
60 pub source: Source,
61}
62
63fn percent(v: &Value) -> Option<f64> {
65 let p = v.as_f64()?;
66 (p.is_finite() && p >= 0.0).then_some(p)
67}
68
69fn window_from_limit(l: &Value) -> Option<Window> {
70 let named = |what: &str| {
73 l.get("scope")
74 .and_then(|s| s.get(what))
75 .and_then(|m| m.get("display_name"))
76 .and_then(Value::as_str)
77 .map(str::to_owned)
78 };
79 Some(Window {
80 kind: l.get("kind")?.as_str()?.to_string(),
81 scope: named("model").or_else(|| named("surface")),
82 severity: l.get("severity").and_then(Value::as_str).map(str::to_owned),
83 percent: percent(l.get("percent")?)?,
84 resets_at: l
85 .get("resets_at")
86 .and_then(Value::as_str)
87 .and_then(time::parse),
88 is_active: l.get("is_active").and_then(Value::as_bool).unwrap_or(false),
89 length_seconds: l
90 .get("kind")
91 .and_then(Value::as_str)
92 .and_then(anthropic_window_length),
93 })
94}
95
96fn window_from_named(kind: &str, v: &Value) -> Option<Window> {
97 Some(Window {
98 kind: kind.to_string(),
99 scope: None,
100 severity: None,
101 percent: percent(v.get("utilization")?)?,
102 resets_at: v
103 .get("resets_at")
104 .and_then(Value::as_str)
105 .and_then(time::parse),
106 is_active: false,
107 length_seconds: anthropic_window_length(kind),
108 })
109}
110
111fn windows_of(u: &Value) -> Vec<Window> {
113 let mut windows: Vec<Window> = u
114 .get("limits")
115 .and_then(Value::as_array)
116 .map(|ls| ls.iter().filter_map(window_from_limit).collect())
117 .unwrap_or_default();
118 if windows.is_empty() {
119 for kind in ["five_hour", "seven_day"] {
120 if let Some(w) = u.get(kind).and_then(|v| window_from_named(kind, v)) {
121 windows.push(w);
122 }
123 }
124 }
125 windows
126}
127
128pub fn from_usage_object(u: &Value, observed_at: i64) -> Snapshot {
130 Snapshot {
131 windows: windows_of(u),
132 observed_at: Some(observed_at),
133 account_uuid: None,
134 source: Source::Live,
135 }
136}
137
138pub fn from_config_cache(config: &Value) -> Option<Snapshot> {
141 let c = config.get("cachedUsageUtilization")?;
142 Some(Snapshot {
143 windows: windows_of(c.get("utilization")?),
144 observed_at: c
145 .get("fetchedAtMs")
146 .and_then(Value::as_i64)
147 .map(|ms| ms / 1000),
148 account_uuid: c
149 .get("accountUuid")
150 .and_then(Value::as_str)
151 .map(str::to_owned),
152 source: Source::ClaudeCodeCache,
153 })
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 fn real_config() -> Value {
162 serde_json::json!({"cachedUsageUtilization": {
163 "fetchedAtMs": 1789933772292i64,
164 "accountUuid": "1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0",
165 "utilization": {
166 "five_hour": {"utilization": 62, "resets_at": "2026-09-20T22:20:00.095287+00:00"},
167 "seven_day": {"utilization": 48, "resets_at": "2026-09-27T02:00:00.095306+00:00"},
168 "limits": [
169 {"kind": "session", "group": "session", "percent": 62,
170 "resets_at": "2026-09-20T22:20:00.095287+00:00", "scope": null, "is_active": true},
171 {"kind": "weekly_all", "group": "weekly", "percent": 48,
172 "resets_at": "2026-09-27T02:00:00.095306+00:00", "scope": null, "is_active": false},
173 {"kind": "weekly_scoped", "group": "weekly", "percent": 0,
174 "resets_at": "2026-09-27T02:00:00+00:00",
175 "scope": {"model": {"id": null, "display_name": "Fable"}}, "is_active": false}
176 ]}}})
177 }
178
179 #[test]
180 fn reads_the_real_cache_shape() {
181 let s = from_config_cache(&real_config()).expect("should parse");
182 assert_eq!(s.windows.len(), 3);
183 assert_eq!(
184 s.account_uuid.as_deref(),
185 Some("1f0e2d3c-4b5a-4968-8776-a5b4c3d2e1f0")
186 );
187 assert_eq!(s.observed_at, Some(1789933772));
188 let scoped = s
189 .windows
190 .iter()
191 .find(|w| w.kind == "weekly_scoped")
192 .unwrap();
193 assert_eq!(scoped.scope.as_deref(), Some("Fable"));
194 }
195
196 #[test]
197 fn falls_back_to_the_named_windows_when_limits_is_missing() {
198 let mut c = real_config();
199 c["cachedUsageUtilization"]["utilization"]
200 .as_object_mut()
201 .unwrap()
202 .remove("limits");
203 let s = from_config_cache(&c).unwrap();
204 assert_eq!(s.windows.len(), 2);
205 assert_eq!(s.windows[0].kind, "five_hour");
206 assert_eq!(s.windows[0].percent, 62.0);
207 }
208
209 #[test]
210 fn a_nonsense_percentage_is_dropped_rather_than_drawn() {
211 for nonsense in [serde_json::json!(-5), serde_json::json!("75")] {
212 let mut c = real_config();
213 c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = nonsense;
214 assert_eq!(from_config_cache(&c).unwrap().windows.len(), 2);
215 }
216 }
217
218 #[test]
219 fn an_exceeded_limit_is_kept_not_dropped() {
220 let mut c = real_config();
221 c["cachedUsageUtilization"]["utilization"]["limits"][0]["percent"] = serde_json::json!(104);
222 let s = from_config_cache(&c).unwrap();
223 assert_eq!(s.windows.len(), 3);
224 assert_eq!(s.windows[0].percent, 104.0);
225 }
226
227 #[test]
228 fn missing_cache_is_not_an_error() {
229 assert!(from_config_cache(&serde_json::json!({})).is_none());
230 }
231}