lanekeep_core/tracked.rs
1//! Tracked effects: what a rule read that was not the file it was checking.
2//!
3//! `ctx.readFile` makes a file's result depend on files other than itself, which breaks the
4//! assumption a per-file cache is built on. Purity is therefore replaced by **tracked
5//! effects**: every read is recorded, and a cache hit requires every recorded dependency to
6//! still hash identically. This is the standard build-system answer, and it is what lets a
7//! rule cross-reference other files without giving up incrementality.
8//!
9//! # Absence is a dependency too
10//!
11//! A rule that asks whether `tsconfig.json` exists and is told no has depended on that
12//! answer just as much as one that read it. If the file later appears, the cached result is
13//! stale — so a miss is recorded with a `None` hash rather than not recorded at all.
14//!
15//! Getting this wrong produces a cache that is correct on every test anyone thinks to write
16//! and wrong on the one case that matters: adding a file makes no difference until something
17//! unrelated invalidates the entry.
18//!
19//! # And a refusal is a third answer, not a spelling of absence
20//!
21//! A path that resolved out of the root through a symlink was refused unread. Recorded as
22//! absent, a validator asks the filesystem whether it is still absent, follows the link, finds
23//! the file and invalidates — every run, forever, for every importer of a pnpm-linked package.
24//! See [`ReadOutcome::Refused`].
25
26use crate::location::FilePath;
27
28/// A blake3 digest of a file's bytes.
29///
30/// Kept as bytes rather than hex, because it is compared far more often than it is printed —
31/// once per dependency per cached file per run.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub struct ContentHash([u8; 32]);
34
35impl ContentHash {
36 /// Wrap a digest.
37 #[must_use]
38 pub const fn new(bytes: [u8; 32]) -> Self {
39 Self(bytes)
40 }
41
42 /// The raw digest.
43 #[must_use]
44 pub const fn as_bytes(&self) -> &[u8; 32] {
45 &self.0
46 }
47}
48
49impl std::fmt::Display for ContentHash {
50 /// Hex, for diagnostics and cache dumps.
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 for byte in &self.0 {
53 write!(f, "{byte:02x}")?;
54 }
55 Ok(())
56 }
57}
58
59/// What a tracked read found.
60///
61/// Three states rather than an `Option<ContentHash>`, because a refusal is not an absence.
62/// A path refused for resolving out of the root through a symlink — `node_modules/pkg` as
63/// pnpm links it — was recorded as absent, and a validator checking absence reads
64/// `root.join(path)` and *follows the link*: the file is there, so the dependency reads as
65/// "appeared", every importer of a store-linked package misses the cache on every run, and
66/// the validator has read bytes outside the project root to decide it. Recording the refusal
67/// as itself is what lets the validator reproduce the decision that was actually made.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub enum ReadOutcome {
70 /// The file was read, and hashed to this.
71 Found(ContentHash),
72 /// Nothing readable was there.
73 ///
74 /// A recorded answer, not a missing record. See the module documentation.
75 Absent,
76 /// It resolved outside the root through a symlink, so it was refused unread.
77 ///
78 /// Still a dependency: the answer that rested on the refusal has to be reconsidered the
79 /// day the path becomes a real in-root file.
80 Refused,
81}
82
83/// One file a rule reached for while checking another.
84#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
85pub struct TrackedRead {
86 /// Path relative to the project root.
87 pub path: FilePath,
88
89 /// What was found there.
90 pub outcome: ReadOutcome,
91}
92
93impl TrackedRead {
94 /// A read that found a file.
95 #[must_use]
96 pub const fn found(path: FilePath, hash: ContentHash) -> Self {
97 Self {
98 path,
99 outcome: ReadOutcome::Found(hash),
100 }
101 }
102
103 /// A read that found nothing.
104 #[must_use]
105 pub const fn absent(path: FilePath) -> Self {
106 Self {
107 path,
108 outcome: ReadOutcome::Absent,
109 }
110 }
111
112 /// A read that was refused because the path left the root through a symlink.
113 #[must_use]
114 pub const fn refused(path: FilePath) -> Self {
115 Self {
116 path,
117 outcome: ReadOutcome::Refused,
118 }
119 }
120
121 /// The hash of what was read, or `None` for either answer that read nothing.
122 ///
123 /// For the callers that only ever asked "same bytes as before?". Anything deciding what
124 /// to *do* about a dependency has to match on [`Self::outcome`] instead: absence and
125 /// refusal are checked against the filesystem in two different ways.
126 #[must_use]
127 pub const fn hash(&self) -> Option<ContentHash> {
128 match self.outcome {
129 ReadOutcome::Found(hash) => Some(hash),
130 ReadOutcome::Absent | ReadOutcome::Refused => None,
131 }
132 }
133}
134
135/// Sort dependencies into the order a cache entry stores them in.
136///
137/// By path. The order a rule happened to read files in is not interesting and would make two
138/// entries for identical dependency sets compare unequal — which would look like a cache
139/// miss with no cause a reader could find.
140pub fn sort(reads: &mut [TrackedRead]) {
141 reads.sort_by(|a, b| a.path.cmp(&b.path));
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn hash(seed: u8) -> ContentHash {
149 ContentHash::new([seed; 32])
150 }
151
152 #[test]
153 fn a_hash_renders_as_hex() {
154 let mut bytes = [0u8; 32];
155 bytes[0] = 0x0a;
156 bytes[31] = 0xff;
157 let rendered = ContentHash::new(bytes).to_string();
158 assert_eq!(rendered.len(), 64, "a blake3 digest is 64 hex characters");
159 assert!(rendered.starts_with("0a"), "{rendered}");
160 assert!(rendered.ends_with("ff"), "{rendered}");
161 }
162
163 #[test]
164 fn an_absent_read_is_still_a_dependency() {
165 // The case that makes a cache wrong rather than merely cold: a rule asked whether a
166 // file existed, was told no, and that answer has to be invalidated when it appears.
167 let read = TrackedRead::absent(FilePath::new("tsconfig.json"));
168 assert_eq!(read.hash(), None);
169 assert_ne!(
170 read,
171 TrackedRead::found(FilePath::new("tsconfig.json"), hash(0)),
172 "absence and presence must not compare equal"
173 );
174 }
175
176 #[test]
177 fn a_refusal_is_neither_absence_nor_a_reading() {
178 // The three have to be distinguishable on the entry, because the validator checks each
179 // of them against the filesystem in a different way: absence by looking for the file,
180 // a refusal by re-resolving the path under the same confinement, a reading by hashing.
181 let path = FilePath::new("node_modules/pkg/index.d.ts");
182 let refused = TrackedRead::refused(path.clone());
183 assert_eq!(refused.outcome, ReadOutcome::Refused);
184 assert_eq!(refused.hash(), None, "nothing was read");
185 assert_ne!(refused, TrackedRead::absent(path.clone()));
186 assert_ne!(refused, TrackedRead::found(path, hash(0)));
187 }
188
189 #[test]
190 fn dependencies_sort_by_path() {
191 let mut reads = vec![
192 TrackedRead::found(FilePath::new("b.json"), hash(1)),
193 TrackedRead::absent(FilePath::new("a.json")),
194 TrackedRead::found(FilePath::new("c.json"), hash(2)),
195 ];
196 sort(&mut reads);
197 assert_eq!(
198 reads.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
199 vec!["a.json", "b.json", "c.json"]
200 );
201 }
202
203 #[test]
204 fn the_order_reads_happened_in_does_not_survive() {
205 // Two entries covering the same dependencies have to compare equal whichever order
206 // the rule reached for them, or an entry would miss for a reason nothing explains.
207 let one = {
208 let mut reads = vec![
209 TrackedRead::found(FilePath::new("b.json"), hash(1)),
210 TrackedRead::found(FilePath::new("a.json"), hash(2)),
211 ];
212 sort(&mut reads);
213 reads
214 };
215 let other = {
216 let mut reads = vec![
217 TrackedRead::found(FilePath::new("a.json"), hash(2)),
218 TrackedRead::found(FilePath::new("b.json"), hash(1)),
219 ];
220 sort(&mut reads);
221 reads
222 };
223 assert_eq!(one, other);
224 }
225}