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, acquire_lock_async};
11use crate::error::{AppError, Result};
12use crate::usage::{SuperGrokPeriod, SuperGrokSnapshot};
13
14use super::scope::ScopePaths;
15use super::{acp, direct, resets, scope, types};
16
17const LOCK_TIMEOUT: Duration = Duration::from_secs(15);
18const CACHE_SCHEMA: u8 = 4;
19
20pub type FetchOutcome = crate::outcome::Outcome<SuperGrokSnapshot>;
23
24pub async fn fetch_snapshot(
25 grok_binary: &Path,
26 scope_paths: &ScopePaths,
27 cache: &Cache,
28 cache_ttl: Duration,
29) -> Result<FetchOutcome> {
30 fetch_snapshot_with(
31 cache,
32 cache_ttl,
33 Utc::now(),
34 || scope::fingerprint(scope_paths),
35 || fetch_billing_any(grok_binary, scope_paths),
36 )
37 .await
38}
39
40async fn fetch_billing_any(
47 grok_binary: &Path,
48 scope_paths: &ScopePaths,
49) -> Result<types::BillingResponse> {
50 let mut response = match direct::fetch_billing(&scope_paths.auth).await {
51 Ok(response) => Ok(response),
52 Err(direct_error) => match acp::fetch_billing(grok_binary).await {
53 Ok(response) => Ok(response),
54 Err(_) => Err(direct_error),
55 },
56 }?;
57 response.reset_credits = resets::fetch(&scope_paths.auth).await.unwrap_or_default();
58 if response.subscription_tier_display.is_none()
59 && let Ok(Some(display)) = direct::fetch_plan_display(&scope_paths.auth).await
60 {
61 response.subscription_tier_display = Some(display);
62 }
63 Ok(response)
64}
65
66async fn fetch_snapshot_with<S, F, Fut>(
67 cache: &Cache,
68 cache_ttl: Duration,
69 now: DateTime<Utc>,
70 read_scope: S,
71 fetch_billing: F,
72) -> Result<FetchOutcome>
73where
74 S: Fn() -> Option<String>,
75 F: FnOnce() -> Fut,
76 Fut: Future<Output = Result<types::BillingResponse>>,
77{
78 cache.ensure_dir()?;
79 let _lock = acquire_lock_async(&cache.lock_path(), LOCK_TIMEOUT).await?;
80 let scope_before = read_scope();
81
82 if let Some(account_scope) = scope_before.as_deref()
83 && let Some(bytes) = cache.fresh_payload(cache_ttl)?
84 && let Ok(outcome) = reuse_cache(&bytes, cache, false, account_scope)
85 && !period_has_ended(&outcome.snapshot, now)
86 {
87 return Ok(outcome);
88 }
89
90 match fetch_billing().await {
91 Ok(response) => {
92 let account_scope = scope_before.as_deref().unwrap_or("uncached");
93 let mut snapshot = match types::to_snapshot(response, account_scope) {
94 Ok(snapshot) => snapshot,
95 Err(error) => return fallback(cache, scope_before.as_deref(), now, error),
96 };
97 let scope_after = read_scope();
98
99 if scope_before.is_some() && scope_before == scope_after {
103 let account_scope = scope_before.as_deref().expect("checked Some");
104 snapshot.account = account_scope.to_string();
105 let bytes =
106 serde_json::to_vec(&CachedEnvelope::from_snapshot(account_scope, &snapshot))?;
107 cache.write_payload(&bytes)?;
108 } else {
109 snapshot.account = "uncached".into();
110 }
111
112 Ok(crate::outcome::Outcome::fresh(snapshot))
113 }
114 Err(error) => fallback(cache, scope_before.as_deref(), now, error),
115 }
116}
117
118fn period_has_ended(snapshot: &SuperGrokSnapshot, now: DateTime<Utc>) -> bool {
119 snapshot.reset_at.is_some_and(|reset| reset <= now)
120}
121
122#[derive(Debug, Serialize, Deserialize)]
123struct CachedEnvelope {
124 schema: u8,
125 scope: String,
126 snapshot: CachedSnapshot,
127}
128
129#[derive(Debug, Serialize, Deserialize)]
130struct CachedSnapshot {
131 plan: String,
132 percent: i32,
133 period: String,
134 reset_at: Option<DateTime<Utc>>,
135 prepaid_balance: Option<f64>,
136 #[serde(default)]
137 reset_credits: crate::usage::ResetCredits,
138 #[serde(default)]
139 products: Vec<crate::usage::SuperGrokProduct>,
140}
141
142impl CachedEnvelope {
143 fn from_snapshot(scope: &str, snapshot: &SuperGrokSnapshot) -> Self {
144 let period = match snapshot.period {
145 SuperGrokPeriod::Weekly => "weekly",
146 SuperGrokPeriod::Monthly => "monthly",
147 SuperGrokPeriod::Unknown => "unknown",
148 };
149 Self {
150 schema: CACHE_SCHEMA,
151 scope: scope.to_string(),
152 snapshot: CachedSnapshot {
153 plan: snapshot.plan.clone(),
154 percent: snapshot.weekly_pct,
155 period: period.to_string(),
156 reset_at: snapshot.reset_at,
157 prepaid_balance: snapshot.prepaid_balance,
158 reset_credits: snapshot.reset_credits.clone(),
159 products: snapshot.products.clone(),
160 },
161 }
162 }
163}
164
165fn parse_cache(bytes: &[u8], account_scope: &str) -> Result<SuperGrokSnapshot> {
166 let cached: CachedEnvelope = serde_json::from_slice(bytes)?;
167 if cached.schema != CACHE_SCHEMA {
168 return Err(AppError::Schema(
169 "SuperGrok cache schema is obsolete; refetching".into(),
170 ));
171 }
172 if cached.scope != account_scope {
173 return Err(AppError::Schema(
174 "SuperGrok cache belongs to a different login; refetching".into(),
175 ));
176 }
177 if !(0..=100).contains(&cached.snapshot.percent) {
178 return Err(AppError::Schema(
179 "SuperGrok cached percentage is out of range".into(),
180 ));
181 }
182 if cached.snapshot.plan.chars().count() > 128
183 || cached.snapshot.plan.chars().any(char::is_control)
184 {
185 return Err(AppError::Schema(
186 "SuperGrok cached plan label is invalid".into(),
187 ));
188 }
189 let period = match cached.snapshot.period.as_str() {
190 "weekly" => SuperGrokPeriod::Weekly,
191 "monthly" => SuperGrokPeriod::Monthly,
192 "unknown" => SuperGrokPeriod::Unknown,
193 _ => {
194 return Err(AppError::Schema(
195 "SuperGrok cached period kind is invalid".into(),
196 ));
197 }
198 };
199 if cached
200 .snapshot
201 .prepaid_balance
202 .is_some_and(|balance| !balance.is_finite() || balance < 0.0)
203 {
204 return Err(AppError::Schema(
205 "SuperGrok cached prepaid balance is invalid".into(),
206 ));
207 }
208 if cached.snapshot.reset_credits.credits.len()
211 > cached.snapshot.reset_credits.available as usize
212 {
213 return Err(AppError::Schema(
214 "SuperGrok cached reset credits are inconsistent".into(),
215 ));
216 }
217 for product in &cached.snapshot.products {
218 if !(0..=100).contains(&product.percent)
219 || product.label.is_empty()
220 || product.label.chars().count() > 128
221 || product.label.chars().any(char::is_control)
222 {
223 return Err(AppError::Schema(
224 "SuperGrok cached product row is invalid".into(),
225 ));
226 }
227 }
228
229 Ok(SuperGrokSnapshot {
230 plan: cached.snapshot.plan,
231 account: account_scope.to_string(),
232 weekly_pct: cached.snapshot.percent,
233 period,
234 reset_at: cached.snapshot.reset_at,
235 prepaid_balance: cached.snapshot.prepaid_balance,
236 reset_credits: cached.snapshot.reset_credits,
237 products: cached.snapshot.products,
238 })
239}
240
241fn reuse_cache(
242 bytes: &[u8],
243 cache: &Cache,
244 stale: bool,
245 account_scope: &str,
246) -> Result<FetchOutcome> {
247 Ok(crate::outcome::Outcome::cached(
248 parse_cache(bytes, account_scope)?,
249 cache,
250 stale,
251 ))
252}
253
254fn fallback(
259 cache: &Cache,
260 account_scope: Option<&str>,
261 now: DateTime<Utc>,
262 original: AppError,
263) -> Result<FetchOutcome> {
264 let Some(account_scope) = account_scope else {
265 return Err(original);
266 };
267 let error = error_to_pair(&original);
268 let outcome = crate::outcome::fallback(cache, Some(error.clone()), original, |bytes| {
269 let snapshot = parse_cache(bytes, account_scope)?;
270 if period_has_ended(&snapshot, now) {
271 return Err(AppError::Schema(
272 "cached SuperGrok period has already ended".into(),
273 ));
274 }
275 Ok(snapshot)
276 })?;
277 cache.mark_stale();
280 cache.write_last_error(error.0, &error.1);
281 Ok(outcome)
282}
283
284fn error_to_pair(error: &AppError) -> (u16, String) {
285 match error {
286 AppError::Http { status, body } => (*status, body.clone()),
287 other => (0, other.to_string()),
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use chrono::TimeZone;
295 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
296 use tempfile::TempDir;
297
298 fn now() -> DateTime<Utc> {
299 Utc.with_ymd_and_hms(2026, 8, 7, 0, 0, 0).unwrap()
300 }
301
302 fn fixture() -> (TempDir, Cache) {
303 let td = TempDir::new().unwrap();
304 let cache = Cache::at(td.path().join("supergrok"));
305 (td, cache)
306 }
307
308 fn weekly_response(percent: f64) -> types::BillingResponse {
309 serde_json::from_value(serde_json::json!({
310 "config": {
311 "creditUsagePercent": percent,
312 "currentPeriod": {
313 "type": "USAGE_PERIOD_TYPE_WEEKLY",
314 "end": "2026-08-14T00:00:00Z"
315 }
316 },
317 "subscription_tier": "SuperGrok"
318 }))
319 .unwrap()
320 }
321
322 #[tokio::test]
326 async fn banked_resets_survive_the_cache_round_trip() {
327 let (_td, cache) = fixture();
328 let expiry = Utc.with_ymd_and_hms(2026, 8, 12, 0, 0, 0).unwrap();
329 let mut response = weekly_response(10.0);
330 response.reset_credits = crate::usage::ResetCredits {
331 available: 2,
332 credits: vec![crate::usage::ResetCredit {
333 title: None,
334 expires_at: Some(expiry),
335 }],
336 };
337 let fresh = fetch_snapshot_with(
338 &cache,
339 Duration::from_secs(3600),
340 now(),
341 || Some("scope-a".into()),
342 || async { Ok(response) },
343 )
344 .await
345 .unwrap();
346 assert_eq!(fresh.snapshot.reset_credits.available, 2);
347
348 let cached = fetch_snapshot_with(
349 &cache,
350 Duration::from_secs(3600),
351 now(),
352 || Some("scope-a".into()),
353 || async { panic!("a fresh cache must not refetch") },
354 )
355 .await
356 .unwrap();
357 assert_eq!(cached.snapshot.reset_credits.available, 2);
358 assert_eq!(cached.snapshot.reset_credits.next_expiry(), Some(expiry));
359 }
360
361 #[test]
362 fn a_cache_claiming_more_expiries_than_credits_is_rejected() {
363 let cached = serde_json::json!({
364 "schema": CACHE_SCHEMA,
365 "scope": "scope-a",
366 "snapshot": {
367 "plan": "SuperGrok",
368 "percent": 5,
369 "period": "weekly",
370 "reset_at": null,
371 "prepaid_balance": null,
372 "reset_credits": {
373 "available": 1,
374 "credits": [
375 {"expires_at": "2026-08-12T00:00:00Z"},
376 {"expires_at": "2026-08-19T00:00:00Z"}
377 ]
378 }
379 }
380 });
381 assert!(parse_cache(cached.to_string().as_bytes(), "scope-a").is_err());
382 }
383
384 #[test]
387 fn a_cache_without_reset_credits_still_loads() {
388 let cached = serde_json::json!({
389 "schema": CACHE_SCHEMA,
390 "scope": "scope-a",
391 "snapshot": {
392 "plan": "SuperGrok",
393 "percent": 5,
394 "period": "weekly",
395 "reset_at": null,
396 "prepaid_balance": null
397 }
398 });
399 let snapshot = parse_cache(cached.to_string().as_bytes(), "scope-a").unwrap();
400 assert!(snapshot.reset_credits.is_empty());
401 }
402
403 #[tokio::test]
404 async fn live_fetch_writes_only_an_opaque_scope_to_cache() {
405 let (_td, cache) = fixture();
406 let outcome = fetch_snapshot_with(
407 &cache,
408 Duration::ZERO,
409 now(),
410 || Some("opaque-digest".into()),
411 || async { Ok(weekly_response(12.4)) },
412 )
413 .await
414 .unwrap();
415 assert_eq!(outcome.snapshot.weekly_pct, 12);
416 assert_eq!(outcome.snapshot.period, SuperGrokPeriod::Weekly);
417
418 let cache_text = std::fs::read_to_string(cache.payload_path()).unwrap();
419 assert!(cache_text.contains("opaque-digest"));
420 assert!(!cache_text.contains("access_token"));
421 assert!(!cache_text.contains("user_id"));
422 }
423
424 #[tokio::test]
425 async fn fresh_cache_skips_the_acp_process() {
426 let (_td, cache) = fixture();
427 cache.ensure_dir().unwrap();
428 let snapshot = types::to_snapshot(weekly_response(7.0), "scope-a").unwrap();
429 cache
430 .write_payload(
431 &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
432 )
433 .unwrap();
434 let called = AtomicBool::new(false);
435
436 let outcome = fetch_snapshot_with(
437 &cache,
438 Duration::from_secs(3600),
439 now(),
440 || Some("scope-a".into()),
441 || async {
442 called.store(true, Ordering::SeqCst);
443 Ok(weekly_response(99.0))
444 },
445 )
446 .await
447 .unwrap();
448 assert_eq!(outcome.snapshot.weekly_pct, 7);
449 assert!(!called.load(Ordering::SeqCst));
450 }
451
452 #[tokio::test]
453 async fn a_scope_change_during_fetch_returns_live_but_does_not_cache() {
454 let (_td, cache) = fixture();
455 let calls = AtomicUsize::new(0);
456 let outcome = fetch_snapshot_with(
457 &cache,
458 Duration::ZERO,
459 now(),
460 || {
461 let call = calls.fetch_add(1, Ordering::SeqCst);
462 Some(if call == 0 { "before" } else { "after" }.into())
463 },
464 || async { Ok(weekly_response(20.0)) },
465 )
466 .await
467 .unwrap();
468 assert_eq!(outcome.snapshot.account, "uncached");
469 assert!(!cache.payload_path().exists());
470 }
471
472 #[tokio::test]
473 async fn failure_falls_back_only_for_the_same_scope_and_live_period() {
474 let (_td, cache) = fixture();
475 cache.ensure_dir().unwrap();
476 let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
477 cache
478 .write_payload(
479 &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
480 )
481 .unwrap();
482
483 let fallback = fetch_snapshot_with(
484 &cache,
485 Duration::ZERO,
486 now(),
487 || Some("scope-a".into()),
488 || async { Err(AppError::Transport("offline".into())) },
489 )
490 .await
491 .unwrap();
492 assert!(fallback.stale);
493 assert_eq!(fallback.snapshot.weekly_pct, 33);
494
495 let other_scope = fetch_snapshot_with(
496 &cache,
497 Duration::ZERO,
498 now(),
499 || Some("scope-b".into()),
500 || async { Err(AppError::Transport("offline".into())) },
501 )
502 .await;
503 assert!(other_scope.is_err());
504 }
505
506 #[tokio::test]
507 async fn malformed_live_billing_preserves_the_last_good_same_scope_cache() {
508 let (_td, cache) = fixture();
509 cache.ensure_dir().unwrap();
510 let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
511 cache
512 .write_payload(
513 &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
514 )
515 .unwrap();
516
517 let fallback = fetch_snapshot_with(
518 &cache,
519 Duration::ZERO,
520 now(),
521 || Some("scope-a".into()),
522 || async { Ok(weekly_response(999.0)) },
523 )
524 .await
525 .unwrap();
526 assert!(fallback.stale);
527 assert_eq!(fallback.snapshot.weekly_pct, 33);
528 assert!(
529 fallback
530 .last_error
531 .as_ref()
532 .is_some_and(|(_, message)| message.contains("outside the supported range"))
533 );
534 }
535
536 #[tokio::test]
537 async fn missing_scope_disables_cache_reuse() {
538 let (_td, cache) = fixture();
539 cache.ensure_dir().unwrap();
540 let snapshot = types::to_snapshot(weekly_response(33.0), "scope-a").unwrap();
541 cache
542 .write_payload(
543 &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
544 )
545 .unwrap();
546 let outcome = fetch_snapshot_with(
547 &cache,
548 Duration::from_secs(3600),
549 now(),
550 || None,
551 || async { Err(AppError::Transport("offline".into())) },
552 )
553 .await;
554 assert!(outcome.is_err());
555 }
556
557 #[tokio::test]
558 async fn an_ended_period_is_never_resurrected_on_failure() {
559 let (_td, cache) = fixture();
560 cache.ensure_dir().unwrap();
561 let snapshot = types::to_snapshot(weekly_response(88.0), "scope-a").unwrap();
562 cache
563 .write_payload(
564 &serde_json::to_vec(&CachedEnvelope::from_snapshot("scope-a", &snapshot)).unwrap(),
565 )
566 .unwrap();
567 let after_reset = Utc.with_ymd_and_hms(2026, 8, 15, 0, 0, 0).unwrap();
568 let outcome = fetch_snapshot_with(
569 &cache,
570 Duration::from_secs(3600),
571 after_reset,
572 || Some("scope-a".into()),
573 || async { Err(AppError::Transport("offline".into())) },
574 )
575 .await;
576 assert!(outcome.is_err());
577 }
578
579 #[test]
580 fn cached_percentages_and_periods_are_strictly_validated() {
581 let base = serde_json::json!({
582 "schema": CACHE_SCHEMA,
583 "scope": "scope-a",
584 "snapshot": {
585 "plan": "SuperGrok",
586 "percent": 5,
587 "period": "weekly",
588 "reset_at": null,
589 "prepaid_balance": null
590 }
591 });
592 for (field, value) in [
593 ("percent", serde_json::json!(101)),
594 ("period", serde_json::json!("yearly")),
595 ("prepaid_balance", serde_json::json!(-1.0)),
596 ] {
597 let mut malformed = base.clone();
598 malformed["snapshot"][field] = value;
599 assert!(
600 parse_cache(malformed.to_string().as_bytes(), "scope-a").is_err(),
601 "field: {field}"
602 );
603 }
604
605 let mut obsolete = base;
606 obsolete["schema"] = serde_json::json!(1);
607 obsolete["account"] = serde_json::json!("person@example.test");
608 assert!(parse_cache(obsolete.to_string().as_bytes(), "scope-a").is_err());
609 }
610}