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
19use crate::location::FilePath;
20
21/// A blake3 digest of a file's bytes.
22///
23/// Kept as bytes rather than hex, because it is compared far more often than it is printed —
24/// once per dependency per cached file per run.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
26pub struct ContentHash([u8; 32]);
27
28impl ContentHash {
29    /// Wrap a digest.
30    #[must_use]
31    pub const fn new(bytes: [u8; 32]) -> Self {
32        Self(bytes)
33    }
34
35    /// The raw digest.
36    #[must_use]
37    pub const fn as_bytes(&self) -> &[u8; 32] {
38        &self.0
39    }
40}
41
42impl std::fmt::Display for ContentHash {
43    /// Hex, for diagnostics and cache dumps.
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        for byte in &self.0 {
46            write!(f, "{byte:02x}")?;
47        }
48        Ok(())
49    }
50}
51
52/// One file a rule reached for while checking another.
53#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
54pub struct TrackedRead {
55    /// Path relative to the project root.
56    pub path: FilePath,
57
58    /// Hash of what was read, or `None` if nothing was there.
59    ///
60    /// `None` is a recorded answer, not a missing record. See the module documentation.
61    pub hash: Option<ContentHash>,
62}
63
64impl TrackedRead {
65    /// A read that found a file.
66    #[must_use]
67    pub const fn found(path: FilePath, hash: ContentHash) -> Self {
68        Self {
69            path,
70            hash: Some(hash),
71        }
72    }
73
74    /// A read that found nothing.
75    #[must_use]
76    pub const fn absent(path: FilePath) -> Self {
77        Self { path, hash: None }
78    }
79}
80
81/// Sort dependencies into the order a cache entry stores them in.
82///
83/// By path. The order a rule happened to read files in is not interesting and would make two
84/// entries for identical dependency sets compare unequal — which would look like a cache
85/// miss with no cause a reader could find.
86pub fn sort(reads: &mut [TrackedRead]) {
87    reads.sort_by(|a, b| a.path.cmp(&b.path));
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    fn hash(seed: u8) -> ContentHash {
95        ContentHash::new([seed; 32])
96    }
97
98    #[test]
99    fn a_hash_renders_as_hex() {
100        let mut bytes = [0u8; 32];
101        bytes[0] = 0x0a;
102        bytes[31] = 0xff;
103        let rendered = ContentHash::new(bytes).to_string();
104        assert_eq!(rendered.len(), 64, "a blake3 digest is 64 hex characters");
105        assert!(rendered.starts_with("0a"), "{rendered}");
106        assert!(rendered.ends_with("ff"), "{rendered}");
107    }
108
109    #[test]
110    fn an_absent_read_is_still_a_dependency() {
111        // The case that makes a cache wrong rather than merely cold: a rule asked whether a
112        // file existed, was told no, and that answer has to be invalidated when it appears.
113        let read = TrackedRead::absent(FilePath::new("tsconfig.json"));
114        assert_eq!(read.hash, None);
115        assert_ne!(
116            read,
117            TrackedRead::found(FilePath::new("tsconfig.json"), hash(0)),
118            "absence and presence must not compare equal"
119        );
120    }
121
122    #[test]
123    fn dependencies_sort_by_path() {
124        let mut reads = vec![
125            TrackedRead::found(FilePath::new("b.json"), hash(1)),
126            TrackedRead::absent(FilePath::new("a.json")),
127            TrackedRead::found(FilePath::new("c.json"), hash(2)),
128        ];
129        sort(&mut reads);
130        assert_eq!(
131            reads.iter().map(|r| r.path.as_str()).collect::<Vec<_>>(),
132            vec!["a.json", "b.json", "c.json"]
133        );
134    }
135
136    #[test]
137    fn the_order_reads_happened_in_does_not_survive() {
138        // Two entries covering the same dependencies have to compare equal whichever order
139        // the rule reached for them, or an entry would miss for a reason nothing explains.
140        let one = {
141            let mut reads = vec![
142                TrackedRead::found(FilePath::new("b.json"), hash(1)),
143                TrackedRead::found(FilePath::new("a.json"), hash(2)),
144            ];
145            sort(&mut reads);
146            reads
147        };
148        let other = {
149            let mut reads = vec![
150                TrackedRead::found(FilePath::new("a.json"), hash(2)),
151                TrackedRead::found(FilePath::new("b.json"), hash(1)),
152            ];
153            sort(&mut reads);
154            reads
155        };
156        assert_eq!(one, other);
157    }
158}