Skip to main content

grabcli_core/cache/
mod.rs

1//! Local cache for read-only browse data, in one SQLite database at
2//! `<cache dir>/cache.sqlite` (WAL, mode 0600, inside a 0700 directory).
3//!
4//! It has three parts:
5//!
6//! - **Responses** (`entries`): the raw upstream JSON of read-only calls
7//!   (search results, menus, store details, category lists), keyed by the
8//!   SHA-256 of a canonical request identity that includes the rounded
9//!   delivery location, with a per-kind TTL. Raw responses (not parsed
10//!   structs) are stored so struct changes never change what an entry means.
11//! - **Catalog** ([`catalog`]): normalized, queryable restaurants, the
12//!   locations they were seen from, menu categories and menu items, with FTS5
13//!   word + trigram indexes. Service crates upsert into it whenever they fetch
14//!   list or menu data, and query it for offline (`--local`) search.
15//! - **Images** ([`images`]): content-addressed image files under
16//!   `<cache dir>/images/`, indexed in SQLite and evicted LRU by total size.
17//!
18//! The cache is best-effort: if the database can't be opened or a row can't
19//! be decoded, reads miss and writes are skipped. A broken cache never fails a
20//! browse command.
21//!
22//! # Safety: what may be cached
23//!
24//! Only the read-only kinds in [`CacheKind`] exist, and every cached call must
25//! pick one. There is deliberately **no** kind for quotes, carts, vouchers,
26//! auth/session/token responses, order submission, order history or tracking:
27//! those must always be fetched live, and adding a kind for them is a safety
28//! regression. The catalog tables likewise only hold restaurant, menu and item
29//! data. Commands on the cart → quote → order path use [`CacheMode::Off`] (or
30//! call the API directly) so prices and availability are always fresh before
31//! money moves; with the mode off nothing is read from or written to any part
32//! of the cache.
33//!
34//! # Usage from a service crate
35//!
36//! ```ignore
37//! let key = CacheKey::new("food", CacheKind::Menu, "merchant")
38//!     .param("id", merchant_id)
39//!     .location(loc.latitude, loc.longitude);
40//! let raw: serde_json::Value = ctx.cached_call(&key, &req).await?;
41//! // with the entry's age (for "(cached 4m ago)" markers):
42//! let hit = ctx.cached_call_meta(&key, &req).await?;
43//! // or, for anything that isn't a single request:
44//! let cache = ctx.cache().clone();
45//! let raw: serde_json::Value = cache.get_or_fetch(&key, || ctx.call(&req)).await?;
46//! ```
47
48pub mod catalog;
49pub(crate) mod db;
50pub mod images;
51
52use std::collections::BTreeMap;
53use std::future::Future;
54use std::path::{Path, PathBuf};
55use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
56
57use rusqlite::{Connection, OptionalExtension, params};
58use serde::de::DeserializeOwned;
59use serde::{Deserialize, Serialize};
60use sha2::{Digest, Sha256};
61
62use crate::error::{Error, Result};
63use crate::{jwt, paths};
64
65pub use catalog::{
66    CategoryRow, ItemHit, ItemQuery, ItemRow, Near, RestaurantHit, RestaurantQuery, RestaurantRow,
67};
68pub use images::{CachedImage, image_url_allowed};
69
70/// Environment variable that disables the cache when set to a truthy value.
71pub const NO_CACHE_ENV: &str = "GRAB_NO_CACHE";
72
73/// What is being cached. Only read-only browse data has a kind; see the module
74/// docs before adding one.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum CacheKind {
78    /// Restaurant/store lists and search results (default 10 min).
79    List,
80    /// Menus / store catalogues (default 30 min).
81    Menu,
82    /// Static restaurant/store details (default 6 h).
83    Details,
84    /// Cuisine/category lists (default 24 h).
85    Categories,
86}
87
88impl CacheKind {
89    pub const ALL: [CacheKind; 4] = [Self::List, Self::Menu, Self::Details, Self::Categories];
90
91    pub fn as_str(self) -> &'static str {
92        match self {
93            Self::List => "list",
94            Self::Menu => "menu",
95            Self::Details => "details",
96            Self::Categories => "categories",
97        }
98    }
99
100    /// Default time-to-live in seconds.
101    pub fn default_ttl(self) -> u64 {
102        match self {
103            Self::List => 10 * 60,
104            Self::Menu => 30 * 60,
105            Self::Details => 6 * 60 * 60,
106            Self::Categories => 24 * 60 * 60,
107        }
108    }
109}
110
111impl std::fmt::Display for CacheKind {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.write_str(self.as_str())
114    }
115}
116
117impl std::str::FromStr for CacheKind {
118    type Err = String;
119    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
120        let s = s.trim().to_ascii_lowercase();
121        Self::ALL
122            .into_iter()
123            .find(|k| k.as_str() == s)
124            .ok_or_else(|| format!("unknown cache kind `{s}` (list|menu|details|categories)"))
125    }
126}
127
128/// How a command uses the cache.
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
130pub enum CacheMode {
131    /// Serve fresh entries, store new responses.
132    #[default]
133    Normal,
134    /// Skip reads, but store the fresh responses (`--refresh`).
135    Refresh,
136    /// No reads, no writes (`--no-cache`, `GRAB_NO_CACHE=1`, ordering paths).
137    Off,
138}
139
140impl CacheMode {
141    /// Resolve the mode from the global CLI flags. `--no-cache` wins over
142    /// `--refresh`. (`GRAB_NO_CACHE` is applied by [`Cache::from_config`].)
143    pub fn from_flags(no_cache: bool, refresh: bool) -> Self {
144        if no_cache {
145            Self::Off
146        } else if refresh {
147            Self::Refresh
148        } else {
149            Self::Normal
150        }
151    }
152
153    pub fn reads(self) -> bool {
154        self == Self::Normal
155    }
156
157    pub fn writes(self) -> bool {
158        self != Self::Off
159    }
160}
161
162/// Default image store cap (`[cache] image_max_mb`).
163pub const DEFAULT_IMAGE_MAX_MB: u64 = 500;
164/// Default age after which `grab cache prune` drops catalog rows.
165pub const DEFAULT_INDEX_MAX_AGE_DAYS: u64 = 30;
166
167/// `[cache]` in config.toml. Every key is optional.
168///
169/// ```toml
170/// [cache]
171/// enabled = true
172/// image_max_mb = 500          # image store cap, LRU-evicted
173/// index_max_age_days = 30     # `grab cache prune` drops older catalog rows
174/// [cache.ttl]                 # seconds; 0 disables caching for that kind
175/// list = 600
176/// menu = 1800
177/// details = 21600
178/// categories = 86400
179/// ```
180#[derive(Debug, Clone, Default, Deserialize)]
181#[serde(default, deny_unknown_fields)]
182pub struct CacheConfig {
183    pub enabled: Option<bool>,
184    pub ttl: CacheTtls,
185    pub image_max_mb: Option<u64>,
186    pub index_max_age_days: Option<u64>,
187}
188
189/// Per-kind TTL overrides in seconds.
190#[derive(Debug, Clone, Default, Deserialize)]
191#[serde(default, deny_unknown_fields)]
192pub struct CacheTtls {
193    pub list: Option<u64>,
194    pub menu: Option<u64>,
195    pub details: Option<u64>,
196    pub categories: Option<u64>,
197}
198
199impl CacheConfig {
200    pub fn enabled(&self) -> bool {
201        self.enabled.unwrap_or(true)
202    }
203
204    pub fn ttl(&self, kind: CacheKind) -> u64 {
205        let o = match kind {
206            CacheKind::List => self.ttl.list,
207            CacheKind::Menu => self.ttl.menu,
208            CacheKind::Details => self.ttl.details,
209            CacheKind::Categories => self.ttl.categories,
210        };
211        o.unwrap_or_else(|| kind.default_ttl())
212    }
213
214    pub fn image_max_bytes(&self) -> u64 {
215        self.image_max_mb
216            .unwrap_or(DEFAULT_IMAGE_MAX_MB)
217            .saturating_mul(1024 * 1024)
218    }
219
220    pub fn index_max_age_secs(&self) -> u64 {
221        self.index_max_age_days
222            .unwrap_or(DEFAULT_INDEX_MAX_AGE_DAYS)
223            .saturating_mul(24 * 60 * 60)
224    }
225}
226
227/// The identity of a cached request: service namespace, kind, endpoint name
228/// and normalized parameters. Two keys with the same identity share an entry.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct CacheKey {
231    namespace: String,
232    kind: CacheKind,
233    endpoint: String,
234    params: BTreeMap<String, String>,
235}
236
237/// Decimal places kept for coordinates in keys (~11 m), so nearby repeat
238/// lookups share entries while different places don't.
239const COORD_DECIMALS: usize = 4;
240
241fn normalize_value(v: &str) -> String {
242    v.split_whitespace().collect::<Vec<_>>().join(" ")
243}
244
245fn round_coord_to(c: f64, decimals: usize) -> String {
246    let s = format!("{c:.decimals$}");
247    // Avoid "-0.0000" vs "0.0000" splitting entries.
248    if s.trim_start_matches('-')
249        .chars()
250        .all(|ch| ch == '0' || ch == '.')
251    {
252        s.trim_start_matches('-').to_string()
253    } else {
254        s
255    }
256}
257
258fn round_coord(c: f64) -> String {
259    round_coord_to(c, COORD_DECIMALS)
260}
261
262impl CacheKey {
263    /// `namespace` is the service (`"food"`, `"mart"`); `endpoint` names the
264    /// call (`"search"`, `"merchant"`), not the full URL.
265    pub fn new(namespace: impl Into<String>, kind: CacheKind, endpoint: impl Into<String>) -> Self {
266        Self {
267            namespace: namespace.into(),
268            kind,
269            endpoint: endpoint.into(),
270            params: BTreeMap::new(),
271        }
272    }
273
274    /// Add a parameter. Order doesn't matter; surrounding/repeated whitespace
275    /// in the value is collapsed. Values are otherwise case-sensitive —
276    /// lowercase them first if the upstream is case-insensitive.
277    #[must_use]
278    pub fn param(mut self, name: impl Into<String>, value: impl std::fmt::Display) -> Self {
279        self.params
280            .insert(name.into(), normalize_value(&value.to_string()));
281        self
282    }
283
284    /// Add the delivery location, rounded to 4 decimals (~11 m).
285    #[must_use]
286    pub fn location(mut self, latitude: f64, longitude: f64) -> Self {
287        self.params.insert("lat".into(), round_coord(latitude));
288        self.params.insert("lng".into(), round_coord(longitude));
289        self
290    }
291
292    pub fn kind(&self) -> CacheKind {
293        self.kind
294    }
295
296    pub fn namespace(&self) -> &str {
297        &self.namespace
298    }
299
300    /// Canonical identity string (JSON, params sorted by name).
301    pub fn identity(&self) -> String {
302        serde_json::json!({
303            "ns": self.namespace,
304            "kind": self.kind.as_str(),
305            "endpoint": self.endpoint,
306            "params": self.params,
307        })
308        .to_string()
309    }
310
311    /// Hex SHA-256 of [`identity`](Self::identity); the entry's primary key.
312    pub fn hash(&self) -> String {
313        hex_sha256(self.identity().as_bytes())
314    }
315}
316
317pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
318    Sha256::digest(bytes)
319        .iter()
320        .map(|b| format!("{b:02x}"))
321        .collect()
322}
323
324/// A value plus where it came from: `cached_at` is the time the cached
325/// response was fetched, or `None` if it was just fetched live.
326#[derive(Debug, Clone, PartialEq)]
327pub struct Fetched<T> {
328    pub value: T,
329    pub cached_at: Option<i64>,
330}
331
332impl<T> Fetched<T> {
333    pub fn live(value: T) -> Self {
334        Self {
335            value,
336            cached_at: None,
337        }
338    }
339
340    pub fn is_cached(&self) -> bool {
341        self.cached_at.is_some()
342    }
343
344    /// Seconds since the cached response was fetched (`None` if live).
345    pub fn age_secs(&self) -> Option<i64> {
346        self.cached_at.map(|t| (jwt::now() - t).max(0))
347    }
348}
349
350/// `4m`, `2h`, `3d` — for "(cached 4m ago)" markers.
351pub fn human_age(secs: i64) -> String {
352    let s = secs.max(0);
353    if s < 60 {
354        format!("{s}s")
355    } else if s < 3600 {
356        format!("{}m", s / 60)
357    } else if s < 86_400 {
358        format!("{}h", s / 3600)
359    } else {
360        format!("{}d", s / 86_400)
361    }
362}
363
364fn is_fresh(fetched_at: i64, expires_at: i64, ttl_secs: u64, now: i64) -> bool {
365    let ttl = i64::try_from(ttl_secs).unwrap_or(i64::MAX);
366    // A timestamp from the future (clock skew) counts as stale. A TTL lowered
367    // in config applies to entries written under a longer one.
368    now >= fetched_at && now < expires_at && now - fetched_at < ttl
369}
370
371/// Per-kind numbers in [`CacheStats`].
372#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
373pub struct KindStats {
374    pub entries: u64,
375    pub bytes: u64,
376    pub expired: u64,
377}
378
379/// Image store numbers in [`CacheStats`].
380#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
381pub struct ImageStats {
382    /// Distinct files (several URLs may share one).
383    pub files: u64,
384    pub urls: u64,
385    pub bytes: u64,
386    pub max_bytes: u64,
387}
388
389/// `grab cache stats`.
390#[derive(Debug, Clone, Default, Serialize)]
391pub struct CacheStats {
392    /// The cache directory.
393    pub path: String,
394    pub db_path: String,
395    /// Database + WAL size on disk.
396    pub db_bytes: u64,
397    pub schema_version: u64,
398    pub enabled: bool,
399    pub entries: u64,
400    /// Total size of stored response bodies.
401    pub total_bytes: u64,
402    pub expired: u64,
403    pub kinds: BTreeMap<String, KindStats>,
404    /// Row counts of the catalog tables.
405    pub tables: BTreeMap<String, u64>,
406    pub images: ImageStats,
407}
408
409/// What `grab cache prune` removed.
410#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
411pub struct PruneReport {
412    pub entries: u64,
413    pub restaurants: u64,
414    pub restaurant_locations: u64,
415    pub menu_categories: u64,
416    pub menu_items: u64,
417    pub images_evicted: u64,
418    pub image_bytes_freed: u64,
419}
420
421/// What `grab cache clear --all` removed.
422#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
423pub struct ClearReport {
424    pub entries: u64,
425    pub catalog_rows: u64,
426    pub images: u64,
427}
428
429/// The lazily opened database, shared by clones of a [`Cache`].
430#[derive(Default)]
431struct DbCell {
432    conn: OnceLock<std::result::Result<Mutex<Connection>, String>>,
433}
434
435impl std::fmt::Debug for DbCell {
436    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
437        let state = match self.conn.get() {
438            None => "unopened",
439            Some(Ok(_)) => "open",
440            Some(Err(_)) => "failed",
441        };
442        f.debug_struct("DbCell").field("state", &state).finish()
443    }
444}
445
446/// A handle on the cache database plus the active mode and TTLs. Cheap to
447/// clone (clones share one connection); clone it out of the
448/// [`Context`](crate::Context) to use [`get_or_fetch`](Self::get_or_fetch)
449/// with a closure that borrows the context. The database is opened on first
450/// use.
451#[derive(Debug, Clone)]
452pub struct Cache {
453    root: PathBuf,
454    config: CacheConfig,
455    mode: CacheMode,
456    db: Arc<DbCell>,
457}
458
459fn env_disables_cache() -> bool {
460    std::env::var(NO_CACHE_ENV).is_ok_and(|v| {
461        let v = v.trim().to_ascii_lowercase();
462        !(v.is_empty() || v == "0" || v == "false" || v == "no")
463    })
464}
465
466impl Cache {
467    /// A cache rooted at `root` (the database is `root/cache.sqlite`).
468    /// `config.enabled == false` forces [`CacheMode::Off`].
469    pub fn new(root: impl Into<PathBuf>, config: CacheConfig, mode: CacheMode) -> Self {
470        let mode = if config.enabled() {
471            mode
472        } else {
473            CacheMode::Off
474        };
475        Self {
476            root: root.into(),
477            config,
478            mode,
479            db: Arc::new(DbCell::default()),
480        }
481    }
482
483    /// The production cache: [`paths::cache_dir`], the `[cache]` config, and
484    /// `GRAB_NO_CACHE=1` forcing it off. If the cache dir can't be resolved the
485    /// cache is simply off.
486    pub fn from_config(config: &CacheConfig, mode: CacheMode) -> Self {
487        let mode = if env_disables_cache() {
488            CacheMode::Off
489        } else {
490            mode
491        };
492        match paths::cache_dir() {
493            Ok(root) => Self::new(root, config.clone(), mode),
494            Err(_) => Self::new(PathBuf::new(), config.clone(), CacheMode::Off),
495        }
496    }
497
498    /// The same cache (sharing its connection) with another mode; still off if
499    /// disabled in config.
500    #[must_use]
501    pub fn with_mode(&self, mode: CacheMode) -> Self {
502        Self {
503            root: self.root.clone(),
504            config: self.config.clone(),
505            mode: if self.config.enabled() {
506                mode
507            } else {
508                CacheMode::Off
509            },
510            db: Arc::clone(&self.db),
511        }
512    }
513
514    pub fn mode(&self) -> CacheMode {
515        self.mode
516    }
517
518    /// The cache directory.
519    pub fn root(&self) -> &Path {
520        &self.root
521    }
522
523    pub fn db_path(&self) -> PathBuf {
524        self.root.join(db::DB_FILE)
525    }
526
527    pub fn config(&self) -> &CacheConfig {
528        &self.config
529    }
530
531    pub fn ttl(&self, kind: CacheKind) -> u64 {
532        self.config.ttl(kind)
533    }
534
535    /// The open connection, opening it on first use. `Err` carries why the
536    /// database is unavailable.
537    fn conn(&self) -> Result<MutexGuard<'_, Connection>> {
538        if self.root.as_os_str().is_empty() {
539            return Err(Error::invalid("no cache directory"));
540        }
541        let cell = self.db.conn.get_or_init(|| {
542            db::open(&self.db_path(), jwt::now())
543                .map(Mutex::new)
544                .map_err(|e| e.message)
545        });
546        match cell {
547            Ok(m) => Ok(m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)),
548            Err(msg) => Err(Error::new(crate::error::ErrorKind::Io, msg.clone())),
549        }
550    }
551
552    /// A fresh cached value, if reads are enabled and one exists. Any problem
553    /// reading or decoding the entry is a miss.
554    pub fn get<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
555        self.lookup(key).map(|f| f.value)
556    }
557
558    /// Like [`get`](Self::get), with the time the entry was fetched.
559    pub fn lookup<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<Fetched<T>> {
560        self.lookup_at(key, jwt::now())
561    }
562
563    fn lookup_at<T: DeserializeOwned>(&self, key: &CacheKey, now: i64) -> Option<Fetched<T>> {
564        if !self.mode.reads() {
565            return None;
566        }
567        let (value, fetched_at, expires_at) = self.read_entry(key)?;
568        is_fresh(fetched_at, expires_at, self.ttl(key.kind()), now).then_some(Fetched {
569            value,
570            cached_at: Some(fetched_at),
571        })
572    }
573
574    /// The stored value for `key` even if it has expired (but not yet been
575    /// pruned), for falling back when the live call fails. `None` when the
576    /// mode is off.
577    pub fn lookup_stale<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<Fetched<T>> {
578        if !self.mode.writes() {
579            return None;
580        }
581        let (value, fetched_at, _) = self.read_entry(key)?;
582        Some(Fetched {
583            value,
584            cached_at: Some(fetched_at),
585        })
586    }
587
588    fn read_entry<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<(T, i64, i64)> {
589        let conn = self.conn().ok()?;
590        let row: (String, String, i64, i64) = conn
591            .query_row(
592                "SELECT identity, body, fetched_at, expires_at FROM entries WHERE key = ?1",
593                [key.hash()],
594                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
595            )
596            .optional()
597            .ok()??;
598        drop(conn);
599        let (identity, body, fetched_at, expires_at) = row;
600        // A hash collision or tampered row is a miss.
601        if identity != key.identity() {
602            return None;
603        }
604        let value = serde_json::from_str(&body).ok()?;
605        Some((value, fetched_at, expires_at))
606    }
607
608    /// Store a value (no-op when writes are off or the kind's TTL is 0).
609    pub fn put<T: Serialize>(&self, key: &CacheKey, value: &T) -> Result<()> {
610        self.put_at(key, value, jwt::now())
611    }
612
613    fn put_at<T: Serialize>(&self, key: &CacheKey, value: &T, now: i64) -> Result<()> {
614        let ttl = self.ttl(key.kind());
615        if !self.mode.writes() || ttl == 0 {
616            return Ok(());
617        }
618        let body = serde_json::to_string(value)?;
619        let expires_at = now.saturating_add(i64::try_from(ttl).unwrap_or(i64::MAX));
620        let conn = self.conn()?;
621        conn.execute(
622            "INSERT OR REPLACE INTO entries \
623             (key, namespace, kind, identity, body, fetched_at, expires_at, etag) \
624             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)",
625            params![
626                key.hash(),
627                key.namespace(),
628                key.kind().as_str(),
629                key.identity(),
630                body,
631                now,
632                expires_at
633            ],
634        )?;
635        Ok(())
636    }
637
638    /// Return the cached value for `key`, or run `fetch`, store its result
639    /// (best-effort) and return it. Errors from `fetch` are returned and never
640    /// cached. Prefer `T = serde_json::Value` holding the raw upstream response.
641    pub async fn get_or_fetch<T, F, Fut>(&self, key: &CacheKey, fetch: F) -> Result<T>
642    where
643        T: Serialize + DeserializeOwned,
644        F: FnOnce() -> Fut,
645        Fut: Future<Output = Result<T>>,
646    {
647        Ok(self.get_or_fetch_meta(key, fetch).await?.value)
648    }
649
650    /// [`get_or_fetch`](Self::get_or_fetch) that also says whether the value
651    /// came from the cache and how old it is.
652    pub async fn get_or_fetch_meta<T, F, Fut>(&self, key: &CacheKey, fetch: F) -> Result<Fetched<T>>
653    where
654        T: Serialize + DeserializeOwned,
655        F: FnOnce() -> Fut,
656        Fut: Future<Output = Result<T>>,
657    {
658        if let Some(hit) = self.lookup(key) {
659            return Ok(hit);
660        }
661        let value = fetch().await?;
662        let _ = self.put(key, &value);
663        Ok(Fetched::live(value))
664    }
665
666    pub fn stats(&self) -> Result<CacheStats> {
667        self.stats_at(jwt::now())
668    }
669
670    fn stats_at(&self, now: i64) -> Result<CacheStats> {
671        let mut s = CacheStats {
672            path: self.root.display().to_string(),
673            db_path: self.db_path().display().to_string(),
674            enabled: self.config.enabled(),
675            ..CacheStats::default()
676        };
677        for kind in CacheKind::ALL {
678            s.kinds.insert(kind.as_str().into(), KindStats::default());
679        }
680        {
681            let conn = self.conn()?;
682            s.schema_version = u64::try_from(db::schema_version(&conn)?).unwrap_or(0);
683            let mut stmt = conn.prepare(
684                "SELECT kind, fetched_at, expires_at, length(CAST(body AS BLOB)) FROM entries",
685            )?;
686            let rows = stmt.query_map([], |r| {
687                Ok((
688                    r.get::<_, String>(0)?,
689                    r.get::<_, i64>(1)?,
690                    r.get::<_, i64>(2)?,
691                    r.get::<_, i64>(3)?,
692                ))
693            })?;
694            for row in rows {
695                let (kind, fetched_at, expires_at, bytes) = row?;
696                let bytes = u64::try_from(bytes).unwrap_or(0);
697                let ttl = kind.parse::<CacheKind>().map(|k| self.ttl(k)).unwrap_or(0);
698                let k = s.kinds.entry(kind).or_default();
699                k.entries += 1;
700                k.bytes += bytes;
701                s.entries += 1;
702                s.total_bytes += bytes;
703                if !is_fresh(fetched_at, expires_at, ttl, now) {
704                    k.expired += 1;
705                    s.expired += 1;
706                }
707            }
708            drop(stmt);
709            for table in catalog::TABLES {
710                let n: i64 =
711                    conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?;
712                s.tables
713                    .insert(table.to_string(), u64::try_from(n).unwrap_or(0));
714            }
715            s.images = images::stats(&conn, self.config.image_max_bytes())?;
716        }
717        s.db_bytes = db::db_size(&self.db_path());
718        Ok(s)
719    }
720
721    /// Remove response entries, optionally only of one kind and/or only
722    /// expired ones. Returns how many were removed. Catalog rows and images are
723    /// kept (see [`clear_all`](Self::clear_all) / [`prune`](Self::prune)).
724    pub fn clear(&self, kind: Option<CacheKind>, expired_only: bool) -> Result<u64> {
725        self.clear_at(kind, expired_only, jwt::now())
726    }
727
728    fn clear_at(&self, kind: Option<CacheKind>, expired_only: bool, now: i64) -> Result<u64> {
729        let conn = self.conn()?;
730        let mut removed = 0;
731        let kinds: Vec<CacheKind> = kind.map_or_else(|| CacheKind::ALL.to_vec(), |k| vec![k]);
732        for k in kinds {
733            let n = if expired_only {
734                let ttl = i64::try_from(self.ttl(k)).unwrap_or(i64::MAX);
735                conn.execute(
736                    "DELETE FROM entries WHERE kind = ?1 AND \
737                     (expires_at <= ?2 OR fetched_at > ?2 OR ?2 - fetched_at >= ?3)",
738                    params![k.as_str(), now, ttl],
739                )?
740            } else {
741                conn.execute("DELETE FROM entries WHERE kind = ?1", [k.as_str()])?
742            };
743            removed += n as u64;
744        }
745        if kind.is_none() && !expired_only {
746            // Rows of kinds this binary doesn't know (written by another
747            // version) go too.
748            removed += conn.execute("DELETE FROM entries", [])? as u64;
749        }
750        Ok(removed)
751    }
752
753    /// Remove everything: responses, catalog rows and image files.
754    pub fn clear_all(&self) -> Result<ClearReport> {
755        let mut report = ClearReport::default();
756        {
757            let conn = self.conn()?;
758            report.entries = conn.execute("DELETE FROM entries", [])? as u64;
759            for table in catalog::TABLES {
760                report.catalog_rows += conn.execute(&format!("DELETE FROM {table}"), [])? as u64;
761            }
762            report.images = images::clear(&conn, &self.root)?;
763            conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); VACUUM;")?;
764        }
765        Ok(report)
766    }
767
768    /// Housekeeping: drop expired responses, catalog rows older than
769    /// `index_max_age_days`, and evict images over `image_max_mb` (LRU), then
770    /// compact the database.
771    pub fn prune(&self) -> Result<PruneReport> {
772        self.prune_at(jwt::now())
773    }
774
775    fn prune_at(&self, now: i64) -> Result<PruneReport> {
776        let mut report = PruneReport {
777            entries: self.clear_at(None, true, now)?,
778            ..PruneReport::default()
779        };
780        let conn = self.conn()?;
781        // Entries of unknown kinds past their stored expiry.
782        report.entries += conn.execute("DELETE FROM entries WHERE expires_at <= ?1", [now])? as u64;
783        let cutoff =
784            now.saturating_sub(i64::try_from(self.config.index_max_age_secs()).unwrap_or(i64::MAX));
785        let c = catalog::prune_older_than(&conn, cutoff)?;
786        report.restaurants = c[0];
787        report.restaurant_locations = c[1];
788        report.menu_categories = c[2];
789        report.menu_items = c[3];
790        let (n, bytes) = images::evict(&conn, &self.root, self.config.image_max_bytes())?;
791        report.images_evicted = n;
792        report.image_bytes_freed = bytes;
793        conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); VACUUM; PRAGMA optimize;")?;
794        Ok(report)
795    }
796}
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use serde_json::{Value, json};
802
803    fn cache(dir: &Path, mode: CacheMode) -> Cache {
804        Cache::new(dir, CacheConfig::default(), mode)
805    }
806
807    fn key() -> CacheKey {
808        CacheKey::new("food", CacheKind::Menu, "merchant")
809            .param("id", "2-EXAMPLE")
810            .location(1.23, 103.81)
811    }
812
813    fn get_at(c: &Cache, k: &CacheKey, now: i64) -> Option<Value> {
814        c.lookup_at::<Value>(k, now).map(|f| f.value)
815    }
816
817    #[test]
818    fn key_normalization() {
819        let a = CacheKey::new("food", CacheKind::List, "search")
820            .param("keyword", "  fried   chicken ")
821            .param("page", 0)
822            .location(1.234_512_3, 103.812_34);
823        let b = CacheKey::new("food", CacheKind::List, "search")
824            .location(1.234_499, 103.812_251)
825            .param("page", "0")
826            .param("keyword", "fried chicken");
827        assert_eq!(a.identity(), b.identity());
828        assert_eq!(a.hash(), b.hash());
829        assert_eq!(a.hash().len(), 64);
830
831        // A different place (> ~11 m), kind or namespace is a different key.
832        let far = b.clone().location(1.2358, 103.8123);
833        assert_ne!(far.hash(), b.hash());
834        let mart = CacheKey::new("mart", CacheKind::List, "search")
835            .param("keyword", "fried chicken")
836            .param("page", 0)
837            .location(1.2345, 103.8123);
838        assert_ne!(mart.hash(), a.hash());
839        // -0.0 and 0.0 are the same place.
840        assert_eq!(round_coord(-0.000_01), round_coord(0.0));
841    }
842
843    #[test]
844    fn roundtrip_and_ttl_expiry() -> Result<()> {
845        let dir = tempfile::tempdir()?;
846        let c = cache(dir.path(), CacheMode::Normal);
847        let k = key();
848        let v = json!({"merchant": {"ID": "2-EXAMPLE"}});
849        c.put_at(&k, &v, 1_000)?;
850        assert_eq!(get_at(&c, &k, 1_000), Some(v.clone()));
851        let ttl = CacheKind::Menu.default_ttl() as i64;
852        let hit = c.lookup_at::<Value>(&k, 1_000 + ttl - 1);
853        assert_eq!(hit.map(|h| h.cached_at), Some(Some(1_000)));
854        assert_eq!(get_at(&c, &k, 1_000 + ttl), None);
855        // Clock skew: an entry from the future is stale.
856        assert_eq!(get_at(&c, &k, 999), None);
857        // Expired but not pruned: still available as a stale fallback.
858        assert!(c.lookup_stale::<Value>(&k).is_some());
859        Ok(())
860    }
861
862    #[test]
863    fn config_ttl_override_and_disable() -> Result<()> {
864        let dir = tempfile::tempdir()?;
865        let cfg: CacheConfig = toml::from_str("[ttl]\nmenu = 5\nlist = 0\n")
866            .map_err(|e| Error::invalid(e.to_string()))?;
867        let c = Cache::new(dir.path(), cfg, CacheMode::Normal);
868        let k = key();
869        c.put_at(&k, &json!(1), 100)?;
870        assert_eq!(get_at(&c, &k, 104), Some(json!(1)));
871        assert_eq!(get_at(&c, &k, 105), None);
872        // TTL 0: never written.
873        let lk = CacheKey::new("food", CacheKind::List, "search");
874        c.put_at(&lk, &json!(1), 100)?;
875        assert_eq!(c.stats_at(100)?.kinds["list"].entries, 0);
876
877        // A lowered TTL applies to entries written under a longer one.
878        let long = cache(dir.path(), CacheMode::Normal);
879        long.put_at(&k, &json!(2), 100)?;
880        assert_eq!(get_at(&c, &k, 110), None);
881        assert_eq!(get_at(&long, &k, 110), Some(json!(2)));
882
883        let off: CacheConfig =
884            toml::from_str("enabled = false").map_err(|e| Error::invalid(e.to_string()))?;
885        assert_eq!(
886            Cache::new(dir.path(), off.clone(), CacheMode::Normal).mode(),
887            CacheMode::Off
888        );
889        assert_eq!(
890            Cache::new(dir.path(), off, CacheMode::Normal)
891                .with_mode(CacheMode::Refresh)
892                .mode(),
893            CacheMode::Off
894        );
895        Ok(())
896    }
897
898    #[test]
899    fn bad_rows_are_misses() -> Result<()> {
900        let dir = tempfile::tempdir()?;
901        let c = cache(dir.path(), CacheMode::Normal);
902        let k = key();
903        // Wrong payload type for T: a miss, not an error.
904        c.put(&k, &json!("a string"))?;
905        assert_eq!(c.get::<Vec<u32>>(&k), None);
906        // A hash collision / tampered identity is a miss.
907        c.conn()?
908            .execute("UPDATE entries SET identity = 'something else'", [])?;
909        assert_eq!(c.get::<Value>(&k), None);
910        // Unparseable body: a miss.
911        c.put(&k, &json!(1))?;
912        c.conn()?.execute("UPDATE entries SET body = '{nope'", [])?;
913        assert_eq!(c.get::<Value>(&k), None);
914
915        // An unusable cache dir: reads miss, get_or_fetch still works.
916        let file = dir.path().join("not-a-dir");
917        std::fs::write(&file, b"x")?;
918        let broken = cache(&file, CacheMode::Normal);
919        assert_eq!(broken.get::<Value>(&k), None);
920        assert!(broken.put(&k, &json!(1)).is_err());
921        Ok(())
922    }
923
924    #[tokio::test]
925    async fn modes() -> Result<()> {
926        let dir = tempfile::tempdir()?;
927        let k = key();
928        let normal = cache(dir.path(), CacheMode::Normal);
929
930        // Normal: miss → fetch + store; then hit without fetching.
931        let v = normal
932            .get_or_fetch_meta(&k, || async { Ok(json!(1)) })
933            .await?;
934        assert_eq!((v.value, v.cached_at), (json!(1), None));
935        let v = normal
936            .get_or_fetch_meta::<Value, _, _>(&k, || async {
937                Err(Error::invalid("must not fetch"))
938            })
939            .await?;
940        assert_eq!(v.value, json!(1));
941        assert!(v.is_cached());
942        assert!(v.age_secs().is_some_and(|a| a >= 0));
943
944        // Off: always fetches, never writes, no stale fallback.
945        let off = normal.with_mode(CacheMode::Off);
946        let v: Value = off.get_or_fetch(&k, || async { Ok(json!(2)) }).await?;
947        assert_eq!(v, json!(2));
948        assert_eq!(normal.get::<Value>(&k), Some(json!(1)));
949        assert!(off.lookup_stale::<Value>(&k).is_none());
950
951        // Refresh: always fetches, overwrites the entry.
952        let refresh = normal.with_mode(CacheMode::Refresh);
953        let v: Value = refresh.get_or_fetch(&k, || async { Ok(json!(3)) }).await?;
954        assert_eq!(v, json!(3));
955        assert_eq!(normal.get::<Value>(&k), Some(json!(3)));
956
957        // Errors propagate and are not cached.
958        let k2 = key().param("id", "other");
959        let r: Result<Value> = normal
960            .get_or_fetch(&k2, || async { Err(Error::invalid("boom")) })
961            .await;
962        assert!(r.is_err());
963        assert_eq!(normal.get::<Value>(&k2), None);
964
965        assert_eq!(CacheMode::from_flags(true, true), CacheMode::Off);
966        assert_eq!(CacheMode::from_flags(false, true), CacheMode::Refresh);
967        assert_eq!(CacheMode::from_flags(false, false), CacheMode::Normal);
968        Ok(())
969    }
970
971    #[test]
972    fn stats_clear_and_prune() -> Result<()> {
973        let dir = tempfile::tempdir()?;
974        let c = cache(dir.path(), CacheMode::Normal);
975        let menu = key();
976        let list = CacheKey::new("food", CacheKind::List, "search").param("keyword", "x");
977        let mart = CacheKey::new("mart", CacheKind::Categories, "categories");
978        c.put_at(&menu, &json!({"a": 1}), 0)?; // expired by `now` below
979        c.put_at(&list, &json!([1, 2]), 10_000)?;
980        c.put_at(&mart, &json!([]), 10_000)?;
981        let now = 10_001;
982
983        let s = c.stats_at(now)?;
984        assert_eq!(s.entries, 3);
985        assert_eq!(s.expired, 1);
986        assert!(s.total_bytes > 0);
987        assert!(s.db_bytes > 0);
988        assert_eq!(s.schema_version, db::MIGRATIONS.len() as u64);
989        assert_eq!(s.kinds["menu"].expired, 1);
990        assert_eq!(s.kinds["list"].entries, 1);
991        assert_eq!(s.kinds["categories"].entries, 1);
992        assert_eq!(s.kinds["details"].entries, 0);
993        assert_eq!(s.tables["restaurants"], 0);
994
995        assert_eq!(c.clear_at(None, true, now)?, 1);
996        assert_eq!(c.stats_at(now)?.entries, 2);
997        assert_eq!(c.clear_at(Some(CacheKind::List), false, now)?, 1);
998        assert_eq!(get_at(&c, &list, now), None);
999        assert_eq!(get_at(&c, &mart, now), Some(json!([])));
1000
1001        // Prune removes what has expired by then.
1002        let later = 10_000 + CacheKind::Categories.default_ttl() as i64;
1003        let r = c.prune_at(later)?;
1004        assert_eq!(r.entries, 1);
1005        assert_eq!(c.stats_at(later)?.entries, 0);
1006
1007        // Expired rows are also pruned (boundedly) when the db is opened.
1008        c.put_at(&menu, &json!(1), 0)?;
1009        let reopened = cache(dir.path(), CacheMode::Normal);
1010        assert_eq!(reopened.stats()?.entries, 0);
1011        Ok(())
1012    }
1013
1014    #[test]
1015    #[cfg(unix)]
1016    fn private_permissions() -> Result<()> {
1017        use std::os::unix::fs::PermissionsExt;
1018        let dir = tempfile::tempdir()?;
1019        let root = dir.path().join("grabcli");
1020        let c = cache(&root, CacheMode::Normal);
1021        c.put(&key(), &json!(1))?;
1022        assert_eq!(
1023            std::fs::metadata(c.db_path())?.permissions().mode() & 0o777,
1024            0o600
1025        );
1026        assert_eq!(
1027            std::fs::metadata(&root)?.permissions().mode() & 0o777,
1028            0o700
1029        );
1030        Ok(())
1031    }
1032
1033    #[test]
1034    fn kind_parses() {
1035        assert_eq!("Menu".parse::<CacheKind>(), Ok(CacheKind::Menu));
1036        assert!("quote".parse::<CacheKind>().is_err());
1037    }
1038
1039    #[test]
1040    fn ages() {
1041        assert_eq!(human_age(5), "5s");
1042        assert_eq!(human_age(240), "4m");
1043        assert_eq!(human_age(7200), "2h");
1044        assert_eq!(human_age(3 * 86_400), "3d");
1045    }
1046}