1use crate::models::{
13 ClaudeQuotaSnapshot, CodexQuotaSnapshot, CopilotQuotaSnapshot, CursorQuotaSnapshot,
14 GrokQuotaSnapshot,
15};
16use crate::utils::{
17 get_claude_usage_cache_path, get_codex_usage_cache_path, get_copilot_usage_cache_path,
18 get_cursor_usage_cache_path, get_grok_usage_cache_path, write_json_atomic,
19};
20use anyhow::Result;
21use serde::de::DeserializeOwned;
22use serde::{Deserialize, Serialize};
23use std::path::PathBuf;
24
25trait CachedQuota: Serialize + DeserializeOwned {
40 const SCHEMA_VERSION: u32;
42}
43
44impl CachedQuota for ClaudeQuotaSnapshot {
45 const SCHEMA_VERSION: u32 = 1;
46}
47
48impl CachedQuota for CodexQuotaSnapshot {
49 const SCHEMA_VERSION: u32 = 1;
50}
51
52impl CachedQuota for CopilotQuotaSnapshot {
53 const SCHEMA_VERSION: u32 = 1;
54}
55
56impl CachedQuota for CursorQuotaSnapshot {
57 const SCHEMA_VERSION: u32 = 1;
58}
59
60impl CachedQuota for GrokQuotaSnapshot {
61 const SCHEMA_VERSION: u32 = 1;
62}
63
64#[derive(Serialize, Deserialize)]
71struct VersionedCache<T> {
72 schema_version: u32,
74 snapshot: T,
76}
77
78fn load_cache<T: CachedQuota>(path: Result<PathBuf>) -> Option<T> {
81 let path = path.ok()?;
82 let body = std::fs::read_to_string(&path).ok()?;
83 let cached: VersionedCache<T> = match serde_json::from_str(&body) {
84 Ok(cached) => cached,
85 Err(error) => {
86 log::debug!("ignoring quota cache {}: {error}", path.display());
87 return None;
88 }
89 };
90 if cached.schema_version != T::SCHEMA_VERSION {
91 log::debug!(
92 "ignoring quota cache {}: written by schema v{}, current is v{}",
93 path.display(),
94 cached.schema_version,
95 T::SCHEMA_VERSION
96 );
97 return None;
98 }
99 Some(cached.snapshot)
100}
101
102fn save_cache<T: CachedQuota>(path: Result<PathBuf>, snapshot: &T) -> Result<()> {
104 write_json_atomic(
105 path?,
106 &VersionedCache {
107 schema_version: T::SCHEMA_VERSION,
108 snapshot,
109 },
110 )
111}
112
113pub fn load_claude_cache() -> Option<ClaudeQuotaSnapshot> {
116 load_cache(get_claude_usage_cache_path())
117}
118
119pub fn save_claude_cache(snap: &ClaudeQuotaSnapshot) -> Result<()> {
121 save_cache(get_claude_usage_cache_path(), snap)
122}
123
124pub fn load_codex_cache() -> Option<CodexQuotaSnapshot> {
127 load_cache(get_codex_usage_cache_path())
128}
129
130pub fn save_codex_cache(snap: &CodexQuotaSnapshot) -> Result<()> {
132 save_cache(get_codex_usage_cache_path(), snap)
133}
134
135pub fn load_copilot_cache() -> Option<CopilotQuotaSnapshot> {
138 load_cache(get_copilot_usage_cache_path())
139}
140
141pub fn save_copilot_cache(snap: &CopilotQuotaSnapshot) -> Result<()> {
143 save_cache(get_copilot_usage_cache_path(), snap)
144}
145
146pub fn load_cursor_cache() -> Option<CursorQuotaSnapshot> {
149 load_cache(get_cursor_usage_cache_path())
150}
151
152pub fn save_cursor_cache(snap: &CursorQuotaSnapshot) -> Result<()> {
154 save_cache(get_cursor_usage_cache_path(), snap)
155}
156
157pub fn load_grok_cache() -> Option<GrokQuotaSnapshot> {
160 load_cache(get_grok_usage_cache_path())
161}
162
163pub fn save_grok_cache(snap: &GrokQuotaSnapshot) -> Result<()> {
165 save_cache(get_grok_usage_cache_path(), snap)
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171 use crate::models::{QuotaSource, QuotaWindow};
172 use tempfile::TempDir;
173
174 fn cache_file() -> (TempDir, PathBuf) {
176 let dir = tempfile::tempdir().unwrap();
177 let path = dir.path().join("provider_usage.json");
178 (dir, path)
179 }
180
181 fn round_trip<T: CachedQuota>(snapshot: &T) -> Option<T> {
183 let (_dir, path) = cache_file();
184 save_cache(Ok(path.clone()), snapshot).unwrap();
185 load_cache::<T>(Ok(path))
186 }
187
188 fn cursor_snapshot() -> CursorQuotaSnapshot {
189 CursorQuotaSnapshot {
190 source: QuotaSource::Api,
191 fetched_at: 1_700_000_000,
192 plan_type: Some("pro".into()),
193 total: Some(QuotaWindow {
194 used_percent: 12.5,
195 resets_at_unix: Some(1_700_600_000),
196 }),
197 auto: Some(QuotaWindow {
198 used_percent: 3.0,
199 resets_at_unix: Some(1_700_600_000),
200 }),
201 api: None,
202 on_demand_dollars: Some(1.25),
203 limit_reached: false,
204 needs_login: false,
205 }
206 }
207
208 #[test]
209 fn round_trip_preserves_cursor_snapshot() {
210 let loaded = round_trip(&cursor_snapshot()).expect("a just-written cache must load");
211 assert_eq!(loaded.source, QuotaSource::Api);
212 assert_eq!(loaded.fetched_at, 1_700_000_000);
213 assert_eq!(loaded.plan_type.as_deref(), Some("pro"));
214 let total = loaded.total.expect("total window");
215 assert_eq!(total.used_percent, 12.5);
216 assert_eq!(total.resets_at_unix, Some(1_700_600_000));
217 assert_eq!(loaded.auto.expect("auto window").used_percent, 3.0);
218 assert!(loaded.api.is_none());
219 assert_eq!(loaded.on_demand_dollars, Some(1.25));
220 assert!(!loaded.limit_reached);
221 }
222
223 #[test]
224 fn round_trip_preserves_other_providers() {
225 let claude = round_trip(&ClaudeQuotaSnapshot {
226 fetched_at: 11,
227 five_hour: Some(QuotaWindow {
228 used_percent: 42.0,
229 resets_at_unix: None,
230 }),
231 ..Default::default()
232 })
233 .expect("claude cache");
234 assert_eq!(claude.fetched_at, 11);
235 assert_eq!(claude.five_hour.unwrap().used_percent, 42.0);
236
237 let codex = round_trip(&CodexQuotaSnapshot {
238 fetched_at: 22,
239 plan_type: Some("plus".into()),
240 reset_credits_available: Some(2),
241 ..Default::default()
242 })
243 .expect("codex cache");
244 assert_eq!(codex.fetched_at, 22);
245 assert_eq!(codex.plan_type.as_deref(), Some("plus"));
246 assert_eq!(codex.reset_credits_available, Some(2));
247
248 let copilot = round_trip(&CopilotQuotaSnapshot {
249 fetched_at: 33,
250 premium_remaining: Some(120),
251 needs_login: true,
252 ..Default::default()
253 })
254 .expect("copilot cache");
255 assert_eq!(copilot.fetched_at, 33);
256 assert_eq!(copilot.premium_remaining, Some(120));
257 assert!(copilot.needs_login);
258
259 let grok = round_trip(&GrokQuotaSnapshot {
260 fetched_at: 44,
261 period_label: Some("week".into()),
262 prepaid_balance_dollars: Some(7.5),
263 ..Default::default()
264 })
265 .expect("grok cache");
266 assert_eq!(grok.fetched_at, 44);
267 assert_eq!(grok.period_label.as_deref(), Some("week"));
268 assert_eq!(grok.prepaid_balance_dollars, Some(7.5));
269 }
270
271 #[test]
273 fn saved_file_carries_the_current_schema_version() {
274 let (_dir, path) = cache_file();
275 save_cache(Ok(path.clone()), &cursor_snapshot()).unwrap();
276
277 let raw = std::fs::read_to_string(&path).unwrap();
278 let body: serde_json::Value = serde_json::from_str(&raw).unwrap();
279 assert_eq!(
280 body["schema_version"],
281 serde_json::json!(CursorQuotaSnapshot::SCHEMA_VERSION)
282 );
283 assert_eq!(body["snapshot"]["plan_type"], "pro");
284 assert!(serde_json::from_str::<CursorQuotaSnapshot>(&raw).is_err());
288 }
289
290 #[test]
293 fn unversioned_cache_is_rejected() {
294 let (_dir, path) = cache_file();
295 let bare = serde_json::to_string(&cursor_snapshot()).unwrap();
296 std::fs::write(&path, &bare).unwrap();
297
298 assert!(serde_json::from_str::<CursorQuotaSnapshot>(&bare).is_ok());
299 assert!(load_cache::<CursorQuotaSnapshot>(Ok(path)).is_none());
300 }
301
302 #[test]
304 fn mismatched_schema_version_is_rejected() {
305 let (_dir, path) = cache_file();
306 std::fs::write(
307 &path,
308 serde_json::to_string(&VersionedCache {
309 schema_version: CursorQuotaSnapshot::SCHEMA_VERSION + 1,
310 snapshot: cursor_snapshot(),
311 })
312 .unwrap(),
313 )
314 .unwrap();
315
316 assert!(load_cache::<CursorQuotaSnapshot>(Ok(path)).is_none());
317 }
318
319 #[test]
320 fn corrupt_or_absent_cache_is_none() {
321 let (_dir, path) = cache_file();
322 assert!(load_cache::<CursorQuotaSnapshot>(Ok(path.clone())).is_none());
323
324 std::fs::write(&path, "{not json").unwrap();
325 assert!(load_cache::<CursorQuotaSnapshot>(Ok(path)).is_none());
326 }
327}