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}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum Source {
25 Live,
27 ClaudeCodeCache,
29 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
41fn 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 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
84fn 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
101pub 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
111pub 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 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}