lattice_plugin_host/scan_cache.rs
1//! OT.3b — agenda scan results, remembered across restarts.
2//!
3//! OT.3 priced the parse at 1–2 ms per file (`benches/agenda_scan_input.rs`).
4//! An agenda is refreshed far more often than its files change — `gr`,
5//! reopening the view, an autocmd, and every editor restart — and without a
6//! cache each of those reparses the whole project and re-calls the guest for
7//! every file.
8//!
9//! ## What is cached, and why not the tree
10//!
11//! The **rows**, not the parse tree. Tree-sitter has no serialisation for a
12//! `Tree` — there is no `to_bytes` / `from_bytes` in the crate — so a
13//! persistent cache physically cannot hold snapshots. It has to hold what the
14//! scan *derived*, which is also what org-roam's database holds for the same
15//! reason.
16//!
17//! That turns out to be the better layer anyway: a hit skips the parse **and**
18//! the guest call, where a snapshot cache would only have skipped the parse.
19//!
20//! ## What it does NOT skip
21//!
22//! The read. You cannot know a file is unchanged without looking at it, and the
23//! host reads it upstream regardless (it needs the text to build the source
24//! `Document`). A warm read is ~10–50 µs against a ~2 ms parse, so this is the
25//! cheap end and not worth an `mtime` pre-filter's correctness risk.
26//!
27//! ## The key
28//!
29//! `(generation, path, content-hash)`.
30//!
31//! **Content hash, not mtime.** The text is already in hand, so hashing costs
32//! ~2–5 µs against the ~2 ms it protects, and it is exactly right: no
33//! one-second mtime granularity, no filesystem that lies, no length collision
34//! to paper over. A file that came back byte-identical genuinely produces the
35//! same rows.
36//!
37//! **Generation** is the opaque `u64` the guest returns from `begin` — for org,
38//! derived from the day the scan is anchored to and the configured TODO
39//! keywords. Cached rows embed presentation computed against that anchor
40//! (`"tomorrow"`, `"overdue by 2 day(s)"`), so serving them under a different
41//! anchor would render yesterday's "tomorrow" as tomorrow — silently wrong at
42//! midnight, which is the exact bug `begin`'s anchor exists to prevent. Keying
43//! on the generation makes a cached value a pure function of its key, so that
44//! cannot happen: the day rolls, the generation changes, the cache is discarded.
45//!
46//! The host never learns what a date group or a TODO keyword is. It compares
47//! two integers.
48//!
49//! ## Failure behaviour
50//!
51//! Every failure degrades to "no cache", never to an error and never to a
52//! wrong answer: a missing file, a schema-version mismatch, corrupt bytes, an
53//! unreadable directory, a failed write. A cache that cannot be trusted is
54//! simply not used, and the scan runs as it did before OT.3b.
55
56use std::collections::HashMap;
57use std::path::{Path, PathBuf};
58
59use serde::{Deserialize, Serialize};
60
61use crate::scan_task::{Annotation, ClockSpan, DisplaySpan, Entry};
62
63/// Bumped whenever the on-disk shape changes. A mismatch discards the file
64/// rather than attempting migration — this is a cache, and rebuilding it costs
65/// one scan.
66const SCHEMA_VERSION: u32 = 1;
67
68/// Above this many files the cache is dropped rather than grown without bound.
69///
70/// Crude on purpose: eviction ORDER does not matter, because a scan repopulates
71/// exactly the files it touches on the next pass. The cost of discarding the
72/// wrong entry is one parse of a file that was about to be parsed anyway, so an
73/// LRU would be bookkeeping bought with nothing.
74const MAX_FILES: usize = 4096;
75
76/// One file's cached rows.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78struct CachedFile {
79 /// Hash of the text these rows were derived from.
80 hash: u64,
81 rows: Vec<CachedEntry>,
82 /// OA.14b: the file's clock spans, cached beside its rows for the reason
83 /// `CachedEntry::spans` records — a hit skips the guest call entirely, so
84 /// anything left out here is simply absent from a warm scan. A clock
85 /// report that is complete on a cold start and lossy on a warm one would
86 /// be the least debuggable version of this feature.
87 #[serde(default)]
88 clock: Vec<CachedClockSpan>,
89}
90
91/// The WIT `clock-span`, in a form that survives a round trip to disk. Same
92/// reasoning as [`CachedEntry`]: bindgen owns the generated type.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94struct CachedClockSpan {
95 line: u32,
96 outline: Vec<String>,
97 day: i64,
98 minutes: u32,
99}
100
101impl From<&ClockSpan> for CachedClockSpan {
102 fn from(c: &ClockSpan) -> Self {
103 Self {
104 line: c.line,
105 outline: c.outline.clone(),
106 day: c.day,
107 minutes: c.minutes,
108 }
109 }
110}
111
112impl From<&CachedClockSpan> for ClockSpan {
113 fn from(c: &CachedClockSpan) -> Self {
114 Self {
115 line: c.line,
116 outline: c.outline.clone(),
117 day: c.day,
118 minutes: c.minutes,
119 }
120 }
121}
122
123/// The WIT `entry`, in a form that survives a round trip to disk.
124///
125/// A separate type rather than `serde` on the generated WIT struct: bindgen
126/// owns that type and regenerates it, so deriving on it would put this file's
127/// on-disk compatibility at the mercy of an ABI change it cannot see. The
128/// conversion below is the one place the two shapes meet.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130struct CachedEntry {
131 line: u32,
132 end_line: u32,
133 group: String,
134 label: String,
135 sort_key: i64,
136 /// OA.5: the row's colour, cached with it — a cache hit skips the guest
137 /// call entirely, so spans left out here would make a warm agenda render
138 /// uncoloured while a cold one rendered correctly.
139 ///
140 /// `serde(default)` so a cache file written before this field loads
141 /// rather than failing: those rows render with no spans until their file
142 /// next changes, which is the same "degrade, never break" the rest of
143 /// this module takes.
144 #[serde(default)]
145 spans: Vec<CachedSpan>,
146 /// HB.5: the row hung below this one, cached with it, for `spans`' reason
147 /// and with a sharper consequence. A cache hit skips the guest entirely, so
148 /// an annotation left out here would make a habit's graph appear on the
149 /// first scan of a file and vanish on every later one — which reads as the
150 /// feature being broken rather than as a cache being incomplete.
151 ///
152 /// `serde(default)` so a cache written before this field still loads; those
153 /// rows carry no annotation until their file next changes.
154 #[serde(default)]
155 annotation: Option<CachedAnnotation>,
156 /// MH.A6: cached for `annotation`'s reason, which bites identically here.
157 /// A cache hit skips the guest, so an emphasis left out would make the
158 /// agenda's today block stand out on the first scan of a file and render
159 /// flat on every one after — the same "the feature is broken" symptom.
160 ///
161 /// `serde(default)` so a cache written before this field still loads;
162 /// those rows render unemphasised until their file next changes.
163 #[serde(default)]
164 emphasis: bool,
165}
166
167/// The WIT `annotation`, in a form that survives a round trip to disk.
168/// Same reasoning as [`CachedEntry`].
169#[derive(Debug, Clone, Serialize, Deserialize)]
170struct CachedAnnotation {
171 text: String,
172 #[serde(default)]
173 spans: Vec<CachedSpan>,
174}
175
176/// The WIT `display-span`, in a form that survives a round trip to disk.
177/// Same reasoning as [`CachedEntry`]: bindgen owns the generated type.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179struct CachedSpan {
180 start: u32,
181 end: u32,
182 slot: String,
183}
184
185impl From<&Entry> for CachedEntry {
186 fn from(e: &Entry) -> Self {
187 Self {
188 line: e.line,
189 end_line: e.end_line,
190 group: e.group.clone(),
191 label: e.label.clone(),
192 sort_key: e.sort_key,
193 spans: e
194 .spans
195 .iter()
196 .map(|s| CachedSpan {
197 start: s.start,
198 end: s.end,
199 slot: s.slot.clone(),
200 })
201 .collect(),
202 annotation: e.annotation.as_ref().map(|a| CachedAnnotation {
203 text: a.text.clone(),
204 spans: a
205 .spans
206 .iter()
207 .map(|s| CachedSpan {
208 start: s.start,
209 end: s.end,
210 slot: s.slot.clone(),
211 })
212 .collect(),
213 }),
214 emphasis: e.emphasis,
215 }
216 }
217}
218
219impl From<&CachedEntry> for Entry {
220 fn from(c: &CachedEntry) -> Self {
221 Self {
222 line: c.line,
223 end_line: c.end_line,
224 group: c.group.clone(),
225 label: c.label.clone(),
226 sort_key: c.sort_key,
227 spans: c
228 .spans
229 .iter()
230 .map(|s| DisplaySpan {
231 start: s.start,
232 end: s.end,
233 slot: s.slot.clone(),
234 })
235 .collect(),
236 annotation: c.annotation.as_ref().map(|a| Annotation {
237 text: a.text.clone(),
238 spans: a
239 .spans
240 .iter()
241 .map(|s| DisplaySpan {
242 start: s.start,
243 end: s.end,
244 slot: s.slot.clone(),
245 })
246 .collect(),
247 }),
248 emphasis: c.emphasis,
249 }
250 }
251}
252
253/// The whole on-disk document.
254#[derive(Debug, Default, Serialize, Deserialize)]
255struct CacheFile {
256 version: u32,
257 /// The guest's `begin` generation these rows belong to. A different value
258 /// invalidates every entry at once.
259 generation: u64,
260 files: HashMap<String, CachedFile>,
261}
262
263/// A persistent agenda-result cache for one source.
264#[derive(Debug)]
265pub struct ScanCache {
266 path: PathBuf,
267 generation: u64,
268 files: HashMap<String, CachedFile>,
269 /// Entries added since the last flush. Bounds how much a hard kill loses.
270 dirty: usize,
271 /// Hits and misses, for tests. Asserting on these beats asserting on
272 /// timing, which is how you write a flaky test for a cache.
273 hits: u64,
274 misses: u64,
275}
276
277/// Flush after this many new entries, so an unclean exit loses at most this
278/// much work rather than a whole scan.
279const FLUSH_EVERY: usize = 64;
280
281impl ScanCache {
282 /// Open (or start) the cache for `source_id` under `dir`.
283 ///
284 /// Never fails: an unreadable, corrupt or stale file yields an empty cache
285 /// with a `debug` log. `dir` is the host's per-plugin data directory, so
286 /// two plugins cannot collide and uninstalling one removes its cache.
287 pub fn open(dir: &Path, source_id: u64) -> Self {
288 let path = dir.join(format!("scan-cache-{source_id}.json"));
289 let loaded = std::fs::read(&path)
290 .ok()
291 .and_then(|bytes| match serde_json::from_slice::<CacheFile>(&bytes) {
292 Ok(file) => Some(file),
293 Err(error) => {
294 tracing::debug!(path = %path.display(), %error, "scan cache unreadable; starting empty");
295 None
296 }
297 })
298 .filter(|file| {
299 let ok = file.version == SCHEMA_VERSION;
300 if !ok {
301 tracing::debug!(
302 path = %path.display(),
303 found = file.version,
304 expected = SCHEMA_VERSION,
305 "scan cache schema mismatch; starting empty"
306 );
307 }
308 ok
309 });
310 let (generation, files) = loaded
311 .map(|f| (f.generation, f.files))
312 .unwrap_or((0, HashMap::new()));
313 Self {
314 path,
315 generation,
316 files,
317 dirty: 0,
318 hits: 0,
319 misses: 0,
320 }
321 }
322
323 /// Start a scan under `generation` (the guest's `begin` return value).
324 ///
325 /// A changed generation means every cached row was computed against
326 /// something that no longer holds — for org, a different day or a different
327 /// keyword set — so the whole cache is dropped rather than partially
328 /// trusted.
329 pub fn begin(&mut self, generation: u64) {
330 if self.generation != generation {
331 tracing::debug!(
332 was = self.generation,
333 now = generation,
334 files = self.files.len(),
335 "scan cache generation changed; discarding"
336 );
337 self.files.clear();
338 self.generation = generation;
339 self.dirty = 0;
340 }
341 }
342
343 /// Cached rows + clock spans for `path`, if the text still hashes the same.
344 pub fn get(&mut self, path: &str, text: &str) -> Option<(Vec<Entry>, Vec<ClockSpan>)> {
345 let hash = content_hash(text);
346 match self.files.get(path) {
347 Some(cached) if cached.hash == hash => {
348 self.hits += 1;
349 Some((
350 cached.rows.iter().map(Entry::from).collect(),
351 cached.clock.iter().map(ClockSpan::from).collect(),
352 ))
353 }
354 _ => {
355 self.misses += 1;
356 None
357 }
358 }
359 }
360
361 /// Remember what the guest returned for `path`.
362 pub fn put(&mut self, path: &str, text: &str, rows: &[Entry], clock: &[ClockSpan]) {
363 if self.files.len() >= MAX_FILES && !self.files.contains_key(path) {
364 tracing::debug!(cap = MAX_FILES, "scan cache full; discarding");
365 self.files.clear();
366 }
367 self.files.insert(
368 path.to_string(),
369 CachedFile {
370 hash: content_hash(text),
371 rows: rows.iter().map(CachedEntry::from).collect(),
372 clock: clock.iter().map(CachedClockSpan::from).collect(),
373 },
374 );
375 self.dirty += 1;
376 if self.dirty >= FLUSH_EVERY {
377 self.flush();
378 }
379 }
380
381 /// Write the cache out. Best-effort: a failure is logged and dropped,
382 /// because a cache that cannot be written is only a slower next scan.
383 ///
384 /// Written to a temporary file and renamed, so a kill mid-write leaves the
385 /// previous cache intact rather than a truncated file the next boot has to
386 /// recognise as corrupt.
387 pub fn flush(&mut self) {
388 self.dirty = 0;
389 let file = CacheFile {
390 version: SCHEMA_VERSION,
391 generation: self.generation,
392 files: self.files.clone(),
393 };
394 let Ok(bytes) = serde_json::to_vec(&file) else {
395 tracing::debug!("scan cache failed to serialise; not written");
396 return;
397 };
398 if let Some(parent) = self.path.parent() {
399 let _ = std::fs::create_dir_all(parent);
400 }
401 let tmp = self.path.with_extension("json.tmp");
402 if let Err(error) = std::fs::write(&tmp, &bytes) {
403 tracing::debug!(path = %tmp.display(), %error, "scan cache write failed");
404 return;
405 }
406 if let Err(error) = std::fs::rename(&tmp, &self.path) {
407 tracing::debug!(path = %self.path.display(), %error, "scan cache rename failed");
408 let _ = std::fs::remove_file(&tmp);
409 }
410 }
411
412 #[cfg(test)]
413 pub(crate) fn stats(&self) -> (u64, u64) {
414 (self.hits, self.misses)
415 }
416}
417
418impl Drop for ScanCache {
419 fn drop(&mut self) {
420 if self.dirty > 0 {
421 self.flush();
422 }
423 }
424}
425
426/// A cheap content fingerprint. Not cryptographic and does not need to be —
427/// the input is a file the user just read, and the cost of a collision is one
428/// stale agenda row until the next refresh.
429fn content_hash(text: &str) -> u64 {
430 use std::hash::{Hash, Hasher};
431 let mut hasher = std::collections::hash_map::DefaultHasher::new();
432 text.hash(&mut hasher);
433 hasher.finish()
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439
440 fn entry(line: u32, label: &str) -> Entry {
441 Entry {
442 line,
443 end_line: line,
444 group: "g".to_string(),
445 label: label.to_string(),
446 sort_key: line as i64,
447 spans: vec![DisplaySpan {
448 start: 2,
449 end: 6,
450 slot: "keyword".to_string(),
451 }],
452 annotation: None,
453 emphasis: false,
454 }
455 }
456
457 fn annotated(line: u32, label: &str) -> Entry {
458 let mut e = entry(line, label);
459 e.annotation = Some(Annotation {
460 text: "···✓··".to_string(),
461 spans: vec![DisplaySpan {
462 start: 0,
463 end: 3,
464 slot: "org.habit.ready".to_string(),
465 }],
466 });
467 e
468 }
469
470 /// OA.5: spans round-trip through the on-disk form. A cache hit skips the
471 /// guest call entirely, so spans dropped here would make a WARM agenda
472 /// render uncoloured while a cold one rendered correctly — a difference
473 /// nothing else in the system would explain.
474 #[test]
475 fn spans_survive_the_cache_round_trip() {
476 let e = entry(4, "row");
477 let cached = CachedEntry::from(&e);
478 let back = Entry::from(&cached);
479 assert_eq!(
480 back.spans
481 .iter()
482 .map(|s| (s.start, s.end, s.slot.as_str()))
483 .collect::<Vec<_>>(),
484 vec![(2, 6, "keyword")]
485 );
486 }
487
488 /// OA.14b: clock spans round-trip too, for the reason above one step
489 /// further on. A hit skips the guest call, so a span left out of the
490 /// on-disk form is simply absent from a warm scan — a clock report that is
491 /// complete on a cold start and lossy afterwards, with nothing to explain
492 /// the difference.
493 ///
494 /// Driven through the real `put`/`get` rather than the two `From` impls:
495 /// the failure this guards is the FIELD being dropped somewhere on the
496 /// path, and a conversion test passes happily while `put` ignores its
497 /// argument.
498 #[test]
499 fn clock_spans_survive_the_cache_round_trip() {
500 let dir = tempfile::tempdir().unwrap();
501 let mut cache = ScanCache::open(dir.path(), 1);
502 cache.begin(7);
503 let spans = vec![ClockSpan {
504 line: 3,
505 outline: vec!["Project".to_string(), "Subtask".to_string()],
506 day: 20_000,
507 minutes: 90,
508 }];
509 cache.put("/p/a.org", "* TODO a\n", &[entry(0, "Today")], &spans);
510
511 let (rows, clock) = cache.get("/p/a.org", "* TODO a\n").expect("a warm hit");
512 assert_eq!(rows.len(), 1, "the rows still come back");
513 assert_eq!(
514 clock
515 .iter()
516 .map(|c| (c.line, c.outline.clone(), c.day, c.minutes))
517 .collect::<Vec<_>>(),
518 vec![(
519 3,
520 vec!["Project".to_string(), "Subtask".to_string()],
521 20_000,
522 90
523 )],
524 "…and so does every field of the clock span, the outline path included"
525 );
526 }
527
528 /// HB.5: and the annotation, for the reason above with the sharpest
529 /// consequence of the three. A hit skips the guest, so an annotation left
530 /// out of the on-disk form makes a habit's graph appear on the first scan
531 /// of a file and vanish on every later one — which reads as the feature
532 /// being broken rather than as a cache being incomplete.
533 ///
534 /// Driven through `put`/`get` like the clock span, and for the same
535 /// reason: the failure is the field being dropped somewhere on the PATH,
536 /// and a `From` test passes while `put` ignores its argument.
537 #[test]
538 fn annotations_survive_the_cache_round_trip() {
539 let dir = tempfile::tempdir().unwrap();
540 let mut cache = ScanCache::open(dir.path(), 1);
541 cache.begin(7);
542 cache.put("/p/a.org", "* TODO a\n", &[annotated(0, "Today")], &[]);
543
544 let (rows, _) = cache.get("/p/a.org", "* TODO a\n").expect("a warm hit");
545 let a = rows[0]
546 .annotation
547 .as_ref()
548 .expect("the annotation came back");
549 assert_eq!(a.text, "···✓··");
550 assert_eq!(
551 a.spans
552 .iter()
553 .map(|s| (s.start, s.end, s.slot.as_str()))
554 .collect::<Vec<_>>(),
555 vec![(0, 3, "org.habit.ready")],
556 "…with its spans, or a warm agenda draws the graph uncoloured"
557 );
558 }
559
560 /// A row that never had one must not grow one on the way back — `none` is
561 /// the ordinary case and the cache has to preserve the distinction.
562 #[test]
563 fn a_row_without_an_annotation_gets_none_back() {
564 let dir = tempfile::tempdir().unwrap();
565 let mut cache = ScanCache::open(dir.path(), 1);
566 cache.begin(7);
567 cache.put("/p/a.org", "* TODO a\n", &[entry(0, "Today")], &[]);
568 let (rows, _) = cache.get("/p/a.org", "* TODO a\n").expect("a warm hit");
569 assert!(rows[0].annotation.is_none());
570 }
571
572 #[test]
573 fn unchanged_text_hits_and_changed_text_misses() {
574 let dir = tempfile::tempdir().unwrap();
575 let mut cache = ScanCache::open(dir.path(), 1);
576 cache.begin(7);
577
578 assert!(cache.get("/p/a.org", "* TODO a\n").is_none());
579 cache.put("/p/a.org", "* TODO a\n", &[entry(0, "Today")], &[]);
580
581 let hit = cache.get("/p/a.org", "* TODO a\n").expect("same text hits");
582 assert_eq!(hit.0.len(), 1);
583 assert_eq!(hit.0[0].label, "Today");
584
585 assert!(
586 cache.get("/p/a.org", "* TODO a changed\n").is_none(),
587 "different content must not serve the old rows"
588 );
589 }
590
591 /// The midnight case, and the reason the generation exists at all. Cached
592 /// rows carry presentation computed against a day — serving them under a
593 /// new one would render yesterday's "tomorrow" as tomorrow.
594 #[test]
595 fn a_new_generation_discards_everything() {
596 let dir = tempfile::tempdir().unwrap();
597 let mut cache = ScanCache::open(dir.path(), 1);
598 cache.begin(7);
599 cache.put("/p/a.org", "* TODO a\n", &[entry(0, "tomorrow")], &[]);
600 assert!(cache.get("/p/a.org", "* TODO a\n").is_some());
601
602 cache.begin(8);
603 assert!(
604 cache.get("/p/a.org", "* TODO a\n").is_none(),
605 "the day rolled, so every label is suspect"
606 );
607 }
608
609 /// The whole point of persisting: a restart must not rebuild.
610 #[test]
611 fn rows_survive_a_restart() {
612 let dir = tempfile::tempdir().unwrap();
613 {
614 let mut cache = ScanCache::open(dir.path(), 1);
615 cache.begin(7);
616 cache.put("/p/a.org", "* TODO a\n", &[entry(3, "Today")], &[]);
617 cache.flush();
618 }
619 let mut reopened = ScanCache::open(dir.path(), 1);
620 reopened.begin(7);
621 let hit = reopened
622 .get("/p/a.org", "* TODO a\n")
623 .expect("a restart reads the cache back");
624 assert_eq!(hit.0[0].line, 3);
625 assert_eq!(hit.0[0].label, "Today");
626 }
627
628 /// Dropping without an explicit flush must still persist — an editor exits
629 /// far more often than it calls `flush`.
630 #[test]
631 fn dropping_the_cache_persists_it() {
632 let dir = tempfile::tempdir().unwrap();
633 {
634 let mut cache = ScanCache::open(dir.path(), 1);
635 cache.begin(7);
636 cache.put("/p/a.org", "* TODO a\n", &[entry(1, "Today")], &[]);
637 // no flush() — Drop is the only thing that can save this
638 }
639 let mut reopened = ScanCache::open(dir.path(), 1);
640 reopened.begin(7);
641 assert!(reopened.get("/p/a.org", "* TODO a\n").is_some());
642 }
643
644 /// Every failure degrades to "no cache", never to a wrong answer.
645 #[test]
646 fn corrupt_bytes_start_empty_rather_than_failing() {
647 let dir = tempfile::tempdir().unwrap();
648 std::fs::write(dir.path().join("scan-cache-1.json"), b"{not json").unwrap();
649 let mut cache = ScanCache::open(dir.path(), 1);
650 cache.begin(7);
651 assert!(cache.get("/p/a.org", "* TODO a\n").is_none());
652 // …and it recovers: the next scan repopulates and persists normally.
653 cache.put("/p/a.org", "* TODO a\n", &[entry(0, "Today")], &[]);
654 assert!(cache.get("/p/a.org", "* TODO a\n").is_some());
655 }
656
657 /// A schema bump must not try to read the old shape.
658 #[test]
659 fn a_schema_mismatch_starts_empty() {
660 let dir = tempfile::tempdir().unwrap();
661 let stale = serde_json::json!({
662 "version": SCHEMA_VERSION + 1,
663 "generation": 7u64,
664 "files": {}
665 });
666 std::fs::write(
667 dir.path().join("scan-cache-1.json"),
668 serde_json::to_vec(&stale).unwrap(),
669 )
670 .unwrap();
671
672 let mut cache = ScanCache::open(dir.path(), 1);
673 cache.begin(7);
674 assert!(cache.get("/p/a.org", "* TODO a\n").is_none());
675 let (hits, _) = cache.stats();
676 assert_eq!(hits, 0, "nothing from a future schema is trusted");
677 }
678
679 /// Two sources must not read each other's rows.
680 #[test]
681 fn each_source_gets_its_own_file() {
682 let dir = tempfile::tempdir().unwrap();
683 {
684 let mut one = ScanCache::open(dir.path(), 1);
685 one.begin(7);
686 one.put("/p/a.org", "* TODO a\n", &[entry(0, "Today")], &[]);
687 one.flush();
688 }
689 let mut two = ScanCache::open(dir.path(), 2);
690 two.begin(7);
691 assert!(two.get("/p/a.org", "* TODO a\n").is_none());
692 }
693}