Skip to main content

grabcli_core/cache/
mod.rs

1//! The read-only browse cache: the [`Cache`] storage trait plus the
2//! [`CacheHandle`] every service call goes through.
3//!
4//! The library owns no storage. An embedder passes an `Arc<dyn Cache>` to
5//! [`Context::new`](crate::Context::new): the CLI uses `SqliteCache` (crate
6//! `grabcli-sqlite`, one SQLite database with FTS5 under the cache dir),
7//! tests use [`MemoryCache`], and an embedder that wants no cache uses
8//! [`NoCache`]. A cache stores three things:
9//!
10//! - **Responses**: the raw upstream JSON of read-only calls (search
11//!   results, menus, store details, category lists), keyed by the SHA-256 of
12//!   a canonical request identity ([`CacheKey`]) that includes the rounded
13//!   delivery location. Raw responses (not parsed structs) are stored so
14//!   struct changes never change what an entry means. TTLs, modes and
15//!   freshness are applied by [`CacheHandle`], not by the backend.
16//! - **Catalog**: normalized, queryable restaurants, where they were seen
17//!   from, menu categories and menu items ([`types`]). Service crates upsert
18//!   into it whenever they fetch list or menu data, and query it for offline
19//!   (`--local`) search.
20//! - **Images**: item images by URL ([`CachedImage`]), optional.
21//!
22//! The cache is best-effort: a backend error on a read is a miss and a
23//! failed write is skipped. A broken cache never fails a browse command.
24//!
25//! The trait is async (`async_trait`) for the same reason as
26//! [`SessionStore`](crate::store::SessionStore): the context is async and a
27//! backend may be an async database client.
28//!
29//! # Safety: what may be cached
30//!
31//! Only the read-only kinds in [`CacheKind`] exist, and every cached call must
32//! pick one. There is deliberately **no** kind for quotes, carts, vouchers,
33//! auth/session/token responses, order submission, order history or tracking:
34//! those must always be fetched live, and adding a kind for them is a safety
35//! regression. The catalog likewise only holds restaurant, menu and item
36//! data. Commands on the cart → quote → order path use [`CacheMode::Off`] (or
37//! call the API directly) so prices and availability are always fresh before
38//! money moves; with the mode off nothing is read from or written to any part
39//! of the cache.
40//!
41//! # Usage from a service crate
42//!
43//! ```ignore
44//! let key = CacheKey::new("food", CacheKind::Menu, "merchant")
45//!     .param("id", merchant_id)
46//!     .location(loc.latitude, loc.longitude);
47//! let raw: serde_json::Value = ctx.cached_call(&key, &req).await?;
48//! // with the entry's age (for "(cached 4m ago)" markers):
49//! let hit = ctx.cached_call_meta(&key, &req).await?;
50//! // or, for anything that isn't a single request:
51//! let cache = ctx.cache().clone();
52//! let raw: serde_json::Value = cache.get_or_fetch(&key, || ctx.call(&req)).await?;
53//! ```
54
55pub mod memory;
56pub mod types;
57
58use std::collections::BTreeMap;
59use std::future::Future;
60use std::sync::Arc;
61use std::time::Duration;
62
63use async_trait::async_trait;
64use serde::de::DeserializeOwned;
65use serde::{Deserialize, Serialize};
66use sha2::{Digest, Sha256};
67
68use crate::error::{Error, ErrorKind, Result};
69use crate::jwt;
70
71pub use memory::{MemoryCache, NoCache};
72pub use types::{
73    CachedImage, CategoryRow, ItemHit, ItemQuery, ItemRow, Near, RestaurantHit, RestaurantQuery,
74    RestaurantRow, effective_price, haversine_km, image_url_allowed, query_terms,
75};
76
77/// Environment variable the CLI reads to disable the cache (truthy value).
78/// The library itself never reads the environment.
79/// Environment variable that disables the cache when set to a truthy value.
80pub const NO_CACHE_ENV: &str = "GRAB_NO_CACHE";
81
82/// What is being cached. Only read-only browse data has a kind; see the module
83/// docs before adding one.
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum CacheKind {
87    /// Restaurant/store lists and search results (default 10 min).
88    List,
89    /// Menus / store catalogues (default 30 min).
90    Menu,
91    /// Static restaurant/store details (default 6 h).
92    Details,
93    /// Cuisine/category lists (default 24 h).
94    Categories,
95}
96
97impl CacheKind {
98    pub const ALL: [CacheKind; 4] = [Self::List, Self::Menu, Self::Details, Self::Categories];
99
100    pub fn as_str(self) -> &'static str {
101        match self {
102            Self::List => "list",
103            Self::Menu => "menu",
104            Self::Details => "details",
105            Self::Categories => "categories",
106        }
107    }
108
109    /// Default time-to-live in seconds.
110    pub fn default_ttl(self) -> u64 {
111        match self {
112            Self::List => 10 * 60,
113            Self::Menu => 30 * 60,
114            Self::Details => 6 * 60 * 60,
115            Self::Categories => 24 * 60 * 60,
116        }
117    }
118}
119
120impl std::fmt::Display for CacheKind {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.write_str(self.as_str())
123    }
124}
125
126impl std::str::FromStr for CacheKind {
127    type Err = String;
128    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
129        let s = s.trim().to_ascii_lowercase();
130        Self::ALL
131            .into_iter()
132            .find(|k| k.as_str() == s)
133            .ok_or_else(|| format!("unknown cache kind `{s}` (list|menu|details|categories)"))
134    }
135}
136
137/// How a command uses the cache.
138#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
139pub enum CacheMode {
140    /// Serve fresh entries, store new responses.
141    #[default]
142    Normal,
143    /// Skip reads, but store the fresh responses (`--refresh`).
144    Refresh,
145    /// No reads, no writes (`--no-cache`, `GRAB_NO_CACHE=1`, ordering paths).
146    Off,
147}
148
149impl CacheMode {
150    /// Resolve the mode from the global CLI flags. `--no-cache` wins over
151    /// `--refresh`. (`GRAB_NO_CACHE` is applied by the CLI.)
152    pub fn from_flags(no_cache: bool, refresh: bool) -> Self {
153        if no_cache {
154            Self::Off
155        } else if refresh {
156            Self::Refresh
157        } else {
158            Self::Normal
159        }
160    }
161
162    pub fn reads(self) -> bool {
163        self == Self::Normal
164    }
165
166    pub fn writes(self) -> bool {
167        self != Self::Off
168    }
169}
170
171/// Default image store cap (`[cache] image_max_mb`).
172pub const DEFAULT_IMAGE_MAX_MB: u64 = 500;
173/// Default age after which `grab cache prune` drops catalog rows.
174pub const DEFAULT_INDEX_MAX_AGE_DAYS: u64 = 30;
175
176/// `[cache]` in config.toml. Every key is optional.
177///
178/// ```toml
179/// [cache]
180/// enabled = true
181/// image_max_mb = 500          # image store cap, LRU-evicted
182/// index_max_age_days = 30     # `grab cache prune` drops older catalog rows
183/// [cache.ttl]                 # seconds; 0 disables caching for that kind
184/// list = 600
185/// menu = 1800
186/// details = 21600
187/// categories = 86400
188/// ```
189#[derive(Debug, Clone, Default, Deserialize)]
190#[serde(default, deny_unknown_fields)]
191pub struct CacheConfig {
192    pub enabled: Option<bool>,
193    pub ttl: CacheTtls,
194    pub image_max_mb: Option<u64>,
195    pub index_max_age_days: Option<u64>,
196}
197
198/// Per-kind TTL overrides in seconds.
199#[derive(Debug, Clone, Default, Deserialize)]
200#[serde(default, deny_unknown_fields)]
201pub struct CacheTtls {
202    pub list: Option<u64>,
203    pub menu: Option<u64>,
204    pub details: Option<u64>,
205    pub categories: Option<u64>,
206}
207
208impl CacheConfig {
209    pub fn enabled(&self) -> bool {
210        self.enabled.unwrap_or(true)
211    }
212
213    pub fn ttl(&self, kind: CacheKind) -> u64 {
214        let o = match kind {
215            CacheKind::List => self.ttl.list,
216            CacheKind::Menu => self.ttl.menu,
217            CacheKind::Details => self.ttl.details,
218            CacheKind::Categories => self.ttl.categories,
219        };
220        o.unwrap_or_else(|| kind.default_ttl())
221    }
222
223    pub fn image_max_bytes(&self) -> u64 {
224        self.image_max_mb
225            .unwrap_or(DEFAULT_IMAGE_MAX_MB)
226            .saturating_mul(1024 * 1024)
227    }
228
229    pub fn index_max_age_secs(&self) -> u64 {
230        self.index_max_age_days
231            .unwrap_or(DEFAULT_INDEX_MAX_AGE_DAYS)
232            .saturating_mul(24 * 60 * 60)
233    }
234}
235
236/// The identity of a cached request: service namespace, kind, endpoint name
237/// and normalized parameters. Two keys with the same identity share an entry.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct CacheKey {
240    namespace: String,
241    kind: CacheKind,
242    endpoint: String,
243    params: BTreeMap<String, String>,
244}
245
246/// Decimal places kept for coordinates in keys (~11 m), so nearby repeat
247/// lookups share entries while different places don't.
248const COORD_DECIMALS: usize = 4;
249
250fn normalize_value(v: &str) -> String {
251    v.split_whitespace().collect::<Vec<_>>().join(" ")
252}
253
254pub fn round_coord_to(c: f64, decimals: usize) -> String {
255    let s = format!("{c:.decimals$}");
256    // Avoid "-0.0000" vs "0.0000" splitting entries.
257    if s.trim_start_matches('-')
258        .chars()
259        .all(|ch| ch == '0' || ch == '.')
260    {
261        s.trim_start_matches('-').to_string()
262    } else {
263        s
264    }
265}
266
267fn round_coord(c: f64) -> String {
268    round_coord_to(c, COORD_DECIMALS)
269}
270
271impl CacheKey {
272    /// `namespace` is the service (`"food"`, `"mart"`); `endpoint` names the
273    /// call (`"search"`, `"merchant"`), not the full URL.
274    pub fn new(namespace: impl Into<String>, kind: CacheKind, endpoint: impl Into<String>) -> Self {
275        Self {
276            namespace: namespace.into(),
277            kind,
278            endpoint: endpoint.into(),
279            params: BTreeMap::new(),
280        }
281    }
282
283    /// Add a parameter. Order doesn't matter; surrounding/repeated whitespace
284    /// in the value is collapsed. Values are otherwise case-sensitive —
285    /// lowercase them first if the upstream is case-insensitive.
286    #[must_use]
287    pub fn param(mut self, name: impl Into<String>, value: impl std::fmt::Display) -> Self {
288        self.params
289            .insert(name.into(), normalize_value(&value.to_string()));
290        self
291    }
292
293    /// Add the delivery location, rounded to 4 decimals (~11 m).
294    #[must_use]
295    pub fn location(mut self, latitude: f64, longitude: f64) -> Self {
296        self.params.insert("lat".into(), round_coord(latitude));
297        self.params.insert("lng".into(), round_coord(longitude));
298        self
299    }
300
301    pub fn kind(&self) -> CacheKind {
302        self.kind
303    }
304
305    pub fn namespace(&self) -> &str {
306        &self.namespace
307    }
308
309    /// Canonical identity string (JSON, params sorted by name).
310    pub fn identity(&self) -> String {
311        serde_json::json!({
312            "ns": self.namespace,
313            "kind": self.kind.as_str(),
314            "endpoint": self.endpoint,
315            "params": self.params,
316        })
317        .to_string()
318    }
319
320    /// Hex SHA-256 of [`identity`](Self::identity); the entry's primary key.
321    pub fn hash(&self) -> String {
322        hex_sha256(self.identity().as_bytes())
323    }
324}
325
326pub fn hex_sha256(bytes: &[u8]) -> String {
327    Sha256::digest(bytes)
328        .iter()
329        .map(|b| format!("{b:02x}"))
330        .collect()
331}
332
333/// A value plus where it came from: `cached_at` is the time the cached
334/// response was fetched, or `None` if it was just fetched live.
335#[derive(Debug, Clone, PartialEq)]
336pub struct Fetched<T> {
337    pub value: T,
338    pub cached_at: Option<i64>,
339}
340
341impl<T> Fetched<T> {
342    pub fn live(value: T) -> Self {
343        Self {
344            value,
345            cached_at: None,
346        }
347    }
348
349    pub fn is_cached(&self) -> bool {
350        self.cached_at.is_some()
351    }
352
353    /// Seconds since the cached response was fetched (`None` if live).
354    pub fn age_secs(&self) -> Option<i64> {
355        self.cached_at.map(|t| (jwt::now() - t).max(0))
356    }
357}
358
359/// `4m`, `2h`, `3d` — for "(cached 4m ago)" markers.
360pub fn human_age(secs: i64) -> String {
361    let s = secs.max(0);
362    if s < 60 {
363        format!("{s}s")
364    } else if s < 3600 {
365        format!("{}m", s / 60)
366    } else if s < 86_400 {
367        format!("{}h", s / 3600)
368    } else {
369        format!("{}d", s / 86_400)
370    }
371}
372
373pub fn is_fresh(fetched_at: i64, expires_at: i64, ttl_secs: u64, now: i64) -> bool {
374    let ttl = i64::try_from(ttl_secs).unwrap_or(i64::MAX);
375    // A timestamp from the future (clock skew) counts as stale. A TTL lowered
376    // in config applies to entries written under a longer one.
377    now >= fetched_at && now < expires_at && now - fetched_at < ttl
378}
379
380/// One stored response, as a backend keeps it.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub struct CacheEntry {
383    /// [`CacheKey::identity`] of the request; a mismatch on read is a miss.
384    pub identity: String,
385    /// The raw response, serialized JSON.
386    pub body: String,
387    pub fetched_at: i64,
388    pub expires_at: i64,
389}
390
391/// Storage for the read-only browse cache. Object-safe: use it as
392/// `Arc<dyn Cache>`. Backends store and return data; TTLs, modes and the
393/// read-only safety rules are applied by [`CacheHandle`].
394///
395/// Responses are keyed by [`CacheKey::hash`] (namespace and kind are on the
396/// key for housekeeping). Catalog upserts must not erase known fields with
397/// unknowns (a row without cuisines keeps the stored ones). Images are
398/// optional: the default methods store nothing.
399#[async_trait]
400pub trait Cache: Send + Sync {
401    /// The stored entry for `key` (expired or not), if any.
402    async fn get_entry(&self, key: &CacheKey) -> Result<Option<CacheEntry>>;
403    /// Insert or replace the entry for `key`.
404    async fn put_entry(&self, key: &CacheKey, entry: &CacheEntry) -> Result<()>;
405
406    /// Upsert restaurants seen in a list response. `seen_from` is the
407    /// delivery location of the request; when given, each row's distance /
408    /// ETA / fee are also recorded for that location.
409    async fn index_restaurants(
410        &self,
411        rows: &[RestaurantRow],
412        seen_from: Option<(f64, f64)>,
413        now: i64,
414    ) -> Result<()>;
415    /// Upsert individual items (e.g. dish hits), keeping known categories.
416    async fn index_items(&self, items: &[ItemRow], now: i64) -> Result<()>;
417    /// Store a full menu: the restaurant, its categories (replacing the old
418    /// ones) and items; items no longer on the menu are removed.
419    async fn index_menu(
420        &self,
421        restaurant: &RestaurantRow,
422        categories: &[CategoryRow],
423        items: &[ItemRow],
424        seen_from: Option<(f64, f64)>,
425        now: i64,
426    ) -> Result<()>;
427    /// Query restaurants in the catalog.
428    async fn search_restaurants(&self, q: &RestaurantQuery) -> Result<Vec<RestaurantHit>>;
429    /// Query menu items in the catalog.
430    async fn search_items(&self, q: &ItemQuery) -> Result<Vec<ItemHit>>;
431    /// One restaurant by id.
432    async fn restaurant(&self, id: &str) -> Result<Option<RestaurantHit>>;
433
434    /// The stored image for `url`, marking it recently used.
435    async fn image(&self, _url: &str, _now: i64) -> Result<Option<CachedImage>> {
436        Ok(None)
437    }
438    /// Store downloaded image bytes for `url`.
439    async fn put_image(
440        &self,
441        _url: &str,
442        _bytes: &[u8],
443        _content_type: &str,
444        _now: i64,
445    ) -> Result<CachedImage> {
446        Err(Error::invalid("this cache does not store images"))
447    }
448}
449
450/// A [`Cache`] backend plus the active [`CacheMode`] and TTLs. Cheap to clone
451/// (clones share the backend); clone it out of the
452/// [`Context`](crate::Context) to call [`get_or_fetch`](Self::get_or_fetch)
453/// with a closure that borrows the context.
454#[derive(Clone)]
455pub struct CacheHandle {
456    backend: Arc<dyn Cache>,
457    config: CacheConfig,
458    mode: CacheMode,
459}
460
461impl std::fmt::Debug for CacheHandle {
462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        f.debug_struct("CacheHandle")
464            .field("mode", &self.mode)
465            .finish_non_exhaustive()
466    }
467}
468
469/// Largest image we will download.
470const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024;
471
472impl CacheHandle {
473    /// `config.enabled == false` forces [`CacheMode::Off`].
474    pub fn new(backend: Arc<dyn Cache>, config: CacheConfig, mode: CacheMode) -> Self {
475        let mode = if config.enabled() {
476            mode
477        } else {
478            CacheMode::Off
479        };
480        Self {
481            backend,
482            config,
483            mode,
484        }
485    }
486
487    /// The same backend with another mode; still off if disabled in config.
488    #[must_use]
489    pub fn with_mode(&self, mode: CacheMode) -> Self {
490        Self::new(Arc::clone(&self.backend), self.config.clone(), mode)
491    }
492
493    pub fn mode(&self) -> CacheMode {
494        self.mode
495    }
496
497    pub fn config(&self) -> &CacheConfig {
498        &self.config
499    }
500
501    pub fn ttl(&self, kind: CacheKind) -> u64 {
502        self.config.ttl(kind)
503    }
504
505    /// The backend, for housekeeping outside the mode rules.
506    pub fn backend(&self) -> &Arc<dyn Cache> {
507        &self.backend
508    }
509
510    /// A fresh cached value, if reads are enabled and one exists. Any problem
511    /// reading or decoding the entry is a miss.
512    pub async fn get<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
513        self.lookup(key).await.map(|f| f.value)
514    }
515
516    /// Like [`get`](Self::get), with the time the entry was fetched.
517    pub async fn lookup<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<Fetched<T>> {
518        self.lookup_at(key, jwt::now()).await
519    }
520
521    #[doc(hidden)]
522    pub async fn lookup_at<T: DeserializeOwned>(
523        &self,
524        key: &CacheKey,
525        now: i64,
526    ) -> Option<Fetched<T>> {
527        if !self.mode.reads() {
528            return None;
529        }
530        let (value, fetched_at, expires_at) = self.read_entry(key).await?;
531        is_fresh(fetched_at, expires_at, self.ttl(key.kind()), now).then_some(Fetched {
532            value,
533            cached_at: Some(fetched_at),
534        })
535    }
536
537    /// The stored value for `key` even if it has expired (but not yet been
538    /// pruned), for falling back when the live call fails. `None` when the
539    /// mode is off.
540    pub async fn lookup_stale<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<Fetched<T>> {
541        if !self.mode.writes() {
542            return None;
543        }
544        let (value, fetched_at, _) = self.read_entry(key).await?;
545        Some(Fetched {
546            value,
547            cached_at: Some(fetched_at),
548        })
549    }
550
551    async fn read_entry<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<(T, i64, i64)> {
552        let e = self.backend.get_entry(key).await.ok()??;
553        // A hash collision or tampered row is a miss.
554        if e.identity != key.identity() {
555            return None;
556        }
557        let value = serde_json::from_str(&e.body).ok()?;
558        Some((value, e.fetched_at, e.expires_at))
559    }
560
561    /// Store a value (no-op when writes are off or the kind's TTL is 0).
562    pub async fn put<T: Serialize>(&self, key: &CacheKey, value: &T) -> Result<()> {
563        self.put_at(key, value, jwt::now()).await
564    }
565
566    #[doc(hidden)]
567    pub async fn put_at<T: Serialize>(&self, key: &CacheKey, value: &T, now: i64) -> Result<()> {
568        let ttl = self.ttl(key.kind());
569        if !self.mode.writes() || ttl == 0 {
570            return Ok(());
571        }
572        let entry = CacheEntry {
573            identity: key.identity(),
574            body: serde_json::to_string(value)?,
575            fetched_at: now,
576            expires_at: now.saturating_add(i64::try_from(ttl).unwrap_or(i64::MAX)),
577        };
578        self.backend.put_entry(key, &entry).await
579    }
580
581    /// Return the cached value for `key`, or run `fetch`, store its result
582    /// (best-effort) and return it. Errors from `fetch` are returned and never
583    /// cached. Prefer `T = serde_json::Value` holding the raw upstream response.
584    pub async fn get_or_fetch<T, F, Fut>(&self, key: &CacheKey, fetch: F) -> Result<T>
585    where
586        T: Serialize + DeserializeOwned,
587        F: FnOnce() -> Fut,
588        Fut: Future<Output = Result<T>>,
589    {
590        Ok(self.get_or_fetch_meta(key, fetch).await?.value)
591    }
592
593    /// [`get_or_fetch`](Self::get_or_fetch) that also says whether the value
594    /// came from the cache and how old it is.
595    pub async fn get_or_fetch_meta<T, F, Fut>(&self, key: &CacheKey, fetch: F) -> Result<Fetched<T>>
596    where
597        T: Serialize + DeserializeOwned,
598        F: FnOnce() -> Fut,
599        Fut: Future<Output = Result<T>>,
600    {
601        if let Some(hit) = self.lookup(key).await {
602            return Ok(hit);
603        }
604        let value = fetch().await?;
605        let _ = self.put(key, &value).await;
606        Ok(Fetched::live(value))
607    }
608
609    /// Upsert restaurants seen in a list response (no-op when writes are off).
610    pub async fn index_restaurants(
611        &self,
612        rows: &[RestaurantRow],
613        seen_from: Option<(f64, f64)>,
614    ) -> Result<()> {
615        if !self.mode.writes() || rows.is_empty() {
616            return Ok(());
617        }
618        self.backend
619            .index_restaurants(rows, seen_from, jwt::now())
620            .await
621    }
622
623    /// Upsert individual items (no-op when writes are off).
624    pub async fn index_items(&self, items: &[ItemRow]) -> Result<()> {
625        if !self.mode.writes() || items.is_empty() {
626            return Ok(());
627        }
628        self.backend.index_items(items, jwt::now()).await
629    }
630
631    /// Store a full menu (no-op when writes are off).
632    pub async fn index_menu(
633        &self,
634        restaurant: &RestaurantRow,
635        categories: &[CategoryRow],
636        items: &[ItemRow],
637        seen_from: Option<(f64, f64)>,
638    ) -> Result<()> {
639        if !self.mode.writes() || restaurant.id.is_empty() {
640            return Ok(());
641        }
642        self.backend
643            .index_menu(restaurant, categories, items, seen_from, jwt::now())
644            .await
645    }
646
647    /// Query restaurants in the catalog (works in every mode: it only reads
648    /// what earlier fetches stored).
649    pub async fn search_restaurants(&self, q: &RestaurantQuery) -> Result<Vec<RestaurantHit>> {
650        self.backend.search_restaurants(q).await
651    }
652
653    /// Query menu items in the catalog.
654    pub async fn search_items(&self, q: &ItemQuery) -> Result<Vec<ItemHit>> {
655        self.backend.search_items(q).await
656    }
657
658    /// One restaurant by id.
659    pub async fn restaurant(&self, id: &str) -> Result<Option<RestaurantHit>> {
660        self.backend.restaurant(id).await
661    }
662
663    /// The cached image for `url`, if present. `None` when the mode is off.
664    pub async fn cached_image(&self, url: &str) -> Option<CachedImage> {
665        if !self.mode.reads() && !self.mode.writes() {
666            return None;
667        }
668        self.backend.image(url, jwt::now()).await.ok().flatten()
669    }
670
671    /// Store downloaded image bytes (refused when writes are off).
672    pub async fn store_image(
673        &self,
674        url: &str,
675        bytes: &[u8],
676        content_type: &str,
677    ) -> Result<CachedImage> {
678        if !self.mode.writes() {
679            return Err(Error::invalid("the cache is off; images are not stored"));
680        }
681        self.backend
682            .put_image(url, bytes, content_type, jwt::now())
683            .await
684    }
685
686    /// The cached image for `url`, downloading it first if needed. Only Grab
687    /// CDN URLs are fetched ([`image_url_allowed`]); no cookies are sent.
688    pub async fn fetch_image(&self, url: &str) -> Result<CachedImage> {
689        if !image_url_allowed(url) {
690            return Err(Error::new(
691                ErrorKind::UrlNotAllowed,
692                "only https image URLs on grab.com hosts can be cached",
693            ));
694        }
695        if let Some(hit) = self.cached_image(url).await {
696            return Ok(hit);
697        }
698        let client = reqwest::Client::builder()
699            .timeout(Duration::from_secs(30))
700            .redirect(reqwest::redirect::Policy::none())
701            .build()?;
702        let resp = client.get(url).send().await?;
703        let status = resp.status();
704        if !status.is_success() {
705            return Err(Error::new(
706                if status.as_u16() == 404 {
707                    ErrorKind::NotFound
708                } else {
709                    ErrorKind::Api
710                },
711                format!("{} fetching image", status.as_u16()),
712            ));
713        }
714        let content_type = resp
715            .headers()
716            .get(reqwest::header::CONTENT_TYPE)
717            .and_then(|v| v.to_str().ok())
718            .unwrap_or("")
719            .to_string();
720        if !content_type.to_ascii_lowercase().starts_with("image/") {
721            return Err(Error::new(
722                ErrorKind::Api,
723                format!("not an image (content-type `{content_type}`)"),
724            ));
725        }
726        if resp
727            .content_length()
728            .is_some_and(|l| l > MAX_IMAGE_BYTES as u64)
729        {
730            return Err(Error::invalid("image is too large"));
731        }
732        let bytes = resp.bytes().await?;
733        if bytes.len() > MAX_IMAGE_BYTES {
734            return Err(Error::invalid("image is too large"));
735        }
736        self.store_image(url, &bytes, &content_type).await
737    }
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use serde_json::{Value, json};
744
745    fn handle(mode: CacheMode) -> (Arc<MemoryCache>, CacheHandle) {
746        let mem = Arc::new(MemoryCache::default());
747        let h = CacheHandle::new(mem.clone(), CacheConfig::default(), mode);
748        (mem, h)
749    }
750
751    fn key() -> CacheKey {
752        CacheKey::new("food", CacheKind::Menu, "merchant")
753            .param("id", "2-EXAMPLE")
754            .location(1.23, 103.81)
755    }
756
757    async fn get_at(c: &CacheHandle, k: &CacheKey, now: i64) -> Option<Value> {
758        c.lookup_at::<Value>(k, now).await.map(|f| f.value)
759    }
760
761    #[test]
762    fn key_normalization() {
763        let a = CacheKey::new("food", CacheKind::List, "search")
764            .param("keyword", "  fried   chicken ")
765            .param("page", 0)
766            .location(1.234_512_3, 103.812_34);
767        let b = CacheKey::new("food", CacheKind::List, "search")
768            .location(1.234_499, 103.812_251)
769            .param("page", "0")
770            .param("keyword", "fried chicken");
771        assert_eq!(a.identity(), b.identity());
772        assert_eq!(a.hash(), b.hash());
773        assert_eq!(a.hash().len(), 64);
774
775        // A different place (> ~11 m), kind or namespace is a different key.
776        let far = b.clone().location(1.2358, 103.8123);
777        assert_ne!(far.hash(), b.hash());
778        let mart = CacheKey::new("mart", CacheKind::List, "search")
779            .param("keyword", "fried chicken")
780            .param("page", 0)
781            .location(1.2345, 103.8123);
782        assert_ne!(mart.hash(), a.hash());
783        // -0.0 and 0.0 are the same place.
784        assert_eq!(round_coord(-0.000_01), round_coord(0.0));
785    }
786
787    #[tokio::test]
788    async fn roundtrip_and_ttl_expiry() -> Result<()> {
789        let (_, c) = handle(CacheMode::Normal);
790        let k = key();
791        let v = json!({"merchant": {"ID": "2-EXAMPLE"}});
792        c.put_at(&k, &v, 1_000).await?;
793        assert_eq!(get_at(&c, &k, 1_000).await, Some(v.clone()));
794        let ttl = CacheKind::Menu.default_ttl() as i64;
795        let hit = c.lookup_at::<Value>(&k, 1_000 + ttl - 1).await;
796        assert_eq!(hit.map(|h| h.cached_at), Some(Some(1_000)));
797        assert_eq!(get_at(&c, &k, 1_000 + ttl).await, None);
798        // Clock skew: an entry from the future is stale.
799        assert_eq!(get_at(&c, &k, 999).await, None);
800        // Expired but not pruned: still available as a stale fallback.
801        assert!(c.lookup_stale::<Value>(&k).await.is_some());
802        Ok(())
803    }
804
805    #[tokio::test]
806    async fn config_ttl_override_and_disable() -> Result<()> {
807        let mem = Arc::new(MemoryCache::default());
808        let cfg: CacheConfig = toml::from_str("[ttl]\nmenu = 5\nlist = 0\n")
809            .map_err(|e| Error::invalid(e.to_string()))?;
810        let c = CacheHandle::new(mem.clone(), cfg, CacheMode::Normal);
811        let k = key();
812        c.put_at(&k, &json!(1), 100).await?;
813        assert_eq!(get_at(&c, &k, 104).await, Some(json!(1)));
814        assert_eq!(get_at(&c, &k, 105).await, None);
815        // TTL 0: never written.
816        let lk = CacheKey::new("food", CacheKind::List, "search");
817        c.put_at(&lk, &json!(1), 100).await?;
818        assert!(mem.get_entry(&lk).await?.is_none());
819
820        // A lowered TTL applies to entries written under a longer one.
821        let long = CacheHandle::new(mem.clone(), CacheConfig::default(), CacheMode::Normal);
822        long.put_at(&k, &json!(2), 100).await?;
823        assert_eq!(get_at(&c, &k, 110).await, None);
824        assert_eq!(get_at(&long, &k, 110).await, Some(json!(2)));
825
826        let off: CacheConfig =
827            toml::from_str("enabled = false").map_err(|e| Error::invalid(e.to_string()))?;
828        let h = CacheHandle::new(mem, off, CacheMode::Normal);
829        assert_eq!(h.mode(), CacheMode::Off);
830        assert_eq!(h.with_mode(CacheMode::Refresh).mode(), CacheMode::Off);
831        Ok(())
832    }
833
834    #[tokio::test]
835    async fn bad_rows_are_misses() -> Result<()> {
836        let (mem, c) = handle(CacheMode::Normal);
837        let k = key();
838        // Wrong payload type for T: a miss, not an error.
839        c.put(&k, &json!("a string")).await?;
840        assert_eq!(c.get::<Vec<u32>>(&k).await, None);
841        // A hash collision / tampered identity is a miss.
842        let mut e = mem
843            .get_entry(&k)
844            .await?
845            .ok_or_else(|| Error::invalid("none"))?;
846        e.identity = "something else".into();
847        mem.put_entry(&k, &e).await?;
848        assert_eq!(c.get::<Value>(&k).await, None);
849        // Unparseable body: a miss.
850        c.put(&k, &json!(1)).await?;
851        let mut e = mem
852            .get_entry(&k)
853            .await?
854            .ok_or_else(|| Error::invalid("none"))?;
855        e.body = "{nope".into();
856        mem.put_entry(&k, &e).await?;
857        assert_eq!(c.get::<Value>(&k).await, None);
858        Ok(())
859    }
860
861    #[tokio::test]
862    async fn modes() -> Result<()> {
863        let k = key();
864        let (_, normal) = handle(CacheMode::Normal);
865
866        // Normal: miss → fetch + store; then hit without fetching.
867        let v = normal
868            .get_or_fetch_meta(&k, || async { Ok(json!(1)) })
869            .await?;
870        assert_eq!((v.value, v.cached_at), (json!(1), None));
871        let v = normal
872            .get_or_fetch_meta::<Value, _, _>(&k, || async {
873                Err(Error::invalid("must not fetch"))
874            })
875            .await?;
876        assert_eq!(v.value, json!(1));
877        assert!(v.is_cached());
878        assert!(v.age_secs().is_some_and(|a| a >= 0));
879
880        // Off: always fetches, never writes, no stale fallback.
881        let off = normal.with_mode(CacheMode::Off);
882        let v: Value = off.get_or_fetch(&k, || async { Ok(json!(2)) }).await?;
883        assert_eq!(v, json!(2));
884        assert_eq!(normal.get::<Value>(&k).await, Some(json!(1)));
885        assert!(off.lookup_stale::<Value>(&k).await.is_none());
886        assert!(
887            off.store_image("https://food-cms.grab.com/x.png", b"x", "image/png")
888                .await
889                .is_err()
890        );
891
892        // Refresh: always fetches, overwrites the entry.
893        let refresh = normal.with_mode(CacheMode::Refresh);
894        let v: Value = refresh.get_or_fetch(&k, || async { Ok(json!(3)) }).await?;
895        assert_eq!(v, json!(3));
896        assert_eq!(normal.get::<Value>(&k).await, Some(json!(3)));
897
898        // Errors propagate and are not cached.
899        let k2 = key().param("id", "other");
900        let r: Result<Value> = normal
901            .get_or_fetch(&k2, || async { Err(Error::invalid("boom")) })
902            .await;
903        assert!(r.is_err());
904        assert_eq!(normal.get::<Value>(&k2).await, None);
905
906        assert_eq!(CacheMode::from_flags(true, true), CacheMode::Off);
907        assert_eq!(CacheMode::from_flags(false, true), CacheMode::Refresh);
908        assert_eq!(CacheMode::from_flags(false, false), CacheMode::Normal);
909        Ok(())
910    }
911
912    #[tokio::test]
913    async fn off_mode_skips_catalog_writes() -> Result<()> {
914        let (mem, normal) = handle(CacheMode::Normal);
915        let off = normal.with_mode(CacheMode::Off);
916        let row = RestaurantRow {
917            id: "R1".into(),
918            namespace: "food".into(),
919            name: "Sisig Place".into(),
920            ..RestaurantRow::default()
921        };
922        off.index_restaurants(std::slice::from_ref(&row), None)
923            .await?;
924        assert!(mem.restaurant("R1").await?.is_none());
925        normal.index_restaurants(&[row], None).await?;
926        assert!(off.restaurant("R1").await?.is_some());
927        Ok(())
928    }
929
930    #[tokio::test]
931    async fn no_cache_never_hits() -> Result<()> {
932        let h = CacheHandle::new(Arc::new(NoCache), CacheConfig::default(), CacheMode::Normal);
933        h.put(&key(), &json!(1)).await?;
934        assert_eq!(h.get::<Value>(&key()).await, None);
935        Ok(())
936    }
937
938    #[test]
939    fn kind_parses() {
940        assert_eq!("Menu".parse::<CacheKind>(), Ok(CacheKind::Menu));
941        assert!("quote".parse::<CacheKind>().is_err());
942    }
943
944    #[test]
945    fn ages() {
946        assert_eq!(human_age(5), "5s");
947        assert_eq!(human_age(240), "4m");
948        assert_eq!(human_age(7200), "2h");
949        assert_eq!(human_age(3 * 86_400), "3d");
950    }
951}