Skip to main content

lanekeep_cache/
store.rs

1//! The on-disk store: one file, read whole, written by atomic rename.
2//!
3//! # One file, not one per entry
4//!
5//! At a couple of thousand files, inode churn dominates: opening, stat-ing and closing a
6//! file per cache entry costs more than the work being cached. A single file is one open and
7//! one read.
8//!
9//! The architecture specifies memory-mapping it. This reads it instead, because the mapping
10//! APIs require `unsafe` and the workspace denies `unsafe_code` — trading a lint that holds
11//! everywhere for a performance claim nothing has measured yet would be the wrong way round.
12//! A whole-file read is a single sequential I/O of a few megabytes; if a benchmark ever shows
13//! it on the critical path, that is the moment to revisit both decisions together.
14//!
15//! # Nothing here can fail a run
16//!
17//! Every operation degrades to "no cache". A missing file, a corrupt one, an unwritable
18//! directory, a file written by a different build — all mean recompute. This is why the
19//! store's fallible operations return `Option` and its write returns `()`: there is no error
20//! a caller could usefully act on, and one that stopped a run would make the cache a
21//! liability rather than an optimization.
22
23use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25
26use crate::entry::Entry;
27use crate::key::{CacheKey, FORMAT_VERSION};
28
29/// Identifies the file as ours before anything else is believed about it.
30const MAGIC: &[u8; 8] = b"LKCACHE\x04";
31
32/// Where the cache lives, relative to the project root.
33const CACHE_PATH: &str = ".lanekeep/cache";
34
35/// A loaded cache: what the last run stored, and what this run has produced.
36#[derive(Debug, Default)]
37pub struct Store {
38    entries: BTreeMap<CacheKey, Entry>,
39}
40
41impl Store {
42    /// An empty store, for a run with caching disabled.
43    #[must_use]
44    pub fn empty() -> Self {
45        Self::default()
46    }
47
48    /// Load the cache under a project root, or an empty store if there is nothing usable.
49    #[must_use]
50    pub fn load(project_root: &Path) -> Self {
51        let Ok(bytes) = std::fs::read(Self::path_for(project_root)) else {
52            return Self::default();
53        };
54        Self::decode(&bytes).unwrap_or_default()
55    }
56
57    /// The entry for a key, if this cache has one.
58    #[must_use]
59    pub fn get(&self, key: &CacheKey) -> Option<&Entry> {
60        self.entries.get(key)
61    }
62
63    /// Record an entry for this run.
64    pub fn insert(&mut self, key: CacheKey, entry: Entry) {
65        self.entries.insert(key, entry);
66    }
67
68    /// Every key held, in order.
69    ///
70    /// For tooling and tests that need to reach an entry without recomputing its key.
71    pub fn keys(&self) -> impl Iterator<Item = &CacheKey> {
72        self.entries.keys()
73    }
74
75    /// How many entries are held.
76    #[must_use]
77    pub fn len(&self) -> usize {
78        self.entries.len()
79    }
80
81    /// Whether anything is held.
82    #[must_use]
83    pub fn is_empty(&self) -> bool {
84        self.entries.is_empty()
85    }
86
87    /// Write the cache under a project root, replacing whatever was there.
88    ///
89    /// Silent on failure by design: an unwritable `.lanekeep` directory means the next run
90    /// is cold, which is not something to interrupt this run over.
91    pub fn save(&self, project_root: &Path) {
92        let path = Self::path_for(project_root);
93        let Some(parent) = path.parent() else {
94            return;
95        };
96        if std::fs::create_dir_all(parent).is_err() {
97            return;
98        }
99
100        // Written beside the target and renamed over it. A rename within a directory is
101        // atomic, so a reader either sees the whole previous cache or the whole new one —
102        // never a half-written file. Writing in place would leave a truncated cache behind
103        // if the process died mid-write, and every subsequent run would read it.
104        let temporary = path.with_extension(format!("tmp{}", std::process::id()));
105        if std::fs::write(&temporary, self.encode()).is_err() {
106            let _ = std::fs::remove_file(&temporary);
107            return;
108        }
109        if std::fs::rename(&temporary, &path).is_err() {
110            let _ = std::fs::remove_file(&temporary);
111        }
112    }
113
114    /// Where the cache file sits for a project.
115    #[must_use]
116    pub fn path_for(project_root: &Path) -> PathBuf {
117        project_root.join(CACHE_PATH)
118    }
119
120    fn encode(&self) -> Vec<u8> {
121        let mut out = Vec::new();
122        out.extend_from_slice(MAGIC);
123        out.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
124        out.extend_from_slice(&(self.entries.len() as u64).to_le_bytes());
125
126        // `BTreeMap` iteration is key-ordered, so the same set of entries always produces
127        // byte-identical output. A cache file that churned on every run would show up as a
128        // spurious diff for anyone who commits it, and would defeat content-addressed
129        // storage of the cache itself.
130        let mut payload = Vec::new();
131        for (key, entry) in &self.entries {
132            out.extend_from_slice(key.as_bytes());
133            payload.clear();
134            entry.encode(&mut payload);
135            out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
136            out.extend_from_slice(&payload);
137        }
138        out
139    }
140
141    fn decode(bytes: &[u8]) -> Option<Self> {
142        let mut at = 0usize;
143
144        let magic = bytes.get(at..at + MAGIC.len())?;
145        if magic != MAGIC {
146            return None;
147        }
148        at += MAGIC.len();
149
150        let version = u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?);
151        if version != FORMAT_VERSION {
152            // Not an error: a cache written by a different build is simply not ours. The
153            // version is in the key too, so this is belt and braces — but a file whose
154            // layout changed must not be parsed with today's reader at all.
155            return None;
156        }
157        at += 4;
158
159        let count = u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?);
160        at += 8;
161
162        let mut entries = BTreeMap::new();
163        for _ in 0..count {
164            let key: [u8; 32] = bytes.get(at..at + 32)?.try_into().ok()?;
165            at += 32;
166
167            let len = u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?);
168            at += 8;
169            let len = usize::try_from(len).ok()?;
170
171            let payload = bytes.get(at..at.checked_add(len)?)?;
172            at += len;
173
174            // One unreadable entry discards the file rather than the entry. A cache that
175            // silently held some entries and dropped others would make a stale result
176            // depend on which byte was damaged.
177            entries.insert(CacheKey::from_bytes(key), Entry::decode(payload)?);
178        }
179
180        (at == bytes.len()).then_some(Self { entries })
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use lanekeep_core::tracked::TrackedRead;
187    use lanekeep_core::{FilePath, Location, Position, Severity, Violation};
188
189    use super::*;
190
191    struct Project {
192        dir: PathBuf,
193    }
194
195    impl Project {
196        fn new(name: &str) -> Self {
197            let dir =
198                std::env::temp_dir().join(format!("lanekeep-store-{name}-{}", std::process::id()));
199            let _ = std::fs::remove_dir_all(&dir);
200            std::fs::create_dir_all(&dir).expect("creates dir");
201            Self { dir }
202        }
203    }
204
205    impl Drop for Project {
206        fn drop(&mut self) {
207            let _ = std::fs::remove_dir_all(&self.dir);
208        }
209    }
210
211    fn key(seed: u8) -> CacheKey {
212        CacheKey::from_bytes([seed; 32])
213    }
214
215    fn entry(line: u32) -> Entry {
216        Entry {
217            violations: vec![Violation {
218                rule_id: "local/a".parse().expect("valid id"),
219                location: Location::new(FilePath::new("src/a.ts"), Position::new(line, 1)),
220                message: "a message".to_owned(),
221                remediation: "a remediation".to_owned(),
222                severity: Severity::Error,
223                fix: None,
224            }],
225            facts: Vec::new(),
226            dependencies: vec![TrackedRead::absent(FilePath::new("tsconfig.json"))],
227            suppressions: Vec::new(),
228            used_suppressions: Vec::new(),
229        }
230    }
231
232    #[test]
233    fn a_saved_cache_loads_back() {
234        let project = Project::new("round-trip");
235        let mut store = Store::empty();
236        store.insert(key(1), entry(10));
237        store.insert(key(2), entry(20));
238        store.save(&project.dir);
239
240        let loaded = Store::load(&project.dir);
241        assert_eq!(loaded.len(), 2);
242        assert_eq!(loaded.get(&key(1)), Some(&entry(10)));
243        assert_eq!(loaded.get(&key(2)), Some(&entry(20)));
244    }
245
246    #[test]
247    fn loading_from_nothing_gives_an_empty_store() {
248        let project = Project::new("absent");
249        assert!(Store::load(&project.dir).is_empty());
250    }
251
252    #[test]
253    fn a_corrupt_file_gives_an_empty_store() {
254        // The disposability requirement: garbage means recompute, never an error.
255        let project = Project::new("corrupt");
256        let path = Store::path_for(&project.dir);
257        std::fs::create_dir_all(path.parent().expect("has a parent")).expect("creates dir");
258        std::fs::write(&path, b"not a cache file at all").expect("writes");
259
260        assert!(Store::load(&project.dir).is_empty());
261    }
262
263    #[test]
264    fn a_truncated_file_gives_an_empty_store() {
265        let project = Project::new("truncated");
266        let mut store = Store::empty();
267        store.insert(key(1), entry(10));
268        store.save(&project.dir);
269
270        let path = Store::path_for(&project.dir);
271        let bytes = std::fs::read(&path).expect("reads");
272        for cut in 0..bytes.len() {
273            std::fs::write(&path, &bytes[..cut]).expect("writes");
274            assert!(
275                Store::load(&project.dir).is_empty(),
276                "a {cut}-byte prefix loaded as a cache"
277            );
278        }
279    }
280
281    #[test]
282    fn a_file_from_another_format_version_is_ignored() {
283        let project = Project::new("version");
284        let mut store = Store::empty();
285        store.insert(key(1), entry(10));
286        store.save(&project.dir);
287
288        let path = Store::path_for(&project.dir);
289        let mut bytes = std::fs::read(&path).expect("reads");
290        bytes[MAGIC.len()] = bytes[MAGIC.len()].wrapping_add(1);
291        std::fs::write(&path, &bytes).expect("writes");
292
293        assert!(Store::load(&project.dir).is_empty());
294    }
295
296    #[test]
297    fn one_damaged_entry_discards_the_whole_file() {
298        // Otherwise which results survive would depend on which byte was damaged, and a
299        // stale entry could outlive the run that should have replaced it.
300        let project = Project::new("damaged");
301        let mut store = Store::empty();
302        store.insert(key(1), entry(10));
303        store.insert(key(2), entry(20));
304        store.save(&project.dir);
305
306        let path = Store::path_for(&project.dir);
307        let mut bytes = std::fs::read(&path).expect("reads");
308        let last = bytes.len() - 1;
309        // The final byte of the last entry is its dependency's presence flag; 9 is neither
310        // present nor absent.
311        bytes[last] = 9;
312        std::fs::write(&path, &bytes).expect("writes");
313
314        assert!(Store::load(&project.dir).is_empty());
315    }
316
317    #[test]
318    fn saving_the_same_entries_produces_identical_bytes() {
319        // A cache file that churned on every run would show as a spurious diff for anyone
320        // who commits it, and would defeat content-addressed storage of the cache itself.
321        let project = Project::new("stable");
322
323        let mut one = Store::empty();
324        one.insert(key(2), entry(20));
325        one.insert(key(1), entry(10));
326        one.save(&project.dir);
327        let first = std::fs::read(Store::path_for(&project.dir)).expect("reads");
328
329        let mut other = Store::empty();
330        other.insert(key(1), entry(10));
331        other.insert(key(2), entry(20));
332        other.save(&project.dir);
333        let second = std::fs::read(Store::path_for(&project.dir)).expect("reads");
334
335        assert_eq!(first, second, "insertion order leaked into the file");
336    }
337
338    #[test]
339    fn saving_replaces_rather_than_appends() {
340        let project = Project::new("replace");
341        let mut store = Store::empty();
342        store.insert(key(1), entry(10));
343        store.save(&project.dir);
344
345        let mut replacement = Store::empty();
346        replacement.insert(key(2), entry(20));
347        replacement.save(&project.dir);
348
349        let loaded = Store::load(&project.dir);
350        assert_eq!(loaded.len(), 1);
351        assert!(loaded.get(&key(1)).is_none());
352    }
353
354    #[test]
355    fn saving_leaves_no_temporary_behind() {
356        let project = Project::new("no-temp");
357        let mut store = Store::empty();
358        store.insert(key(1), entry(10));
359        store.save(&project.dir);
360
361        let dir = Store::path_for(&project.dir);
362        let parent = dir.parent().expect("has a parent");
363        let leftovers: Vec<String> = std::fs::read_dir(parent)
364            .expect("reads dir")
365            .filter_map(Result::ok)
366            .map(|e| e.file_name().to_string_lossy().into_owned())
367            .filter(|name| name.contains("tmp"))
368            .collect();
369        assert!(leftovers.is_empty(), "left behind: {leftovers:?}");
370    }
371
372    #[test]
373    fn saving_into_an_unwritable_place_is_silent() {
374        // A cache that could fail a run would be a liability. There is deliberately no
375        // error to observe here — only that the call returns and the run continues.
376        let store = Store::empty();
377        store.save(Path::new("/definitely/not/a/writable/place"));
378    }
379}