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/// Everything the changelog verdict reads from the repository.
31///
32/// Gathered in one go rather than on demand. The lazy shape — skip the git calls when no
33/// fragment directory exists — cannot be split into a testable decision and an untestable
34/// seam, because the laziness *is* the branch. Every repository behaves identically either
35/// way: a repository with no fragment directories still reports "skipped", because the
36/// layout, not the diff, decides that. Only a path that is not a repository at all changes,
37/// and it now errors rather than reporting a clean skip.
38struct Facts {
39    layout: Option<Layout>,
40    bodies: String,
41    changed: Vec<String>,
42    added: Vec<String>,
43    migrations: bool,
44}
45
46/// Read the repository facts the verdict decides on.
47fn facts(root: &Path, base: &str) -> Result<Facts> {
48    Ok(Facts {
49        layout: discover_layout(root),
50        bodies: commit_bodies(root, base)?,
51        changed: changed_files(root, base)?,
52        added: added_files(root, base)?,
53        migrations: migrations_enforced(root),
54    })
55}
56
57/// The exit code and the lines to print for gathered `facts`. The whole decision, with no
58/// repository in reach: `0` for a tree keeping no fragments, `0` for a bypass, `0` when every
59/// scope paid, and `1` with one annotation per scope that did not.
60fn verdict(root: &Path, facts: &Facts) -> (i32, Vec<String>) {
61    let Some(layout) = &facts.layout else {
62        return (
63            0,
64            vec![format!(
65                "No fragment directories under `{}`; changelog check skipped.",
66                root.display()
67            )],
68        );
69    };
70    if has_skip_line(&facts.bodies) {
71        return (
72            0,
73            vec!["A `skip-changelog:` line is present; changelog check bypassed.".to_string()],
74        );
75    }
76    let found = findings(layout, facts.migrations, &facts.changed, &facts.added);
77    if found.is_empty() {
78        return (
79            0,
80            vec!["Every scope that changed public surface added its fragments.".to_string()],
81        );
82    }
83    (1, found.iter().map(annotation).collect())
84}
85
86/// Report every scope in `<base>...HEAD` that changed public surface without adding the
87/// fragments recording it. `0` when `root` keeps no fragment directories.
88///
89/// Branch-free on purpose: it reads the repository through [`facts`], so replacing its body
90/// whole has no unit-tier contract. [`verdict`] holds the decision and is tested directly.
91pub fn run(root: &Path, base: &str) -> Result<i32> {
92    let (code, lines) = verdict(root, &facts(root, base)?);
93    println!("{}", lines.join("\n"));
94    Ok(code)
95}
96
97/// `finding` as the GitHub Actions annotation that reports it: file-scoped when the finding
98/// names a file, workflow-scoped when it does not.
99pub fn annotation(finding: &Finding) -> String {
100    match &finding.file {
101        Some(file) => format!("::error file={file}::{}", finding.message),
102        None => format!("::error::{}", finding.message),
103    }
104}
105
106/// Directories the fragment walk never descends into.
107const SKIPPED_DIRS: [&str; 2] = ["node_modules", "target"];
108
109/// The layout `root` keeps its fragments in, or `None` when it keeps none.
110pub fn discover_layout(root: &Path) -> Option<Layout> {
111    let dirs = fragment_dirs(root);
112    if dirs.is_empty() {
113        return None;
114    }
115    let mut containers: Vec<String> = dirs
116        .iter()
117        .filter(|segs| segs.len() == 3)
118        .map(|segs| segs[0].clone())
119        .collect();
120    containers.sort();
121    containers.dedup();
122    if containers.is_empty() {
123        return Some(Layout::Pooled);
124    }
125    Some(Layout::PerPackage(containers))
126}
127
128/// `true` when `root` keeps migration fragments alongside its changelog fragments.
129pub fn migrations_enforced(root: &Path) -> bool {
130    fragment_dirs(root)
131        .iter()
132        .any(|segs| segs.last().is_some_and(|last| last == "migrations.d"))
133}
134
135/// `true` when `name` is `YYYY-MM-DD-<slug>.md` — the UTC merge date, then lowercase letters,
136/// digits and hyphens.
137pub fn fragment_name_ok(name: &str) -> bool {
138    let Some(stem) = name.strip_suffix(".md") else {
139        return false;
140    };
141    let bytes = stem.as_bytes();
142    if bytes.len() < 12 {
143        return false;
144    }
145    let digit = |i: usize| bytes[i].is_ascii_digit();
146    let dated = (0..4).all(digit)
147        && bytes[4] == b'-'
148        && (5..7).all(digit)
149        && bytes[7] == b'-'
150        && (8..10).all(digit)
151        && bytes[10] == b'-';
152    dated
153        && bytes[11..]
154            .iter()
155            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
156}
157
158/// `true` when any line of `bodies` opens with `skip-changelog:`.
159pub fn has_skip_line(bodies: &str) -> bool {
160    const SKIP: &[u8] = b"skip-changelog:";
161    bodies.lines().any(|line| {
162        let bytes = line.as_bytes();
163        bytes.len() >= SKIP.len() && bytes[..SKIP.len()].eq_ignore_ascii_case(SKIP)
164    })
165}
166
167/// `true` when `path` sits under `pkg` and is not public surface.
168pub fn is_exempt(path: &str, pkg: &str) -> bool {
169    path.strip_prefix(pkg)
170        .and_then(|rest| rest.strip_prefix('/'))
171        .is_some_and(exempt_at_any_boundary)
172}
173
174/// The package directories `changed` touches, unique and sorted.
175pub fn changed_packages(changed: &[String]) -> Vec<String> {
176    let mut out: Vec<String> = changed
177        .iter()
178        .filter_map(|path| {
179            let segs: Vec<&str> = path.split('/').collect();
180            (segs.len() > 2).then(|| format!("{}/{}", segs[0], segs[1]))
181        })
182        .collect();
183    out.sort();
184    out.dedup();
185    out
186}
187
188/// Everything the pull request owes: fragments whose names break the convention, then the
189/// fragments each changed scope is still missing.
190pub fn findings(
191    layout: &Layout,
192    migrations: bool,
193    changed: &[String],
194    added: &[String],
195) -> Vec<Finding> {
196    let mut out: Vec<Finding> = malformed(layout, changed)
197        .into_iter()
198        .map(|path| Finding {
199            file: Some(path),
200            message: "fragment filenames are YYYY-MM-DD-<slug>.md — the UTC merge date, then \
201                      lowercase letters, digits and hyphens. See docs/reference/checks/changelog."
202                .to_string(),
203        })
204        .collect();
205
206    match layout {
207        Layout::PerPackage(containers) => {
208            for pkg in changed_packages(changed) {
209                let in_a_container = containers.iter().any(|c| pkg.split('/').next() == Some(c));
210                if in_a_container && code_touched(changed, &pkg) {
211                    for kind in missing_kinds(layout, added, Some(&pkg), migrations) {
212                        out.push(owed(&format!("{pkg} "), &format!("{pkg}/{kind}.d"), kind));
213                    }
214                }
215            }
216        }
217        Layout::Pooled => {
218            if changed.iter().any(|path| !exempt_at_any_boundary(path)) {
219                for kind in missing_kinds(layout, added, None, migrations) {
220                    out.push(owed("", &format!("{kind}.d"), kind));
221                }
222            }
223        }
224    }
225    out
226}
227
228/// The bodies of every commit in `<base>..HEAD`, concatenated.
229pub fn commit_bodies(repo: &Path, base: &str) -> Result<String> {
230    git(repo, &["log", "--format=%B", &format!("{base}..HEAD")])
231}
232
233/// Every path `<base>...HEAD` changed.
234pub fn changed_files(repo: &Path, base: &str) -> Result<Vec<String>> {
235    let out = git(repo, &["diff", "--name-only", &format!("{base}...HEAD")])?;
236    Ok(lines(&out))
237}
238
239/// The paths `<base>...HEAD` added. A fragment satisfies the check only when the pull request
240/// adds it, so the diff is filtered to additions.
241pub fn added_files(repo: &Path, base: &str) -> Result<Vec<String>> {
242    let range = format!("{base}...HEAD");
243    let out = git(repo, &["diff", "--name-only", "--diff-filter=A", &range])?;
244    Ok(lines(&out))
245}
246
247fn owed(scope: &str, dir: &str, kind: &str) -> Finding {
248    Finding {
249        file: None,
250        message: format!(
251            "{scope}changed public surface without adding a {kind} fragment. Add \
252             {dir}/YYYY-MM-DD-<slug>.md, or put a `skip-changelog: <reason>` line on any commit \
253             for a genuinely internal refactor. See docs/reference/checks/changelog."
254        ),
255    }
256}
257
258/// A fragment path, split into the scope that owns it, its kind, and its filename.
259struct Fragment {
260    /// The owning package, or `None` under the pooled layout.
261    pkg: Option<String>,
262    kind: &'static str,
263    name: String,
264}
265
266fn fragment(path: &str, layout: &Layout) -> Option<Fragment> {
267    let segs: Vec<&str> = path.split('/').collect();
268    let i = segs.iter().position(|seg| kind_of(seg).is_some())?;
269    let kind = kind_of(segs[i])?;
270    if i + 2 != segs.len() {
271        return None;
272    }
273    let name = segs[i + 1].to_string();
274    match layout {
275        Layout::PerPackage(containers) if i == 2 && containers.iter().any(|c| c == segs[0]) => {
276            Some(Fragment {
277                pkg: Some(format!("{}/{}", segs[0], segs[1])),
278                kind,
279                name,
280            })
281        }
282        Layout::PerPackage(_) => None,
283        Layout::Pooled => Some(Fragment {
284            pkg: None,
285            kind,
286            name,
287        }),
288    }
289}
290
291fn kind_of(segment: &str) -> Option<&'static str> {
292    let stem = segment.strip_suffix(".d")?;
293    KINDS.into_iter().find(|kind| *kind == stem)
294}
295
296/// Touched fragment paths whose filenames break the convention. Each fragment directory carries
297/// a `README.md` describing that convention, which is not an entry.
298fn malformed(layout: &Layout, changed: &[String]) -> Vec<String> {
299    changed
300        .iter()
301        .filter(|path| {
302            fragment(path, layout)
303                .is_some_and(|frag| frag.name != "README.md" && !fragment_name_ok(&frag.name))
304        })
305        .cloned()
306        .collect()
307}
308
309fn missing_kinds(
310    layout: &Layout,
311    added: &[String],
312    pkg: Option<&str>,
313    migrations: bool,
314) -> Vec<&'static str> {
315    let present: Vec<&'static str> = added
316        .iter()
317        .filter_map(|path| fragment(path, layout))
318        .filter(|frag| fragment_name_ok(&frag.name) && frag.pkg.as_deref() == pkg)
319        .map(|frag| frag.kind)
320        .collect();
321    KINDS
322        .into_iter()
323        .filter(|kind| migrations || *kind != "migrations")
324        .filter(|kind| !present.contains(kind))
325        .collect()
326}
327
328fn code_touched(changed: &[String], pkg: &str) -> bool {
329    let prefix = format!("{pkg}/");
330    changed
331        .iter()
332        .any(|path| path.starts_with(&prefix) && !is_exempt(path, pkg))
333}
334
335fn exempt_at_any_boundary(rel: &str) -> bool {
336    std::iter::once(rel)
337        .chain(rel.match_indices('/').map(|(i, _)| &rel[i + 1..]))
338        .any(exempt_shape)
339}
340
341fn exempt_shape(rel: &str) -> bool {
342    matches!(rel, "CHANGELOG.md" | "MIGRATIONS.md")
343        || KINDS
344            .iter()
345            .any(|kind| rel.starts_with(&format!("{kind}.d/")))
346        || rel.starts_with("e2e-attestations/")
347        || (rel.contains('/')
348            && matches!(rel.split('/').next(), Some("tests" | "test" | "__tests__")))
349        || rel.ends_with("_test.py")
350        || is_test_or_spec(rel)
351}
352
353fn is_test_or_spec(rel: &str) -> bool {
354    let name = rel.rsplit('/').next().unwrap_or(rel);
355    ["ts", "tsx", "js", "mjs", "cjs", "py", "rs"]
356        .iter()
357        .any(|ext| {
358            name.ends_with(&format!(".test.{ext}")) || name.ends_with(&format!(".spec.{ext}"))
359        })
360}
361
362/// Every fragment directory under `root`, as its root-relative segments. The convention puts a
363/// fragment directory at most two levels down, which bounds the walk.
364fn fragment_dirs(root: &Path) -> Vec<Vec<String>> {
365    let mut out = Vec::new();
366    scan(root, &[], &mut out);
367    out
368}
369
370fn scan(dir: &Path, prefix: &[String], out: &mut Vec<Vec<String>>) {
371    let Ok(entries) = std::fs::read_dir(dir) else {
372        return;
373    };
374    for entry in entries.flatten() {
375        if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
376            continue;
377        }
378        let name = entry.file_name().to_string_lossy().into_owned();
379        if name.starts_with('.') || SKIPPED_DIRS.contains(&name.as_str()) {
380            continue;
381        }
382        let mut segs = prefix.to_vec();
383        segs.push(name.clone());
384        if kind_of(&name).is_some() {
385            out.push(segs);
386        } else if segs.len() < 3 {
387            scan(&entry.path(), &segs, out);
388        }
389    }
390}
391
392fn git(repo: &Path, args: &[&str]) -> Result<String> {
393    let out = Command::new("git")
394        .current_dir(repo)
395        .args(args)
396        .output()
397        .with_context(|| format!("running `git {}` in `{}`", args.join(" "), repo.display()))?;
398    if !out.status.success() {
399        bail!(
400            "`git {}` failed: {}",
401            args.join(" "),
402            String::from_utf8_lossy(&out.stderr).trim()
403        );
404    }
405    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
406}
407
408fn lines(out: &str) -> Vec<String> {
409    out.lines()
410        .filter(|line| !line.is_empty())
411        .map(str::to_string)
412        .collect()
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    fn facts_with(layout: Option<Layout>, bodies: &str, changed: &[&str], added: &[&str]) -> Facts {
420        Facts {
421            layout,
422            bodies: bodies.to_string(),
423            changed: changed.iter().map(|s| s.to_string()).collect(),
424            added: added.iter().map(|s| s.to_string()).collect(),
425            migrations: false,
426        }
427    }
428
429    #[test]
430    fn no_layout_skips_the_check_and_names_the_tree() {
431        let facts = facts_with(None, "", &["packages/parser/src/a.ts"], &[]);
432        let (code, lines) = verdict(Path::new("/repo"), &facts);
433        assert_eq!(code, 0);
434        assert_eq!(
435            lines,
436            vec!["No fragment directories under `/repo`; changelog check skipped."]
437        );
438    }
439
440    #[test]
441    fn a_skip_line_bypasses_a_tree_that_would_otherwise_owe_fragments() {
442        let owing = &["packages/parser/src/a.ts"];
443        let layout = Some(Layout::PerPackage(vec!["packages".to_string()]));
444        // Same facts, with and without the trailer: the trailer is the only difference.
445        assert_eq!(
446            verdict(
447                Path::new("/repo"),
448                &facts_with(layout.clone(), "", owing, &[])
449            )
450            .0,
451            1
452        );
453        let (code, lines) = verdict(
454            Path::new("/repo"),
455            &facts_with(
456                layout,
457                "refactor: rename\n\nskip-changelog: no public surface",
458                owing,
459                &[],
460            ),
461        );
462        assert_eq!(code, 0);
463        assert_eq!(
464            lines,
465            vec!["A `skip-changelog:` line is present; changelog check bypassed."]
466        );
467    }
468
469    #[test]
470    fn a_scope_that_added_its_fragment_passes() {
471        let facts = facts_with(
472            Some(Layout::PerPackage(vec!["packages".to_string()])),
473            "",
474            &["packages/parser/src/a.ts"],
475            &["packages/parser/changelog.d/2026-10-05-change-a.md"],
476        );
477        let (code, lines) = verdict(Path::new("/repo"), &facts);
478        assert_eq!(code, 0);
479        assert_eq!(
480            lines,
481            vec!["Every scope that changed public surface added its fragments."]
482        );
483    }
484
485    #[test]
486    fn an_unpaid_scope_fails_with_one_annotation_per_finding() {
487        let facts = facts_with(
488            Some(Layout::PerPackage(vec!["packages".to_string()])),
489            "",
490            &["packages/parser/src/a.ts"],
491            &[],
492        );
493        let (code, lines) = verdict(Path::new("/repo"), &facts);
494        assert_eq!(code, 1);
495        // One line per finding, each already rendered as an annotation.
496        let found = findings(
497            facts.layout.as_ref().unwrap(),
498            facts.migrations,
499            &facts.changed,
500            &facts.added,
501        );
502        assert_eq!(lines.len(), found.len());
503        assert!(lines.iter().all(|line| line.starts_with("::error")));
504    }
505
506    #[test]
507    fn a_finding_with_a_file_annotates_that_file() {
508        let finding = Finding {
509            file: Some("packages/rust/src/lib.rs".into()),
510            message: "changed public surface".into(),
511        };
512        assert_eq!(
513            annotation(&finding),
514            "::error file=packages/rust/src/lib.rs::changed public surface"
515        );
516    }
517
518    #[test]
519    fn a_finding_with_no_file_annotates_the_workflow() {
520        let finding = Finding {
521            file: None,
522            message: "changed public surface".into(),
523        };
524        assert_eq!(annotation(&finding), "::error::changed public surface");
525    }
526
527    #[test]
528    fn owed_names_the_scope_kind_and_fragment_directory() {
529        let finding = owed(
530            "packages/parser ",
531            "packages/parser/changelog.d",
532            "changelog",
533        );
534        assert_eq!(finding.file, None);
535        assert!(finding
536            .message
537            .contains("packages/parser changed public surface"));
538        assert!(finding
539            .message
540            .contains("packages/parser/changelog.d/YYYY-MM-DD-<slug>.md"));
541        assert!(finding.message.contains("skip-changelog: <reason>"));
542    }
543
544    #[test]
545    fn fragment_recognizes_only_a_package_fragment_at_the_expected_depth() {
546        let layout = Layout::PerPackage(vec!["packages".to_string()]);
547        let found = fragment("packages/parser/changelog.d/2026-09-26-change.md", &layout).unwrap();
548        assert_eq!(found.pkg.as_deref(), Some("packages/parser"));
549        assert_eq!(found.kind, "changelog");
550        assert_eq!(found.name, "2026-09-26-change.md");
551        assert!(fragment("other/parser/changelog.d/2026-09-26-change.md", &layout).is_none());
552        assert!(fragment("packages/parser/changelog.d/nested/change.md", &layout).is_none());
553    }
554
555    #[test]
556    fn kind_of_accepts_only_the_two_fragment_directories() {
557        assert_eq!(kind_of("changelog.d"), Some("changelog"));
558        assert_eq!(kind_of("migrations.d"), Some("migrations"));
559        assert_eq!(kind_of("notes.d"), None);
560        assert_eq!(kind_of("changelog"), None);
561    }
562
563    #[test]
564    fn malformed_reports_entries_but_not_fragment_readmes() {
565        let layout = Layout::Pooled;
566        let changed = vec![
567            "changelog.d/README.md".to_string(),
568            "changelog.d/2026-09-26-valid.md".to_string(),
569            "migrations.d/bad-name.md".to_string(),
570            "src/bad-name.md".to_string(),
571        ];
572        assert_eq!(
573            malformed(&layout, &changed),
574            vec!["migrations.d/bad-name.md"]
575        );
576    }
577
578    #[test]
579    fn missing_kinds_requires_added_valid_fragments_for_the_same_package() {
580        let layout = Layout::PerPackage(vec!["packages".to_string()]);
581        let added = vec![
582            "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
583            "packages/parser/migrations.d/bad-name.md".to_string(),
584            "packages/other/migrations.d/2026-09-26-change.md".to_string(),
585        ];
586        assert_eq!(
587            missing_kinds(&layout, &added, Some("packages/parser"), true),
588            vec!["migrations"]
589        );
590        assert!(missing_kinds(&layout, &added, Some("packages/parser"), false).is_empty());
591    }
592
593    #[test]
594    fn code_touched_ignores_fragments_and_tests_inside_the_package() {
595        let pkg = "packages/parser";
596        let exempt = vec![
597            "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
598            "packages/parser/tests/parser.rs".to_string(),
599            "packages/other/src/parser.rs".to_string(),
600        ];
601        assert!(!code_touched(&exempt, pkg));
602        assert!(code_touched(
603            &["packages/parser/src/parser.rs".to_string()],
604            pkg
605        ));
606    }
607
608    #[test]
609    fn exempt_at_any_boundary_checks_nested_test_and_fragment_paths() {
610        assert!(exempt_at_any_boundary("packages/parser/tests/parser.rs"));
611        assert!(exempt_at_any_boundary(
612            "packages/parser/changelog.d/2026-09-26-change.md"
613        ));
614        assert!(!exempt_at_any_boundary("packages/parser/src/parser.rs"));
615    }
616
617    #[test]
618    fn exempt_shape_recognizes_archives_attestations_and_test_paths() {
619        assert!(exempt_shape("CHANGELOG.md"));
620        assert!(exempt_shape("MIGRATIONS.md"));
621        assert!(exempt_shape("e2e-attestations/run.json"));
622        assert!(exempt_shape("tests/parser.rs"));
623        assert!(exempt_shape("src/parser_test.py"));
624        assert!(!exempt_shape("src/parser.rs"));
625    }
626
627    #[test]
628    fn is_test_or_spec_matches_supported_extensions_only() {
629        assert!(is_test_or_spec("src/parser.test.rs"));
630        assert!(is_test_or_spec("src/parser.spec.tsx"));
631        assert!(!is_test_or_spec("src/parser.test.txt"));
632        assert!(!is_test_or_spec("src/parser.rs"));
633    }
634
635    #[test]
636    fn fragment_dirs_finds_shallow_directories_and_skips_build_trees() {
637        let root = std::env::temp_dir().join(format!("tc-changelog-inline-{}", std::process::id()));
638        std::fs::create_dir_all(root.join("packages/parser/changelog.d")).unwrap();
639        std::fs::create_dir_all(root.join("target/debug/migrations.d")).unwrap();
640        std::fs::create_dir_all(root.join("packages/parser/deep/migrations.d")).unwrap();
641        let dirs = fragment_dirs(&root);
642        assert_eq!(
643            dirs,
644            vec![vec![
645                "packages".to_string(),
646                "parser".to_string(),
647                "changelog.d".to_string()
648            ]]
649        );
650        std::fs::remove_dir_all(root).unwrap();
651    }
652}