1use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum AnchorKind {
20 File,
22 Dir,
24 Glob,
26}
27
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum AnchorMode {
32 #[default]
34 Content,
35 Shape,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum AnchorRole {
43 Derived,
45 Cites,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum AnchorState {
53 Fresh,
55 Stale,
57 Noted,
59 Missing,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct Anchor {
67 pub kind: AnchorKind,
68 #[serde(default, skip_serializing_if = "is_content")]
69 pub mode: AnchorMode,
70 pub role: AnchorRole,
71 pub path: String,
72 pub digest: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub commit: Option<String>,
77}
78
79#[allow(clippy::trivially_copy_pass_by_ref)] fn is_content(m: &AnchorMode) -> bool {
81 *m == AnchorMode::Content
82}
83
84impl Anchor {
85 pub fn reference(&self) -> String {
87 let scheme = match self.kind {
88 AnchorKind::File => "file",
89 AnchorKind::Dir => "dir",
90 AnchorKind::Glob => "glob",
91 };
92 format!("{scheme}:{}", self.path)
93 }
94
95 pub fn derives(&self, file: &str) -> bool {
98 self.role == AnchorRole::Derived && self.mode == AnchorMode::Content && self.matches(file)
99 }
100
101 pub fn matches(&self, file: &str) -> bool {
103 match self.kind {
104 AnchorKind::File => file == self.path,
105 AnchorKind::Dir => under(&self.path, file),
106 AnchorKind::Glob => glob_match(&self.path, file),
107 }
108 }
109
110 pub fn state(&self, current: Option<&str>, covered: bool) -> AnchorState {
113 match current {
114 None => AnchorState::Missing,
115 Some(d) if d == self.digest => AnchorState::Fresh,
116 Some(_) if covered => AnchorState::Noted,
117 Some(_) => AnchorState::Stale,
118 }
119 }
120}
121
122fn under(dir: &str, file: &str) -> bool {
124 let dir = dir.trim_end_matches('/');
125 dir.is_empty() || file == dir || file.strip_prefix(dir).is_some_and(|r| r.starts_with('/'))
126}
127
128pub fn glob_match(pattern: &str, path: &str) -> bool {
130 let pat: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
131 let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
132 match_segments(&pat, &segs)
133}
134
135fn match_segments(pat: &[&str], segs: &[&str]) -> bool {
136 match pat.split_first() {
137 None => segs.is_empty(),
138 Some((&"**", rest)) => (0..=segs.len()).any(|i| match_segments(rest, &segs[i..])),
139 Some((p, rest)) => segs.split_first().is_some_and(|(s, tail)| {
140 match_segment(p.as_bytes(), s.as_bytes()) && match_segments(rest, tail)
141 }),
142 }
143}
144
145fn match_segment(p: &[u8], s: &[u8]) -> bool {
146 match p.split_first() {
147 None => s.is_empty(),
148 Some((b'*', rest)) => (0..=s.len()).any(|i| match_segment(rest, &s[i..])),
149 Some((b'?', rest)) => !s.is_empty() && match_segment(rest, &s[1..]),
150 Some((c, rest)) => s.first() == Some(c) && match_segment(rest, &s[1..]),
151 }
152}
153
154pub fn is_glob(path: &str) -> bool {
156 path.contains(['*', '?', '['])
157}
158
159pub fn file_digest(bytes: &[u8]) -> String {
161 hex(&Sha256::digest(bytes))
162}
163
164pub fn tree_digest(entries: &[(String, String)]) -> String {
167 let mut sorted: Vec<&(String, String)> = entries.iter().collect();
168 sorted.sort();
169 let mut h = Sha256::new();
170 for (path, digest) in sorted {
171 h.update(path.as_bytes());
172 h.update(b"\0");
173 h.update(digest.as_bytes());
174 h.update(b"\n");
175 }
176 hex(&h.finalize())
177}
178
179pub fn shape_digest(paths: &[String]) -> String {
181 let mut sorted: Vec<&String> = paths.iter().collect();
182 sorted.sort();
183 let mut h = Sha256::new();
184 for p in sorted {
185 h.update(p.as_bytes());
186 h.update(b"\n");
187 }
188 hex(&h.finalize())
189}
190
191fn hex(bytes: &[u8]) -> String {
192 use std::fmt::Write as _;
193 bytes
194 .iter()
195 .fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
196 let _ = write!(s, "{b:02x}");
197 s
198 })
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct Coverage {
204 pub files: usize,
205 pub covered: usize,
206 pub uncovered: Vec<String>,
208}
209
210pub fn coverage<'a>(files: &[&str], anchors: impl IntoIterator<Item = &'a Anchor>) -> Coverage {
212 let anchors: Vec<&Anchor> = anchors
213 .into_iter()
214 .filter(|a| a.role == AnchorRole::Derived)
215 .collect();
216 let mut uncovered: Vec<String> = files
217 .iter()
218 .filter(|f| !anchors.iter().any(|a| a.derives(f)))
219 .map(|f| (*f).to_owned())
220 .collect();
221 uncovered.sort();
222 Coverage {
223 files: files.len(),
224 covered: files.len() - uncovered.len(),
225 uncovered,
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn anchor(kind: AnchorKind, role: AnchorRole, path: &str) -> Anchor {
234 Anchor {
235 kind,
236 mode: AnchorMode::Content,
237 role,
238 path: path.into(),
239 digest: "aa".into(),
240 commit: None,
241 }
242 }
243
244 #[test]
245 fn globs_match_segments_and_double_stars() {
246 assert!(glob_match("rust/src/**/*.rs", "rust/src/a.rs"));
247 assert!(glob_match("rust/src/**/*.rs", "rust/src/core/hive/b.rs"));
248 assert!(!glob_match("rust/src/**/*.rs", "rust/srcx/a.rs"));
249 assert!(!glob_match("rust/src/*.rs", "rust/src/core/a.rs"));
250 assert!(glob_match("a/?.md", "a/b.md"));
251 assert!(glob_match("**", "anything/at/all"));
252 }
253
254 #[test]
255 fn path_anchors_cover_their_subtree_only() {
256 let a = anchor(AnchorKind::Dir, AnchorRole::Derived, "rust/src/core");
257 assert!(a.derives("rust/src/core/x.rs"));
258 assert!(!a.derives("rust/src/corex/y.rs"));
259 assert!(anchor(AnchorKind::Dir, AnchorRole::Derived, "").derives("x"));
260 let f = anchor(AnchorKind::File, AnchorRole::Derived, "rust/src/a.rs");
261 assert!(f.derives("rust/src/a.rs") && !f.derives("rust/src/a.rs/b"));
262 assert_eq!(a.reference(), "dir:rust/src/core");
263 }
264
265 #[test]
266 fn coverage_ignores_cites_and_shapes() {
267 let anchors = [
268 anchor(AnchorKind::File, AnchorRole::Derived, "src/a.rs"),
269 anchor(AnchorKind::File, AnchorRole::Cites, "src/b.rs"),
270 Anchor {
271 mode: AnchorMode::Shape,
272 ..anchor(AnchorKind::Dir, AnchorRole::Derived, "src")
273 },
274 anchor(AnchorKind::Glob, AnchorRole::Derived, "src/g/*.rs"),
275 ];
276 let c = coverage(
277 &["src/a.rs", "src/b.rs", "src/g/c.rs", "src/d.rs"],
278 &anchors,
279 );
280 assert_eq!(c.covered, 2);
281 assert_eq!(c.uncovered, ["src/b.rs", "src/d.rs"]);
282 }
283
284 #[test]
285 fn states_follow_the_digest_and_notes() {
286 let a = anchor(AnchorKind::File, AnchorRole::Derived, "x");
287 assert_eq!(a.state(Some("aa"), false), AnchorState::Fresh);
288 assert_eq!(a.state(Some("bb"), false), AnchorState::Stale);
289 assert_eq!(a.state(Some("bb"), true), AnchorState::Noted);
290 assert_eq!(a.state(None, false), AnchorState::Missing);
291 }
292
293 #[test]
295 fn digest_vectors() {
296 let lib = file_digest(b"pub mod x;\n");
297 assert_eq!(
298 lib,
299 "1497ba2ae9c58431b70c98c925ac011cdfc28b8b74ce9e0e5ae54c0261ec5fb9"
300 );
301 let dir = tree_digest(&[
302 ("b.md".to_owned(), file_digest(b"beta\n")),
303 ("a.md".to_owned(), file_digest(b"alpha\n")),
304 ]);
305 assert_eq!(
306 dir,
307 "1d71a3193a1273437b70644f06adaff7d368b9c6f0598776ba0503574103f93f"
308 );
309 let glob = tree_digest(&[
310 ("src/lib.rs".to_owned(), lib),
311 ("src/x/y.rs".to_owned(), file_digest(b"pub fn y() {}\n")),
312 ]);
313 assert_eq!(
314 glob,
315 "e5ba8609e0f1153e71e6015814700e24ddf89abe7b5953363ccc53f79aff32f6"
316 );
317 assert!(
318 glob_match("src/**/*.rs", "src/lib.rs") && !glob_match("src/**/*.rs", "src/README.md")
319 );
320 assert_eq!(
321 shape_digest(&["b.md".into(), "a.md".into()]),
322 "80470d491b39cfbc95db36d718eddff3c90a184765b75ab9353d3b53408d65d1"
323 );
324 assert_eq!(
325 tree_digest(&[]),
326 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
327 );
328 }
329
330 #[test]
331 fn digests_are_order_independent_and_path_sensitive() {
332 let a = vec![
333 ("a".to_owned(), "d1".to_owned()),
334 ("b".to_owned(), "d2".to_owned()),
335 ];
336 let b = vec![a[1].clone(), a[0].clone()];
337 assert_eq!(tree_digest(&a), tree_digest(&b));
338 assert_ne!(
339 tree_digest(&a),
340 tree_digest(&[("c".to_owned(), "d1".to_owned())])
341 );
342 assert_ne!(shape_digest(&["a".into()]), shape_digest(&["b".into()]));
343 assert_eq!(
344 file_digest(b"abc"),
345 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
346 );
347 }
348}