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 = 3;
18const MANIFEST_FILE_NAME: &str = "manifest.json";
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(
82 entry: &IncrementalManifestEntry,
83 path: &Path,
84 metadata: &fs::Metadata,
85) -> io::Result<bool> {
86 if !metadata_fingerprint(metadata).is_some_and(|fingerprint| fingerprint == entry.state) {
87 return Ok(false);
88 }
89
90 let bytes = fs::read(path)?;
91 Ok(calculate_sha256(&bytes) == entry.content_sha256)
92}
93
94pub fn load_incremental_manifest(
95 manifest_path: &Path,
96 options_fingerprint: &str,
97) -> io::Result<Option<IncrementalManifest>> {
98 let bytes = match fs::read(manifest_path) {
99 Ok(bytes) => bytes,
100 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
101 Err(err) => return Err(err),
102 };
103
104 let manifest: IncrementalManifest = match serde_json::from_slice(&bytes) {
105 Ok(manifest) => manifest,
106 Err(_) => return Ok(None),
107 };
108
109 if !manifest.is_compatible_with(options_fingerprint) {
110 return Ok(None);
111 }
112
113 Ok(Some(manifest))
114}
115
116pub fn write_incremental_manifest(
117 cache_root: &Path,
118 manifest_path: &Path,
119 manifest: &IncrementalManifest,
120) -> io::Result<()> {
121 let bytes = serde_json::to_vec_pretty(manifest)
122 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
123
124 with_exclusive_cache_lock(cache_root, || write_bytes_atomically(manifest_path, &bytes))
125}
126
127#[cfg(test)]
128mod tests {
129 use tempfile::TempDir;
130
131 use super::*;
132 use crate::models::{DiagnosticSeverity, FileInfo, FileType, ScanDiagnostic};
133
134 fn sample_manifest(options_fingerprint: &str) -> IncrementalManifest {
135 let mut entries = BTreeMap::new();
136 entries.insert(
137 "src/main.rs".to_string(),
138 IncrementalManifestEntry {
139 state: FileStateFingerprint {
140 size: 12,
141 modified_seconds: 10,
142 modified_nanos: 20,
143 },
144 content_sha256: Sha256Digest::from_hex(
145 "f2ca1bb6c7e907d06dafe4687e579fce9f2b2c8a179a4e7c1f6c5052d4f7d070",
146 )
147 .unwrap(),
148 file_info: FileInfo::new(
149 "main.rs".to_string(),
150 "main".to_string(),
151 ".rs".to_string(),
152 "/tmp/project/src/main.rs".to_string(),
153 FileType::File,
154 None,
155 None,
156 12,
157 None,
158 None,
159 None,
160 None,
161 None,
162 Vec::new(),
163 None,
164 Vec::new(),
165 Vec::new(),
166 Vec::new(),
167 Vec::new(),
168 Vec::new(),
169 Vec::new(),
170 Vec::new(),
171 Vec::new(),
172 Vec::new(),
173 ),
174 },
175 );
176
177 IncrementalManifest::new(options_fingerprint.to_string(), entries)
178 }
179
180 #[test]
181 fn test_load_incremental_manifest_returns_none_for_incompatible_options() {
182 let temp_dir = TempDir::new().expect("create temp dir");
183 let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
184 let manifest = sample_manifest("options-v1");
185
186 write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
187 .expect("write manifest");
188
189 let loaded =
190 load_incremental_manifest(&manifest_path, "options-v2").expect("load manifest");
191
192 assert!(loaded.is_none());
193 }
194
195 #[test]
196 fn test_load_incremental_manifest_returns_none_for_older_manifest_version() {
197 let temp_dir = TempDir::new().expect("create temp dir");
198 let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
199 let mut manifest = sample_manifest("options-v1");
200 manifest.version = 1;
201
202 write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
203 .expect("write manifest");
204
205 let loaded =
206 load_incremental_manifest(&manifest_path, "options-v1").expect("load manifest");
207
208 assert!(loaded.is_none());
209 }
210
211 #[test]
212 fn test_write_and_load_incremental_manifest_round_trip() {
213 let temp_dir = TempDir::new().expect("create temp dir");
214 let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
215 let manifest = sample_manifest("options-v1");
216
217 write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
218 .expect("write manifest");
219
220 let loaded = load_incremental_manifest(&manifest_path, "options-v1")
221 .expect("load manifest")
222 .expect("expected manifest");
223
224 assert_eq!(loaded.entries.len(), 1);
225 assert!(loaded.entry("src/main.rs").is_some());
226 }
227
228 #[test]
229 fn test_incremental_manifest_preserves_scan_diagnostic_severity() {
230 let temp_dir = TempDir::new().expect("create temp dir");
231 let manifest_path = incremental_manifest_path(temp_dir.path(), "diag");
232 let mut manifest = sample_manifest("options-v1");
233 let entry = manifest
234 .entries
235 .get_mut("src/main.rs")
236 .expect("manifest entry");
237 entry.file_info.scan_errors = vec!["custom recoverable warning".to_string()];
238 entry.file_info.scan_diagnostics =
239 vec![ScanDiagnostic::warning("custom recoverable warning")];
240
241 write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
242 .expect("write manifest");
243
244 let loaded = load_incremental_manifest(&manifest_path, "options-v1")
245 .expect("load manifest")
246 .expect("expected manifest");
247
248 let loaded_entry = loaded.entry("src/main.rs").expect("loaded entry");
249 assert_eq!(loaded_entry.file_info.scan_diagnostics.len(), 1);
250 assert_eq!(
251 loaded_entry.file_info.scan_diagnostics[0].severity,
252 DiagnosticSeverity::Warning
253 );
254 }
255
256 #[test]
257 fn test_manifest_entry_matches_path_detects_content_changes() {
258 let temp_dir = TempDir::new().expect("create temp dir");
259 let file_path = temp_dir.path().join("src/main.rs");
260 fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
261 fs::write(&file_path, "fn main() {}\n").expect("write file");
262 let metadata = fs::metadata(&file_path).expect("metadata");
263
264 let entry = IncrementalManifestEntry {
265 state: metadata_fingerprint(&metadata).expect("fingerprint"),
266 content_sha256: Sha256Digest::from_hex("not-the-real-hash")
267 .unwrap_or(Sha256Digest::EMPTY),
268 file_info: FileInfo::new(
269 "main.rs".to_string(),
270 "main".to_string(),
271 ".rs".to_string(),
272 file_path.to_string_lossy().to_string(),
273 FileType::File,
274 None,
275 None,
276 metadata.len(),
277 None,
278 None,
279 None,
280 None,
281 None,
282 Vec::new(),
283 None,
284 Vec::new(),
285 Vec::new(),
286 Vec::new(),
287 Vec::new(),
288 Vec::new(),
289 Vec::new(),
290 Vec::new(),
291 Vec::new(),
292 Vec::new(),
293 ),
294 };
295
296 assert!(!manifest_entry_matches_path(&entry, &file_path, &metadata).expect("compare path"));
297 }
298}