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)
}
pub fn stale_reason(&self) -> Option<&str> {
match self {
Freshness::Fresh => None,
Freshness::Stale { reason } => Some(reason),
}
}
pub fn is_import_trigger(&self) -> bool {
self.stale_reason().is_some_and(|reason| {
reason.starts_with(IMPORT_FILE_CHANGED) || reason.starts_with(IMPORT_FILE_MISSING)
})
}
}
const IMPORT_FILE_CHANGED: &str = "import-file-changed:";
const IMPORT_FILE_MISSING: &str = "import-file-missing:";
#[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
);
}
#[test]
fn only_the_import_triggers_are_classed_as_moved_imports() {
for token in [
"source-snapshot-dirty-at-admission",
"source-snapshot-missing",
"working-tree-dirty",
"source-head-mismatch",
"source-head-unavailable",
] {
let stale = Freshness::Stale {
reason: token.to_string(),
};
assert_eq!(stale.stale_reason(), Some(token));
assert!(!stale.is_import_trigger(), "{token}");
}
assert_eq!(Freshness::Fresh.stale_reason(), None);
assert!(!Freshness::Fresh.is_import_trigger());
}
#[test]
fn the_reasons_check_imports_mints_are_classed_as_moved_imports() {
let dir = scratch_repo("import-class");
let head = head_of(&dir);
let missing = evaluate(
&dir,
Some(&head),
false,
&[ImportRef {
identity: "external-file:../absent.props".to_string(),
hash: sha1_hex(b"x"),
}],
);
assert!(missing.is_import_trigger(), "{missing:?}");
std::fs::write(dir.join("tracked.props"), b"new").unwrap();
let run = |args: &[&str]| {
let status = Command::new("git")
.args(args)
.current_dir(&dir)
.status()
.unwrap();
assert!(status.success());
};
run(&["add", "."]);
run(&["commit", "-q", "-m", "props"]);
let changed = evaluate(
&dir,
Some(&head_of(&dir)),
false,
&[ImportRef {
identity: "external-file:tracked.props".to_string(),
hash: sha1_hex(b"old"),
}],
);
assert!(changed.is_import_trigger(), "{changed:?}");
}
}