1use std::future::Future;
4use std::path::Path;
5use std::time::Duration;
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::cache::{Cache, MAX_STALE, acquire_lock_async};
11use crate::error::{AppError, Result};
12use crate::usage::{SuperGrokPeriod, SuperGrokSnapshot};
13
14use super::scope::ScopePaths;
15use super::{acp, scope, types};
16
17const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
18const CACHE_SCHEMA: u8 = 2;
19
20#[derive(Debug, Clone)]
21pub struct FetchOutcome {
22 pub snapshot: SuperGrokSnapshot,
23 pub stale: bool,
24 pub last_error: Option<(u16, String)>,
25 pub cache_age: Option<Duration>,
26}
27
28pub async fn fetch_snapshot(
29 grok_binary: &Path,
30 scope_paths: &ScopePaths,
31 cache: &Cache,
32 cache_ttl: Duration,
33) -> Result<FetchOutcome> {
34 fetch_snapshot_with(
35 cache,
36 cache_ttl,
37 Utc::now(),
38 || scope::fingerprint(scope_paths),
39 || acp::fetch_billing(grok_binary),
40 )
41 .await
42}
43
44async fn fetch_snapshot_with<S, F, Fut>(
45 cache: &Cache,
46 cache_ttl: Duration,
47 now: DateTime<Utc>,
48 read_scope: S,
49 fetch_billing: F,
50) -> Result<FetchOutcome>
51where
52 S: Fn() -> Option<String>,
53 F: FnOnce() -> Fut,
54 Fut: Future<Output = Result<types::BillingResponse>>,
55{
56 cache.ensure_dir()?;
57 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
58 let scope_before = read_scope();
59
60 if let Some(account_scope) = scope_before.as_deref()
61 && let Some(bytes) = cache.fresh_payload(cache_ttl)?
62 && let Ok(outcome) = reuse_cache(&bytes, cache, false, account_scope)
63 && !period_has_ended(&outcome.snapshot, now)
64 {
65 return Ok(outcome);
66 }
67
68 match fetch_billing().await {
69 Ok(response) => {
70 let account_scope = scope_before.as_deref().unwrap_or("uncached");
71 let mut snapshot = match types::to_snapshot(response, account_scope) {
72 Ok(snapshot) => snapshot,
73 Err(error) => return fallback(cache, scope_before.as_deref(), now, error),
74 };
75 let scope_after = read_scope();
76
77 if scope_before.is_some() && scope_before == scope_after {
81 let account_scope = scope_before.as_deref().expect("checked Some");
82 snapshot.account = account_scope.to_string();
83 let bytes =
84 serde_json::to_vec(&CachedEnvelope::from_snapshot(account_scope, &snapshot))?;
85 cache.write_payload(&bytes)?;
86 } else {
87 snapshot.account = "uncached".into();
88 }
89
90 Ok(FetchOutcome {
91 snapshot,
92 stale: false,
93 last_error: None,
94 cache_age: Some(Duration::ZERO),
95 })
96 }
97 Err(error) => fallback(cache, scope_before.as_deref(), now, error),
98 }
99}
100
101fn period_has_ended(snapshot: &SuperGrokSnapshot, now: DateTime<Utc>) -> bool {
102 snapshot.reset_at.is_some_and(|reset| reset <= now)
103}
104
105#[derive(Debug, Serialize, Deserialize)]
106struct CachedEnvelope {
107 schema: u8,
108 scope: String,
109 snapshot: CachedSnapshot,
110}
111
112#[derive(Debug, Serialize, Deserialize)]
113struct CachedSnapshot {
114 plan: String,
115 percent: i32,
116 period: String,
117 reset_at: Option<DateTime<Utc>>,
118 prepaid_balance: Option<f64>,
119}
120
121impl CachedEnvelope {
122 fn from_snapshot(scope: &str, snapshot: &SuperGrokSnapshot) -> Self {
123 let period = match snapshot.period {
124 SuperGrokPeriod::Weekly => "weekly",
125 SuperGrokPeriod::Monthly => "monthly",
126 SuperGrokPeriod::Unknown => "unknown",
127 };
128 Self {
129 schema: CACHE_SCHEMA,
130 scope: scope.to_string(),
131 snapshot: CachedSnapshot {
132 plan: snapshot.plan.clone(),
133 percent: snapshot.weekly_pct,
134 period: period.to_string(),
135 reset_at: snapshot.reset_at,
136 prepaid_balance: snapshot.prepaid_balance,
137 },
138 }
139 }
140}
141
142fn parse_cache(bytes: &[u8], account_scope: &str) -> Result<SuperGrokSnapshot> {
143 let cached: CachedEnvelope = serde_json::from_slice(bytes)?;
144 if cached.schema != CACHE_SCHEMA {
145 return Err(AppError::Schema(
146 "SuperGrok cache schema is obsolete; refetching".into(),
147 ));
148 }
149 if cached.scope != account_scope {
150 return Err(AppError::Schema(
151 "SuperGrok cache belongs to a different login; refetching".into(),
152 ));
153 }
154 if !(0..=100).contains(&cached.snapshot.percent) {
155 return Err(AppError::Schema(
156 "SuperGrok cached percentage is out of range".into(),
157 ));
158 }
159 if cached.snapshot.plan.chars().count() > 128
160 || cached.snapshot.plan.chars().any(char::is_control)
161 {
162 return Err(AppError::Schema(
163 "SuperGrok cached plan label is invalid".into(),
164 ));
165 }
166 let period = match cached.snapshot.period.as_str() {
167 "weekly" => SuperGrokPeriod::Weekly,
168 "monthly" => SuperGrokPeriod::Monthly,
169 "unknown" => SuperGrokPeriod::Unknown,
170 _ => {
171 return Err(AppError::Schema(
172 "SuperGrok cached period kind is invalid".into(),
173 ));
174 }
175 };
176 if cached
177 .snapshot
178 .prepaid_balance
179 .is_some_and(|balance| !balance.is_finite() || balance < 0.0)
180 {
181 return Err(AppError::Schema(
182 "SuperGrok cached prepaid balance is invalid".into(),
183 ));
184 }
185
186 Ok(SuperGrokSnapshot {
187 plan: cached.snapshot.plan,
188 account: account_scope.to_string(),
189 weekly_pct: cached.snapshot.percent,
190 period,
191 reset_at: cached.snapshot.reset_at,
192 prepaid_balance: cached.snapshot.prepaid_balance,
193 })
194}
195
196fn reuse_cache(
197 bytes: &[u8],
198 cache: &Cache,
199 stale: bool,
200 account_scope: &str,
201) -> Result<FetchOutcome> {
202 Ok(FetchOutcome {
203 snapshot: parse_cache(bytes, account_scope)?,
204 stale,
205 last_error: cache.read_last_error(),
206 cache_age: cache.payload_age(),
207 })
208}
209
210fn fallback(
211 cache: &Cache,
212 account_scope: Option<&str>,
213 now: DateTime<Utc>,
214 original: AppError,
215) -> Result<FetchOutcome> {
216 let Some(account_scope) = account_scope else {
217 return Err(original);
218 };
219 let Some(bytes) = cache.fallback_payload(MAX_STALE)? else {
220 return Err(original);
221 };
222 match reuse_cache(&bytes, cache, true, account_scope) {
223 Ok(mut outcome) if !period_has_ended(&outcome.snapshot, now) => {
224 let error = error_to_pair(&original);
225 cache.mark_stale();
226 cache.write_last_error(error.0, &error.1);
227 outcome.last_error = Some(error);
228 Ok(outcome)
229 }
230 _ => Err(original),
231 }
232}
233
234fn error_to_pair(error: &AppError) -> (u16, String) {
235 match error {
236 AppError::Http { status, body } => (*status, body.clone()),
237 other => (0, other.to_string()),
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use chrono::TimeZone;
245 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
246 use tempfile::TempDir;
247
248 fn now() -> DateTime<Utc> {
249 Utc.with_ymd_and_hms(2026, 8, 7, 0, 0, 0).unwrap()
250 }
251
252 fn fixture() -> (TempDir, Cache) {
253 let td = TempDir::new().unwrap();
254 let cache = Cache::at(td.path().join("supergrok"));
255 (td, cache)
256 }
257
258 fn weekly_response(percent: f64) -> types::BillingResponse {
259 serde_json::from_value(serde_json::json!({
260 "config": {
261 "creditUsagePercent": percent,
262 "currentPeriod": {
263 "type": "USAGE_PERIOD_TYPE_WEEKLY",
264 "end": "2026-08-14T00:00:00Z"
265 }
266 },
267 "subscription_tier": "SuperGrok"
268 }))
269 .unwrap()
270 }
271
272 #[tokio::test]
273 async fn live_fetch_writes_only_an_opaque_scope_to_cache() {
274 let (_td, cache) = fixture();
275 let outcome = fetch_snapshot_with(
276 &cache,
277 Duration::ZERO,
278 now(),
279 || Some("opaque-digest".into()),
280 || async { Ok(weekly_response(12.4)) },
281 )
282 .await
283 .unwrap();
284 assert_eq!(outcome.snapshot.weekly_pct, 12);
285 assert_eq!(outcome.snapshot.period, SuperGrokPeriod::Weekly);
286
287 let cache_text = std::fs::read_to_string(cache.payload_path()).unwrap();
288 assert!(cache_text.contains("opaque-digest"));
289 assert!(!cache_text.contains("access_token"));
290 assert!(!cache_text.contains("user_id"));
291 }
292
293 #[tokio::test]
294 async fn fresh_cache_skips_the_acp_process() {
295 let (_td, cache) = fixture();
296 cache.ensure_dir().unwrap();
297 let snapshot = types::to_snapshot(weekly_response(7.0), "scope-a").unwrap();
298 cache
299 .write_payload(
300 &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
301 )
302 .unwrap();
303 let called = AtomicBool::new(false);
304
305 let outcome = fetch_snapshot_with(
306 &cache,
307 Duration::from_secs(3600),
308 now(),
309 || Some("scope-a".into()),
310 || async {
311 called.store(true, Ordering::SeqCst);
312 Ok(weekly_response(99.0))
313 },
314 )
315 .await
316 .unwrap();
317 assert_eq!(outcome.snapshot.weekly_pct, 7);
318 assert!(!called.load(Ordering::SeqCst));
319 }
320
321 #[tokio::test]
322 async fn a_scope_change_during_fetch_returns_live_but_does_not_cache() {
323 let (_td, cache) = fixture();
324 let calls = AtomicUsize::new(0);
325 let outcome = fetch_snapshot_with(
326 &cache,
327 Duration::ZERO,
328 now(),
329 || {
330 let call = calls.fetch_add(1, Ordering::SeqCst);
331 Some(if call == 0 { "before" } else { "after" }.into())
332 },
333 || async { Ok(weekly_response(20.0)) },
334 )
335 .await
336 .unwrap();
337 assert_eq!(outcome.snapshot.account, "uncached");
338 assert!(!cache.payload_path().exists());
339 }
340
341 #[tokio::test]
342 async fn failure_falls_back_only_for_the_same_scope_and_live_period() {
343 let (_td, cache) = fixture();
344 cache.ensure_dir().unwrap();
345 let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
346 cache
347 .write_payload(
348 &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
349 )
350 .unwrap();
351
352 let fallback = fetch_snapshot_with(
353 &cache,
354 Duration::ZERO,
355 now(),
356 || Some("scope-a".into()),
357 || async { Err(AppError::Transport("offline".into())) },
358 )
359 .await
360 .unwrap();
361 assert!(fallback.stale);
362 assert_eq!(fallback.snapshot.weekly_pct, 33);
363
364 let other_scope = fetch_snapshot_with(
365 &cache,
366 Duration::ZERO,
367 now(),
368 || Some("scope-b".into()),
369 || async { Err(AppError::Transport("offline".into())) },
370 )
371 .await;
372 assert!(other_scope.is_err());
373 }
374
375 #[tokio::test]
376 async fn malformed_live_billing_preserves_the_last_good_same_scope_cache() {
377 let (_td, cache) = fixture();
378 cache.ensure_dir().unwrap();
379 let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
380 cache
381 .write_payload(
382 &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
383 )
384 .unwrap();
385
386 let fallback = fetch_snapshot_with(
387 &cache,
388 Duration::ZERO,
389 now(),
390 || Some("scope-a".into()),
391 || async { Ok(weekly_response(999.0)) },
392 )
393 .await
394 .unwrap();
395 assert!(fallback.stale);
396 assert_eq!(fallback.snapshot.weekly_pct, 33);
397 assert!(
398 fallback
399 .last_error
400 .as_ref()
401 .is_some_and(|(_, message)| message.contains("outside the supported range"))
402 );
403 }
404
405 #[tokio::test]
406 async fn missing_scope_disables_cache_reuse() {
407 let (_td, cache) = fixture();
408 cache.ensure_dir().unwrap();
409 let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
410 cache
411 .write_payload(
412 &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
413 )
414 .unwrap();
415 let outcome = fetch_snapshot_with(
416 &cache,
417 Duration::from_secs(3600),
418 now(),
419 || None,
420 || async { Err(AppError::Transport("offline".into())) },
421 )
422 .await;
423 assert!(outcome.is_err());
424 }
425
426 #[tokio::test]
427 async fn an_ended_period_is_never_resurrected_on_failure() {
428 let (_td, cache) = fixture();
429 cache.ensure_dir().unwrap();
430 let snapshot = types::to_snapshot(weekly_response(88.0), "scope-a").unwrap();
431 cache
432 .write_payload(
433 &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
434 )
435 .unwrap();
436 let after_reset = Utc.with_ymd_and_hms(2026, 8, 15, 0, 0, 0).unwrap();
437 let outcome = fetch_snapshot_with(
438 &cache,
439 Duration::from_secs(3600),
440 after_reset,
441 || Some("scope-a".into()),
442 || async { Err(AppError::Transport("offline".into())) },
443 )
444 .await;
445 assert!(outcome.is_err());
446 }
447
448 #[test]
449 fn cached_percentages_and_periods_are_strictly_validated() {
450 let base = serde_json::json!({
451 "schema": CACHE_SCHEMA,
452 "scope": "scope-a",
453 "snapshot": {
454 "plan": "SuperGrok",
455 "percent": 5,
456 "period": "weekly",
457 "reset_at": null,
458 "prepaid_balance": null
459 }
460 });
461 for (field, value) in [
462 ("percent", serde_json::json!(101)),
463 ("period", serde_json::json!("yearly")),
464 ("prepaid_balance", serde_json::json!(-1.0)),
465 ] {
466 let mut malformed = base.clone();
467 malformed["snapshot"][field] = value;
468 assert!(
469 parse_cache(malformed.to_string().as_bytes(), "scope-a").is_err(),
470 "field: {field}"
471 );
472 }
473
474 let mut obsolete = base;
475 obsolete["schema"] = serde_json::json!(1);
476 obsolete["account"] = serde_json::json!("person@example.test");
477 assert!(parse_cache(obsolete.to_string().as_bytes(), "scope-a").is_err());
478 }
479}