use anyhow::{Context, Result};
use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
const DB_PATH: &str = ".rsconstruct/webcache.redb";
const TABLE: TableDefinition<&str, &[u8]> = TableDefinition::new("webcache_v2");
pub struct CacheEntry {
pub url: String,
pub size: usize,
pub age_secs: u64,
pub expired: bool,
}
#[derive(Serialize, Deserialize)]
struct StoredEntry {
fetched_at_secs: u64,
body: String,
}
static DB: OnceLock<Mutex<Option<Database>>> = OnceLock::new();
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn with_db<T>(f: impl FnOnce(&Database) -> Result<T>) -> Result<T> {
let cell = DB.get_or_init(|| Mutex::new(None));
let mut guard = cell.lock().unwrap();
if guard.is_none() {
let path = Path::new(DB_PATH);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory {}", parent.display()))?;
}
let db = Database::create(path)
.with_context(|| format!("Failed to open webcache database {}", path.display()))?;
*guard = Some(db);
}
let db = guard.as_ref().expect("webcache database just opened");
f(db)
}
fn db_exists() -> bool {
Path::new(DB_PATH).exists()
}
fn get_fresh(url: &str, ttl_secs: u64) -> Result<Option<String>> {
if ttl_secs == 0 || !db_exists() {
return Ok(None);
}
with_db(|db| {
let read_txn = db.begin_read()
.context("Failed to begin read transaction on webcache")?;
let Ok(table) = read_txn.open_table(TABLE) else {
return Ok(None);
};
let Some(raw) = table.get(url)
.with_context(|| format!("Failed to read webcache entry for {url}"))?
else {
return Ok(None);
};
let Ok(entry) = serde_json::from_slice::<StoredEntry>(raw.value()) else {
return Ok(None);
};
let age = now_secs().saturating_sub(entry.fetched_at_secs);
if age >= ttl_secs {
return Ok(None);
}
Ok(Some(entry.body))
})
}
pub fn fetch(url: &str, ttl_secs: u64) -> Result<String> {
if let Some(body) = get_fresh(url, ttl_secs)? {
return Ok(body);
}
let body = crate::download::with_retry(|| {
ureq::get(url)
.call()
.with_context(|| format!("Failed to fetch {url}"))?
.body_mut()
.read_to_string()
.with_context(|| format!("Failed to read response body from {url}"))
})?;
if ttl_secs == 0 {
return Ok(body);
}
let stored = serde_json::to_vec(&StoredEntry {
fetched_at_secs: now_secs(),
body: body.clone(),
}).context("Failed to serialize webcache entry")?;
with_db(|db| {
let write_txn = db.begin_write()
.context("Failed to begin write transaction on webcache")?;
{
let mut table = write_txn.open_table(TABLE)
.context("Failed to open webcache table for write")?;
table.insert(url, stored.as_slice())
.with_context(|| format!("Failed to insert webcache entry for {url}"))?;
}
write_txn.commit()
.context("Failed to commit webcache write")?;
Ok(())
})?;
Ok(body)
}
pub fn clear() -> Result<usize> {
if !db_exists() {
return Ok(0);
}
let count = list()?.len();
with_db(|db| {
let write_txn = db.begin_write()
.context("Failed to begin write transaction for webcache clear")?;
write_txn.delete_table(TABLE)
.context("Failed to delete webcache table")?;
write_txn.commit()
.context("Failed to commit webcache clear")?;
Ok(())
})?;
Ok(count)
}
pub fn prune(ttl_secs: u64) -> Result<usize> {
if !db_exists() {
return Ok(0);
}
let expired: Vec<String> = list_with_ttl(ttl_secs)?.into_iter()
.filter(|e| e.expired)
.map(|e| e.url)
.collect();
if expired.is_empty() {
return Ok(0);
}
with_db(|db| {
let write_txn = db.begin_write()
.context("Failed to begin write transaction for webcache prune")?;
{
let mut table = write_txn.open_table(TABLE)
.context("Failed to open webcache table for prune")?;
for url in &expired {
table.remove(url.as_str())
.with_context(|| format!("Failed to remove webcache entry {url}"))?;
}
}
write_txn.commit()
.context("Failed to commit webcache prune")?;
Ok(())
})?;
Ok(expired.len())
}
pub fn list() -> Result<Vec<CacheEntry>> {
list_with_ttl(default_ttl_for_display())
}
const fn default_ttl_for_display() -> u64 {
7 * 24 * 60 * 60
}
pub fn list_with_ttl(ttl_secs: u64) -> Result<Vec<CacheEntry>> {
if !db_exists() {
return Ok(Vec::new());
}
let now = now_secs();
with_db(|db| {
let read_txn = db.begin_read()
.context("Failed to begin read transaction on webcache")?;
let Ok(table) = read_txn.open_table(TABLE) else {
return Ok(Vec::new());
};
let mut entries = Vec::new();
for result in table.iter().context("Failed to iterate webcache entries")? {
let (key, value) = result.context("Failed to read webcache entry")?;
let Ok(stored) = serde_json::from_slice::<StoredEntry>(value.value()) else {
continue;
};
let age_secs = now.saturating_sub(stored.fetched_at_secs);
entries.push(CacheEntry {
url: key.value().to_string(),
size: stored.body.len(),
age_secs,
expired: ttl_secs == 0 || age_secs >= ttl_secs,
});
}
Ok(entries)
})
}
pub fn stats() -> Result<(u64, usize)> {
let entries = list()?;
let total: u64 = entries.iter().map(|e| e.size as u64).sum();
Ok((total, entries.len()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ttl_zero_disables_the_cache() {
assert!(get_fresh("http://example.invalid/x", 0).unwrap().is_none());
}
#[test]
fn expiry_is_at_the_boundary() {
let entry = StoredEntry { fetched_at_secs: 1000, body: "b".into() };
let age = 2000u64.saturating_sub(entry.fetched_at_secs);
assert_eq!(age, 1000);
assert!(age >= 1000, "an entry exactly at the TTL is expired");
assert!(age < 1001, "and still fresh just under it");
}
#[test]
fn backwards_clock_does_not_underflow() {
let fetched_in_the_future = 9_000u64;
assert_eq!(1_000u64.saturating_sub(fetched_in_the_future), 0);
}
}