1use std::path::Path;
32
33use sha1::{Digest, Sha1};
34
35use crate::manifest;
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Freshness {
40 Fresh,
42 Stale {
45 reason: String,
50 },
51}
52
53impl Freshness {
54 pub fn is_fresh(&self) -> bool {
56 matches!(self, Freshness::Fresh)
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct ImportRef {
67 pub identity: String,
70 pub hash: String,
73}
74
75const EXTERNAL_FILE_PREFIX: &str = "external-file:";
80
81fn check_imports(root: &Path, imports: &[ImportRef]) -> Option<String> {
91 for import in imports {
92 let Some(rel) = import.identity.strip_prefix(EXTERNAL_FILE_PREFIX) else {
93 continue;
94 };
95 let Ok(bytes) = std::fs::read(root.join(rel)) else {
96 return Some(format!("import-file-missing:{}", import.identity));
97 };
98 let digest = Sha1::digest(&bytes);
99 let current_hash: String = digest.iter().map(|b| format!("{b:02x}")).collect();
100 if current_hash != import.hash {
101 return Some(format!("import-file-changed:{}", import.identity));
102 }
103 }
104 None
105}
106
107pub fn evaluate(
115 root: &Path,
116 admitted_head_sha: Option<&str>,
117 admitted_dirty: bool,
118 imports: &[ImportRef],
119) -> Freshness {
120 if admitted_dirty {
121 return Freshness::Stale {
122 reason: "source-snapshot-dirty-at-admission".to_string(),
123 };
124 }
125 let Some(admitted_head) = admitted_head_sha else {
126 return Freshness::Stale {
127 reason: "source-snapshot-missing".to_string(),
128 };
129 };
130 if manifest::is_working_tree_dirty(root, &[".".to_string()]) {
131 return Freshness::Stale {
132 reason: "working-tree-dirty".to_string(),
133 };
134 }
135 match manifest::git_head(root) {
136 Some(current) if current == admitted_head => {}
137 Some(_) => {
138 return Freshness::Stale {
139 reason: "source-head-mismatch".to_string(),
140 }
141 }
142 None => {
143 return Freshness::Stale {
144 reason: "source-head-unavailable".to_string(),
145 }
146 }
147 }
148 if let Some(reason) = check_imports(root, imports) {
149 return Freshness::Stale { reason };
150 }
151 Freshness::Fresh
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use std::process::Command;
158 use std::sync::atomic::{AtomicU64, Ordering};
159
160 static COUNTER: AtomicU64 = AtomicU64::new(0);
161
162 fn scratch_repo(label: &str) -> std::path::PathBuf {
163 let n = COUNTER.fetch_add(1, Ordering::SeqCst);
164 let dir = std::env::temp_dir().join(format!(
165 "scout-semantic-freshness-{label}-{}-{n}",
166 std::process::id()
167 ));
168 std::fs::create_dir_all(&dir).unwrap();
169 let run = |args: &[&str]| {
170 let status = Command::new("git")
171 .args(args)
172 .current_dir(&dir)
173 .status()
174 .expect("git available for the test");
175 assert!(status.success(), "git {args:?} failed");
176 };
177 run(&["init", "-q"]);
178 run(&["config", "user.email", "test@example.com"]);
179 run(&["config", "user.name", "test"]);
180 std::fs::write(dir.join("a.txt"), "a").unwrap();
181 run(&["add", "."]);
182 run(&["commit", "-q", "-m", "init"]);
183 dir
184 }
185
186 fn head_of(dir: &Path) -> String {
187 manifest::git_head(dir).expect("head available")
188 }
189
190 #[test]
191 fn dirty_at_admission_is_stale_regardless_of_the_current_tree() {
192 let dir = scratch_repo("dirty-admission");
193 let head = head_of(&dir);
194 assert_eq!(
195 evaluate(&dir, Some(&head), true, &[]),
196 Freshness::Stale {
197 reason: "source-snapshot-dirty-at-admission".to_string()
198 }
199 );
200 }
201
202 #[test]
203 fn a_missing_head_sha_is_stale() {
204 let dir = scratch_repo("missing-head");
205 assert_eq!(
206 evaluate(&dir, None, false, &[]),
207 Freshness::Stale {
208 reason: "source-snapshot-missing".to_string()
209 }
210 );
211 }
212
213 #[test]
214 fn a_matching_clean_head_is_fresh() {
215 let dir = scratch_repo("matching-head");
216 let head = head_of(&dir);
217 assert_eq!(evaluate(&dir, Some(&head), false, &[]), Freshness::Fresh);
218 }
219
220 #[test]
221 fn a_moved_head_is_stale_even_with_the_consuming_file_untouched() {
222 let dir = scratch_repo("moved-head");
223 let admitted_head = head_of(&dir);
224 std::fs::write(dir.join("b.txt"), "b").unwrap();
225 Command::new("git")
226 .args(["add", "."])
227 .current_dir(&dir)
228 .status()
229 .unwrap();
230 Command::new("git")
231 .args(["commit", "-q", "-m", "second"])
232 .current_dir(&dir)
233 .status()
234 .unwrap();
235 assert_eq!(
236 evaluate(&dir, Some(&admitted_head), false, &[]),
237 Freshness::Stale {
238 reason: "source-head-mismatch".to_string()
239 }
240 );
241 }
242
243 #[test]
244 fn an_uncommitted_edit_now_is_stale_even_at_the_same_admitted_head() {
245 let dir = scratch_repo("dirty-now");
246 let head = head_of(&dir);
247 std::fs::write(dir.join("a.txt"), "changed").unwrap();
248 assert_eq!(
249 evaluate(&dir, Some(&head), false, &[]),
250 Freshness::Stale {
251 reason: "working-tree-dirty".to_string()
252 }
253 );
254 }
255
256 fn sha1_hex(bytes: &[u8]) -> String {
270 Sha1::digest(bytes)
271 .iter()
272 .map(|b| format!("{b:02x}"))
273 .collect()
274 }
275
276 #[test]
277 fn an_import_matching_its_recorded_hash_is_fresh() {
278 let dir = scratch_repo("import-fresh");
279 let head = head_of(&dir);
280 let import_path = dir.parent().unwrap().join(format!(
281 "Directory.Build.props-{}",
282 dir.file_name().unwrap().to_str().unwrap()
283 ));
284 std::fs::write(&import_path, b"<Project />").unwrap();
285 let imports = [ImportRef {
286 identity: format!(
287 "external-file:../{}",
288 import_path.file_name().unwrap().to_str().unwrap()
289 ),
290 hash: sha1_hex(b"<Project />"),
291 }];
292 assert_eq!(
293 evaluate(&dir, Some(&head), false, &imports),
294 Freshness::Fresh
295 );
296 std::fs::remove_file(&import_path).ok();
297 }
298
299 #[test]
300 fn a_changed_import_is_stale_even_with_head_unchanged_and_the_tree_clean() {
301 let dir = scratch_repo("import-changed");
302 let head = head_of(&dir);
303 let import_path = dir.parent().unwrap().join(format!(
304 "Directory.Build.props-{}",
305 dir.file_name().unwrap().to_str().unwrap()
306 ));
307 std::fs::write(
308 &import_path,
309 b"<Project><Nullable>enable</Nullable></Project>",
310 )
311 .unwrap();
312 let identity = format!(
315 "external-file:../{}",
316 import_path.file_name().unwrap().to_str().unwrap()
317 );
318 let imports = [ImportRef {
319 identity: identity.clone(),
320 hash: sha1_hex(b"<Project />"),
321 }];
322 assert_eq!(
323 evaluate(&dir, Some(&head), false, &imports),
324 Freshness::Stale {
325 reason: format!("import-file-changed:{identity}"),
326 },
327 "the reason must name WHICH import moved, not just that one did"
328 );
329 std::fs::remove_file(&import_path).ok();
330 }
331
332 #[test]
333 fn a_missing_import_is_stale() {
334 let dir = scratch_repo("import-missing");
335 let head = head_of(&dir);
336 let identity = "external-file:../does-not-exist.props".to_string();
337 let imports = [ImportRef {
338 identity: identity.clone(),
339 hash: sha1_hex(b"anything"),
340 }];
341 assert_eq!(
342 evaluate(&dir, Some(&head), false, &imports),
343 Freshness::Stale {
344 reason: format!("import-file-missing:{identity}"),
345 },
346 "the reason must name WHICH import is missing, not just that one is"
347 );
348 }
349
350 #[test]
351 fn an_import_identity_with_no_external_file_marker_is_never_checked() {
352 let dir = scratch_repo("import-unmarked");
353 let head = head_of(&dir);
354 let imports = [ImportRef {
355 identity: "metadata:System.Private.CoreLib|mvid:00000000".to_string(),
356 hash: "not-a-real-hash".to_string(),
357 }];
358 assert_eq!(
359 evaluate(&dir, Some(&head), false, &imports),
360 Freshness::Fresh
361 );
362 }
363}