1pub 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
70pub const NO_CACHE_ENV: &str = "GRAB_NO_CACHE";
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum CacheKind {
78 List,
80 Menu,
82 Details,
84 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
130pub enum CacheMode {
131 #[default]
133 Normal,
134 Refresh,
136 Off,
138}
139
140impl CacheMode {
141 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
162pub const DEFAULT_IMAGE_MAX_MB: u64 = 500;
164pub const DEFAULT_INDEX_MAX_AGE_DAYS: u64 = 30;
166
167#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct CacheKey {
231 namespace: String,
232 kind: CacheKind,
233 endpoint: String,
234 params: BTreeMap<String, String>,
235}
236
237const 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 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 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 #[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 #[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 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 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#[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 pub fn age_secs(&self) -> Option<i64> {
346 self.cached_at.map(|t| (jwt::now() - t).max(0))
347 }
348}
349
350pub 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 now >= fetched_at && now < expires_at && now - fetched_at < ttl
369}
370
371#[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#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
381pub struct ImageStats {
382 pub files: u64,
384 pub urls: u64,
385 pub bytes: u64,
386 pub max_bytes: u64,
387}
388
389#[derive(Debug, Clone, Default, Serialize)]
391pub struct CacheStats {
392 pub path: String,
394 pub db_path: String,
395 pub db_bytes: u64,
397 pub schema_version: u64,
398 pub enabled: bool,
399 pub entries: u64,
400 pub total_bytes: u64,
402 pub expired: u64,
403 pub kinds: BTreeMap<String, KindStats>,
404 pub tables: BTreeMap<String, u64>,
406 pub images: ImageStats,
407}
408
409#[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#[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#[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#[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 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 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 #[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 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 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 pub fn get<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
555 self.lookup(key).map(|f| f.value)
556 }
557
558 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 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 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 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 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 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 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 removed += conn.execute("DELETE FROM entries", [])? as u64;
749 }
750 Ok(removed)
751 }
752
753 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 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 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 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 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 assert_eq!(get_at(&c, &k, 999), None);
857 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 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 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 c.put(&k, &json!("a string"))?;
905 assert_eq!(c.get::<Vec<u32>>(&k), None);
906 c.conn()?
908 .execute("UPDATE entries SET identity = 'something else'", [])?;
909 assert_eq!(c.get::<Value>(&k), None);
910 c.put(&k, &json!(1))?;
912 c.conn()?.execute("UPDATE entries SET body = '{nope'", [])?;
913 assert_eq!(c.get::<Value>(&k), None);
914
915 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 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 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 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 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)?; 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 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 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}