use std::collections::BTreeMap;
use std::path::Path;
pub const ABSENT_INPUT: &str = "absent";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputFreshness {
Unrecorded,
Fresh,
Stale { changed: Vec<String> },
}
impl InputFreshness {
pub fn is_current(&self) -> bool {
!matches!(self, InputFreshness::Stale { .. })
}
}
pub fn input_freshness(
recorded: &BTreeMap<String, String>,
actual: &BTreeMap<String, String>,
) -> InputFreshness {
if recorded.is_empty() {
return InputFreshness::Unrecorded;
}
let mut changed: Vec<String> = recorded
.iter()
.filter(|(path, pinned)| actual.get(*path).map(|a| a != *pinned).unwrap_or(true))
.map(|(path, _)| path.clone())
.collect();
if changed.is_empty() {
InputFreshness::Fresh
} else {
changed.sort();
InputFreshness::Stale { changed }
}
}
pub fn hash_declared_inputs(root: &Path, declared: &[std::path::PathBuf]) -> BTreeMap<String, String> {
declared
.iter()
.map(|rel| {
let key = rel.display().to_string();
let digest = match std::fs::read(root.join(rel)) {
Ok(bytes) => crate::import::content_hash(&bytes),
Err(_) => ABSENT_INPUT.to_string(),
};
(key, digest)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn empty_recorded_is_unrecorded_not_fresh() {
assert_eq!(
input_freshness(&BTreeMap::new(), &map(&[("a", "deadbeef")])),
InputFreshness::Unrecorded
);
assert!(InputFreshness::Unrecorded.is_current());
}
#[test]
fn matching_digests_are_fresh() {
let m = map(&[("a.cfg", "aaa"), ("b.sh", "bbb")]);
assert_eq!(input_freshness(&m, &m), InputFreshness::Fresh);
}
#[test]
fn drift_names_every_changed_path_sorted() {
let recorded = map(&[("z.cfg", "zzz"), ("a.sh", "aaa"), ("m.txt", "mmm")]);
let actual = map(&[("z.cfg", "CHANGED"), ("a.sh", "aaa"), ("m.txt", "ALSO")]);
assert_eq!(
input_freshness(&recorded, &actual),
InputFreshness::Stale {
changed: vec!["m.txt".to_string(), "z.cfg".to_string()],
}
);
assert!(!input_freshness(&recorded, &actual).is_current());
}
#[test]
fn a_path_the_reader_could_not_resolve_counts_as_drift() {
let recorded = map(&[("gone.cfg", "aaa")]);
assert_eq!(
input_freshness(&recorded, &BTreeMap::new()),
InputFreshness::Stale {
changed: vec!["gone.cfg".to_string()],
}
);
}
#[test]
fn extra_actual_keys_are_ignored() {
let recorded = map(&[("a.sh", "aaa")]);
let actual = map(&[("a.sh", "aaa"), ("new.cfg", "nnn")]);
assert_eq!(input_freshness(&recorded, &actual), InputFreshness::Fresh);
}
#[test]
fn hashing_records_declared_keys_and_detects_a_one_byte_edit() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("nested")).unwrap();
std::fs::write(root.join("nested/a.cfg"), b"boot = true\n").unwrap();
let declared = vec![PathBuf::from("nested/a.cfg")];
let first = hash_declared_inputs(root, &declared);
assert_eq!(first.keys().collect::<Vec<_>>(), vec!["nested/a.cfg"]);
assert_eq!(first["nested/a.cfg"].len(), 64, "blake3 hex");
assert_eq!(input_freshness(&first, &first), InputFreshness::Fresh);
std::fs::write(root.join("nested/a.cfg"), b"boot = false\n").unwrap();
let second = hash_declared_inputs(root, &declared);
assert_eq!(
input_freshness(&first, &second),
InputFreshness::Stale {
changed: vec!["nested/a.cfg".to_string()],
},
"the W257 stale-ISO relation: edit the .cfg, the build cell goes stale"
);
}
#[test]
fn a_missing_input_records_the_sentinel_and_appearing_later_is_drift() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let declared = vec![PathBuf::from("not-there.sh")];
let recorded = hash_declared_inputs(root, &declared);
assert_eq!(recorded["not-there.sh"], ABSENT_INPUT);
assert_eq!(input_freshness(&recorded, &recorded), InputFreshness::Fresh);
std::fs::write(root.join("not-there.sh"), b"#!/bin/sh\n").unwrap();
assert_eq!(
input_freshness(&recorded, &hash_declared_inputs(root, &declared)),
InputFreshness::Stale {
changed: vec!["not-there.sh".to_string()],
}
);
}
#[test]
fn the_sentinel_can_never_collide_with_a_digest() {
assert_ne!(ABSENT_INPUT.len(), 64);
}
}