pub mod catalog;
pub(crate) mod db;
pub mod images;
use std::collections::BTreeMap;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use rusqlite::{Connection, OptionalExtension, params};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::error::{Error, Result};
use crate::{jwt, paths};
pub use catalog::{
CategoryRow, ItemHit, ItemQuery, ItemRow, Near, RestaurantHit, RestaurantQuery, RestaurantRow,
};
pub use images::{CachedImage, image_url_allowed};
pub const NO_CACHE_ENV: &str = "GRAB_NO_CACHE";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CacheKind {
List,
Menu,
Details,
Categories,
}
impl CacheKind {
pub const ALL: [CacheKind; 4] = [Self::List, Self::Menu, Self::Details, Self::Categories];
pub fn as_str(self) -> &'static str {
match self {
Self::List => "list",
Self::Menu => "menu",
Self::Details => "details",
Self::Categories => "categories",
}
}
pub fn default_ttl(self) -> u64 {
match self {
Self::List => 10 * 60,
Self::Menu => 30 * 60,
Self::Details => 6 * 60 * 60,
Self::Categories => 24 * 60 * 60,
}
}
}
impl std::fmt::Display for CacheKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for CacheKind {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let s = s.trim().to_ascii_lowercase();
Self::ALL
.into_iter()
.find(|k| k.as_str() == s)
.ok_or_else(|| format!("unknown cache kind `{s}` (list|menu|details|categories)"))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum CacheMode {
#[default]
Normal,
Refresh,
Off,
}
impl CacheMode {
pub fn from_flags(no_cache: bool, refresh: bool) -> Self {
if no_cache {
Self::Off
} else if refresh {
Self::Refresh
} else {
Self::Normal
}
}
pub fn reads(self) -> bool {
self == Self::Normal
}
pub fn writes(self) -> bool {
self != Self::Off
}
}
pub const DEFAULT_IMAGE_MAX_MB: u64 = 500;
pub const DEFAULT_INDEX_MAX_AGE_DAYS: u64 = 30;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CacheConfig {
pub enabled: Option<bool>,
pub ttl: CacheTtls,
pub image_max_mb: Option<u64>,
pub index_max_age_days: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CacheTtls {
pub list: Option<u64>,
pub menu: Option<u64>,
pub details: Option<u64>,
pub categories: Option<u64>,
}
impl CacheConfig {
pub fn enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
pub fn ttl(&self, kind: CacheKind) -> u64 {
let o = match kind {
CacheKind::List => self.ttl.list,
CacheKind::Menu => self.ttl.menu,
CacheKind::Details => self.ttl.details,
CacheKind::Categories => self.ttl.categories,
};
o.unwrap_or_else(|| kind.default_ttl())
}
pub fn image_max_bytes(&self) -> u64 {
self.image_max_mb
.unwrap_or(DEFAULT_IMAGE_MAX_MB)
.saturating_mul(1024 * 1024)
}
pub fn index_max_age_secs(&self) -> u64 {
self.index_max_age_days
.unwrap_or(DEFAULT_INDEX_MAX_AGE_DAYS)
.saturating_mul(24 * 60 * 60)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CacheKey {
namespace: String,
kind: CacheKind,
endpoint: String,
params: BTreeMap<String, String>,
}
const COORD_DECIMALS: usize = 4;
fn normalize_value(v: &str) -> String {
v.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn round_coord_to(c: f64, decimals: usize) -> String {
let s = format!("{c:.decimals$}");
if s.trim_start_matches('-')
.chars()
.all(|ch| ch == '0' || ch == '.')
{
s.trim_start_matches('-').to_string()
} else {
s
}
}
fn round_coord(c: f64) -> String {
round_coord_to(c, COORD_DECIMALS)
}
impl CacheKey {
pub fn new(namespace: impl Into<String>, kind: CacheKind, endpoint: impl Into<String>) -> Self {
Self {
namespace: namespace.into(),
kind,
endpoint: endpoint.into(),
params: BTreeMap::new(),
}
}
#[must_use]
pub fn param(mut self, name: impl Into<String>, value: impl std::fmt::Display) -> Self {
self.params
.insert(name.into(), normalize_value(&value.to_string()));
self
}
#[must_use]
pub fn location(mut self, latitude: f64, longitude: f64) -> Self {
self.params.insert("lat".into(), round_coord(latitude));
self.params.insert("lng".into(), round_coord(longitude));
self
}
pub fn kind(&self) -> CacheKind {
self.kind
}
pub fn namespace(&self) -> &str {
&self.namespace
}
pub fn identity(&self) -> String {
serde_json::json!({
"ns": self.namespace,
"kind": self.kind.as_str(),
"endpoint": self.endpoint,
"params": self.params,
})
.to_string()
}
pub fn hash(&self) -> String {
hex_sha256(self.identity().as_bytes())
}
}
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[derive(Debug, Clone, PartialEq)]
pub struct Fetched<T> {
pub value: T,
pub cached_at: Option<i64>,
}
impl<T> Fetched<T> {
pub fn live(value: T) -> Self {
Self {
value,
cached_at: None,
}
}
pub fn is_cached(&self) -> bool {
self.cached_at.is_some()
}
pub fn age_secs(&self) -> Option<i64> {
self.cached_at.map(|t| (jwt::now() - t).max(0))
}
}
pub fn human_age(secs: i64) -> String {
let s = secs.max(0);
if s < 60 {
format!("{s}s")
} else if s < 3600 {
format!("{}m", s / 60)
} else if s < 86_400 {
format!("{}h", s / 3600)
} else {
format!("{}d", s / 86_400)
}
}
fn is_fresh(fetched_at: i64, expires_at: i64, ttl_secs: u64, now: i64) -> bool {
let ttl = i64::try_from(ttl_secs).unwrap_or(i64::MAX);
now >= fetched_at && now < expires_at && now - fetched_at < ttl
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct KindStats {
pub entries: u64,
pub bytes: u64,
pub expired: u64,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct ImageStats {
pub files: u64,
pub urls: u64,
pub bytes: u64,
pub max_bytes: u64,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct CacheStats {
pub path: String,
pub db_path: String,
pub db_bytes: u64,
pub schema_version: u64,
pub enabled: bool,
pub entries: u64,
pub total_bytes: u64,
pub expired: u64,
pub kinds: BTreeMap<String, KindStats>,
pub tables: BTreeMap<String, u64>,
pub images: ImageStats,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct PruneReport {
pub entries: u64,
pub restaurants: u64,
pub restaurant_locations: u64,
pub menu_categories: u64,
pub menu_items: u64,
pub images_evicted: u64,
pub image_bytes_freed: u64,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct ClearReport {
pub entries: u64,
pub catalog_rows: u64,
pub images: u64,
}
#[derive(Default)]
struct DbCell {
conn: OnceLock<std::result::Result<Mutex<Connection>, String>>,
}
impl std::fmt::Debug for DbCell {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let state = match self.conn.get() {
None => "unopened",
Some(Ok(_)) => "open",
Some(Err(_)) => "failed",
};
f.debug_struct("DbCell").field("state", &state).finish()
}
}
#[derive(Debug, Clone)]
pub struct Cache {
root: PathBuf,
config: CacheConfig,
mode: CacheMode,
db: Arc<DbCell>,
}
fn env_disables_cache() -> bool {
std::env::var(NO_CACHE_ENV).is_ok_and(|v| {
let v = v.trim().to_ascii_lowercase();
!(v.is_empty() || v == "0" || v == "false" || v == "no")
})
}
impl Cache {
pub fn new(root: impl Into<PathBuf>, config: CacheConfig, mode: CacheMode) -> Self {
let mode = if config.enabled() {
mode
} else {
CacheMode::Off
};
Self {
root: root.into(),
config,
mode,
db: Arc::new(DbCell::default()),
}
}
pub fn from_config(config: &CacheConfig, mode: CacheMode) -> Self {
let mode = if env_disables_cache() {
CacheMode::Off
} else {
mode
};
match paths::cache_dir() {
Ok(root) => Self::new(root, config.clone(), mode),
Err(_) => Self::new(PathBuf::new(), config.clone(), CacheMode::Off),
}
}
#[must_use]
pub fn with_mode(&self, mode: CacheMode) -> Self {
Self {
root: self.root.clone(),
config: self.config.clone(),
mode: if self.config.enabled() {
mode
} else {
CacheMode::Off
},
db: Arc::clone(&self.db),
}
}
pub fn mode(&self) -> CacheMode {
self.mode
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn db_path(&self) -> PathBuf {
self.root.join(db::DB_FILE)
}
pub fn config(&self) -> &CacheConfig {
&self.config
}
pub fn ttl(&self, kind: CacheKind) -> u64 {
self.config.ttl(kind)
}
fn conn(&self) -> Result<MutexGuard<'_, Connection>> {
if self.root.as_os_str().is_empty() {
return Err(Error::invalid("no cache directory"));
}
let cell = self.db.conn.get_or_init(|| {
db::open(&self.db_path(), jwt::now())
.map(Mutex::new)
.map_err(|e| e.message)
});
match cell {
Ok(m) => Ok(m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)),
Err(msg) => Err(Error::new(crate::error::ErrorKind::Io, msg.clone())),
}
}
pub fn get<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<T> {
self.lookup(key).map(|f| f.value)
}
pub fn lookup<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<Fetched<T>> {
self.lookup_at(key, jwt::now())
}
fn lookup_at<T: DeserializeOwned>(&self, key: &CacheKey, now: i64) -> Option<Fetched<T>> {
if !self.mode.reads() {
return None;
}
let (value, fetched_at, expires_at) = self.read_entry(key)?;
is_fresh(fetched_at, expires_at, self.ttl(key.kind()), now).then_some(Fetched {
value,
cached_at: Some(fetched_at),
})
}
pub fn lookup_stale<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<Fetched<T>> {
if !self.mode.writes() {
return None;
}
let (value, fetched_at, _) = self.read_entry(key)?;
Some(Fetched {
value,
cached_at: Some(fetched_at),
})
}
fn read_entry<T: DeserializeOwned>(&self, key: &CacheKey) -> Option<(T, i64, i64)> {
let conn = self.conn().ok()?;
let row: (String, String, i64, i64) = conn
.query_row(
"SELECT identity, body, fetched_at, expires_at FROM entries WHERE key = ?1",
[key.hash()],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.optional()
.ok()??;
drop(conn);
let (identity, body, fetched_at, expires_at) = row;
if identity != key.identity() {
return None;
}
let value = serde_json::from_str(&body).ok()?;
Some((value, fetched_at, expires_at))
}
pub fn put<T: Serialize>(&self, key: &CacheKey, value: &T) -> Result<()> {
self.put_at(key, value, jwt::now())
}
fn put_at<T: Serialize>(&self, key: &CacheKey, value: &T, now: i64) -> Result<()> {
let ttl = self.ttl(key.kind());
if !self.mode.writes() || ttl == 0 {
return Ok(());
}
let body = serde_json::to_string(value)?;
let expires_at = now.saturating_add(i64::try_from(ttl).unwrap_or(i64::MAX));
let conn = self.conn()?;
conn.execute(
"INSERT OR REPLACE INTO entries \
(key, namespace, kind, identity, body, fetched_at, expires_at, etag) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)",
params![
key.hash(),
key.namespace(),
key.kind().as_str(),
key.identity(),
body,
now,
expires_at
],
)?;
Ok(())
}
pub async fn get_or_fetch<T, F, Fut>(&self, key: &CacheKey, fetch: F) -> Result<T>
where
T: Serialize + DeserializeOwned,
F: FnOnce() -> Fut,
Fut: Future<Output = Result<T>>,
{
Ok(self.get_or_fetch_meta(key, fetch).await?.value)
}
pub async fn get_or_fetch_meta<T, F, Fut>(&self, key: &CacheKey, fetch: F) -> Result<Fetched<T>>
where
T: Serialize + DeserializeOwned,
F: FnOnce() -> Fut,
Fut: Future<Output = Result<T>>,
{
if let Some(hit) = self.lookup(key) {
return Ok(hit);
}
let value = fetch().await?;
let _ = self.put(key, &value);
Ok(Fetched::live(value))
}
pub fn stats(&self) -> Result<CacheStats> {
self.stats_at(jwt::now())
}
fn stats_at(&self, now: i64) -> Result<CacheStats> {
let mut s = CacheStats {
path: self.root.display().to_string(),
db_path: self.db_path().display().to_string(),
enabled: self.config.enabled(),
..CacheStats::default()
};
for kind in CacheKind::ALL {
s.kinds.insert(kind.as_str().into(), KindStats::default());
}
{
let conn = self.conn()?;
s.schema_version = u64::try_from(db::schema_version(&conn)?).unwrap_or(0);
let mut stmt = conn.prepare(
"SELECT kind, fetched_at, expires_at, length(CAST(body AS BLOB)) FROM entries",
)?;
let rows = stmt.query_map([], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
))
})?;
for row in rows {
let (kind, fetched_at, expires_at, bytes) = row?;
let bytes = u64::try_from(bytes).unwrap_or(0);
let ttl = kind.parse::<CacheKind>().map(|k| self.ttl(k)).unwrap_or(0);
let k = s.kinds.entry(kind).or_default();
k.entries += 1;
k.bytes += bytes;
s.entries += 1;
s.total_bytes += bytes;
if !is_fresh(fetched_at, expires_at, ttl, now) {
k.expired += 1;
s.expired += 1;
}
}
drop(stmt);
for table in catalog::TABLES {
let n: i64 =
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))?;
s.tables
.insert(table.to_string(), u64::try_from(n).unwrap_or(0));
}
s.images = images::stats(&conn, self.config.image_max_bytes())?;
}
s.db_bytes = db::db_size(&self.db_path());
Ok(s)
}
pub fn clear(&self, kind: Option<CacheKind>, expired_only: bool) -> Result<u64> {
self.clear_at(kind, expired_only, jwt::now())
}
fn clear_at(&self, kind: Option<CacheKind>, expired_only: bool, now: i64) -> Result<u64> {
let conn = self.conn()?;
let mut removed = 0;
let kinds: Vec<CacheKind> = kind.map_or_else(|| CacheKind::ALL.to_vec(), |k| vec![k]);
for k in kinds {
let n = if expired_only {
let ttl = i64::try_from(self.ttl(k)).unwrap_or(i64::MAX);
conn.execute(
"DELETE FROM entries WHERE kind = ?1 AND \
(expires_at <= ?2 OR fetched_at > ?2 OR ?2 - fetched_at >= ?3)",
params![k.as_str(), now, ttl],
)?
} else {
conn.execute("DELETE FROM entries WHERE kind = ?1", [k.as_str()])?
};
removed += n as u64;
}
if kind.is_none() && !expired_only {
removed += conn.execute("DELETE FROM entries", [])? as u64;
}
Ok(removed)
}
pub fn clear_all(&self) -> Result<ClearReport> {
let mut report = ClearReport::default();
{
let conn = self.conn()?;
report.entries = conn.execute("DELETE FROM entries", [])? as u64;
for table in catalog::TABLES {
report.catalog_rows += conn.execute(&format!("DELETE FROM {table}"), [])? as u64;
}
report.images = images::clear(&conn, &self.root)?;
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); VACUUM;")?;
}
Ok(report)
}
pub fn prune(&self) -> Result<PruneReport> {
self.prune_at(jwt::now())
}
fn prune_at(&self, now: i64) -> Result<PruneReport> {
let mut report = PruneReport {
entries: self.clear_at(None, true, now)?,
..PruneReport::default()
};
let conn = self.conn()?;
report.entries += conn.execute("DELETE FROM entries WHERE expires_at <= ?1", [now])? as u64;
let cutoff =
now.saturating_sub(i64::try_from(self.config.index_max_age_secs()).unwrap_or(i64::MAX));
let c = catalog::prune_older_than(&conn, cutoff)?;
report.restaurants = c[0];
report.restaurant_locations = c[1];
report.menu_categories = c[2];
report.menu_items = c[3];
let (n, bytes) = images::evict(&conn, &self.root, self.config.image_max_bytes())?;
report.images_evicted = n;
report.image_bytes_freed = bytes;
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE); VACUUM; PRAGMA optimize;")?;
Ok(report)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::{Value, json};
fn cache(dir: &Path, mode: CacheMode) -> Cache {
Cache::new(dir, CacheConfig::default(), mode)
}
fn key() -> CacheKey {
CacheKey::new("food", CacheKind::Menu, "merchant")
.param("id", "2-EXAMPLE")
.location(1.23, 103.81)
}
fn get_at(c: &Cache, k: &CacheKey, now: i64) -> Option<Value> {
c.lookup_at::<Value>(k, now).map(|f| f.value)
}
#[test]
fn key_normalization() {
let a = CacheKey::new("food", CacheKind::List, "search")
.param("keyword", " fried chicken ")
.param("page", 0)
.location(1.234_512_3, 103.812_34);
let b = CacheKey::new("food", CacheKind::List, "search")
.location(1.234_499, 103.812_251)
.param("page", "0")
.param("keyword", "fried chicken");
assert_eq!(a.identity(), b.identity());
assert_eq!(a.hash(), b.hash());
assert_eq!(a.hash().len(), 64);
let far = b.clone().location(1.2358, 103.8123);
assert_ne!(far.hash(), b.hash());
let mart = CacheKey::new("mart", CacheKind::List, "search")
.param("keyword", "fried chicken")
.param("page", 0)
.location(1.2345, 103.8123);
assert_ne!(mart.hash(), a.hash());
assert_eq!(round_coord(-0.000_01), round_coord(0.0));
}
#[test]
fn roundtrip_and_ttl_expiry() -> Result<()> {
let dir = tempfile::tempdir()?;
let c = cache(dir.path(), CacheMode::Normal);
let k = key();
let v = json!({"merchant": {"ID": "2-EXAMPLE"}});
c.put_at(&k, &v, 1_000)?;
assert_eq!(get_at(&c, &k, 1_000), Some(v.clone()));
let ttl = CacheKind::Menu.default_ttl() as i64;
let hit = c.lookup_at::<Value>(&k, 1_000 + ttl - 1);
assert_eq!(hit.map(|h| h.cached_at), Some(Some(1_000)));
assert_eq!(get_at(&c, &k, 1_000 + ttl), None);
assert_eq!(get_at(&c, &k, 999), None);
assert!(c.lookup_stale::<Value>(&k).is_some());
Ok(())
}
#[test]
fn config_ttl_override_and_disable() -> Result<()> {
let dir = tempfile::tempdir()?;
let cfg: CacheConfig = toml::from_str("[ttl]\nmenu = 5\nlist = 0\n")
.map_err(|e| Error::invalid(e.to_string()))?;
let c = Cache::new(dir.path(), cfg, CacheMode::Normal);
let k = key();
c.put_at(&k, &json!(1), 100)?;
assert_eq!(get_at(&c, &k, 104), Some(json!(1)));
assert_eq!(get_at(&c, &k, 105), None);
let lk = CacheKey::new("food", CacheKind::List, "search");
c.put_at(&lk, &json!(1), 100)?;
assert_eq!(c.stats_at(100)?.kinds["list"].entries, 0);
let long = cache(dir.path(), CacheMode::Normal);
long.put_at(&k, &json!(2), 100)?;
assert_eq!(get_at(&c, &k, 110), None);
assert_eq!(get_at(&long, &k, 110), Some(json!(2)));
let off: CacheConfig =
toml::from_str("enabled = false").map_err(|e| Error::invalid(e.to_string()))?;
assert_eq!(
Cache::new(dir.path(), off.clone(), CacheMode::Normal).mode(),
CacheMode::Off
);
assert_eq!(
Cache::new(dir.path(), off, CacheMode::Normal)
.with_mode(CacheMode::Refresh)
.mode(),
CacheMode::Off
);
Ok(())
}
#[test]
fn bad_rows_are_misses() -> Result<()> {
let dir = tempfile::tempdir()?;
let c = cache(dir.path(), CacheMode::Normal);
let k = key();
c.put(&k, &json!("a string"))?;
assert_eq!(c.get::<Vec<u32>>(&k), None);
c.conn()?
.execute("UPDATE entries SET identity = 'something else'", [])?;
assert_eq!(c.get::<Value>(&k), None);
c.put(&k, &json!(1))?;
c.conn()?.execute("UPDATE entries SET body = '{nope'", [])?;
assert_eq!(c.get::<Value>(&k), None);
let file = dir.path().join("not-a-dir");
std::fs::write(&file, b"x")?;
let broken = cache(&file, CacheMode::Normal);
assert_eq!(broken.get::<Value>(&k), None);
assert!(broken.put(&k, &json!(1)).is_err());
Ok(())
}
#[tokio::test]
async fn modes() -> Result<()> {
let dir = tempfile::tempdir()?;
let k = key();
let normal = cache(dir.path(), CacheMode::Normal);
let v = normal
.get_or_fetch_meta(&k, || async { Ok(json!(1)) })
.await?;
assert_eq!((v.value, v.cached_at), (json!(1), None));
let v = normal
.get_or_fetch_meta::<Value, _, _>(&k, || async {
Err(Error::invalid("must not fetch"))
})
.await?;
assert_eq!(v.value, json!(1));
assert!(v.is_cached());
assert!(v.age_secs().is_some_and(|a| a >= 0));
let off = normal.with_mode(CacheMode::Off);
let v: Value = off.get_or_fetch(&k, || async { Ok(json!(2)) }).await?;
assert_eq!(v, json!(2));
assert_eq!(normal.get::<Value>(&k), Some(json!(1)));
assert!(off.lookup_stale::<Value>(&k).is_none());
let refresh = normal.with_mode(CacheMode::Refresh);
let v: Value = refresh.get_or_fetch(&k, || async { Ok(json!(3)) }).await?;
assert_eq!(v, json!(3));
assert_eq!(normal.get::<Value>(&k), Some(json!(3)));
let k2 = key().param("id", "other");
let r: Result<Value> = normal
.get_or_fetch(&k2, || async { Err(Error::invalid("boom")) })
.await;
assert!(r.is_err());
assert_eq!(normal.get::<Value>(&k2), None);
assert_eq!(CacheMode::from_flags(true, true), CacheMode::Off);
assert_eq!(CacheMode::from_flags(false, true), CacheMode::Refresh);
assert_eq!(CacheMode::from_flags(false, false), CacheMode::Normal);
Ok(())
}
#[test]
fn stats_clear_and_prune() -> Result<()> {
let dir = tempfile::tempdir()?;
let c = cache(dir.path(), CacheMode::Normal);
let menu = key();
let list = CacheKey::new("food", CacheKind::List, "search").param("keyword", "x");
let mart = CacheKey::new("mart", CacheKind::Categories, "categories");
c.put_at(&menu, &json!({"a": 1}), 0)?; c.put_at(&list, &json!([1, 2]), 10_000)?;
c.put_at(&mart, &json!([]), 10_000)?;
let now = 10_001;
let s = c.stats_at(now)?;
assert_eq!(s.entries, 3);
assert_eq!(s.expired, 1);
assert!(s.total_bytes > 0);
assert!(s.db_bytes > 0);
assert_eq!(s.schema_version, db::MIGRATIONS.len() as u64);
assert_eq!(s.kinds["menu"].expired, 1);
assert_eq!(s.kinds["list"].entries, 1);
assert_eq!(s.kinds["categories"].entries, 1);
assert_eq!(s.kinds["details"].entries, 0);
assert_eq!(s.tables["restaurants"], 0);
assert_eq!(c.clear_at(None, true, now)?, 1);
assert_eq!(c.stats_at(now)?.entries, 2);
assert_eq!(c.clear_at(Some(CacheKind::List), false, now)?, 1);
assert_eq!(get_at(&c, &list, now), None);
assert_eq!(get_at(&c, &mart, now), Some(json!([])));
let later = 10_000 + CacheKind::Categories.default_ttl() as i64;
let r = c.prune_at(later)?;
assert_eq!(r.entries, 1);
assert_eq!(c.stats_at(later)?.entries, 0);
c.put_at(&menu, &json!(1), 0)?;
let reopened = cache(dir.path(), CacheMode::Normal);
assert_eq!(reopened.stats()?.entries, 0);
Ok(())
}
#[test]
#[cfg(unix)]
fn private_permissions() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir()?;
let root = dir.path().join("grabcli");
let c = cache(&root, CacheMode::Normal);
c.put(&key(), &json!(1))?;
assert_eq!(
std::fs::metadata(c.db_path())?.permissions().mode() & 0o777,
0o600
);
assert_eq!(
std::fs::metadata(&root)?.permissions().mode() & 0o777,
0o700
);
Ok(())
}
#[test]
fn kind_parses() {
assert_eq!("Menu".parse::<CacheKind>(), Ok(CacheKind::Menu));
assert!("quote".parse::<CacheKind>().is_err());
}
#[test]
fn ages() {
assert_eq!(human_age(5), "5s");
assert_eq!(human_age(240), "4m");
assert_eq!(human_age(7200), "2h");
assert_eq!(human_age(3 * 86_400), "3d");
}
}