1use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25
26use crate::entry::Entry;
27use crate::key::{CacheKey, FORMAT_VERSION};
28
29const MAGIC: &[u8; 8] = b"LKCACHE\x04";
31
32const CACHE_PATH: &str = ".lanekeep/cache";
34
35#[derive(Debug, Default)]
37pub struct Store {
38 entries: BTreeMap<CacheKey, Entry>,
39}
40
41impl Store {
42 #[must_use]
44 pub fn empty() -> Self {
45 Self::default()
46 }
47
48 #[must_use]
50 pub fn load(project_root: &Path) -> Self {
51 let Ok(bytes) = std::fs::read(Self::path_for(project_root)) else {
52 return Self::default();
53 };
54 Self::decode(&bytes).unwrap_or_default()
55 }
56
57 #[must_use]
59 pub fn get(&self, key: &CacheKey) -> Option<&Entry> {
60 self.entries.get(key)
61 }
62
63 pub fn insert(&mut self, key: CacheKey, entry: Entry) {
65 self.entries.insert(key, entry);
66 }
67
68 pub fn keys(&self) -> impl Iterator<Item = &CacheKey> {
72 self.entries.keys()
73 }
74
75 #[must_use]
77 pub fn len(&self) -> usize {
78 self.entries.len()
79 }
80
81 #[must_use]
83 pub fn is_empty(&self) -> bool {
84 self.entries.is_empty()
85 }
86
87 pub fn save(&self, project_root: &Path) {
92 let path = Self::path_for(project_root);
93 let Some(parent) = path.parent() else {
94 return;
95 };
96 if std::fs::create_dir_all(parent).is_err() {
97 return;
98 }
99
100 let temporary = path.with_extension(format!("tmp{}", std::process::id()));
105 if std::fs::write(&temporary, self.encode()).is_err() {
106 let _ = std::fs::remove_file(&temporary);
107 return;
108 }
109 if std::fs::rename(&temporary, &path).is_err() {
110 let _ = std::fs::remove_file(&temporary);
111 }
112 }
113
114 #[must_use]
116 pub fn path_for(project_root: &Path) -> PathBuf {
117 project_root.join(CACHE_PATH)
118 }
119
120 fn encode(&self) -> Vec<u8> {
121 let mut out = Vec::new();
122 out.extend_from_slice(MAGIC);
123 out.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
124 out.extend_from_slice(&(self.entries.len() as u64).to_le_bytes());
125
126 let mut payload = Vec::new();
131 for (key, entry) in &self.entries {
132 out.extend_from_slice(key.as_bytes());
133 payload.clear();
134 entry.encode(&mut payload);
135 out.extend_from_slice(&(payload.len() as u64).to_le_bytes());
136 out.extend_from_slice(&payload);
137 }
138 out
139 }
140
141 fn decode(bytes: &[u8]) -> Option<Self> {
142 let mut at = 0usize;
143
144 let magic = bytes.get(at..at + MAGIC.len())?;
145 if magic != MAGIC {
146 return None;
147 }
148 at += MAGIC.len();
149
150 let version = u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?);
151 if version != FORMAT_VERSION {
152 return None;
156 }
157 at += 4;
158
159 let count = u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?);
160 at += 8;
161
162 let mut entries = BTreeMap::new();
163 for _ in 0..count {
164 let key: [u8; 32] = bytes.get(at..at + 32)?.try_into().ok()?;
165 at += 32;
166
167 let len = u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?);
168 at += 8;
169 let len = usize::try_from(len).ok()?;
170
171 let payload = bytes.get(at..at.checked_add(len)?)?;
172 at += len;
173
174 entries.insert(CacheKey::from_bytes(key), Entry::decode(payload)?);
178 }
179
180 (at == bytes.len()).then_some(Self { entries })
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use lanekeep_core::tracked::TrackedRead;
187 use lanekeep_core::{FilePath, Location, Position, Severity, Violation};
188
189 use super::*;
190
191 struct Project {
192 dir: PathBuf,
193 }
194
195 impl Project {
196 fn new(name: &str) -> Self {
197 let dir =
198 std::env::temp_dir().join(format!("lanekeep-store-{name}-{}", std::process::id()));
199 let _ = std::fs::remove_dir_all(&dir);
200 std::fs::create_dir_all(&dir).expect("creates dir");
201 Self { dir }
202 }
203 }
204
205 impl Drop for Project {
206 fn drop(&mut self) {
207 let _ = std::fs::remove_dir_all(&self.dir);
208 }
209 }
210
211 fn key(seed: u8) -> CacheKey {
212 CacheKey::from_bytes([seed; 32])
213 }
214
215 fn entry(line: u32) -> Entry {
216 Entry {
217 violations: vec![Violation {
218 rule_id: "local/a".parse().expect("valid id"),
219 location: Location::new(FilePath::new("src/a.ts"), Position::new(line, 1)),
220 message: "a message".to_owned(),
221 remediation: "a remediation".to_owned(),
222 severity: Severity::Error,
223 fix: None,
224 }],
225 facts: Vec::new(),
226 dependencies: vec![TrackedRead::absent(FilePath::new("tsconfig.json"))],
227 suppressions: Vec::new(),
228 used_suppressions: Vec::new(),
229 }
230 }
231
232 #[test]
233 fn a_saved_cache_loads_back() {
234 let project = Project::new("round-trip");
235 let mut store = Store::empty();
236 store.insert(key(1), entry(10));
237 store.insert(key(2), entry(20));
238 store.save(&project.dir);
239
240 let loaded = Store::load(&project.dir);
241 assert_eq!(loaded.len(), 2);
242 assert_eq!(loaded.get(&key(1)), Some(&entry(10)));
243 assert_eq!(loaded.get(&key(2)), Some(&entry(20)));
244 }
245
246 #[test]
247 fn loading_from_nothing_gives_an_empty_store() {
248 let project = Project::new("absent");
249 assert!(Store::load(&project.dir).is_empty());
250 }
251
252 #[test]
253 fn a_corrupt_file_gives_an_empty_store() {
254 let project = Project::new("corrupt");
256 let path = Store::path_for(&project.dir);
257 std::fs::create_dir_all(path.parent().expect("has a parent")).expect("creates dir");
258 std::fs::write(&path, b"not a cache file at all").expect("writes");
259
260 assert!(Store::load(&project.dir).is_empty());
261 }
262
263 #[test]
264 fn a_truncated_file_gives_an_empty_store() {
265 let project = Project::new("truncated");
266 let mut store = Store::empty();
267 store.insert(key(1), entry(10));
268 store.save(&project.dir);
269
270 let path = Store::path_for(&project.dir);
271 let bytes = std::fs::read(&path).expect("reads");
272 for cut in 0..bytes.len() {
273 std::fs::write(&path, &bytes[..cut]).expect("writes");
274 assert!(
275 Store::load(&project.dir).is_empty(),
276 "a {cut}-byte prefix loaded as a cache"
277 );
278 }
279 }
280
281 #[test]
282 fn a_file_from_another_format_version_is_ignored() {
283 let project = Project::new("version");
284 let mut store = Store::empty();
285 store.insert(key(1), entry(10));
286 store.save(&project.dir);
287
288 let path = Store::path_for(&project.dir);
289 let mut bytes = std::fs::read(&path).expect("reads");
290 bytes[MAGIC.len()] = bytes[MAGIC.len()].wrapping_add(1);
291 std::fs::write(&path, &bytes).expect("writes");
292
293 assert!(Store::load(&project.dir).is_empty());
294 }
295
296 #[test]
297 fn one_damaged_entry_discards_the_whole_file() {
298 let project = Project::new("damaged");
301 let mut store = Store::empty();
302 store.insert(key(1), entry(10));
303 store.insert(key(2), entry(20));
304 store.save(&project.dir);
305
306 let path = Store::path_for(&project.dir);
307 let mut bytes = std::fs::read(&path).expect("reads");
308 let last = bytes.len() - 1;
309 bytes[last] = 9;
312 std::fs::write(&path, &bytes).expect("writes");
313
314 assert!(Store::load(&project.dir).is_empty());
315 }
316
317 #[test]
318 fn saving_the_same_entries_produces_identical_bytes() {
319 let project = Project::new("stable");
322
323 let mut one = Store::empty();
324 one.insert(key(2), entry(20));
325 one.insert(key(1), entry(10));
326 one.save(&project.dir);
327 let first = std::fs::read(Store::path_for(&project.dir)).expect("reads");
328
329 let mut other = Store::empty();
330 other.insert(key(1), entry(10));
331 other.insert(key(2), entry(20));
332 other.save(&project.dir);
333 let second = std::fs::read(Store::path_for(&project.dir)).expect("reads");
334
335 assert_eq!(first, second, "insertion order leaked into the file");
336 }
337
338 #[test]
339 fn saving_replaces_rather_than_appends() {
340 let project = Project::new("replace");
341 let mut store = Store::empty();
342 store.insert(key(1), entry(10));
343 store.save(&project.dir);
344
345 let mut replacement = Store::empty();
346 replacement.insert(key(2), entry(20));
347 replacement.save(&project.dir);
348
349 let loaded = Store::load(&project.dir);
350 assert_eq!(loaded.len(), 1);
351 assert!(loaded.get(&key(1)).is_none());
352 }
353
354 #[test]
355 fn saving_leaves_no_temporary_behind() {
356 let project = Project::new("no-temp");
357 let mut store = Store::empty();
358 store.insert(key(1), entry(10));
359 store.save(&project.dir);
360
361 let dir = Store::path_for(&project.dir);
362 let parent = dir.parent().expect("has a parent");
363 let leftovers: Vec<String> = std::fs::read_dir(parent)
364 .expect("reads dir")
365 .filter_map(Result::ok)
366 .map(|e| e.file_name().to_string_lossy().into_owned())
367 .filter(|name| name.contains("tmp"))
368 .collect();
369 assert!(leftovers.is_empty(), "left behind: {leftovers:?}");
370 }
371
372 #[test]
373 fn saving_into_an_unwritable_place_is_silent() {
374 let store = Store::empty();
377 store.save(Path::new("/definitely/not/a/writable/place"));
378 }
379}