Skip to main content

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}