Skip to main content

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}