Skip to main content

vct_core/quota/
cache.rs

1//! On-disk caches for the latest per-provider quota snapshots
2//! (`~/.vct/{claude,codex,copilot,cursor,grok}_usage.json`).
3//!
4//! Each is a single last-known-good file (not dated like the pricing cache,
5//! since we always want the latest value). A fresh `vct usage` launch seeds
6//! the panels from here instantly while the background workers refresh them.
7//!
8//! What lands on disk is the already-derived snapshot, so every file is stamped
9//! with the writing build's [`CachedQuota::SCHEMA_VERSION`] and a file carrying
10//! any other version is ignored instead of displayed.
11
12use 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
25/// A normalized snapshot persisted to one of the per-provider cache files.
26///
27/// [`Self::SCHEMA_VERSION`] versions how the stored fields are *derived*, not
28/// the JSON shape: the file holds normalized values (a gauge's `used_percent`,
29/// a pre-formatted money amount), so a build that computes one of them
30/// differently must not paint the previous build's numbers. "The next fetch
31/// replaces them" is no bound — a transient failure keeps the last-known-good
32/// snapshot indefinitely, so an offline or rate-limited upgrade would sit on
33/// them.
34///
35/// Bump a provider's version in the same change that alters how any of its
36/// snapshot fields is derived. A file whose version differs — including a
37/// pre-versioning file, which carries none — is dropped on load and the panel
38/// waits for its own first fetch.
39trait CachedQuota: Serialize + DeserializeOwned {
40    /// Derivation-semantics version of this provider's snapshot.
41    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/// The on-disk envelope: the writing build's schema version + the snapshot.
65///
66/// The snapshot is nested rather than `#[serde(flatten)]`ed so both directions
67/// fail safe: a pre-versioning file has no `snapshot` key and is rejected here,
68/// and an older build reading this shape finds none of its own fields, so it
69/// shows an empty panel rather than reading values it would misinterpret.
70#[derive(Serialize, Deserialize)]
71struct VersionedCache<T> {
72    /// [`CachedQuota::SCHEMA_VERSION`] of the build that wrote the file.
73    schema_version: u32,
74    /// The normalized snapshot itself.
75    snapshot: T,
76}
77
78/// Loads and parses a cache file, returning `None` when it is absent, corrupt,
79/// or was written under different derivation semantics.
80fn 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
102/// Persists a snapshot atomically to `path`, stamped with its schema version.
103fn 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
113/// Loads the last-known Claude quota snapshot, or `None` when it is absent,
114/// corrupt, or stamped with a schema version this build does not share.
115pub fn load_claude_cache() -> Option<ClaudeQuotaSnapshot> {
116    load_cache(get_claude_usage_cache_path())
117}
118
119/// Persists the Claude quota snapshot atomically.
120pub fn save_claude_cache(snap: &ClaudeQuotaSnapshot) -> Result<()> {
121    save_cache(get_claude_usage_cache_path(), snap)
122}
123
124/// Loads the last-known Codex quota snapshot, or `None` when it is absent,
125/// corrupt, or stamped with a schema version this build does not share.
126pub fn load_codex_cache() -> Option<CodexQuotaSnapshot> {
127    load_cache(get_codex_usage_cache_path())
128}
129
130/// Persists the Codex quota snapshot atomically.
131pub fn save_codex_cache(snap: &CodexQuotaSnapshot) -> Result<()> {
132    save_cache(get_codex_usage_cache_path(), snap)
133}
134
135/// Loads the last-known Copilot quota snapshot, or `None` when it is absent,
136/// corrupt, or stamped with a schema version this build does not share.
137pub fn load_copilot_cache() -> Option<CopilotQuotaSnapshot> {
138    load_cache(get_copilot_usage_cache_path())
139}
140
141/// Persists the Copilot quota snapshot atomically.
142pub fn save_copilot_cache(snap: &CopilotQuotaSnapshot) -> Result<()> {
143    save_cache(get_copilot_usage_cache_path(), snap)
144}
145
146/// Loads the last-known Cursor quota snapshot, or `None` when it is absent,
147/// corrupt, or stamped with a schema version this build does not share.
148pub fn load_cursor_cache() -> Option<CursorQuotaSnapshot> {
149    load_cache(get_cursor_usage_cache_path())
150}
151
152/// Persists the Cursor quota snapshot atomically.
153pub fn save_cursor_cache(snap: &CursorQuotaSnapshot) -> Result<()> {
154    save_cache(get_cursor_usage_cache_path(), snap)
155}
156
157/// Loads the last-known Grok quota snapshot, or `None` when it is absent,
158/// corrupt, or stamped with a schema version this build does not share.
159pub fn load_grok_cache() -> Option<GrokQuotaSnapshot> {
160    load_cache(get_grok_usage_cache_path())
161}
162
163/// Persists the Grok quota snapshot atomically.
164pub 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    /// A temp dir plus the cache path inside it, so no test touches `$HOME`.
175    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    /// Saves then loads `snapshot` through the real envelope.
182    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    /// The stamp is the writing type's own version, under a nested `snapshot`.
272    #[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        // The other direction of the nesting: an older build cannot read this as
285        // a bare snapshot either, so it blanks the panel instead of misreading
286        // values whose derivation it does not share.
287        assert!(serde_json::from_str::<CursorQuotaSnapshot>(&raw).is_err());
288    }
289
290    /// A pre-versioning file (the bare snapshot) is never displayed, even though
291    /// it still deserializes cleanly as a snapshot — the #111 regression.
292    #[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    /// A file written by a build with different derivation semantics is dropped.
303    #[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}