Skip to main content

lanekeep_cache/
lib.rs

1//! Content-addressed result cache with dependency tracking for lanekeep.
2//!
3//! A single-file, content-addressed store holding the violations, facts and tracked read
4//! dependencies of each file.
5//!
6//! The cache is disposable by design: any read error means a cold recompute, never a
7//! failure. That is what makes a purpose-built on-disk format acceptable rather than
8//! reckless — nothing here can break a run, so the worst a format bug can do is cost time.
9//!
10//! # A hit needs two things
11//!
12//! 1. **The key matches** — same engine, same host API, same grammar, same ruleset, same
13//!    config, same path, same bytes. See [`key`].
14//! 2. **Every dependency still hashes the same** — because `ctx.readFile` lets a result
15//!    depend on files other than the one being checked. See [`validate`].
16//!
17//! The second is what makes the first safe. Without it a rule that read `package.json` would
18//! keep its verdict after `package.json` changed, and nothing about the checked file would
19//! have changed to say otherwise.
20//!
21//! # The asymmetry that shapes everything here
22//!
23//! Over-invalidating costs a recompute. Under-invalidating reports a stale answer and gives
24//! no sign it did — the output looks exactly like a correct one. So every doubtful input
25//! goes in the key, one damaged entry discards the whole file, and a dependency that cannot
26//! be hashed counts as changed.
27
28pub mod entry;
29pub mod key;
30pub mod store;
31
32use std::path::Path;
33
34use lanekeep_core::ContentHash;
35
36pub use entry::Entry;
37pub use key::{CacheKey, FORMAT_VERSION, GrammarKey, RunKey};
38pub use store::Store;
39
40/// Hash a file's bytes for use as a cache-key input.
41#[must_use]
42pub fn hash_bytes(bytes: &[u8]) -> ContentHash {
43    ContentHash::new(*blake3::hash(bytes).as_bytes())
44}
45
46/// Whether every dependency an entry recorded still holds.
47///
48/// `root` is the project root the recorded paths are relative to.
49///
50/// A dependency that cannot be read now counts as changed, whether it was recorded as
51/// present or absent. Permissions, a vanished directory, a race — none of them are grounds
52/// for trusting a cached answer, and the cost of being wrong is a recompute.
53#[must_use]
54pub fn validate(entry: &Entry, root: &Path) -> bool {
55    entry.dependencies.iter().all(|read| {
56        let current = std::fs::read(root.join(read.path.as_str()))
57            .ok()
58            .map(|bytes| hash_bytes(&bytes));
59
60        match (read.hash, current) {
61            // It was there and still hashes the same.
62            (Some(recorded), Some(now)) => recorded == now,
63            // It was not there and still is not. This is the case a cache is wrong without:
64            // a rule that branched on absence has to be reconsidered when the file appears.
65            (None, None) => true,
66            // Appeared, or vanished, or became unreadable.
67            _ => false,
68        }
69    })
70}
71
72#[cfg(test)]
73mod tests {
74    use std::path::PathBuf;
75
76    use lanekeep_core::FilePath;
77    use lanekeep_core::tracked::TrackedRead;
78
79    use super::*;
80
81    struct Project {
82        dir: PathBuf,
83    }
84
85    impl Project {
86        fn new(name: &str, files: &[(&str, &str)]) -> Self {
87            let dir = std::env::temp_dir()
88                .join(format!("lanekeep-validate-{name}-{}", std::process::id()));
89            let _ = std::fs::remove_dir_all(&dir);
90            std::fs::create_dir_all(&dir).expect("creates dir");
91            let project = Self { dir };
92            for (path, contents) in files {
93                project.write(path, contents);
94            }
95            project
96        }
97
98        fn write(&self, path: &str, contents: &str) {
99            let full = self.dir.join(path);
100            if let Some(parent) = full.parent() {
101                std::fs::create_dir_all(parent).expect("creates parent");
102            }
103            std::fs::write(full, contents).expect("writes");
104        }
105    }
106
107    impl Drop for Project {
108        fn drop(&mut self) {
109            let _ = std::fs::remove_dir_all(&self.dir);
110        }
111    }
112
113    fn entry_depending_on(reads: Vec<TrackedRead>) -> Entry {
114        Entry {
115            dependencies: reads,
116            ..Entry::default()
117        }
118    }
119
120    #[test]
121    fn an_entry_with_no_dependencies_is_always_valid() {
122        let project = Project::new("none", &[]);
123        assert!(validate(&Entry::default(), &project.dir));
124    }
125
126    #[test]
127    fn an_unchanged_dependency_holds() {
128        let project = Project::new("unchanged", &[("package.json", "{}")]);
129        let entry = entry_depending_on(vec![TrackedRead::found(
130            FilePath::new("package.json"),
131            hash_bytes(b"{}"),
132        )]);
133        assert!(validate(&entry, &project.dir));
134    }
135
136    #[test]
137    fn a_changed_dependency_invalidates() {
138        let project = Project::new("changed", &[("package.json", "{\"type\":\"module\"}")]);
139        let entry = entry_depending_on(vec![TrackedRead::found(
140            FilePath::new("package.json"),
141            hash_bytes(b"{}"),
142        )]);
143        assert!(!validate(&entry, &project.dir));
144    }
145
146    #[test]
147    fn a_vanished_dependency_invalidates() {
148        let project = Project::new("vanished", &[]);
149        let entry = entry_depending_on(vec![TrackedRead::found(
150            FilePath::new("package.json"),
151            hash_bytes(b"{}"),
152        )]);
153        assert!(!validate(&entry, &project.dir));
154    }
155
156    #[test]
157    fn an_absent_dependency_that_is_still_absent_holds() {
158        let project = Project::new("still-absent", &[]);
159        let entry = entry_depending_on(vec![TrackedRead::absent(FilePath::new("tsconfig.json"))]);
160        assert!(validate(&entry, &project.dir));
161    }
162
163    #[test]
164    fn a_dependency_that_appeared_invalidates() {
165        // The case that makes a cache wrong rather than merely cold. A rule told
166        // `tsconfig.json` was absent must be reconsidered once it exists — and nothing
167        // about the checked file changed to say so.
168        let project = Project::new("appeared", &[("tsconfig.json", "{}")]);
169        let entry = entry_depending_on(vec![TrackedRead::absent(FilePath::new("tsconfig.json"))]);
170        assert!(!validate(&entry, &project.dir));
171    }
172
173    #[test]
174    fn one_changed_dependency_among_many_invalidates() {
175        let project = Project::new(
176            "one-of-many",
177            &[("a.json", "{}"), ("b.json", "changed"), ("c.json", "{}")],
178        );
179        let entry = entry_depending_on(vec![
180            TrackedRead::found(FilePath::new("a.json"), hash_bytes(b"{}")),
181            TrackedRead::found(FilePath::new("b.json"), hash_bytes(b"{}")),
182            TrackedRead::found(FilePath::new("c.json"), hash_bytes(b"{}")),
183        ]);
184        assert!(!validate(&entry, &project.dir));
185    }
186
187    #[test]
188    fn a_directory_where_a_file_was_invalidates() {
189        // Reading a directory fails, which counts as changed rather than as unchanged.
190        let project = Project::new("directory", &[]);
191        std::fs::create_dir_all(project.dir.join("package.json")).expect("creates dir");
192        let entry = entry_depending_on(vec![TrackedRead::found(
193            FilePath::new("package.json"),
194            hash_bytes(b"{}"),
195        )]);
196        assert!(!validate(&entry, &project.dir));
197    }
198
199    #[test]
200    fn identical_bytes_hash_identically() {
201        assert_eq!(hash_bytes(b"hello"), hash_bytes(b"hello"));
202        assert_ne!(hash_bytes(b"hello"), hash_bytes(b"hellp"));
203    }
204}