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
30struct Facts {
39 layout: Option<Layout>,
40 bodies: String,
41 changed: Vec<String>,
42 added: Vec<String>,
43}
44
45fn facts(root: &Path, base: &str) -> Result<Facts> {
47 Ok(Facts {
48 layout: discover_layout(root),
49 bodies: commit_bodies(root, base)?,
50 changed: changed_files(root, base)?,
51 added: added_files(root, base)?,
52 })
53}
54
55fn verdict(root: &Path, facts: &Facts) -> (i32, Vec<String>) {
59 let Some(layout) = &facts.layout else {
60 return (
61 0,
62 vec![format!(
63 "No fragment directories under `{}`; changelog check skipped.",
64 root.display()
65 )],
66 );
67 };
68 if has_skip_line(&facts.bodies) {
69 return (
70 0,
71 vec!["A `skip-changelog:` line is present; changelog check bypassed.".to_string()],
72 );
73 }
74 let breaking = has_breaking_line(&facts.bodies);
75 let found = findings(layout, breaking, &facts.changed, &facts.added);
76 if found.is_empty() {
77 return (
78 0,
79 vec!["Every scope that changed public surface added its fragments.".to_string()],
80 );
81 }
82 (1, found.iter().map(annotation).collect())
83}
84
85pub fn run(root: &Path, base: &str) -> Result<i32> {
91 let (code, lines) = verdict(root, &facts(root, base)?);
92 println!("{}", lines.join("\n"));
93 Ok(code)
94}
95
96pub fn annotation(finding: &Finding) -> String {
99 match &finding.file {
100 Some(file) => format!("::error file={file}::{}", finding.message),
101 None => format!("::error::{}", finding.message),
102 }
103}
104
105const SKIPPED_DIRS: [&str; 2] = ["node_modules", "target"];
107
108pub fn discover_layout(root: &Path) -> Option<Layout> {
110 let dirs = fragment_dirs(root);
111 if dirs.is_empty() {
112 return None;
113 }
114 let mut containers: Vec<String> = dirs
115 .iter()
116 .filter(|segs| segs.len() == 3)
117 .map(|segs| segs[0].clone())
118 .collect();
119 containers.sort();
120 containers.dedup();
121 if containers.is_empty() {
122 return Some(Layout::Pooled);
123 }
124 Some(Layout::PerPackage(containers))
125}
126
127pub fn fragment_name_ok(name: &str) -> bool {
130 let Some(stem) = name.strip_suffix(".md") else {
131 return false;
132 };
133 let bytes = stem.as_bytes();
134 if bytes.len() < 12 {
135 return false;
136 }
137 let digit = |i: usize| bytes[i].is_ascii_digit();
138 let dated = (0..4).all(digit)
139 && bytes[4] == b'-'
140 && (5..7).all(digit)
141 && bytes[7] == b'-'
142 && (8..10).all(digit)
143 && bytes[10] == b'-';
144 dated
145 && bytes[11..]
146 .iter()
147 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-')
148}
149
150fn has_line_opening(bodies: &str, marker: &[u8]) -> bool {
152 bodies.lines().any(|line| {
153 let bytes = line.as_bytes();
154 bytes.len() >= marker.len() && bytes[..marker.len()].eq_ignore_ascii_case(marker)
155 })
156}
157
158pub fn has_skip_line(bodies: &str) -> bool {
160 has_line_opening(bodies, b"skip-changelog:")
161}
162
163pub fn has_breaking_line(bodies: &str) -> bool {
167 has_line_opening(bodies, b"breaking:")
168}
169
170pub fn is_exempt(path: &str, pkg: &str) -> bool {
172 path.strip_prefix(pkg)
173 .and_then(|rest| rest.strip_prefix('/'))
174 .is_some_and(exempt_at_any_boundary)
175}
176
177pub fn changed_packages(changed: &[String]) -> Vec<String> {
179 let mut out: Vec<String> = changed
180 .iter()
181 .filter_map(|path| {
182 let segs: Vec<&str> = path.split('/').collect();
183 (segs.len() > 2).then(|| format!("{}/{}", segs[0], segs[1]))
184 })
185 .collect();
186 out.sort();
187 out.dedup();
188 out
189}
190
191pub fn findings(
198 layout: &Layout,
199 migrations: bool,
200 changed: &[String],
201 added: &[String],
202) -> Vec<Finding> {
203 let mut out = malformed_findings(layout, changed);
204 out.extend(owed_findings(layout, migrations, changed, added));
205 out
206}
207
208fn malformed_findings(layout: &Layout, changed: &[String]) -> Vec<Finding> {
210 malformed(layout, changed)
211 .into_iter()
212 .map(|path| Finding {
213 file: Some(path),
214 message: "fragment filenames are YYYY-MM-DD-<slug>.md — the UTC merge date, then \
215 lowercase letters, digits and hyphens. See docs/reference/checks/changelog."
216 .to_string(),
217 })
218 .collect()
219}
220
221fn owed_findings(
224 layout: &Layout,
225 migrations: bool,
226 changed: &[String],
227 added: &[String],
228) -> Vec<Finding> {
229 let mut out = Vec::new();
230 match layout {
231 Layout::PerPackage(containers) => {
232 for pkg in changed_packages(changed) {
233 let in_a_container = containers.iter().any(|c| pkg.split('/').next() == Some(c));
234 if in_a_container && code_touched(changed, &pkg) {
235 out.extend(package_owed(layout, migrations, added, &pkg));
236 }
237 }
238 }
239 Layout::Pooled => {
240 if changed.iter().any(|path| !exempt_at_any_boundary(path)) {
241 for kind in missing_kinds(layout, added, None, migrations) {
242 out.push(owed("", &format!("{kind}.d"), kind));
243 }
244 }
245 }
246 }
247 out
248}
249
250fn package_owed(layout: &Layout, migrations: bool, added: &[String], pkg: &str) -> Vec<Finding> {
252 missing_kinds(layout, added, Some(pkg), migrations)
253 .into_iter()
254 .map(|kind| owed(&format!("{pkg} "), &format!("{pkg}/{kind}.d"), kind))
255 .collect()
256}
257
258pub fn commit_bodies(repo: &Path, base: &str) -> Result<String> {
260 git(repo, &["log", "--format=%B", &format!("{base}..HEAD")])
261}
262
263pub fn changed_files(repo: &Path, base: &str) -> Result<Vec<String>> {
265 let out = git(repo, &["diff", "--name-only", &format!("{base}...HEAD")])?;
266 Ok(lines(&out))
267}
268
269pub fn added_files(repo: &Path, base: &str) -> Result<Vec<String>> {
272 let range = format!("{base}...HEAD");
273 let out = git(repo, &["diff", "--name-only", "--diff-filter=A", &range])?;
274 Ok(lines(&out))
275}
276
277fn phrasing(kind: &str) -> (&'static str, &'static str) {
280 match kind {
281 "migrations" => (
282 "marked a breaking change",
283 "drop the `breaking:` line if the change is not breaking",
284 ),
285 _ => (
286 "changed public surface",
287 "put a `skip-changelog: <reason>` line on any commit for a genuinely internal \
288 refactor",
289 ),
290 }
291}
292
293fn owed(scope: &str, dir: &str, kind: &str) -> Finding {
294 let (did, way_out) = phrasing(kind);
295 Finding {
296 file: None,
297 message: format!(
298 "{scope}{did} without adding a {kind} fragment. Add {dir}/YYYY-MM-DD-<slug>.md, or \
299 {way_out}. See docs/reference/checks/changelog."
300 ),
301 }
302}
303
304struct Fragment {
306 pkg: Option<String>,
308 kind: &'static str,
309 name: String,
310}
311
312fn fragment(path: &str, layout: &Layout) -> Option<Fragment> {
313 let segs: Vec<&str> = path.split('/').collect();
314 let i = segs.iter().position(|seg| kind_of(seg).is_some())?;
315 let kind = kind_of(segs[i])?;
316 if i + 2 != segs.len() {
317 return None;
318 }
319 let pkg = fragment_scope(&segs, i, layout)?;
320 Some(Fragment {
321 pkg,
322 kind,
323 name: segs[i + 1].to_string(),
324 })
325}
326
327fn fragment_scope(segs: &[&str], i: usize, layout: &Layout) -> Option<Option<String>> {
331 match layout {
332 Layout::PerPackage(containers) if i == 2 && containers.iter().any(|c| c == segs[0]) => {
333 Some(Some(format!("{}/{}", segs[0], segs[1])))
334 }
335 Layout::PerPackage(_) => None,
336 Layout::Pooled => Some(None),
337 }
338}
339
340fn kind_of(segment: &str) -> Option<&'static str> {
341 let stem = segment.strip_suffix(".d")?;
342 KINDS.into_iter().find(|kind| *kind == stem)
343}
344
345fn malformed(layout: &Layout, changed: &[String]) -> Vec<String> {
348 changed
349 .iter()
350 .filter(|path| {
351 fragment(path, layout)
352 .is_some_and(|frag| frag.name != "README.md" && !fragment_name_ok(&frag.name))
353 })
354 .cloned()
355 .collect()
356}
357
358fn missing_kinds(
359 layout: &Layout,
360 added: &[String],
361 pkg: Option<&str>,
362 migrations: bool,
363) -> Vec<&'static str> {
364 let present: Vec<&'static str> = added
365 .iter()
366 .filter_map(|path| fragment(path, layout))
367 .filter(|frag| fragment_name_ok(&frag.name) && frag.pkg.as_deref() == pkg)
368 .map(|frag| frag.kind)
369 .collect();
370 KINDS
371 .into_iter()
372 .filter(|kind| migrations || *kind != "migrations")
373 .filter(|kind| !present.contains(kind))
374 .collect()
375}
376
377fn code_touched(changed: &[String], pkg: &str) -> bool {
378 let prefix = format!("{pkg}/");
379 changed
380 .iter()
381 .any(|path| path.starts_with(&prefix) && !is_exempt(path, pkg))
382}
383
384fn exempt_at_any_boundary(rel: &str) -> bool {
385 std::iter::once(rel)
386 .chain(rel.match_indices('/').map(|(i, _)| &rel[i + 1..]))
387 .any(exempt_shape)
388}
389
390fn exempt_shape(rel: &str) -> bool {
391 matches!(rel, "CHANGELOG.md" | "MIGRATIONS.md")
392 || KINDS
393 .iter()
394 .any(|kind| rel.starts_with(&format!("{kind}.d/")))
395 || rel.starts_with("e2e-attestations/")
396 || (rel.contains('/')
397 && matches!(rel.split('/').next(), Some("tests" | "test" | "__tests__")))
398 || rel.ends_with("_test.py")
399 || is_test_or_spec(rel)
400}
401
402fn is_test_or_spec(rel: &str) -> bool {
403 let name = rel.rsplit('/').next().unwrap_or(rel);
404 ["ts", "tsx", "js", "mjs", "cjs", "py", "rs"]
405 .iter()
406 .any(|ext| {
407 name.ends_with(&format!(".test.{ext}")) || name.ends_with(&format!(".spec.{ext}"))
408 })
409}
410
411fn fragment_dirs(root: &Path) -> Vec<Vec<String>> {
414 let mut out = Vec::new();
415 scan(root, &[], &mut out);
416 out
417}
418
419fn scan(dir: &Path, prefix: &[String], out: &mut Vec<Vec<String>>) {
420 let Ok(entries) = std::fs::read_dir(dir) else {
421 return;
422 };
423 for entry in entries.flatten() {
424 if !entry.file_type().is_ok_and(|kind| kind.is_dir()) {
425 continue;
426 }
427 let name = entry.file_name().to_string_lossy().into_owned();
428 if name.starts_with('.') || SKIPPED_DIRS.contains(&name.as_str()) {
429 continue;
430 }
431 let mut segs = prefix.to_vec();
432 segs.push(name.clone());
433 if kind_of(&name).is_some() {
434 out.push(segs);
435 } else if segs.len() < 3 {
436 scan(&entry.path(), &segs, out);
437 }
438 }
439}
440
441fn git(repo: &Path, args: &[&str]) -> Result<String> {
442 let out = Command::new("git")
443 .current_dir(repo)
444 .args(args)
445 .output()
446 .with_context(|| format!("running `git {}` in `{}`", args.join(" "), repo.display()))?;
447 if !out.status.success() {
448 bail!(
449 "`git {}` failed: {}",
450 args.join(" "),
451 String::from_utf8_lossy(&out.stderr).trim()
452 );
453 }
454 Ok(String::from_utf8_lossy(&out.stdout).into_owned())
455}
456
457fn lines(out: &str) -> Vec<String> {
458 out.lines()
459 .filter(|line| !line.is_empty())
460 .map(str::to_string)
461 .collect()
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 fn facts_with(layout: Option<Layout>, bodies: &str, changed: &[&str], added: &[&str]) -> Facts {
469 Facts {
470 layout,
471 bodies: bodies.to_string(),
472 changed: changed.iter().map(|s| s.to_string()).collect(),
473 added: added.iter().map(|s| s.to_string()).collect(),
474 }
475 }
476
477 #[test]
478 fn no_layout_skips_the_check_and_names_the_tree() {
479 let facts = facts_with(None, "", &["packages/parser/src/a.ts"], &[]);
480 let (code, lines) = verdict(Path::new("/repo"), &facts);
481 assert_eq!(code, 0);
482 assert_eq!(
483 lines,
484 vec!["No fragment directories under `/repo`; changelog check skipped."]
485 );
486 }
487
488 #[test]
489 fn a_skip_line_bypasses_a_tree_that_would_otherwise_owe_fragments() {
490 let owing = &["packages/parser/src/a.ts"];
491 let layout = Some(Layout::PerPackage(vec!["packages".to_string()]));
492 assert_eq!(
494 verdict(
495 Path::new("/repo"),
496 &facts_with(layout.clone(), "", owing, &[])
497 )
498 .0,
499 1
500 );
501 let (code, lines) = verdict(
502 Path::new("/repo"),
503 &facts_with(
504 layout,
505 "refactor: rename\n\nskip-changelog: no public surface",
506 owing,
507 &[],
508 ),
509 );
510 assert_eq!(code, 0);
511 assert_eq!(
512 lines,
513 vec!["A `skip-changelog:` line is present; changelog check bypassed."]
514 );
515 }
516
517 #[test]
518 fn a_breaking_line_makes_a_migrations_fragment_required() {
519 let layout = Some(Layout::PerPackage(vec!["packages".to_string()]));
520 let changed = &["packages/parser/src/a.ts"];
521 let paid = &["packages/parser/changelog.d/2026-10-06-change-a.md"];
522 assert_eq!(
524 verdict(
525 Path::new("/repo"),
526 &facts_with(layout.clone(), "", changed, paid)
527 )
528 .0,
529 0
530 );
531 let (code, lines) = verdict(
532 Path::new("/repo"),
533 &facts_with(
534 layout,
535 "feat!: drop the flag\n\nbreaking: the flag is gone",
536 changed,
537 paid,
538 ),
539 );
540 assert_eq!(code, 1);
541 assert_eq!(lines.len(), 1);
542 assert!(lines[0].contains("migrations fragment"));
543 assert!(lines[0].contains("breaking change"));
544 }
545
546 #[test]
547 fn a_migrations_fragment_no_breaking_line_asked_for_is_allowed() {
548 let facts = facts_with(
551 Some(Layout::PerPackage(vec!["packages".to_string()])),
552 "",
553 &["packages/parser/src/a.ts"],
554 &[
555 "packages/parser/changelog.d/2026-10-06-change-a.md",
556 "packages/parser/migrations.d/2026-10-06-change-a.md",
557 ],
558 );
559 assert_eq!(verdict(Path::new("/repo"), &facts).0, 0);
560 }
561
562 #[test]
563 fn a_breaking_line_is_read_only_at_the_opening_of_a_line() {
564 assert!(has_breaking_line("breaking: the flag is gone"));
565 assert!(has_breaking_line(
566 "feat!: drop it\n\nBREAKING: the flag is gone"
567 ));
568 assert!(!has_breaking_line(
569 "fix: a comment saying this is not breaking: really"
570 ));
571 assert!(!has_breaking_line("breaking the flag"));
572 assert!(!has_breaking_line(""));
573 }
574
575 #[test]
576 fn phrasing_tells_a_breaking_change_apart_from_a_surface_change() {
577 assert_eq!(
578 phrasing("migrations"),
579 (
580 "marked a breaking change",
581 "drop the `breaking:` line if the change is not breaking"
582 )
583 );
584 let (did, way_out) = phrasing("changelog");
585 assert_eq!(did, "changed public surface");
586 assert!(way_out.contains("skip-changelog: <reason>"));
587 }
588
589 #[test]
590 fn a_scope_that_added_its_fragment_passes() {
591 let facts = facts_with(
592 Some(Layout::PerPackage(vec!["packages".to_string()])),
593 "",
594 &["packages/parser/src/a.ts"],
595 &["packages/parser/changelog.d/2026-10-05-change-a.md"],
596 );
597 let (code, lines) = verdict(Path::new("/repo"), &facts);
598 assert_eq!(code, 0);
599 assert_eq!(
600 lines,
601 vec!["Every scope that changed public surface added its fragments."]
602 );
603 }
604
605 #[test]
606 fn an_unpaid_scope_fails_with_one_annotation_per_finding() {
607 let facts = facts_with(
608 Some(Layout::PerPackage(vec!["packages".to_string()])),
609 "",
610 &["packages/parser/src/a.ts"],
611 &[],
612 );
613 let (code, lines) = verdict(Path::new("/repo"), &facts);
614 assert_eq!(code, 1);
615 let found = findings(
617 facts.layout.as_ref().unwrap(),
618 has_breaking_line(&facts.bodies),
619 &facts.changed,
620 &facts.added,
621 );
622 assert_eq!(lines.len(), found.len());
623 assert!(lines.iter().all(|line| line.starts_with("::error")));
624 }
625
626 #[test]
627 fn a_malformed_fragment_name_is_a_finding_even_where_nothing_is_owed() {
628 let facts = facts_with(
630 Some(Layout::PerPackage(vec!["packages".to_string()])),
631 "",
632 &["packages/parser/changelog.d/Nope.md"],
633 &[],
634 );
635 let found = findings(
636 facts.layout.as_ref().unwrap(),
637 facts.migrations,
638 &facts.changed,
639 &facts.added,
640 );
641 assert_eq!(found.len(), 1);
642 assert_eq!(
643 found[0].file.as_deref(),
644 Some("packages/parser/changelog.d/Nope.md")
645 );
646 assert!(found[0].message.starts_with("fragment filenames are"));
647 }
648
649 #[test]
650 fn a_scope_owes_only_where_it_is_in_a_container_and_its_code_changed() {
651 let containers = Some(Layout::PerPackage(vec!["packages".to_string()]));
652 let owed_for = |changed: &[&str]| {
653 let facts = facts_with(containers.clone(), "", changed, &[]);
654 findings(
655 facts.layout.as_ref().unwrap(),
656 facts.migrations,
657 &facts.changed,
658 &facts.added,
659 )
660 };
661 assert!(owed_for(&["packages/parser/changelog.d/2026-10-05-ok.md"]).is_empty());
663 assert!(owed_for(&["internals/checks/src/a.py"]).is_empty());
665 assert_eq!(owed_for(&["packages/parser/src/a.ts"]).len(), 1);
667 }
668
669 #[test]
670 fn a_finding_with_a_file_annotates_that_file() {
671 let finding = Finding {
672 file: Some("packages/rust/src/lib.rs".into()),
673 message: "changed public surface".into(),
674 };
675 assert_eq!(
676 annotation(&finding),
677 "::error file=packages/rust/src/lib.rs::changed public surface"
678 );
679 }
680
681 #[test]
682 fn a_finding_with_no_file_annotates_the_workflow() {
683 let finding = Finding {
684 file: None,
685 message: "changed public surface".into(),
686 };
687 assert_eq!(annotation(&finding), "::error::changed public surface");
688 }
689
690 #[test]
691 fn owed_names_the_scope_kind_and_fragment_directory() {
692 let finding = owed(
693 "packages/parser ",
694 "packages/parser/changelog.d",
695 "changelog",
696 );
697 assert_eq!(finding.file, None);
698 assert!(finding
699 .message
700 .contains("packages/parser changed public surface"));
701 assert!(finding
702 .message
703 .contains("packages/parser/changelog.d/YYYY-MM-DD-<slug>.md"));
704 assert!(finding.message.contains("skip-changelog: <reason>"));
705 }
706
707 #[test]
708 fn fragment_recognizes_only_a_package_fragment_at_the_expected_depth() {
709 let layout = Layout::PerPackage(vec!["packages".to_string()]);
710 let found = fragment("packages/parser/changelog.d/2026-09-26-change.md", &layout).unwrap();
711 assert_eq!(found.pkg.as_deref(), Some("packages/parser"));
712 assert_eq!(found.kind, "changelog");
713 assert_eq!(found.name, "2026-09-26-change.md");
714 assert!(fragment("other/parser/changelog.d/2026-09-26-change.md", &layout).is_none());
715 assert!(fragment("packages/parser/changelog.d/nested/change.md", &layout).is_none());
716 }
717
718 #[test]
719 fn kind_of_accepts_only_the_two_fragment_directories() {
720 assert_eq!(kind_of("changelog.d"), Some("changelog"));
721 assert_eq!(kind_of("migrations.d"), Some("migrations"));
722 assert_eq!(kind_of("notes.d"), None);
723 assert_eq!(kind_of("changelog"), None);
724 }
725
726 #[test]
727 fn malformed_reports_entries_but_not_fragment_readmes() {
728 let layout = Layout::Pooled;
729 let changed = vec![
730 "changelog.d/README.md".to_string(),
731 "changelog.d/2026-09-26-valid.md".to_string(),
732 "migrations.d/bad-name.md".to_string(),
733 "src/bad-name.md".to_string(),
734 ];
735 assert_eq!(
736 malformed(&layout, &changed),
737 vec!["migrations.d/bad-name.md"]
738 );
739 }
740
741 #[test]
742 fn missing_kinds_requires_added_valid_fragments_for_the_same_package() {
743 let layout = Layout::PerPackage(vec!["packages".to_string()]);
744 let added = vec![
745 "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
746 "packages/parser/migrations.d/bad-name.md".to_string(),
747 "packages/other/migrations.d/2026-09-26-change.md".to_string(),
748 ];
749 assert_eq!(
750 missing_kinds(&layout, &added, Some("packages/parser"), true),
751 vec!["migrations"]
752 );
753 assert!(missing_kinds(&layout, &added, Some("packages/parser"), false).is_empty());
754 }
755
756 #[test]
757 fn code_touched_ignores_fragments_and_tests_inside_the_package() {
758 let pkg = "packages/parser";
759 let exempt = vec![
760 "packages/parser/changelog.d/2026-09-26-change.md".to_string(),
761 "packages/parser/tests/parser.rs".to_string(),
762 "packages/other/src/parser.rs".to_string(),
763 ];
764 assert!(!code_touched(&exempt, pkg));
765 assert!(code_touched(
766 &["packages/parser/src/parser.rs".to_string()],
767 pkg
768 ));
769 }
770
771 #[test]
772 fn exempt_at_any_boundary_checks_nested_test_and_fragment_paths() {
773 assert!(exempt_at_any_boundary("packages/parser/tests/parser.rs"));
774 assert!(exempt_at_any_boundary(
775 "packages/parser/changelog.d/2026-09-26-change.md"
776 ));
777 assert!(!exempt_at_any_boundary("packages/parser/src/parser.rs"));
778 }
779
780 #[test]
781 fn exempt_shape_recognizes_archives_attestations_and_test_paths() {
782 assert!(exempt_shape("CHANGELOG.md"));
783 assert!(exempt_shape("MIGRATIONS.md"));
784 assert!(exempt_shape("e2e-attestations/run.json"));
785 assert!(exempt_shape("tests/parser.rs"));
786 assert!(exempt_shape("src/parser_test.py"));
787 assert!(!exempt_shape("src/parser.rs"));
788 }
789
790 #[test]
791 fn is_test_or_spec_matches_supported_extensions_only() {
792 assert!(is_test_or_spec("src/parser.test.rs"));
793 assert!(is_test_or_spec("src/parser.spec.tsx"));
794 assert!(!is_test_or_spec("src/parser.test.txt"));
795 assert!(!is_test_or_spec("src/parser.rs"));
796 }
797
798 #[test]
799 fn fragment_dirs_finds_shallow_directories_and_skips_build_trees() {
800 let root = std::env::temp_dir().join(format!("tc-changelog-inline-{}", std::process::id()));
801 std::fs::create_dir_all(root.join("packages/parser/changelog.d")).unwrap();
802 std::fs::create_dir_all(root.join("target/debug/migrations.d")).unwrap();
803 std::fs::create_dir_all(root.join("packages/parser/deep/migrations.d")).unwrap();
804 let dirs = fragment_dirs(&root);
805 assert_eq!(
806 dirs,
807 vec![vec![
808 "packages".to_string(),
809 "parser".to_string(),
810 "changelog.d".to_string()
811 ]]
812 );
813 std::fs::remove_dir_all(root).unwrap();
814 }
815}