use std::path::Path;
use sha1::{Digest, Sha1};
use crate::manifest;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Freshness {
Fresh,
Stale {
reason: String,
},
}
impl Freshness {
pub fn is_fresh(&self) -> bool {
matches!(self, Freshness::Fresh)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportRef {
pub identity: String,
pub hash: String,
}
const EXTERNAL_FILE_PREFIX: &str = "external-file:";
fn check_imports(root: &Path, imports: &[ImportRef]) -> Option<String> {
for import in imports {
let Some(rel) = import.identity.strip_prefix(EXTERNAL_FILE_PREFIX) else {
continue;
};
let Ok(bytes) = std::fs::read(root.join(rel)) else {
return Some(format!("import-file-missing:{}", import.identity));
};
let digest = Sha1::digest(&bytes);
let current_hash: String = digest.iter().map(|b| format!("{b:02x}")).collect();
if current_hash != import.hash {
return Some(format!("import-file-changed:{}", import.identity));
}
}
None
}
pub fn evaluate(
root: &Path,
admitted_head_sha: Option<&str>,
admitted_dirty: bool,
imports: &[ImportRef],
) -> Freshness {
if admitted_dirty {
return Freshness::Stale {
reason: "source-snapshot-dirty-at-admission".to_string(),
};
}
let Some(admitted_head) = admitted_head_sha else {
return Freshness::Stale {
reason: "source-snapshot-missing".to_string(),
};
};
if manifest::is_working_tree_dirty(root, &[".".to_string()]) {
return Freshness::Stale {
reason: "working-tree-dirty".to_string(),
};
}
match manifest::git_head(root) {
Some(current) if current == admitted_head => {}
Some(_) => {
return Freshness::Stale {
reason: "source-head-mismatch".to_string(),
}
}
None => {
return Freshness::Stale {
reason: "source-head-unavailable".to_string(),
}
}
}
if let Some(reason) = check_imports(root, imports) {
return Freshness::Stale { reason };
}
Freshness::Fresh
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
fn scratch_repo(label: &str) -> std::path::PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"scout-semantic-freshness-{label}-{}-{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let run = |args: &[&str]| {
let status = Command::new("git")
.args(args)
.current_dir(&dir)
.status()
.expect("git available for the test");
assert!(status.success(), "git {args:?} failed");
};
run(&["init", "-q"]);
run(&["config", "user.email", "test@example.com"]);
run(&["config", "user.name", "test"]);
std::fs::write(dir.join("a.txt"), "a").unwrap();
run(&["add", "."]);
run(&["commit", "-q", "-m", "init"]);
dir
}
fn head_of(dir: &Path) -> String {
manifest::git_head(dir).expect("head available")
}
#[test]
fn dirty_at_admission_is_stale_regardless_of_the_current_tree() {
let dir = scratch_repo("dirty-admission");
let head = head_of(&dir);
assert_eq!(
evaluate(&dir, Some(&head), true, &[]),
Freshness::Stale {
reason: "source-snapshot-dirty-at-admission".to_string()
}
);
}
#[test]
fn a_missing_head_sha_is_stale() {
let dir = scratch_repo("missing-head");
assert_eq!(
evaluate(&dir, None, false, &[]),
Freshness::Stale {
reason: "source-snapshot-missing".to_string()
}
);
}
#[test]
fn a_matching_clean_head_is_fresh() {
let dir = scratch_repo("matching-head");
let head = head_of(&dir);
assert_eq!(evaluate(&dir, Some(&head), false, &[]), Freshness::Fresh);
}
#[test]
fn a_moved_head_is_stale_even_with_the_consuming_file_untouched() {
let dir = scratch_repo("moved-head");
let admitted_head = head_of(&dir);
std::fs::write(dir.join("b.txt"), "b").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&dir)
.status()
.unwrap();
Command::new("git")
.args(["commit", "-q", "-m", "second"])
.current_dir(&dir)
.status()
.unwrap();
assert_eq!(
evaluate(&dir, Some(&admitted_head), false, &[]),
Freshness::Stale {
reason: "source-head-mismatch".to_string()
}
);
}
#[test]
fn an_uncommitted_edit_now_is_stale_even_at_the_same_admitted_head() {
let dir = scratch_repo("dirty-now");
let head = head_of(&dir);
std::fs::write(dir.join("a.txt"), "changed").unwrap();
assert_eq!(
evaluate(&dir, Some(&head), false, &[]),
Freshness::Stale {
reason: "working-tree-dirty".to_string()
}
);
}
fn sha1_hex(bytes: &[u8]) -> String {
Sha1::digest(bytes)
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
#[test]
fn an_import_matching_its_recorded_hash_is_fresh() {
let dir = scratch_repo("import-fresh");
let head = head_of(&dir);
let import_path = dir.parent().unwrap().join(format!(
"Directory.Build.props-{}",
dir.file_name().unwrap().to_str().unwrap()
));
std::fs::write(&import_path, b"<Project />").unwrap();
let imports = [ImportRef {
identity: format!(
"external-file:../{}",
import_path.file_name().unwrap().to_str().unwrap()
),
hash: sha1_hex(b"<Project />"),
}];
assert_eq!(
evaluate(&dir, Some(&head), false, &imports),
Freshness::Fresh
);
std::fs::remove_file(&import_path).ok();
}
#[test]
fn a_changed_import_is_stale_even_with_head_unchanged_and_the_tree_clean() {
let dir = scratch_repo("import-changed");
let head = head_of(&dir);
let import_path = dir.parent().unwrap().join(format!(
"Directory.Build.props-{}",
dir.file_name().unwrap().to_str().unwrap()
));
std::fs::write(
&import_path,
b"<Project><Nullable>enable</Nullable></Project>",
)
.unwrap();
let identity = format!(
"external-file:../{}",
import_path.file_name().unwrap().to_str().unwrap()
);
let imports = [ImportRef {
identity: identity.clone(),
hash: sha1_hex(b"<Project />"),
}];
assert_eq!(
evaluate(&dir, Some(&head), false, &imports),
Freshness::Stale {
reason: format!("import-file-changed:{identity}"),
},
"the reason must name WHICH import moved, not just that one did"
);
std::fs::remove_file(&import_path).ok();
}
#[test]
fn a_missing_import_is_stale() {
let dir = scratch_repo("import-missing");
let head = head_of(&dir);
let identity = "external-file:../does-not-exist.props".to_string();
let imports = [ImportRef {
identity: identity.clone(),
hash: sha1_hex(b"anything"),
}];
assert_eq!(
evaluate(&dir, Some(&head), false, &imports),
Freshness::Stale {
reason: format!("import-file-missing:{identity}"),
},
"the reason must name WHICH import is missing, not just that one is"
);
}
#[test]
fn an_import_identity_with_no_external_file_marker_is_never_checked() {
let dir = scratch_repo("import-unmarked");
let head = head_of(&dir);
let imports = [ImportRef {
identity: "metadata:System.Private.CoreLib|mvid:00000000".to_string(),
hash: "not-a-real-hash".to_string(),
}];
assert_eq!(
evaluate(&dir, Some(&head), false, &imports),
Freshness::Fresh
);
}
}