use std::{
collections::BTreeSet,
fs,
io::{self, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicUsize, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use topos_bible::data::books::BookId;
use crate::search::Hit;
const FORMAT: u32 = 2;
const PRUNE_EVERY: Duration = Duration::from_secs(24 * 60 * 60);
const UNUSED_FOR: Duration = Duration::from_secs(30 * 24 * 60 * 60);
#[derive(Serialize, Deserialize)]
struct Header {
path: PathBuf,
size: u64,
modified_ns: u128,
books: [u64; 4],
}
pub enum Cached {
Hits(Vec<Hit>),
NoMatch,
}
pub struct Cache {
dir: PathBuf,
}
pub fn root() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache")))?;
Some(base.join("topos"))
}
fn stable_hash(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0100_0000_01b3)
})
}
impl Cache {
pub fn open(fingerprint: &str) -> Option<Self> {
let root = root()?;
remove_legacy(&root);
let key = format!("{}\0{FORMAT}\0{fingerprint}", env!("CARGO_PKG_VERSION"));
let dir = root
.join(format!("v{FORMAT}"))
.join(format!("{:016x}", stable_hash(key.as_bytes())));
fs::create_dir_all(&dir).ok()?;
let _ = fs::write(dir.join(".used"), "");
Some(Self { dir })
}
fn entry(&self, key: &Path) -> PathBuf {
let hash = stable_hash(key.as_os_str().as_encoded_bytes());
self.dir.join(format!("{hash:016x}.bin"))
}
pub fn get(&self, path: &Path, books: Option<&BTreeSet<BookId>>) -> Option<Cached> {
let (key, size, modified_ns) = stamp(path)?;
let bytes = fs::read(self.entry(&key)).ok()?;
let (header, rest) = postcard::take_from_bytes::<Header>(&bytes).ok()?;
if header.path != key || header.size != size || header.modified_ns != modified_ns {
return None;
}
if let Some(books) = books
&& !books.iter().any(|book| has_book(&header.books, *book))
{
return Some(Cached::NoMatch);
}
postcard::from_bytes(rest).ok().map(Cached::Hits)
}
pub fn insert(&self, path: &Path, hits: &[Hit]) {
let Some((key, size, modified_ns)) = stamp(path) else {
return;
};
let mut books = [0; 4];
for hit in hits {
let id = usize::from(hit.passage.book.0);
books[id / 64] |= 1 << (id % 64);
}
let header = Header {
path: key.clone(),
size,
modified_ns,
books,
};
let Ok(mut bytes) = postcard::to_allocvec(&header) else {
return;
};
let Ok(body) = postcard::to_allocvec(hits) else {
return;
};
bytes.extend(body);
let _ = write_atomically(&self.entry(&key), &bytes);
}
pub fn finish(&self) {
let marker = self.dir.join(".pruned");
if modified_within(&marker, PRUNE_EVERY) {
return;
}
let _ = fs::write(&marker, "");
for entry in fs::read_dir(&self.dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "bin") {
continue;
}
let source = fs::read(&path)
.ok()
.and_then(|bytes| Some(postcard::take_from_bytes::<Header>(&bytes).ok()?.0.path));
if source.is_none_or(|source| !source.exists()) {
let _ = fs::remove_file(path);
}
}
let Some(versions) = self.dir.parent() else {
return;
};
for other in fs::read_dir(versions).into_iter().flatten().flatten() {
let other = other.path();
if other != self.dir && !modified_within(&other.join(".used"), UNUSED_FOR) {
let _ = fs::remove_dir_all(other);
}
}
}
}
pub fn clear() -> io::Result<()> {
match root() {
Some(root) if root.exists() => fs::remove_dir_all(root),
_ => Ok(()),
}
}
fn has_book(books: &[u64; 4], book: BookId) -> bool {
let id = usize::from(book.0);
books[id / 64] & (1 << (id % 64)) != 0
}
fn stamp(path: &Path) -> Option<(PathBuf, u64, u128)> {
let key = fs::canonicalize(path).ok()?;
let meta = fs::metadata(&key).ok()?;
let modified = meta.modified().ok()?.duration_since(UNIX_EPOCH).ok()?;
Some((key, meta.len(), modified.as_nanos()))
}
fn modified_within(path: &Path, age: Duration) -> bool {
fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
.is_some_and(|elapsed| elapsed < age)
}
fn write_atomically(path: &Path, bytes: &[u8]) -> io::Result<()> {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let temp = path.with_extension(format!("tmp.{}.{n}", std::process::id()));
let mut file = fs::File::create(&temp)?;
file.write_all(bytes)?;
drop(file);
fs::rename(&temp, path).inspect_err(|_| {
let _ = fs::remove_file(&temp);
})
}
fn remove_legacy(root: &Path) {
for entry in fs::read_dir(root).into_iter().flatten().flatten() {
let path = entry.path();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if path.is_file() && (name.ends_with(".json") || name.ends_with(".json.tmp")) {
let _ = fs::remove_file(path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stable_hash_is_fnv1a() {
assert_eq!(stable_hash(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(stable_hash(b"a"), 0xaf63_dc4c_8601_ec8c);
}
#[test]
fn book_bits() {
let mut books = [0; 4];
for id in [1u8, 64, 66, 255] {
books[usize::from(id) / 64] |= 1 << (usize::from(id) % 64);
}
assert!(has_book(&books, BookId(66)));
assert!(has_book(&books, BookId(255)));
assert!(!has_book(&books, BookId(2)));
}
}