1use lanekeep_core::ContentHash;
13
14pub const FORMAT_VERSION: u32 = 4;
20
21#[derive(Debug, Clone)]
26pub struct RunKey {
27 prefix: blake3::Hasher,
28}
29
30impl RunKey {
31 #[must_use]
37 pub fn new(
38 engine_version: &str,
39 host_api_version: u32,
40 ruleset_hash: &[u8],
41 config_hash: &[u8],
42 grammars: &[GrammarKey],
43 ) -> Self {
44 let mut prefix = blake3::Hasher::new();
45
46 write_field(&mut prefix, b"lanekeep-cache");
50 write_field(&mut prefix, &FORMAT_VERSION.to_le_bytes());
51 write_field(&mut prefix, engine_version.as_bytes());
52 write_field(&mut prefix, &host_api_version.to_le_bytes());
53 write_field(&mut prefix, ruleset_hash);
54 write_field(&mut prefix, config_hash);
55
56 write_field(&mut prefix, &(grammars.len() as u64).to_le_bytes());
62 for grammar in grammars {
63 write_field(&mut prefix, grammar.id.as_bytes());
64 write_field(&mut prefix, &grammar.abi.to_le_bytes());
65 }
66
67 Self { prefix }
68 }
69
70 #[must_use]
77 pub fn for_dated_file(&self, path: &str, content: &ContentHash, today: &str) -> CacheKey {
78 let mut hasher = self.prefix.clone();
79 write_field(&mut hasher, path.as_bytes());
80 write_field(&mut hasher, content.as_bytes());
81 write_field(&mut hasher, today.as_bytes());
82 CacheKey(*hasher.finalize().as_bytes())
83 }
84
85 #[must_use]
90 pub fn for_file(&self, path: &str, content: &ContentHash) -> CacheKey {
91 let mut hasher = self.prefix.clone();
92 write_field(&mut hasher, path.as_bytes());
93 write_field(&mut hasher, content.as_bytes());
94 CacheKey(*hasher.finalize().as_bytes())
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct GrammarKey {
101 pub id: String,
103 pub abi: u32,
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
109pub struct CacheKey([u8; 32]);
110
111impl CacheKey {
112 #[must_use]
114 pub const fn from_bytes(bytes: [u8; 32]) -> Self {
115 Self(bytes)
116 }
117
118 #[must_use]
120 pub const fn as_bytes(&self) -> &[u8; 32] {
121 &self.0
122 }
123}
124
125impl std::fmt::Display for CacheKey {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 for byte in &self.0[..4] {
129 write!(f, "{byte:02x}")?;
130 }
131 Ok(())
132 }
133}
134
135fn write_field(hasher: &mut blake3::Hasher, bytes: &[u8]) {
137 hasher.update(&(bytes.len() as u64).to_le_bytes());
140 hasher.update(bytes);
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146
147 fn run() -> RunKey {
148 RunKey::new("0.1", 1, b"ruleset", b"config", &[grammar()])
149 }
150
151 fn grammar() -> GrammarKey {
152 GrammarKey {
153 id: "typescript".to_owned(),
154 abi: 15,
155 }
156 }
157
158 fn content(seed: u8) -> ContentHash {
159 ContentHash::new([seed; 32])
160 }
161
162 fn key_of(run: &RunKey, path: &str, seed: u8) -> CacheKey {
163 run.for_file(path, &content(seed))
164 }
165
166 #[test]
167 fn the_same_inputs_give_the_same_key() {
168 assert_eq!(key_of(&run(), "src/a.ts", 1), key_of(&run(), "src/a.ts", 1));
169 }
170
171 #[test]
172 fn changing_the_content_changes_the_key() {
173 assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&run(), "src/a.ts", 2));
174 }
175
176 #[test]
177 fn moving_a_file_changes_the_key() {
178 assert_ne!(
182 key_of(&run(), "src/a.ts", 1),
183 key_of(&run(), "test/a.ts", 1)
184 );
185 }
186
187 #[test]
188 fn changing_the_ruleset_changes_the_key() {
189 let other = RunKey::new("0.1", 1, b"different", b"config", &[grammar()]);
190 assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
191 }
192
193 #[test]
194 fn changing_the_config_changes_the_key() {
195 let other = RunKey::new("0.1", 1, b"ruleset", b"different", &[grammar()]);
196 assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
197 }
198
199 #[test]
200 fn changing_the_engine_version_changes_the_key() {
201 let other = RunKey::new("0.2", 1, b"ruleset", b"config", &[grammar()]);
202 assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
203 }
204
205 #[test]
206 fn changing_the_host_api_version_changes_the_key() {
207 let other = RunKey::new("0.1", 2, b"ruleset", b"config", &[grammar()]);
210 assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
211 }
212
213 #[test]
214 fn adding_a_grammar_changes_the_key() {
215 let more = RunKey::new(
216 "0.1",
217 1,
218 b"ruleset",
219 b"config",
220 &[
221 grammar(),
222 GrammarKey {
223 id: "javascript".to_owned(),
224 abi: 15,
225 },
226 ],
227 );
228 assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&more, "src/a.ts", 1));
229 }
230
231 #[test]
232 fn changing_the_grammar_abi_changes_the_key() {
233 let bumped = RunKey::new(
235 "0.1",
236 1,
237 b"ruleset",
238 b"config",
239 &[GrammarKey {
240 id: "typescript".to_owned(),
241 abi: 16,
242 }],
243 );
244 assert_ne!(
245 key_of(&run(), "src/a.ts", 1),
246 key_of(&bumped, "src/a.ts", 1)
247 );
248 }
249
250 #[test]
251 fn changing_the_language_changes_the_key() {
252 let other = RunKey::new(
253 "0.1",
254 1,
255 b"ruleset",
256 b"config",
257 &[GrammarKey {
258 id: "javascript".to_owned(),
259 abi: 15,
260 }],
261 );
262 assert_ne!(key_of(&run(), "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
263 }
264
265 #[test]
266 fn fields_cannot_run_together() {
267 let one = RunKey::new("0.1", 1, b"ab", b"c", &[grammar()]);
271 let other = RunKey::new("0.1", 1, b"a", b"bc", &[grammar()]);
272 assert_ne!(key_of(&one, "src/a.ts", 1), key_of(&other, "src/a.ts", 1));
273
274 assert_ne!(
277 run().for_file("src/ab.ts", &content(1)),
278 run().for_file("src/a", &content(1))
279 );
280 }
281
282 #[test]
283 fn a_dated_key_changes_with_the_date() {
284 let content = content(1);
286 assert_ne!(
287 run().for_dated_file("src/a.ts", &content, "2026-08-01"),
288 run().for_dated_file("src/a.ts", &content, "2026-08-02")
289 );
290 }
291
292 #[test]
293 fn a_dated_key_differs_from_an_undated_one() {
294 let content = content(1);
295 assert_ne!(
296 run().for_file("src/a.ts", &content),
297 run().for_dated_file("src/a.ts", &content, "2026-08-01")
298 );
299 }
300
301 #[test]
302 fn a_key_renders_short_for_diagnostics() {
303 let rendered = key_of(&run(), "src/a.ts", 1).to_string();
304 assert_eq!(rendered.len(), 8);
305 assert!(rendered.chars().all(|c| c.is_ascii_hexdigit()));
306 }
307}