1use std::collections::BTreeMap;
5use std::fs;
6use std::io;
7use std::path::{Path, PathBuf};
8use std::time::UNIX_EPOCH;
9
10use serde::{Deserialize, Serialize};
11
12use super::io::write_bytes_atomically;
13use super::locking::with_exclusive_cache_lock;
14use crate::models::{FileInfo, Sha256Digest};
15use crate::utils::hash::calculate_sha256;
16
17const INCREMENTAL_MANIFEST_VERSION: u32 = 5;
18const MANIFEST_FILE_NAME: &str = "manifest.postcard";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct FileStateFingerprint {
22 pub size: u64,
23 pub modified_seconds: u64,
24 pub modified_nanos: u32,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct IncrementalManifestEntry {
29 pub state: FileStateFingerprint,
30 pub content_sha256: Sha256Digest,
31 pub file_info: FileInfo,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct IncrementalManifest {
36 pub version: u32,
37 pub options_fingerprint: String,
38 pub entries: BTreeMap<String, IncrementalManifestEntry>,
39}
40
41impl IncrementalManifest {
42 pub fn new(
43 options_fingerprint: String,
44 entries: BTreeMap<String, IncrementalManifestEntry>,
45 ) -> Self {
46 Self {
47 version: INCREMENTAL_MANIFEST_VERSION,
48 options_fingerprint,
49 entries,
50 }
51 }
52
53 pub fn entry(&self, relative_path: &str) -> Option<&IncrementalManifestEntry> {
54 self.entries.get(relative_path)
55 }
56
57 pub fn is_compatible_with(&self, options_fingerprint: &str) -> bool {
58 self.version == INCREMENTAL_MANIFEST_VERSION
59 && self.options_fingerprint == options_fingerprint
60 }
61}
62
63pub fn incremental_manifest_path(cache_root: &Path, manifest_key: &str) -> PathBuf {
64 cache_root
65 .join("incremental")
66 .join(manifest_key)
67 .join(MANIFEST_FILE_NAME)
68}
69
70pub fn metadata_fingerprint(metadata: &fs::Metadata) -> Option<FileStateFingerprint> {
71 let modified = metadata.modified().ok()?;
72 let duration = modified.duration_since(UNIX_EPOCH).ok()?;
73
74 Some(FileStateFingerprint {
75 size: metadata.len(),
76 modified_seconds: duration.as_secs(),
77 modified_nanos: duration.subsec_nanos(),
78 })
79}
80
81pub fn manifest_entry_matches_path(
91 entry: &IncrementalManifestEntry,
92 path: &Path,
93 metadata: &fs::Metadata,
94 trust_mtime: bool,
95) -> io::Result<bool> {
96 if !metadata_fingerprint(metadata).is_some_and(|fingerprint| fingerprint == entry.state) {
97 return Ok(false);
98 }
99
100 if trust_mtime {
101 return Ok(true);
102 }
103
104 let bytes = fs::read(path)?;
105 Ok(calculate_sha256(&bytes) == entry.content_sha256)
106}
107
108pub fn load_incremental_manifest(
109 manifest_path: &Path,
110 options_fingerprint: &str,
111) -> io::Result<Option<IncrementalManifest>> {
112 let bytes = match fs::read(manifest_path) {
113 Ok(bytes) => bytes,
114 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
115 Err(err) => return Err(err),
116 };
117
118 let manifest: IncrementalManifest = match postcard::from_bytes(&bytes) {
119 Ok(manifest) => manifest,
120 Err(_) => return Ok(None),
121 };
122
123 if !manifest.is_compatible_with(options_fingerprint) {
124 return Ok(None);
125 }
126
127 Ok(Some(manifest))
128}
129
130pub fn write_incremental_manifest(
131 cache_root: &Path,
132 manifest_path: &Path,
133 manifest: &IncrementalManifest,
134) -> io::Result<()> {
135 let bytes = postcard::to_allocvec(manifest)
136 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
137
138 with_exclusive_cache_lock(cache_root, || write_bytes_atomically(manifest_path, &bytes))
139}
140
141#[cfg(test)]
142mod tests {
143 use tempfile::TempDir;
144
145 use super::*;
146 use crate::models::{DiagnosticSeverity, FileInfo, FileType, ScanDiagnostic};
147
148 fn sample_manifest(options_fingerprint: &str) -> IncrementalManifest {
149 let mut entries = BTreeMap::new();
150 entries.insert(
151 "src/main.rs".to_string(),
152 IncrementalManifestEntry {
153 state: FileStateFingerprint {
154 size: 12,
155 modified_seconds: 10,
156 modified_nanos: 20,
157 },
158 content_sha256: Sha256Digest::from_hex(
159 "f2ca1bb6c7e907d06dafe4687e579fce9f2b2c8a179a4e7c1f6c5052d4f7d070",
160 )
161 .unwrap(),
162 file_info: FileInfo::new(
163 "main.rs".to_string(),
164 "main".to_string(),
165 ".rs".to_string(),
166 "/tmp/project/src/main.rs".to_string(),
167 FileType::File,
168 None,
169 None,
170 12,
171 None,
172 None,
173 None,
174 None,
175 None,
176 Vec::new(),
177 None,
178 Vec::new(),
179 Vec::new(),
180 Vec::new(),
181 Vec::new(),
182 Vec::new(),
183 Vec::new(),
184 Vec::new(),
185 Vec::new(),
186 Vec::new(),
187 ),
188 },
189 );
190
191 IncrementalManifest::new(options_fingerprint.to_string(), entries)
192 }
193
194 #[test]
195 fn test_load_incremental_manifest_returns_none_for_incompatible_options() {
196 let temp_dir = TempDir::new().expect("create temp dir");
197 let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
198 let manifest = sample_manifest("options-v1");
199
200 write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
201 .expect("write manifest");
202
203 let loaded =
204 load_incremental_manifest(&manifest_path, "options-v2").expect("load manifest");
205
206 assert!(loaded.is_none());
207 }
208
209 #[test]
210 fn test_load_incremental_manifest_returns_none_for_older_manifest_version() {
211 let temp_dir = TempDir::new().expect("create temp dir");
212 let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
213 let mut manifest = sample_manifest("options-v1");
214 manifest.version = 1;
215
216 write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
217 .expect("write manifest");
218
219 let loaded =
220 load_incremental_manifest(&manifest_path, "options-v1").expect("load manifest");
221
222 assert!(loaded.is_none());
223 }
224
225 #[test]
226 fn test_write_and_load_incremental_manifest_round_trip() {
227 let temp_dir = TempDir::new().expect("create temp dir");
228 let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
229 let manifest = sample_manifest("options-v1");
230
231 write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
232 .expect("write manifest");
233
234 let loaded = load_incremental_manifest(&manifest_path, "options-v1")
235 .expect("load manifest")
236 .expect("expected manifest");
237
238 assert_eq!(loaded.entries.len(), 1);
239 assert!(loaded.entry("src/main.rs").is_some());
240 }
241
242 #[test]
243 fn test_incremental_manifest_preserves_scan_diagnostic_severity() {
244 let temp_dir = TempDir::new().expect("create temp dir");
245 let manifest_path = incremental_manifest_path(temp_dir.path(), "diag");
246 let mut manifest = sample_manifest("options-v1");
247 let entry = manifest
248 .entries
249 .get_mut("src/main.rs")
250 .expect("manifest entry");
251 entry.file_info.scan_diagnostics =
252 vec![ScanDiagnostic::warning("custom recoverable warning")];
253
254 write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
255 .expect("write manifest");
256
257 let loaded = load_incremental_manifest(&manifest_path, "options-v1")
258 .expect("load manifest")
259 .expect("expected manifest");
260
261 let loaded_entry = loaded.entry("src/main.rs").expect("loaded entry");
262 assert_eq!(loaded_entry.file_info.scan_diagnostics.len(), 1);
263 assert_eq!(
264 loaded_entry.file_info.scan_diagnostics[0].severity,
265 DiagnosticSeverity::Warning
266 );
267 }
268
269 #[test]
270 fn test_manifest_entry_matches_path_detects_content_changes() {
271 let temp_dir = TempDir::new().expect("create temp dir");
272 let file_path = temp_dir.path().join("src/main.rs");
273 fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
274 fs::write(&file_path, "fn main() {}\n").expect("write file");
275 let metadata = fs::metadata(&file_path).expect("metadata");
276
277 let entry = IncrementalManifestEntry {
278 state: metadata_fingerprint(&metadata).expect("fingerprint"),
279 content_sha256: Sha256Digest::from_hex("not-the-real-hash")
280 .unwrap_or(Sha256Digest::EMPTY),
281 file_info: FileInfo::new(
282 "main.rs".to_string(),
283 "main".to_string(),
284 ".rs".to_string(),
285 file_path.to_string_lossy().to_string(),
286 FileType::File,
287 None,
288 None,
289 metadata.len(),
290 None,
291 None,
292 None,
293 None,
294 None,
295 Vec::new(),
296 None,
297 Vec::new(),
298 Vec::new(),
299 Vec::new(),
300 Vec::new(),
301 Vec::new(),
302 Vec::new(),
303 Vec::new(),
304 Vec::new(),
305 Vec::new(),
306 ),
307 };
308
309 assert!(
310 !manifest_entry_matches_path(&entry, &file_path, &metadata, false)
311 .expect("compare path")
312 );
313 }
314
315 fn entry_with_wrong_hash(
319 file_path: &Path,
320 metadata: &fs::Metadata,
321 ) -> IncrementalManifestEntry {
322 IncrementalManifestEntry {
323 state: metadata_fingerprint(metadata).expect("fingerprint"),
324 content_sha256: Sha256Digest::EMPTY,
325 file_info: FileInfo::new(
326 "main.rs".to_string(),
327 "main".to_string(),
328 ".rs".to_string(),
329 file_path.to_string_lossy().to_string(),
330 FileType::File,
331 None,
332 None,
333 metadata.len(),
334 None,
335 None,
336 None,
337 None,
338 None,
339 Vec::new(),
340 None,
341 Vec::new(),
342 Vec::new(),
343 Vec::new(),
344 Vec::new(),
345 Vec::new(),
346 Vec::new(),
347 Vec::new(),
348 Vec::new(),
349 Vec::new(),
350 ),
351 }
352 }
353
354 #[test]
355 fn test_trust_mtime_accepts_fingerprint_match_without_rehashing() {
356 let temp_dir = TempDir::new().expect("create temp dir");
357 let file_path = temp_dir.path().join("src/main.rs");
358 fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
359 fs::write(&file_path, "fn main() {}\n").expect("write file");
360 let metadata = fs::metadata(&file_path).expect("metadata");
361
362 let entry = entry_with_wrong_hash(&file_path, &metadata);
365
366 assert!(
367 manifest_entry_matches_path(&entry, &file_path, &metadata, true).expect("compare path"),
368 "trust-mtime mode must accept a size+mtime match without re-hashing"
369 );
370 }
371
372 #[test]
373 fn test_default_mode_rehashes_on_fingerprint_match() {
374 let temp_dir = TempDir::new().expect("create temp dir");
375 let file_path = temp_dir.path().join("src/main.rs");
376 fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
377 fs::write(&file_path, "fn main() {}\n").expect("write file");
378 let metadata = fs::metadata(&file_path).expect("metadata");
379
380 let entry = entry_with_wrong_hash(&file_path, &metadata);
382
383 assert!(
384 !manifest_entry_matches_path(&entry, &file_path, &metadata, false)
385 .expect("compare path"),
386 "default mode must re-hash and reject a fingerprint match whose content differs"
387 );
388 }
389
390 #[test]
391 fn test_trust_mtime_still_detects_changed_fingerprint() {
392 let temp_dir = TempDir::new().expect("create temp dir");
393 let file_path = temp_dir.path().join("src/main.rs");
394 fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
395 fs::write(&file_path, "fn main() {}\n").expect("write file");
396 let metadata = fs::metadata(&file_path).expect("metadata");
397
398 let mut state = metadata_fingerprint(&metadata).expect("fingerprint");
400 state.size += 1;
401 let mut entry = entry_with_wrong_hash(&file_path, &metadata);
402 entry.state = state;
403
404 assert!(
405 !manifest_entry_matches_path(&entry, &file_path, &metadata, true)
406 .expect("compare path"),
407 "trust-mtime mode must still treat a changed size/mtime fingerprint as changed"
408 );
409 }
410}