1use redb::{Database, Durability, ReadableTable, TableDefinition};
16use serde::{Deserialize, Serialize};
17use std::path::Path;
18
19use crate::error::Result;
20
21const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files");
22const META: TableDefinition<&str, u64> = TableDefinition::new("meta");
23
24const SCHEMA_VERSION: u64 = 2;
27const SCHEMA_KEY: &str = "schema_version";
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FileRecord {
32 pub inode: u64,
35 pub mtime_ns: i64,
38 pub size: u64,
39 pub hash: u64,
40 pub segment_id: u64,
41 pub doc_id: u32,
42 pub symbols: u32,
46}
47
48pub struct Cache {
50 db: Database,
51}
52
53impl Cache {
54 pub fn open(path: &Path) -> Result<Cache> {
55 let db = Database::create(path)?;
56 let wtxn = db.begin_write()?;
58 {
59 let _ = wtxn.open_table(FILES)?;
60 let _ = wtxn.open_table(META)?;
61 }
62 wtxn.commit()?;
63 let cache = Cache { db };
64 cache.ensure_schema()?;
65 Ok(cache)
66 }
67
68 fn ensure_schema(&self) -> Result<()> {
72 let stored = {
73 let rtxn = self.db.begin_read()?;
74 let table = rtxn.open_table(META)?;
75 table.get(SCHEMA_KEY)?.map(|v| v.value())
76 };
77 if stored != Some(SCHEMA_VERSION) {
78 self.clear()?;
79 let wtxn = self.db.begin_write()?;
80 {
81 let mut table = wtxn.open_table(META)?;
82 table.insert(SCHEMA_KEY, SCHEMA_VERSION)?;
83 }
84 wtxn.commit()?;
85 }
86 Ok(())
87 }
88
89 pub fn get(&self, path: &str) -> Result<Option<FileRecord>> {
90 let rtxn = self.db.begin_read()?;
91 let table = rtxn.open_table(FILES)?;
92 match table.get(path)? {
93 Some(v) => Ok(Some(postcard::from_bytes(v.value())?)),
94 None => Ok(None),
95 }
96 }
97
98 pub fn load_all(&self) -> Result<std::collections::HashMap<String, FileRecord>> {
100 let rtxn = self.db.begin_read()?;
101 let table = rtxn.open_table(FILES)?;
102 let mut out = std::collections::HashMap::new();
103 for entry in table.iter()? {
104 let (k, v) = entry?;
105 let rec: FileRecord = postcard::from_bytes(v.value())?;
106 out.insert(k.value().to_string(), rec);
107 }
108 Ok(out)
109 }
110
111 pub fn apply(&self, upserts: &[(String, FileRecord)], deletes: &[String]) -> Result<()> {
113 let encoded: Vec<(&str, Vec<u8>)> = upserts
115 .iter()
116 .map(|(path, rec)| Ok((path.as_str(), postcard::to_allocvec(rec)?)))
117 .collect::<Result<_>>()?;
118
119 let mut wtxn = self.db.begin_write()?;
120 wtxn.set_durability(Durability::None);
123 {
124 let mut table = wtxn.open_table(FILES)?;
125 for (path, bytes) in &encoded {
126 table.insert(*path, bytes.as_slice())?;
127 }
128 for path in deletes {
129 table.remove(path.as_str())?;
130 }
131 }
132 wtxn.commit()?;
133 Ok(())
134 }
135
136 pub fn replace_all(&self, records: &[(String, FileRecord)]) -> Result<()> {
139 let encoded: Vec<(&str, Vec<u8>)> = records
140 .iter()
141 .map(|(path, rec)| Ok((path.as_str(), postcard::to_allocvec(rec)?)))
142 .collect::<Result<_>>()?;
143
144 let wtxn = self.db.begin_write()?;
145 {
146 let mut table = wtxn.open_table(FILES)?;
147 table.retain(|_, _| false)?;
150 for (path, bytes) in &encoded {
151 table.insert(*path, bytes.as_slice())?;
152 }
153 }
154 wtxn.commit()?;
155 Ok(())
156 }
157
158 pub fn clear(&self) -> Result<()> {
159 self.replace_all(&[])
160 }
161}
162
163pub fn fast_hash(data: &[u8]) -> u64 {
165 xxhash_rust::xxh3::xxh3_64(data)
166}
167
168#[cfg(unix)]
170pub fn stat_key(meta: &std::fs::Metadata) -> (u64, i64, u64) {
171 use std::os::unix::fs::MetadataExt;
172 let mtime_ns = meta.mtime() * 1_000_000_000 + meta.mtime_nsec();
173 (meta.ino(), mtime_ns, meta.len())
174}
175
176#[cfg(not(unix))]
177pub fn stat_key(meta: &std::fs::Metadata) -> (u64, i64, u64) {
178 let mtime_ns = meta
179 .modified()
180 .ok()
181 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
182 .map(|d| d.as_nanos() as i64)
183 .unwrap_or(0);
184 (0, mtime_ns, meta.len())
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use std::path::PathBuf;
191
192 struct TempDb(PathBuf);
194
195 impl TempDb {
196 fn new(tag: &str) -> TempDb {
197 let mut p = std::env::temp_dir();
198 let nanos = std::time::SystemTime::now()
199 .duration_since(std::time::UNIX_EPOCH)
200 .unwrap()
201 .as_nanos();
202 p.push(format!("greplm-cache-{tag}-{nanos}.redb"));
203 TempDb(p)
204 }
205 fn path(&self) -> &Path {
206 &self.0
207 }
208 }
209
210 impl Drop for TempDb {
211 fn drop(&mut self) {
212 let _ = std::fs::remove_file(&self.0);
213 }
214 }
215
216 fn rec(seed: u64) -> FileRecord {
217 FileRecord {
218 inode: seed,
219 mtime_ns: seed as i64 * 1_000_000_000 + 7,
220 size: seed * 13,
221 hash: seed.wrapping_mul(0x9E37_79B9_7F4A_7C15),
222 segment_id: seed + 100,
223 doc_id: seed as u32 + 5,
224 symbols: seed as u32 * 2,
225 }
226 }
227
228 fn assert_same(a: &FileRecord, b: &FileRecord) {
229 assert_eq!(a.inode, b.inode);
230 assert_eq!(a.mtime_ns, b.mtime_ns);
231 assert_eq!(a.size, b.size);
232 assert_eq!(a.hash, b.hash);
233 assert_eq!(a.segment_id, b.segment_id);
234 assert_eq!(a.doc_id, b.doc_id);
235 assert_eq!(a.symbols, b.symbols);
236 }
237
238 #[test]
239 fn fresh_db_is_empty_and_get_misses() {
240 let tmp = TempDb::new("fresh");
241 let cache = Cache::open(tmp.path()).unwrap();
242 assert!(cache.get("anything").unwrap().is_none());
243 assert!(cache.load_all().unwrap().is_empty());
244 }
245
246 #[test]
247 fn apply_roundtrips_all_fields() {
248 let tmp = TempDb::new("roundtrip");
249 let cache = Cache::open(tmp.path()).unwrap();
250 let r = rec(42);
251 cache.apply(&[("src/a.rs".into(), r.clone())], &[]).unwrap();
252
253 let got = cache.get("src/a.rs").unwrap().expect("record present");
254 assert_same(&got, &r);
255
256 let all = cache.load_all().unwrap();
257 assert_eq!(all.len(), 1);
258 assert_same(&all["src/a.rs"], &r);
259 }
260
261 #[test]
262 fn apply_upserts_and_deletes() {
263 let tmp = TempDb::new("upsert");
264 let cache = Cache::open(tmp.path()).unwrap();
265 cache
266 .apply(
267 &[
268 ("a".into(), rec(1)),
269 ("b".into(), rec(2)),
270 ("c".into(), rec(3)),
271 ],
272 &[],
273 )
274 .unwrap();
275
276 cache
278 .apply(&[("a".into(), rec(99))], &["b".to_string()])
279 .unwrap();
280
281 let all = cache.load_all().unwrap();
282 assert_eq!(all.len(), 2);
283 assert_same(&all["a"], &rec(99));
284 assert!(!all.contains_key("b"));
285 assert_same(&all["c"], &rec(3));
286 }
287
288 #[test]
289 fn replace_all_wipes_then_inserts() {
290 let tmp = TempDb::new("replace");
291 let cache = Cache::open(tmp.path()).unwrap();
292 cache
293 .apply(&[("old1".into(), rec(1)), ("old2".into(), rec(2))], &[])
294 .unwrap();
295
296 cache
297 .replace_all(&[("new1".into(), rec(10)), ("new2".into(), rec(20))])
298 .unwrap();
299
300 let all = cache.load_all().unwrap();
301 assert_eq!(all.len(), 2);
302 assert!(all.contains_key("new1") && all.contains_key("new2"));
303 assert!(!all.contains_key("old1") && !all.contains_key("old2"));
304 assert_same(&all["new1"], &rec(10));
305 }
306
307 #[test]
308 fn clear_empties_everything() {
309 let tmp = TempDb::new("clear");
310 let cache = Cache::open(tmp.path()).unwrap();
311 cache
312 .apply(&[("a".into(), rec(1)), ("b".into(), rec(2))], &[])
313 .unwrap();
314 cache.clear().unwrap();
315 assert!(cache.load_all().unwrap().is_empty());
316
317 cache.clear().unwrap();
319 assert!(cache.load_all().unwrap().is_empty());
320 }
321
322 #[test]
323 fn data_survives_reopen_with_matching_schema() {
324 let tmp = TempDb::new("persist");
325 {
326 let cache = Cache::open(tmp.path()).unwrap();
327 cache.apply(&[("keep.rs".into(), rec(7))], &[]).unwrap();
328 }
329 let cache = Cache::open(tmp.path()).unwrap();
331 let got = cache.get("keep.rs").unwrap().expect("survives reopen");
332 assert_same(&got, &rec(7));
333 }
334
335 #[test]
336 fn schema_version_mismatch_wipes_cache() {
337 let tmp = TempDb::new("schema");
338 {
339 let cache = Cache::open(tmp.path()).unwrap();
340 cache.apply(&[("stale.rs".into(), rec(3))], &[]).unwrap();
341 assert_eq!(cache.load_all().unwrap().len(), 1);
342 }
343
344 {
347 let db = Database::create(tmp.path()).unwrap();
348 let wtxn = db.begin_write().unwrap();
349 {
350 let mut t = wtxn.open_table(META).unwrap();
351 t.insert(SCHEMA_KEY, SCHEMA_VERSION + 1).unwrap();
352 }
353 wtxn.commit().unwrap();
354 }
355
356 let cache = Cache::open(tmp.path()).unwrap();
359 assert!(
360 cache.load_all().unwrap().is_empty(),
361 "schema mismatch should wipe stale records"
362 );
363
364 cache.apply(&[("fresh.rs".into(), rec(8))], &[]).unwrap();
366 assert_same(&cache.get("fresh.rs").unwrap().unwrap(), &rec(8));
367 }
368
369 #[test]
370 fn apply_deletes_only() {
371 let tmp = TempDb::new("delete-only");
372 let cache = Cache::open(tmp.path()).unwrap();
373 cache
374 .apply(&[("a".into(), rec(1)), ("b".into(), rec(2))], &[])
375 .unwrap();
376
377 cache
378 .apply(&[], &["a".to_string(), "b".to_string()])
379 .unwrap();
380
381 assert!(cache.load_all().unwrap().is_empty());
382 assert!(cache.get("a").unwrap().is_none());
383 }
384
385 #[test]
386 fn apply_empty_batch_is_noop() {
387 let tmp = TempDb::new("empty-batch");
388 let cache = Cache::open(tmp.path()).unwrap();
389 cache.apply(&[("a".into(), rec(1))], &[]).unwrap();
390
391 cache.apply(&[], &[]).unwrap();
392
393 let all = cache.load_all().unwrap();
394 assert_eq!(all.len(), 1);
395 assert_same(&all["a"], &rec(1));
396 }
397
398 #[test]
399 fn replace_all_on_empty_cache_is_noop() {
400 let tmp = TempDb::new("replace-empty");
401 let cache = Cache::open(tmp.path()).unwrap();
402 cache.replace_all(&[]).unwrap();
403 assert!(cache.load_all().unwrap().is_empty());
404 }
405
406 #[test]
407 fn missing_schema_key_wipes_legacy_records() {
408 let tmp = TempDb::new("legacy-schema");
409 {
411 let db = Database::create(tmp.path()).unwrap();
412 let wtxn = db.begin_write().unwrap();
413 {
414 let mut files = wtxn.open_table(FILES).unwrap();
415 let bytes = postcard::to_allocvec(&rec(3)).unwrap();
416 files.insert("legacy.rs", bytes.as_slice()).unwrap();
417 }
418 wtxn.commit().unwrap();
419 }
420
421 let cache = Cache::open(tmp.path()).unwrap();
422 assert!(
423 cache.load_all().unwrap().is_empty(),
424 "absent schema key should wipe legacy records"
425 );
426
427 cache.apply(&[("fresh.rs".into(), rec(8))], &[]).unwrap();
428 assert_same(&cache.get("fresh.rs").unwrap().unwrap(), &rec(8));
429 }
430
431 #[test]
432 fn corrupt_record_bytes_surface_as_errors() {
433 let tmp = TempDb::new("corrupt");
434 let valid = rec(1);
435 let mut truncated = postcard::to_allocvec(&valid).unwrap();
438 truncated.truncate(truncated.len().saturating_sub(1));
439
440 {
441 let cache = Cache::open(tmp.path()).unwrap();
442 cache
443 .apply(&[("good.rs".into(), valid.clone())], &[])
444 .unwrap();
445 }
446 {
447 let db = Database::create(tmp.path()).unwrap();
448 let wtxn = db.begin_write().unwrap();
449 {
450 let mut table = wtxn.open_table(FILES).unwrap();
451 table.insert("bad.rs", truncated.as_slice()).unwrap();
452 }
453 wtxn.commit().unwrap();
454 }
455
456 let cache = Cache::open(tmp.path()).unwrap();
457 assert!(
458 cache.get("bad.rs").is_err(),
459 "truncated record should fail decode"
460 );
461 assert!(
462 cache.load_all().is_err(),
463 "load_all should fail on undecodable records"
464 );
465 assert_same(&cache.get("good.rs").unwrap().unwrap(), &valid);
467 }
468
469 #[test]
470 fn stat_key_matches_file_metadata() {
471 let mut path = std::env::temp_dir();
472 let nanos = std::time::SystemTime::now()
473 .duration_since(std::time::UNIX_EPOCH)
474 .unwrap()
475 .as_nanos();
476 path.push(format!("greplm-stat-{nanos}.txt"));
477 let contents = b"greplm stat_key probe";
478 std::fs::write(&path, contents).unwrap();
479
480 let meta = std::fs::metadata(&path).unwrap();
481 let (inode, mtime_ns, size) = stat_key(&meta);
482
483 assert_eq!(size, contents.len() as u64);
484 assert!(
485 mtime_ns > 0,
486 "mtime_ns should reflect file modification time"
487 );
488 #[cfg(unix)]
489 assert!(inode > 0, "unix inode should be non-zero");
490 #[cfg(not(unix))]
491 assert_eq!(inode, 0, "non-unix platforms disable inode detection");
492
493 let _ = std::fs::remove_file(&path);
494 }
495
496 #[test]
497 fn fast_hash_is_stable_and_distinguishes() {
498 assert_eq!(fast_hash(b"hello world"), fast_hash(b"hello world"));
499 assert_ne!(fast_hash(b"hello world"), fast_hash(b"hello worle"));
500
501 assert_eq!(fast_hash(b""), fast_hash(b""));
502
503 let large = vec![0xABu8; 1_000_000];
504 assert_eq!(fast_hash(&large), fast_hash(&large));
505 assert_ne!(fast_hash(&large), fast_hash(b"small"));
506 }
507}