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}