1use std::path::Path;
6use std::process::Command;
7
8use anyhow::{bail, Context, Result};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Layout {
13 PerPackage(Vec<String>),
15 Pooled,
17}
18
19pub const KINDS: [&str; 2] = ["changelog", "migrations"];
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Finding {
25 pub file: Option<String>,
27 pub message: String,
28}
29
30const SKIPPED_DIRS: [&str; 2] = ["node_modules", "target"];
32
33pub fn discover_layout(root: &Path) -> Option<Layout> {
35 let dirs = fragment_dirs(root);
36 if dirs.is_empty() {
37 return None;
38 }
39 let mut containers: Vec<String> = dirs
40 .iter()
41 .filter(|segs| segs.len() == 3)
42 .map(|segs| segs[0].clone())
43 .collect();
44 containers.sort();
45 containers.dedup();
46 if containers.is_empty() {
47 return Some(Layout::Pooled);
48 }
49 Some(Layout::PerPackage(containers))
50}
51
52pub fn migrations_enforced(root: &Path) -> bool {
54 fragment_dirs(root)
55 .iter()
56 .any(|segs| segs.last().is_some_and(|last| last == "migrations.d"))
57}
58
59pub fn fragment_name_ok(name: &str) -> bool {
62 let Some(stem) = name.strip_suffix(".md") else {
63 return false;
64 };
65 let bytes = stem.as_bytes();
66 if bytes.len() < 12 {
67 return false;
68 }
69 let digit = |i: usize| bytes[i].is_ascii_digit();
70 let dated = (0..4).all(digit)
71 && bytes[4] == b'-'
72 && (5..7).all(digit)
73 && bytes[7] == b'-'
74 && (8..10).all(digit)
75 && bytes[10] == b'-';
76 dated
77 && bytes[11..]
78 .iter()
79 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
80}
81
82pub fn has_skip_line(bodies: &str) -> bool {
84 const SKIP: &[u8] = b"skip-changelog:";
85 bodies.lines().any(|line| {
86 let bytes = line.as_bytes();
87 bytes.len() >= SKIP.len() && bytes[..SKIP.len()].eq_ignore_ascii_case(SKIP)
88 })
89}
90
91pub fn is_exempt(path: &str, pkg: &str) -> bool {
93 path.strip_prefix(pkg)
94 .and_then(|rest| rest.strip_prefix('/'))
95 .is_some_and(exempt_at_any_boundary)
96}
97
98pub fn changed_packages(changed: &[String]) -> Vec<String> {
100 let mut out: Vec<String> = changed
101 .iter()
102 .filter_map(|path| {
103 let segs: Vec<&str> = path.split('/').collect();
104 (segs.len() > 2).then(|| format!("{}/{}", segs[0], segs[1]))
105 })
106 .collect();
107 out.sort();
108 out.dedup();
109 out
110}
111
112pub fn findings(
115 layout: &Layout,
116 migrations: bool,
117 changed: &[String],
118 added: &[String],
119) -> Vec<Finding> {
120 let mut out: Vec<Finding> = malformed(layout, changed)
121 .into_iter()
122 .map(|path| Finding {
123 file: Some(path),
124 message: "fragment filenames are YYYY-MM-DD-<slug>.md — the UTC merge date, then \
125 lowercase letters, digits and hyphens. See docs/reference/checks/changelog."
126 .to_string(),
127 })
128 .collect();
129
130 match layout {
131 Layout::PerPackage(containers) => {
132 for pkg in changed_packages(changed) {
133 let in_a_container = containers.iter().any(|c| pkg.split('/').next() == Some(c));
134 if in_a_container && code_touched(changed, &pkg) {
135 for kind in missing_kinds(layout, added, Some(&pkg), migrations) {
136 out.push(owed(&format!("{pkg} "), &format!("{pkg}/{kind}.d"), kind));
137 }
138 }
139 }
140 }
141 Layout::Pooled => {
142 if changed.iter().any(|path| !exempt_at_any_boundary(path)) {
143 for kind in missing_kinds(layout, added, None, migrations) {
144 out.push(owed("", &format!("{kind}.d"), kind));
145 }
146 }
147 }
148 }
149 out
150}
151
152pub fn commit_bodies(repo: &Path, base: &str) -> Result<String> {
154 git(repo, &["log", "--format=%B", &format!("{base}..HEAD")])
155}
156
157pub fn changed_files(repo: &Path, base: &str) -> Result<Vec<String>> {
159 let out = git(repo, &["diff", "--name-only", &format!("{base}...HEAD")])?;
160 Ok(lines(&out))
161}
162
163pub fn added_files(repo: &Path, base: &str) -> Result<Vec<String>> {
166 let range = format!("{base}...HEAD");
167 let out = git(repo, &["diff", "--name-only", "--diff-filter=A", &range])?;
168 Ok(lines(&out))
169}
170
171fn owed(scope: &str, dir: &str, kind: &str) -> Finding {
172 Finding {
173 file: None,
174 message: format!(
175 "{scope}changed public surface without adding a {kind} fragment. Add \
176 {dir}/YYYY-MM-DD-<slug>.md, or put a `skip-changelog: <reason>` line on any commit \
177 for a genuinely internal refactor. See docs/reference/checks/changelog."
178 ),
179 }
180}
181
182struct Fragment {
184 pkg: Option<String>,
186 kind: &'static str,
187 name: String,
188}
189
190fn fragment(path: &str, layout: &Layout) -> Option<Fragment> {
191 let segs: Vec<&str> = path.split('/').collect();
192 let i = segs.iter().position(|seg| kind_of(seg).is_some())?;
193 let kind = kind_of(segs[i])?;
194 if i + 2 != segs.len() {
195 return None;
196 }
197 let name = segs[i + 1].to_string();
198 match layout {
199 Layout::PerPackage(containers) if i == 2 && containers.iter().any(|c| c == segs[0]) => {
200 Some(Fragment {
201 pkg: Some(format!("{}/{}", segs[0], segs[1])),
202 kind,
203 name,
204 })
205 }
206 Layout::PerPackage(_) => None,
207 Layout::Pooled => Some(Fragment {
208 pkg: None,
209 kind,
210 name,
211 }),
212 }
213}
214
215fn kind_of(segment: &str) -> Option<&'static str> {
216 let stem = segment.strip_suffix(".d")?;
217 KINDS.into_iter().find(|kind| *kind == stem)
218}
219
220fn malformed(layout: &Layout, changed: &[String]) -> Vec<String> {
223 changed
224 .iter()
225 .filter(|path| {
226 fragment(path, layout)
227 .is_some_and(|frag| frag.name != "README.md" && !fragment_name_ok(&frag.name))
228 })
229 .cloned()
230 .collect()
231}
232
233fn missing_kinds(
234 layout: &Layout,
235 added: &[String],
236 pkg: Option<&str>,
237 migrations: bool,
238) -> Vec<&'static str> {
239 let present: Vec<&'static str> = added
240 .iter()
241 .filter_map(|path| fragment(path, layout))
242 .filter(|frag| fragment_name_ok(&frag.name) && frag.pkg.as_deref() == pkg)
243 .map(|frag| frag.kind)
244 .collect();
245 KINDS
246 .into_iter()
247 .filter(|kind| migrations || *kind != "migrations")
248 .filter(|kind| !present.contains(kind))
249 .collect()
250}
251
252fn code_touched(changed: &[String], pkg: &str) -> bool {
253 let prefix = format!("{pkg}/");
254 changed
255 .iter()
256 .any(|path| path.starts_with(&prefix) && !is_exempt(path, pkg))
257}
258
259fn exempt_at_any_boundary(rel: &str) -> bool {
260 std::iter::once(rel)
261 .chain(rel.match_indices('/').map(|(i, _)| &rel[i + 1..]))
262 .any(exempt_shape)
263}
264
265fn exempt_shape(rel: &str) -> bool {
266 matches!(rel, "CHANGELOG.md" | "MIGRATIONS.md")
267 || KINDS
268 .iter()
269 .any(|kind| rel.starts_with(&format!("{kind}.d/")))
270 || rel.starts_with("e2e-attestations/")
271 || (rel.contains('/')
272 && matches!(rel.split('/').next(), Some("tests" | "test" | "__tests__")))
273 || rel.ends_with("_test.py")
274 || is_test_or_spec(rel)
275}
276
277fn is_test_or_spec(rel: &str) -> bool {
278 let name = rel.rsplit('/').next().unwrap_or(rel);
279 ["ts", "tsx", "js", "mjs", "cjs", "py", "rs"]
280 .iter()
281 .any(|ext| {
282 name.ends_with(&format!(".test.{ext}")) || name.ends_with(&format!(".spec.{ext}"))
283 })
284}
285
286fn fragment_dirs(root: &Path) -> Vec<Vec<String>> {
289 let mut out = Vec::new();
290 scan(root, &[], &mut out);
291 out
292}
293
294fn scan(dir: &Path, prefix: &[String], out: &mut Vec<Vec<String>>) {
295 let Ok(entries) = std::fs::read_dir(dir) else {
296 return;
297 };
298 for entry in entries.flatten() {
299 if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
300 continue;
301 }
302 let name = entry.file_name().to_string_lossy().into_owned();
303 if name.starts_with('.') || SKIPPED_DIRS.contains(&name.as_str()) {
304 continue;
305 }
306 let mut segs = prefix.to_vec();
307 segs.push(name.clone());
308 if kind_of(&name).is_some() {
309 out.push(segs);
310 } else if segs.len() < 3 {
311 scan(&entry.path(), &segs, out);
312 }
313 }
314}
315
316fn git(repo: &Path, args: &[&str]) -> Result<String> {
317 let out = Command::new("git")
318 .current_dir(repo)
319 .args(args)
320 .output()
321 .with_context(|| format!("running `git {}` in `{}`", args.join(" "), repo.display()))?;
322 if !out.status.success() {
323 bail!(
324 "`git {}` failed: {}",
325 args.join(" "),
326 String::from_utf8_lossy(&out.stderr).trim()
327 );
328 }
329 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
330}
331
332fn lines(out: &str) -> Vec<String> {
333 out.lines()
334 .filter(|line| !line.is_empty())
335 .map(str::to_string)
336 .collect()
337}