use std::collections::BTreeMap;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
const DIGEST_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct StatEntry {
pub mtime: i64,
pub size: u64,
}
pub type StatMap = BTreeMap<String, StatEntry>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Digest {
pub count: u64,
pub watermark: i64,
pub aggregate: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatDiff {
pub added: Vec<String>,
pub modified: Vec<String>,
pub deleted: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize)]
struct TokenWire {
v: u32,
count: u64,
watermark: i64,
aggregate: String,
}
fn system_time_to_millis(t: SystemTime) -> i64 {
match t.duration_since(UNIX_EPOCH) {
Ok(d) => (d.as_nanos() as f64 / 1_000_000.0).round() as i64,
Err(e) => -((e.duration().as_nanos() as f64 / 1_000_000.0).round() as i64),
}
}
pub fn compute_stat_map<S: AsRef<str>>(rel_paths: &[S], root: &Path) -> StatMap {
let mut map = StatMap::new();
for rel in rel_paths {
let rel = rel.as_ref();
let md = match std::fs::metadata(root.join(rel)) {
Ok(md) => md,
Err(_) => continue,
};
if !md.is_file() {
continue;
}
let mtime = md.modified().ok().map(system_time_to_millis).unwrap_or(0);
map.insert(
rel.to_string(),
StatEntry {
mtime,
size: md.len(),
},
);
}
map
}
pub fn digest_stat_map(map: &StatMap) -> Digest {
let mut hasher = Sha256::new();
let mut watermark: i64 = 0;
for (path, entry) in map {
if entry.mtime > watermark {
watermark = entry.mtime;
}
hasher.update(format!("{path}\0{}\0{}\n", entry.mtime, entry.size).as_bytes());
}
let aggregate = crate::hex_lower(&hasher.finalize())[..16].to_string();
Digest {
count: map.len() as u64,
watermark,
aggregate,
}
}
pub fn serialize_digest_token(digest: &Digest) -> String {
serde_json::to_string(&TokenWire {
v: DIGEST_VERSION,
count: digest.count,
watermark: digest.watermark,
aggregate: digest.aggregate.clone(),
})
.expect("digest token always serializes")
}
pub fn parse_digest_token(token: &str) -> Option<Digest> {
if token.is_empty() {
return None;
}
let value: serde_json::Value = serde_json::from_str(token).ok()?;
let obj = value.as_object()?;
if obj.get("v").and_then(serde_json::Value::as_u64) != Some(u64::from(DIGEST_VERSION)) {
return None;
}
let count = obj.get("count")?.as_u64()?;
let watermark = obj.get("watermark")?.as_i64()?;
let aggregate = obj.get("aggregate")?.as_str()?.to_string();
Some(Digest {
count,
watermark,
aggregate,
})
}
pub fn digests_equal(a: Option<&Digest>, b: Option<&Digest>) -> bool {
matches!((a, b), (Some(x), Some(y)) if x == y)
}
pub fn diff_stat_maps(prev: &StatMap, now: &StatMap) -> StatDiff {
let mut added = Vec::new();
let mut modified = Vec::new();
let mut deleted = Vec::new();
for (path, b) in now {
match prev.get(path) {
None => added.push(path.clone()),
Some(a) => {
if a.mtime != b.mtime || a.size != b.size {
modified.push(path.clone());
}
}
}
}
for path in prev.keys() {
if !now.contains_key(path) {
deleted.push(path.clone());
}
}
added.sort();
modified.sort();
deleted.sort();
StatDiff {
added,
modified,
deleted,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn entry(mtime: i64, size: u64) -> StatEntry {
StatEntry { mtime, size }
}
fn map(pairs: &[(&str, i64, u64)]) -> StatMap {
pairs
.iter()
.map(|(k, m, s)| ((*k).to_string(), entry(*m, *s)))
.collect()
}
#[test]
fn digest_token_round_trips() {
let d = Digest {
count: 3,
watermark: 1_700_000_000_000,
aggregate: "abc123def456abcd".to_string(),
};
let back = parse_digest_token(&serialize_digest_token(&d));
assert_eq!(back, Some(d));
}
#[test]
fn unrecognized_tokens_parse_to_none() {
assert_eq!(parse_digest_token("a1b2c3d4e5f6"), None); assert_eq!(parse_digest_token(r#"{"v":2,"count":1}"#), None); assert_eq!(parse_digest_token("not json"), None);
assert_eq!(parse_digest_token(""), None);
assert_eq!(parse_digest_token(r#"{"v":1,"count":"x"}"#), None); }
#[test]
fn digests_equal_requires_every_field() {
let a = Digest {
count: 1,
watermark: 10,
aggregate: "x".to_string(),
};
assert!(digests_equal(Some(&a), Some(&a.clone())));
assert!(!digests_equal(
Some(&a),
Some(&Digest {
count: 2,
..a.clone()
})
));
assert!(!digests_equal(
Some(&a),
Some(&Digest {
watermark: 11,
..a.clone()
})
));
assert!(!digests_equal(
Some(&a),
Some(&Digest {
aggregate: "y".to_string(),
..a.clone()
})
));
assert!(!digests_equal(Some(&a), None));
}
#[test]
fn digest_is_stable_and_change_sensitive() {
let m1 = map(&[("a.rs", 100, 10), ("b.rs", 200, 20)]);
let m2 = map(&[("b.rs", 200, 20), ("a.rs", 100, 10)]);
assert_eq!(
digest_stat_map(&m1),
digest_stat_map(&m2),
"key order must not matter"
);
let mut m3 = m1.clone();
m3.insert("a.rs".to_string(), entry(100, 11));
assert_ne!(
digest_stat_map(&m1),
digest_stat_map(&m3),
"a size change moves the digest"
);
let mut m4 = m1.clone();
m4.insert("a.rs".to_string(), entry(101, 10));
assert_ne!(
digest_stat_map(&m1),
digest_stat_map(&m4),
"an mtime change moves the digest"
);
}
#[test]
fn digest_watermark_and_count() {
let d = digest_stat_map(&map(&[("a", 5, 1), ("b", 99, 1)]));
assert_eq!(d.count, 2);
assert_eq!(d.watermark, 99);
}
#[test]
fn diff_classifies_added_modified_deleted() {
let prev = map(&[
("keep.rs", 100, 10),
("touch.rs", 100, 10),
("grow.rs", 100, 10),
("gone.rs", 100, 10),
]);
let now = map(&[
("keep.rs", 100, 10), ("touch.rs", 200, 10), ("grow.rs", 100, 99), ("new.rs", 300, 5), ]);
let StatDiff {
added,
modified,
deleted,
} = diff_stat_maps(&prev, &now);
assert_eq!(added, ["new.rs"]);
assert_eq!(modified, ["grow.rs", "touch.rs"]);
assert_eq!(deleted, ["gone.rs"]);
assert!(
!modified.contains(&"keep.rs".to_string()),
"identical (mtime,size) is absent from the slice"
);
}
#[test]
fn diff_empty_vs_empty() {
assert_eq!(
diff_stat_maps(&StatMap::new(), &StatMap::new()),
StatDiff {
added: vec![],
modified: vec![],
deleted: vec![],
}
);
}
#[test]
fn compute_stat_map_over_a_real_directory() {
let root = tempfile::tempdir().unwrap();
let base = root.path();
fs::create_dir_all(base.join("sub")).unwrap();
fs::write(base.join("a.txt"), "hello").unwrap();
fs::write(base.join("sub/b.txt"), "worldworld").unwrap();
let paths = ["a.txt", "sub/b.txt", "missing.txt", "sub"];
let m = compute_stat_map(&paths, base);
assert!(
!m.contains_key("missing.txt"),
"a path that does not exist is omitted"
);
assert!(
!m.contains_key("sub"),
"a directory is not a file — skipped"
);
assert_eq!(m["a.txt"].size, 5);
assert_eq!(m["sub/b.txt"].size, 10);
assert!(
m["a.txt"].mtime > 0,
"a freshly written file has a populated integer-ms mtime"
);
}
}