use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorKind {
File,
Dir,
Glob,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorMode {
#[default]
Content,
Shape,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorRole {
Derived,
Cites,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnchorState {
Fresh,
Stale,
Noted,
Missing,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Anchor {
pub kind: AnchorKind,
#[serde(default, skip_serializing_if = "is_content")]
pub mode: AnchorMode,
pub role: AnchorRole,
pub path: String,
pub digest: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_content(m: &AnchorMode) -> bool {
*m == AnchorMode::Content
}
impl Anchor {
pub fn reference(&self) -> String {
let scheme = match self.kind {
AnchorKind::File => "file",
AnchorKind::Dir => "dir",
AnchorKind::Glob => "glob",
};
format!("{scheme}:{}", self.path)
}
pub fn derives(&self, file: &str) -> bool {
self.role == AnchorRole::Derived && self.mode == AnchorMode::Content && self.matches(file)
}
pub fn matches(&self, file: &str) -> bool {
match self.kind {
AnchorKind::File => file == self.path,
AnchorKind::Dir => under(&self.path, file),
AnchorKind::Glob => glob_match(&self.path, file),
}
}
pub fn state(&self, current: Option<&str>, covered: bool) -> AnchorState {
match current {
None => AnchorState::Missing,
Some(d) if d == self.digest => AnchorState::Fresh,
Some(_) if covered => AnchorState::Noted,
Some(_) => AnchorState::Stale,
}
}
}
fn under(dir: &str, file: &str) -> bool {
let dir = dir.trim_end_matches('/');
dir.is_empty() || file == dir || file.strip_prefix(dir).is_some_and(|r| r.starts_with('/'))
}
pub fn glob_match(pattern: &str, path: &str) -> bool {
let pat: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
match_segments(&pat, &segs)
}
fn match_segments(pat: &[&str], segs: &[&str]) -> bool {
match pat.split_first() {
None => segs.is_empty(),
Some((&"**", rest)) => (0..=segs.len()).any(|i| match_segments(rest, &segs[i..])),
Some((p, rest)) => segs.split_first().is_some_and(|(s, tail)| {
match_segment(p.as_bytes(), s.as_bytes()) && match_segments(rest, tail)
}),
}
}
fn match_segment(p: &[u8], s: &[u8]) -> bool {
match p.split_first() {
None => s.is_empty(),
Some((b'*', rest)) => (0..=s.len()).any(|i| match_segment(rest, &s[i..])),
Some((b'?', rest)) => !s.is_empty() && match_segment(rest, &s[1..]),
Some((c, rest)) => s.first() == Some(c) && match_segment(rest, &s[1..]),
}
}
pub fn is_glob(path: &str) -> bool {
path.contains(['*', '?', '['])
}
pub fn file_digest(bytes: &[u8]) -> String {
hex(&Sha256::digest(bytes))
}
pub fn tree_digest(entries: &[(String, String)]) -> String {
let mut sorted: Vec<&(String, String)> = entries.iter().collect();
sorted.sort();
let mut h = Sha256::new();
for (path, digest) in sorted {
h.update(path.as_bytes());
h.update(b"\0");
h.update(digest.as_bytes());
h.update(b"\n");
}
hex(&h.finalize())
}
pub fn shape_digest(paths: &[String]) -> String {
let mut sorted: Vec<&String> = paths.iter().collect();
sorted.sort();
let mut h = Sha256::new();
for p in sorted {
h.update(p.as_bytes());
h.update(b"\n");
}
hex(&h.finalize())
}
fn hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Coverage {
pub files: usize,
pub covered: usize,
pub uncovered: Vec<String>,
}
pub fn coverage<'a>(files: &[&str], anchors: impl IntoIterator<Item = &'a Anchor>) -> Coverage {
let anchors: Vec<&Anchor> = anchors
.into_iter()
.filter(|a| a.role == AnchorRole::Derived)
.collect();
let mut uncovered: Vec<String> = files
.iter()
.filter(|f| !anchors.iter().any(|a| a.derives(f)))
.map(|f| (*f).to_owned())
.collect();
uncovered.sort();
Coverage {
files: files.len(),
covered: files.len() - uncovered.len(),
uncovered,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn anchor(kind: AnchorKind, role: AnchorRole, path: &str) -> Anchor {
Anchor {
kind,
mode: AnchorMode::Content,
role,
path: path.into(),
digest: "aa".into(),
commit: None,
}
}
#[test]
fn globs_match_segments_and_double_stars() {
assert!(glob_match("rust/src/**/*.rs", "rust/src/a.rs"));
assert!(glob_match("rust/src/**/*.rs", "rust/src/core/hive/b.rs"));
assert!(!glob_match("rust/src/**/*.rs", "rust/srcx/a.rs"));
assert!(!glob_match("rust/src/*.rs", "rust/src/core/a.rs"));
assert!(glob_match("a/?.md", "a/b.md"));
assert!(glob_match("**", "anything/at/all"));
}
#[test]
fn path_anchors_cover_their_subtree_only() {
let a = anchor(AnchorKind::Dir, AnchorRole::Derived, "rust/src/core");
assert!(a.derives("rust/src/core/x.rs"));
assert!(!a.derives("rust/src/corex/y.rs"));
assert!(anchor(AnchorKind::Dir, AnchorRole::Derived, "").derives("x"));
let f = anchor(AnchorKind::File, AnchorRole::Derived, "rust/src/a.rs");
assert!(f.derives("rust/src/a.rs") && !f.derives("rust/src/a.rs/b"));
assert_eq!(a.reference(), "dir:rust/src/core");
}
#[test]
fn coverage_ignores_cites_and_shapes() {
let anchors = [
anchor(AnchorKind::File, AnchorRole::Derived, "src/a.rs"),
anchor(AnchorKind::File, AnchorRole::Cites, "src/b.rs"),
Anchor {
mode: AnchorMode::Shape,
..anchor(AnchorKind::Dir, AnchorRole::Derived, "src")
},
anchor(AnchorKind::Glob, AnchorRole::Derived, "src/g/*.rs"),
];
let c = coverage(
&["src/a.rs", "src/b.rs", "src/g/c.rs", "src/d.rs"],
&anchors,
);
assert_eq!(c.covered, 2);
assert_eq!(c.uncovered, ["src/b.rs", "src/d.rs"]);
}
#[test]
fn states_follow_the_digest_and_notes() {
let a = anchor(AnchorKind::File, AnchorRole::Derived, "x");
assert_eq!(a.state(Some("aa"), false), AnchorState::Fresh);
assert_eq!(a.state(Some("bb"), false), AnchorState::Stale);
assert_eq!(a.state(Some("bb"), true), AnchorState::Noted);
assert_eq!(a.state(None, false), AnchorState::Missing);
}
#[test]
fn digest_vectors() {
let lib = file_digest(b"pub mod x;\n");
assert_eq!(
lib,
"1497ba2ae9c58431b70c98c925ac011cdfc28b8b74ce9e0e5ae54c0261ec5fb9"
);
let dir = tree_digest(&[
("b.md".to_owned(), file_digest(b"beta\n")),
("a.md".to_owned(), file_digest(b"alpha\n")),
]);
assert_eq!(
dir,
"1d71a3193a1273437b70644f06adaff7d368b9c6f0598776ba0503574103f93f"
);
let glob = tree_digest(&[
("src/lib.rs".to_owned(), lib),
("src/x/y.rs".to_owned(), file_digest(b"pub fn y() {}\n")),
]);
assert_eq!(
glob,
"e5ba8609e0f1153e71e6015814700e24ddf89abe7b5953363ccc53f79aff32f6"
);
assert!(
glob_match("src/**/*.rs", "src/lib.rs") && !glob_match("src/**/*.rs", "src/README.md")
);
assert_eq!(
shape_digest(&["b.md".into(), "a.md".into()]),
"80470d491b39cfbc95db36d718eddff3c90a184765b75ab9353d3b53408d65d1"
);
assert_eq!(
tree_digest(&[]),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn digests_are_order_independent_and_path_sensitive() {
let a = vec![
("a".to_owned(), "d1".to_owned()),
("b".to_owned(), "d2".to_owned()),
];
let b = vec![a[1].clone(), a[0].clone()];
assert_eq!(tree_digest(&a), tree_digest(&b));
assert_ne!(
tree_digest(&a),
tree_digest(&[("c".to_owned(), "d1".to_owned())])
);
assert_ne!(shape_digest(&["a".into()]), shape_digest(&["b".into()]));
assert_eq!(
file_digest(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
}