use redb::{Database, Durability, ReadableTable, TableDefinition};
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::error::Result;
const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files");
const META: TableDefinition<&str, u64> = TableDefinition::new("meta");
const SCHEMA_VERSION: u64 = 2;
const SCHEMA_KEY: &str = "schema_version";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileRecord {
pub inode: u64,
pub mtime_ns: i64,
pub size: u64,
pub hash: u64,
pub segment_id: u64,
pub doc_id: u32,
pub symbols: u32,
}
pub struct Cache {
db: Database,
}
impl Cache {
pub fn open(path: &Path) -> Result<Cache> {
let db = Database::create(path)?;
let wtxn = db.begin_write()?;
{
let _ = wtxn.open_table(FILES)?;
let _ = wtxn.open_table(META)?;
}
wtxn.commit()?;
let cache = Cache { db };
cache.ensure_schema()?;
Ok(cache)
}
fn ensure_schema(&self) -> Result<()> {
let stored = {
let rtxn = self.db.begin_read()?;
let table = rtxn.open_table(META)?;
table.get(SCHEMA_KEY)?.map(|v| v.value())
};
if stored != Some(SCHEMA_VERSION) {
self.clear()?;
let wtxn = self.db.begin_write()?;
{
let mut table = wtxn.open_table(META)?;
table.insert(SCHEMA_KEY, SCHEMA_VERSION)?;
}
wtxn.commit()?;
}
Ok(())
}
pub fn get(&self, path: &str) -> Result<Option<FileRecord>> {
let rtxn = self.db.begin_read()?;
let table = rtxn.open_table(FILES)?;
match table.get(path)? {
Some(v) => Ok(Some(postcard::from_bytes(v.value())?)),
None => Ok(None),
}
}
pub fn load_all(&self) -> Result<std::collections::HashMap<String, FileRecord>> {
let rtxn = self.db.begin_read()?;
let table = rtxn.open_table(FILES)?;
let mut out = std::collections::HashMap::new();
for entry in table.iter()? {
let (k, v) = entry?;
let rec: FileRecord = postcard::from_bytes(v.value())?;
out.insert(k.value().to_string(), rec);
}
Ok(out)
}
pub fn apply(&self, upserts: &[(String, FileRecord)], deletes: &[String]) -> Result<()> {
let encoded: Vec<(&str, Vec<u8>)> = upserts
.iter()
.map(|(path, rec)| Ok((path.as_str(), postcard::to_allocvec(rec)?)))
.collect::<Result<_>>()?;
let mut wtxn = self.db.begin_write()?;
wtxn.set_durability(Durability::None);
{
let mut table = wtxn.open_table(FILES)?;
for (path, bytes) in &encoded {
table.insert(*path, bytes.as_slice())?;
}
for path in deletes {
table.remove(path.as_str())?;
}
}
wtxn.commit()?;
Ok(())
}
pub fn replace_all(&self, records: &[(String, FileRecord)]) -> Result<()> {
let encoded: Vec<(&str, Vec<u8>)> = records
.iter()
.map(|(path, rec)| Ok((path.as_str(), postcard::to_allocvec(rec)?)))
.collect::<Result<_>>()?;
let wtxn = self.db.begin_write()?;
{
let mut table = wtxn.open_table(FILES)?;
table.retain(|_, _| false)?;
for (path, bytes) in &encoded {
table.insert(*path, bytes.as_slice())?;
}
}
wtxn.commit()?;
Ok(())
}
pub fn clear(&self) -> Result<()> {
self.replace_all(&[])
}
}
pub fn fast_hash(data: &[u8]) -> u64 {
xxhash_rust::xxh3::xxh3_64(data)
}
#[cfg(unix)]
pub fn stat_key(meta: &std::fs::Metadata) -> (u64, i64, u64) {
use std::os::unix::fs::MetadataExt;
let mtime_ns = meta.mtime() * 1_000_000_000 + meta.mtime_nsec();
(meta.ino(), mtime_ns, meta.len())
}
#[cfg(not(unix))]
pub fn stat_key(meta: &std::fs::Metadata) -> (u64, i64, u64) {
let mtime_ns = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i64)
.unwrap_or(0);
(0, mtime_ns, meta.len())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
struct TempDb(PathBuf);
impl TempDb {
fn new(tag: &str) -> TempDb {
let mut p = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
p.push(format!("greplm-cache-{tag}-{nanos}.redb"));
TempDb(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDb {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn rec(seed: u64) -> FileRecord {
FileRecord {
inode: seed,
mtime_ns: seed as i64 * 1_000_000_000 + 7,
size: seed * 13,
hash: seed.wrapping_mul(0x9E37_79B9_7F4A_7C15),
segment_id: seed + 100,
doc_id: seed as u32 + 5,
symbols: seed as u32 * 2,
}
}
fn assert_same(a: &FileRecord, b: &FileRecord) {
assert_eq!(a.inode, b.inode);
assert_eq!(a.mtime_ns, b.mtime_ns);
assert_eq!(a.size, b.size);
assert_eq!(a.hash, b.hash);
assert_eq!(a.segment_id, b.segment_id);
assert_eq!(a.doc_id, b.doc_id);
assert_eq!(a.symbols, b.symbols);
}
#[test]
fn fresh_db_is_empty_and_get_misses() {
let tmp = TempDb::new("fresh");
let cache = Cache::open(tmp.path()).unwrap();
assert!(cache.get("anything").unwrap().is_none());
assert!(cache.load_all().unwrap().is_empty());
}
#[test]
fn apply_roundtrips_all_fields() {
let tmp = TempDb::new("roundtrip");
let cache = Cache::open(tmp.path()).unwrap();
let r = rec(42);
cache.apply(&[("src/a.rs".into(), r.clone())], &[]).unwrap();
let got = cache.get("src/a.rs").unwrap().expect("record present");
assert_same(&got, &r);
let all = cache.load_all().unwrap();
assert_eq!(all.len(), 1);
assert_same(&all["src/a.rs"], &r);
}
#[test]
fn apply_upserts_and_deletes() {
let tmp = TempDb::new("upsert");
let cache = Cache::open(tmp.path()).unwrap();
cache
.apply(
&[
("a".into(), rec(1)),
("b".into(), rec(2)),
("c".into(), rec(3)),
],
&[],
)
.unwrap();
cache
.apply(&[("a".into(), rec(99))], &["b".to_string()])
.unwrap();
let all = cache.load_all().unwrap();
assert_eq!(all.len(), 2);
assert_same(&all["a"], &rec(99));
assert!(!all.contains_key("b"));
assert_same(&all["c"], &rec(3));
}
#[test]
fn replace_all_wipes_then_inserts() {
let tmp = TempDb::new("replace");
let cache = Cache::open(tmp.path()).unwrap();
cache
.apply(&[("old1".into(), rec(1)), ("old2".into(), rec(2))], &[])
.unwrap();
cache
.replace_all(&[("new1".into(), rec(10)), ("new2".into(), rec(20))])
.unwrap();
let all = cache.load_all().unwrap();
assert_eq!(all.len(), 2);
assert!(all.contains_key("new1") && all.contains_key("new2"));
assert!(!all.contains_key("old1") && !all.contains_key("old2"));
assert_same(&all["new1"], &rec(10));
}
#[test]
fn clear_empties_everything() {
let tmp = TempDb::new("clear");
let cache = Cache::open(tmp.path()).unwrap();
cache
.apply(&[("a".into(), rec(1)), ("b".into(), rec(2))], &[])
.unwrap();
cache.clear().unwrap();
assert!(cache.load_all().unwrap().is_empty());
cache.clear().unwrap();
assert!(cache.load_all().unwrap().is_empty());
}
#[test]
fn data_survives_reopen_with_matching_schema() {
let tmp = TempDb::new("persist");
{
let cache = Cache::open(tmp.path()).unwrap();
cache.apply(&[("keep.rs".into(), rec(7))], &[]).unwrap();
}
let cache = Cache::open(tmp.path()).unwrap();
let got = cache.get("keep.rs").unwrap().expect("survives reopen");
assert_same(&got, &rec(7));
}
#[test]
fn schema_version_mismatch_wipes_cache() {
let tmp = TempDb::new("schema");
{
let cache = Cache::open(tmp.path()).unwrap();
cache.apply(&[("stale.rs".into(), rec(3))], &[]).unwrap();
assert_eq!(cache.load_all().unwrap().len(), 1);
}
{
let db = Database::create(tmp.path()).unwrap();
let wtxn = db.begin_write().unwrap();
{
let mut t = wtxn.open_table(META).unwrap();
t.insert(SCHEMA_KEY, SCHEMA_VERSION + 1).unwrap();
}
wtxn.commit().unwrap();
}
let cache = Cache::open(tmp.path()).unwrap();
assert!(
cache.load_all().unwrap().is_empty(),
"schema mismatch should wipe stale records"
);
cache.apply(&[("fresh.rs".into(), rec(8))], &[]).unwrap();
assert_same(&cache.get("fresh.rs").unwrap().unwrap(), &rec(8));
}
#[test]
fn apply_deletes_only() {
let tmp = TempDb::new("delete-only");
let cache = Cache::open(tmp.path()).unwrap();
cache
.apply(&[("a".into(), rec(1)), ("b".into(), rec(2))], &[])
.unwrap();
cache
.apply(&[], &["a".to_string(), "b".to_string()])
.unwrap();
assert!(cache.load_all().unwrap().is_empty());
assert!(cache.get("a").unwrap().is_none());
}
#[test]
fn apply_empty_batch_is_noop() {
let tmp = TempDb::new("empty-batch");
let cache = Cache::open(tmp.path()).unwrap();
cache.apply(&[("a".into(), rec(1))], &[]).unwrap();
cache.apply(&[], &[]).unwrap();
let all = cache.load_all().unwrap();
assert_eq!(all.len(), 1);
assert_same(&all["a"], &rec(1));
}
#[test]
fn replace_all_on_empty_cache_is_noop() {
let tmp = TempDb::new("replace-empty");
let cache = Cache::open(tmp.path()).unwrap();
cache.replace_all(&[]).unwrap();
assert!(cache.load_all().unwrap().is_empty());
}
#[test]
fn missing_schema_key_wipes_legacy_records() {
let tmp = TempDb::new("legacy-schema");
{
let db = Database::create(tmp.path()).unwrap();
let wtxn = db.begin_write().unwrap();
{
let mut files = wtxn.open_table(FILES).unwrap();
let bytes = postcard::to_allocvec(&rec(3)).unwrap();
files.insert("legacy.rs", bytes.as_slice()).unwrap();
}
wtxn.commit().unwrap();
}
let cache = Cache::open(tmp.path()).unwrap();
assert!(
cache.load_all().unwrap().is_empty(),
"absent schema key should wipe legacy records"
);
cache.apply(&[("fresh.rs".into(), rec(8))], &[]).unwrap();
assert_same(&cache.get("fresh.rs").unwrap().unwrap(), &rec(8));
}
#[test]
fn corrupt_record_bytes_surface_as_errors() {
let tmp = TempDb::new("corrupt");
let valid = rec(1);
let mut truncated = postcard::to_allocvec(&valid).unwrap();
truncated.truncate(truncated.len().saturating_sub(1));
{
let cache = Cache::open(tmp.path()).unwrap();
cache
.apply(&[("good.rs".into(), valid.clone())], &[])
.unwrap();
}
{
let db = Database::create(tmp.path()).unwrap();
let wtxn = db.begin_write().unwrap();
{
let mut table = wtxn.open_table(FILES).unwrap();
table.insert("bad.rs", truncated.as_slice()).unwrap();
}
wtxn.commit().unwrap();
}
let cache = Cache::open(tmp.path()).unwrap();
assert!(
cache.get("bad.rs").is_err(),
"truncated record should fail decode"
);
assert!(
cache.load_all().is_err(),
"load_all should fail on undecodable records"
);
assert_same(&cache.get("good.rs").unwrap().unwrap(), &valid);
}
#[test]
fn stat_key_matches_file_metadata() {
let mut path = std::env::temp_dir();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
path.push(format!("greplm-stat-{nanos}.txt"));
let contents = b"greplm stat_key probe";
std::fs::write(&path, contents).unwrap();
let meta = std::fs::metadata(&path).unwrap();
let (inode, mtime_ns, size) = stat_key(&meta);
assert_eq!(size, contents.len() as u64);
assert!(
mtime_ns > 0,
"mtime_ns should reflect file modification time"
);
#[cfg(unix)]
assert!(inode > 0, "unix inode should be non-zero");
#[cfg(not(unix))]
assert_eq!(inode, 0, "non-unix platforms disable inode detection");
let _ = std::fs::remove_file(&path);
}
#[test]
fn fast_hash_is_stable_and_distinguishes() {
assert_eq!(fast_hash(b"hello world"), fast_hash(b"hello world"));
assert_ne!(fast_hash(b"hello world"), fast_hash(b"hello worle"));
assert_eq!(fast_hash(b""), fast_hash(b""));
let large = vec![0xABu8; 1_000_000];
assert_eq!(fast_hash(&large), fast_hash(&large));
assert_ne!(fast_hash(&large), fast_hash(b"small"));
}
}