1use std::collections::BTreeMap;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::time::UNIX_EPOCH;
6
7use serde::{Deserialize, Serialize};
8
9use super::io::write_bytes_atomically;
10use super::locking::with_exclusive_cache_lock;
11use crate::models::{FileInfo, Sha256Digest};
12use crate::utils::hash::calculate_sha256;
13
14const INCREMENTAL_MANIFEST_VERSION: u32 = 1;
15const MANIFEST_FILE_NAME: &str = "manifest.json";
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct FileStateFingerprint {
19 pub size: u64,
20 pub modified_seconds: u64,
21 pub modified_nanos: u32,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct IncrementalManifestEntry {
26 pub state: FileStateFingerprint,
27 pub content_sha256: Sha256Digest,
28 pub file_info: FileInfo,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct IncrementalManifest {
33 pub version: u32,
34 pub options_fingerprint: String,
35 pub entries: BTreeMap<String, IncrementalManifestEntry>,
36}
37
38impl IncrementalManifest {
39 pub fn new(
40 options_fingerprint: String,
41 entries: BTreeMap<String, IncrementalManifestEntry>,
42 ) -> Self {
43 Self {
44 version: INCREMENTAL_MANIFEST_VERSION,
45 options_fingerprint,
46 entries,
47 }
48 }
49
50 pub fn entry(&self, relative_path: &str) -> Option<&IncrementalManifestEntry> {
51 self.entries.get(relative_path)
52 }
53
54 pub fn is_compatible_with(&self, options_fingerprint: &str) -> bool {
55 self.version == INCREMENTAL_MANIFEST_VERSION
56 && self.options_fingerprint == options_fingerprint
57 }
58}
59
60pub fn incremental_manifest_path(cache_root: &Path, manifest_key: &str) -> PathBuf {
61 cache_root
62 .join("incremental")
63 .join(manifest_key)
64 .join(MANIFEST_FILE_NAME)
65}
66
67pub fn metadata_fingerprint(metadata: &fs::Metadata) -> Option<FileStateFingerprint> {
68 let modified = metadata.modified().ok()?;
69 let duration = modified.duration_since(UNIX_EPOCH).ok()?;
70
71 Some(FileStateFingerprint {
72 size: metadata.len(),
73 modified_seconds: duration.as_secs(),
74 modified_nanos: duration.subsec_nanos(),
75 })
76}
77
78pub fn manifest_entry_matches_path(
79 entry: &IncrementalManifestEntry,
80 path: &Path,
81 metadata: &fs::Metadata,
82) -> io::Result<bool> {
83 if !metadata_fingerprint(metadata).is_some_and(|fingerprint| fingerprint == entry.state) {
84 return Ok(false);
85 }
86
87 let bytes = fs::read(path)?;
88 Ok(calculate_sha256(&bytes) == entry.content_sha256)
89}
90
91pub fn load_incremental_manifest(
92 manifest_path: &Path,
93 options_fingerprint: &str,
94) -> io::Result<Option<IncrementalManifest>> {
95 let bytes = match fs::read(manifest_path) {
96 Ok(bytes) => bytes,
97 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
98 Err(err) => return Err(err),
99 };
100
101 let manifest: IncrementalManifest = match serde_json::from_slice(&bytes) {
102 Ok(manifest) => manifest,
103 Err(_) => return Ok(None),
104 };
105
106 if !manifest.is_compatible_with(options_fingerprint) {
107 return Ok(None);
108 }
109
110 Ok(Some(manifest))
111}
112
113pub fn write_incremental_manifest(
114 cache_root: &Path,
115 manifest_path: &Path,
116 manifest: &IncrementalManifest,
117) -> io::Result<()> {
118 let bytes = serde_json::to_vec_pretty(manifest)
119 .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
120
121 with_exclusive_cache_lock(cache_root, || write_bytes_atomically(manifest_path, &bytes))
122}
123
124#[cfg(test)]
125mod tests {
126 use tempfile::TempDir;
127
128 use super::*;
129 use crate::models::{FileInfo, FileType};
130
131 fn sample_manifest(options_fingerprint: &str) -> IncrementalManifest {
132 let mut entries = BTreeMap::new();
133 entries.insert(
134 "src/main.rs".to_string(),
135 IncrementalManifestEntry {
136 state: FileStateFingerprint {
137 size: 12,
138 modified_seconds: 10,
139 modified_nanos: 20,
140 },
141 content_sha256: Sha256Digest::from_hex(
142 "f2ca1bb6c7e907d06dafe4687e579fce9f2b2c8a179a4e7c1f6c5052d4f7d070",
143 )
144 .unwrap(),
145 file_info: FileInfo::new(
146 "main.rs".to_string(),
147 "main".to_string(),
148 ".rs".to_string(),
149 "/tmp/project/src/main.rs".to_string(),
150 FileType::File,
151 None,
152 None,
153 12,
154 None,
155 None,
156 None,
157 None,
158 None,
159 Vec::new(),
160 None,
161 Vec::new(),
162 Vec::new(),
163 Vec::new(),
164 Vec::new(),
165 Vec::new(),
166 Vec::new(),
167 Vec::new(),
168 Vec::new(),
169 Vec::new(),
170 ),
171 },
172 );
173
174 IncrementalManifest::new(options_fingerprint.to_string(), entries)
175 }
176
177 #[test]
178 fn test_load_incremental_manifest_returns_none_for_incompatible_options() {
179 let temp_dir = TempDir::new().expect("create temp dir");
180 let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
181 let manifest = sample_manifest("options-v1");
182
183 write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
184 .expect("write manifest");
185
186 let loaded =
187 load_incremental_manifest(&manifest_path, "options-v2").expect("load manifest");
188
189 assert!(loaded.is_none());
190 }
191
192 #[test]
193 fn test_write_and_load_incremental_manifest_round_trip() {
194 let temp_dir = TempDir::new().expect("create temp dir");
195 let manifest_path = incremental_manifest_path(temp_dir.path(), "abc123");
196 let manifest = sample_manifest("options-v1");
197
198 write_incremental_manifest(temp_dir.path(), &manifest_path, &manifest)
199 .expect("write manifest");
200
201 let loaded = load_incremental_manifest(&manifest_path, "options-v1")
202 .expect("load manifest")
203 .expect("expected manifest");
204
205 assert_eq!(loaded.entries.len(), 1);
206 assert!(loaded.entry("src/main.rs").is_some());
207 }
208
209 #[test]
210 fn test_manifest_entry_matches_path_detects_content_changes() {
211 let temp_dir = TempDir::new().expect("create temp dir");
212 let file_path = temp_dir.path().join("src/main.rs");
213 fs::create_dir_all(file_path.parent().expect("parent")).expect("create parent");
214 fs::write(&file_path, "fn main() {}\n").expect("write file");
215 let metadata = fs::metadata(&file_path).expect("metadata");
216
217 let entry = IncrementalManifestEntry {
218 state: metadata_fingerprint(&metadata).expect("fingerprint"),
219 content_sha256: Sha256Digest::from_hex("not-the-real-hash")
220 .unwrap_or(Sha256Digest::EMPTY),
221 file_info: FileInfo::new(
222 "main.rs".to_string(),
223 "main".to_string(),
224 ".rs".to_string(),
225 file_path.to_string_lossy().to_string(),
226 FileType::File,
227 None,
228 None,
229 metadata.len(),
230 None,
231 None,
232 None,
233 None,
234 None,
235 Vec::new(),
236 None,
237 Vec::new(),
238 Vec::new(),
239 Vec::new(),
240 Vec::new(),
241 Vec::new(),
242 Vec::new(),
243 Vec::new(),
244 Vec::new(),
245 Vec::new(),
246 ),
247 };
248
249 assert!(!manifest_entry_matches_path(&entry, &file_path, &metadata).expect("compare path"));
250 }
251}