use std::collections::BTreeMap;
use std::io;
use std::path::Path;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct ContentSet(BTreeMap<String, String>);
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SetDrift {
pub(crate) changed: Vec<String>,
pub(crate) added: Vec<String>,
pub(crate) removed: Vec<String>,
}
impl SetDrift {
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "leaf API (is_stale_against shortcut); warm-cache consumer uses diff for the path list — IMP-025 primitive surface"
)
)]
fn is_empty(&self) -> bool {
self.changed.is_empty() && self.added.is_empty() && self.removed.is_empty()
}
}
impl ContentSet {
pub(crate) fn from_hashes(map: BTreeMap<String, String>) -> Self {
Self(map)
}
pub(crate) fn hashes(&self) -> &BTreeMap<String, String> {
&self.0
}
#[cfg(test)]
fn from_pairs<I, S>(pairs: I) -> Self
where
I: IntoIterator<Item = (S, S)>,
S: Into<String>,
{
Self(
pairs
.into_iter()
.map(|(p, h)| (p.into(), h.into()))
.collect(),
)
}
pub(crate) fn diff(&self, current: &ContentSet) -> SetDrift {
let mut drift = SetDrift::default();
for (path, hash) in &self.0 {
match current.0.get(path) {
Some(current_hash) if current_hash == hash => {}
Some(_) => drift.changed.push(path.clone()),
None => drift.removed.push(path.clone()),
}
}
for path in current.0.keys() {
if !self.0.contains_key(path) {
drift.added.push(path.clone());
}
}
drift
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "leaf API boolean shortcut; warm-cache consumer uses diff for the path list — IMP-025 primitive surface"
)
)]
pub(crate) fn is_stale_against(&self, current: &ContentSet) -> bool {
!self.diff(current).is_empty()
}
}
pub(crate) fn compute(root: &Path, paths: &[String]) -> io::Result<ContentSet> {
let mut set = BTreeMap::new();
for rel in paths {
match std::fs::read(root.join(rel)) {
Ok(bytes) => {
set.insert(rel.clone(), sha256_hex(&bytes));
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
Ok(ContentSet(set))
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
fn set(pairs: &[(&str, &str)]) -> ContentSet {
ContentSet::from_pairs(pairs.iter().map(|&(p, h)| (p, h)))
}
#[test]
fn diff_identical_sets_is_empty() {
let a = set(&[("a", "1"), ("b", "2")]);
assert!(!a.is_stale_against(&a));
assert_eq!(a.diff(&a), SetDrift::default());
}
#[test]
fn diff_classifies_changed_added_removed() {
let base = set(&[("keep", "1"), ("mutate", "2"), ("gone", "3")]);
let current = set(&[("keep", "1"), ("mutate", "9"), ("fresh", "7")]);
let drift = base.diff(¤t);
assert_eq!(drift.changed, vec!["mutate".to_owned()]);
assert_eq!(drift.added, vec!["fresh".to_owned()]);
assert_eq!(drift.removed, vec!["gone".to_owned()]);
assert!(base.is_stale_against(¤t));
}
#[test]
fn absent_path_is_removed_and_therefore_stale() {
let base = set(&[("present", "1"), ("absent", "2")]);
let current = set(&[("present", "1")]);
let drift = base.diff(¤t);
assert_eq!(drift.removed, vec!["absent".to_owned()]);
assert!(drift.changed.is_empty() && drift.added.is_empty());
assert!(base.is_stale_against(¤t));
}
#[test]
fn diff_class_vectors_are_path_sorted() {
let base = set(&[("z", "1"), ("a", "1")]);
let current = set(&[("z", "9"), ("a", "9")]);
assert_eq!(
base.diff(¤t).changed,
vec!["a".to_owned(), "z".to_owned()]
);
}
#[test]
fn compute_omits_absent_path_yielding_stale_baseline() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("here.txt"), b"hello").unwrap();
let current = compute(
dir.path(),
&["here.txt".to_owned(), "missing.txt".to_owned()],
)
.unwrap();
let present_only = compute(dir.path(), &["here.txt".to_owned()]).unwrap();
assert_eq!(current, present_only);
let baseline = compute(dir.path(), &["here.txt".to_owned()]).unwrap();
let baseline_with_extra = {
let mut s = baseline.0.clone();
s.insert("missing.txt".to_owned(), "deadbeef".to_owned());
ContentSet(s)
};
assert!(baseline_with_extra.is_stale_against(¤t));
assert_eq!(
baseline_with_extra.diff(¤t).removed,
vec!["missing.txt".to_owned()]
);
}
#[test]
fn compute_hash_matches_sha256_of_bytes() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("f"), b"content").unwrap();
let cs = compute(dir.path(), &["f".to_owned()]).unwrap();
assert_eq!(cs.0.get("f").unwrap(), &sha256_hex(b"content"));
}
#[test]
fn compute_propagates_non_notfound_io_error() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
let err = compute(dir.path(), &["sub".to_owned()]).unwrap_err();
assert_ne!(err.kind(), io::ErrorKind::NotFound);
}
}