Skip to main content

testing_conventions/
changelog.rs

1//! Changelog rule: a pull request that changes a package's public surface adds a fragment
2//! recording it. The layout is discovered from where the fragment directories sit, so a
3//! consumer declares nothing.
4
5use std::path::Path;
6use std::process::Command;
7
8use anyhow::{bail, Context, Result};
9
10/// Where a repository keeps its fragments.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Layout {
13    /// One fragment directory per package; the payload is the directories holding the packages.
14    PerPackage(Vec<String>),
15    /// One fragment directory for the whole repository.
16    Pooled,
17}
18
19/// The two fragment kinds, in the order the check reports them missing.
20pub const KINDS: [&str; 2] = ["changelog", "migrations"];
21
22/// One thing a pull request owes, ready to render as an annotation.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Finding {
25    /// The offending file, where the finding has one.
26    pub file: Option<String>,
27    pub message: String,
28}
29
30/// Directories the fragment walk never descends into.
31const SKIPPED_DIRS: [&str; 2] = ["node_modules", "target"];
32
33/// The layout `root` keeps its fragments in, or `None` when it keeps none.
34pub 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
52/// `true` when `root` keeps migration fragments alongside its changelog fragments.
53pub 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
59/// `true` when `name` is `YYYY-MM-DD-<slug>.md` — the UTC merge date, then lowercase letters,
60/// digits and hyphens.
61pub 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
82/// `true` when any line of `bodies` opens with `skip-changelog:`.
83pub 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
91/// `true` when `path` sits under `pkg` and is not public surface.
92pub 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
98/// The package directories `changed` touches, unique and sorted.
99pub 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
112/// Everything the pull request owes: fragments whose names break the convention, then the
113/// fragments each changed scope is still missing.
114pub 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
152/// The bodies of every commit in `<base>..HEAD`, concatenated.
153pub fn commit_bodies(repo: &Path, base: &str) -> Result<String> {
154    git(repo, &["log", "--format=%B", &format!("{base}..HEAD")])
155}
156
157/// Every path `<base>...HEAD` changed.
158pub 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
163/// The paths `<base>...HEAD` added. A fragment satisfies the check only when the pull request
164/// adds it, so the diff is filtered to additions.
165pub 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
182/// A fragment path, split into the scope that owns it, its kind, and its filename.
183struct Fragment {
184    /// The owning package, or `None` under the pooled layout.
185    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
220/// Touched fragment paths whose filenames break the convention. Each fragment directory carries
221/// a `README.md` describing that convention, which is not an entry.
222fn 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
286/// Every fragment directory under `root`, as its root-relative segments. The convention puts a
287/// fragment directory at most two levels down, which bounds the walk.
288fn 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}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    #[test]
344    fn owed_names_the_scope_kind_and_fragment_directory() {
345        let finding = owed(
346            "packages/parser ",
347            "packages/parser/changelog.d",
348            "changelog",
349        );
350        assert_eq!(finding.file, None);
351        assert!(finding
352            .message
353            .contains("packages/parser changed public surface"));
354        assert!(finding
355            .message
356            .contains("packages/parser/changelog.d/YYYY-MM-DD-<slug>.md"));
357        assert!(finding.message.contains("skip-changelog: <reason>"));
358    }
359
360    #[test]
361    fn fragment_recognizes_only_a_package_fragment_at_the_expected_depth() {
362        let layout = Layout::PerPackage(vec!["packages".to_string()]);
363        let found = fragment("packages/parser/changelog.d/2026-09-26-change.md", &layout).unwrap();
364        assert_eq!(found.pkg.as_deref(), Some("packages/parser"));
365        assert_eq!(found.kind, "changelog");
366        assert_eq!(found.name, "2026-09-26-change.md");
367        assert!(fragment("other/parser/changelog.d/2026-09-26-change.md", &layout).is_none());
368        assert!(fragment("packages/parser/changelog.d/nested/change.md", &layout).is_none());
369    }
370
371    #[test]
372    fn kind_of_accepts_only_the_two_fragment_directories() {
373        assert_eq!(kind_of("changelog.d"), Some("changelog"));
374        assert_eq!(kind_of("migrations.d"), Some("migrations"));
375        assert_eq!(kind_of("notes.d"), None);
376        assert_eq!(kind_of("changelog"), None);
377    }
378
379    #[test]
380    fn malformed_reports_entries_but_not_fragment_readmes() {
381        let layout = Layout::Pooled;
382        let changed = vec![
383            "changelog.d/README.md".to_string(),
384            "changelog.d/2026-09-26-valid.md".to_string(),
385            "migrations.d/bad-name.md".to_string(),
386            "src/bad-name.md".to_string(),
387        ];
388        assert_eq!(
389            malformed(&layout, &changed),
390            vec!["migrations.d/bad-name.md"]
391        );
392    }
393
394    #[test]
395    fn missing_kinds_requires_added_valid_fragments_for_the_same_package() {
396        let layout = Layout::PerPackage(vec!["packages".to_string()]);
397        let added = vec![
398            "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
399            "packages/parser/migrations.d/bad-name.md".to_string(),
400            "packages/other/migrations.d/2026-09-26-change.md".to_string(),
401        ];
402        assert_eq!(
403            missing_kinds(&layout, &added, Some("packages/parser"), true),
404            vec!["migrations"]
405        );
406        assert!(missing_kinds(&layout, &added, Some("packages/parser"), false).is_empty());
407    }
408
409    #[test]
410    fn code_touched_ignores_fragments_and_tests_inside_the_package() {
411        let pkg = "packages/parser";
412        let exempt = vec![
413            "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
414            "packages/parser/tests/parser.rs".to_string(),
415            "packages/other/src/parser.rs".to_string(),
416        ];
417        assert!(!code_touched(&exempt, pkg));
418        assert!(code_touched(
419            &["packages/parser/src/parser.rs".to_string()],
420            pkg
421        ));
422    }
423
424    #[test]
425    fn exempt_at_any_boundary_checks_nested_test_and_fragment_paths() {
426        assert!(exempt_at_any_boundary("packages/parser/tests/parser.rs"));
427        assert!(exempt_at_any_boundary(
428            "packages/parser/changelog.d/2026-09-26-change.md"
429        ));
430        assert!(!exempt_at_any_boundary("packages/parser/src/parser.rs"));
431    }
432
433    #[test]
434    fn exempt_shape_recognizes_archives_attestations_and_test_paths() {
435        assert!(exempt_shape("CHANGELOG.md"));
436        assert!(exempt_shape("MIGRATIONS.md"));
437        assert!(exempt_shape("e2e-attestations/run.json"));
438        assert!(exempt_shape("tests/parser.rs"));
439        assert!(exempt_shape("src/parser_test.py"));
440        assert!(!exempt_shape("src/parser.rs"));
441    }
442
443    #[test]
444    fn is_test_or_spec_matches_supported_extensions_only() {
445        assert!(is_test_or_spec("src/parser.test.rs"));
446        assert!(is_test_or_spec("src/parser.spec.tsx"));
447        assert!(!is_test_or_spec("src/parser.test.txt"));
448        assert!(!is_test_or_spec("src/parser.rs"));
449    }
450
451    #[test]
452    fn fragment_dirs_finds_shallow_directories_and_skips_build_trees() {
453        let root = std::env::temp_dir().join(format!("tc-changelog-inline-{}", std::process::id()));
454        std::fs::create_dir_all(root.join("packages/parser/changelog.d")).unwrap();
455        std::fs::create_dir_all(root.join("target/debug/migrations.d")).unwrap();
456        std::fs::create_dir_all(root.join("packages/parser/deep/migrations.d")).unwrap();
457        let dirs = fragment_dirs(&root);
458        assert_eq!(
459            dirs,
460            vec![vec![
461                "packages".to_string(),
462                "parser".to_string(),
463                "changelog.d".to_string()
464            ]]
465        );
466        std::fs::remove_dir_all(root).unwrap();
467    }
468}