1use std::fs;
18use std::path::{Path, PathBuf};
19
20pub mod greenfield_status;
21pub mod stamp;
22
23#[derive(Debug, PartialEq, Eq)]
26pub enum Level {
27 Minor,
28 Patch,
29}
30
31#[derive(Debug, PartialEq, Eq)]
37pub struct Adr {
38 pub slug: String,
39 pub title: String,
40 pub summary: Option<String>,
41 pub status: Option<String>,
42 pub body: String,
43}
44
45impl Adr {
46 pub fn summary(&self) -> &str {
49 self.summary.as_deref().unwrap_or(&self.title)
50 }
51
52 pub fn status(&self) -> &str {
54 self.status.as_deref().unwrap_or("Accepted")
55 }
56}
57
58#[derive(Debug, PartialEq, Eq)]
60pub struct Pending {
61 pub level: Level,
62 pub changelog: String,
63 pub adrs: Vec<Adr>,
64 pub closes_rule: Vec<String>,
71}
72
73pub fn repo_root() -> PathBuf {
76 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
77}
78
79pub fn pending_dir() -> PathBuf {
81 repo_root().join("design/pending")
82}
83
84pub fn check_all() -> Result<usize, Vec<String>> {
89 validated_pending_in(&repo_root()).map(|ps| ps.len())
90}
91
92pub fn validated_pending_in(root: &Path) -> Result<Vec<(String, Pending)>, Vec<String>> {
103 let dir = root.join("design/pending");
104 let entries = match fs::read_dir(&dir) {
105 Ok(e) => e,
106 Err(err) => return Err(vec![format!("cannot read {}: {err}", dir.display())]),
107 };
108
109 let mut names: Vec<String> = entries
110 .filter_map(Result::ok)
111 .map(|e| e.file_name().to_string_lossy().into_owned())
112 .filter(|n| n.ends_with(".md") && n != "README.md")
113 .collect();
114 names.sort();
115
116 let mut parsed = Vec::new();
117 let mut errors = Vec::new();
118 for name in names {
119 let content = match fs::read_to_string(dir.join(&name)) {
120 Ok(c) => c,
121 Err(err) => {
122 errors.push(format!("{name}: cannot read: {err}"));
123 continue;
124 }
125 };
126 match validate(&name, &content) {
127 Ok(p) => parsed.push((name, p)),
128 Err(errs) => errors.extend(errs.into_iter().map(|e| format!("{name}: {e}"))),
129 }
130 }
131
132 if !parsed.iter().any(|(_, p)| !p.closes_rule.is_empty()) {
133 } else {
135 match known_rule_ids(root) {
136 Ok(known) => {
137 for (name, pending) in &parsed {
138 for rule in &pending.closes_rule {
139 if !known.contains(rule) {
140 errors.push(format!(
141 "{name}: closes_rule cites {rule:?}, which is not a rule id in \
142 design/bynk-greenfield-compiler.md"
143 ));
144 }
145 }
146 }
147 }
148 Err(e) => errors.push(format!(
149 "cannot validate closes_rule entries against the reference: {e}"
150 )),
151 }
152 }
153
154 if errors.is_empty() {
155 Ok(parsed)
156 } else {
157 Err(errors)
158 }
159}
160
161pub fn validate(filename: &str, content: &str) -> Result<Pending, Vec<String>> {
165 let mut errors = Vec::new();
166
167 let stem = Path::new(filename)
168 .file_stem()
169 .map(|s| s.to_string_lossy().into_owned())
170 .unwrap_or_default();
171 if !is_kebab(&stem) {
172 errors.push(format!(
173 "filename stem {stem:?} is not a kebab-case slug (a-z, 0-9, single hyphens)"
174 ));
175 }
176
177 let (level, changelog, closes_rule) = match parse_frontmatter(content, &mut errors) {
178 Some(fm) => fm,
179 None => return Err(errors),
180 };
181 let adrs = parse_adrs(content, &mut errors);
182
183 if errors.is_empty() {
184 Ok(Pending {
185 level: level.expect("no errors implies a level"),
186 changelog: changelog.expect("no errors implies a changelog"),
187 adrs,
188 closes_rule,
189 })
190 } else {
191 Err(errors)
192 }
193}
194
195fn parse_frontmatter(
201 content: &str,
202 errors: &mut Vec<String>,
203) -> Option<(Option<Level>, Option<String>, Vec<String>)> {
204 let mut lines = content.lines();
205 if lines.next().map(str::trim_end) != Some("---") {
206 errors.push("must open with a `---` frontmatter fence on line 1".into());
207 return None;
208 }
209
210 let mut header = Vec::new();
211 let mut closed = false;
212 for line in lines {
213 if line.trim_end() == "---" {
214 closed = true;
215 break;
216 }
217 header.push(line);
218 }
219 if !closed {
220 errors.push("frontmatter is not closed with a `---` fence".into());
221 return None;
222 }
223
224 let mut level = None;
225 let mut changelog = None;
226 let mut closes_rule = Vec::new();
227 let mut saw_level = false;
230 let mut saw_changelog = false;
231 let mut saw_closes_rule = false;
232 for raw in header {
233 let line = raw.trim();
234 if line.is_empty() {
235 continue;
236 }
237 let Some((key, value)) = line.split_once(':') else {
238 errors.push(format!("frontmatter line is not `key: value`: {raw:?}"));
239 continue;
240 };
241 let key = key.trim();
242 let value = value.trim();
243 match key {
244 "level" => {
245 if saw_level {
246 errors.push("duplicate frontmatter key `level`".into());
247 }
248 saw_level = true;
249 level = match value {
250 "minor" => Some(Level::Minor),
251 "patch" => Some(Level::Patch),
252 other => {
253 errors.push(format!("level must be `minor` or `patch`, got {other:?}"));
254 None
255 }
256 };
257 }
258 "changelog" => {
259 if saw_changelog {
260 errors.push("duplicate frontmatter key `changelog`".into());
261 }
262 saw_changelog = true;
263 if value.is_empty() {
264 errors.push("changelog must not be empty".into());
265 } else if looks_like_version_prefix(value) {
266 errors.push(format!(
267 "changelog must not start with a version number (the stamp adds it): {value:?}"
268 ));
269 } else if let Some(dest) = relative_markdown_link_in(value) {
270 errors.push(format!(
271 "changelog reads as a Markdown link to the relative destination {dest:?} \
272 — the blurb is inserted verbatim into the Book's changelog table, so a \
273 bare `x[T](y)` in prose becomes a link the docs site's link-checker \
274 rejects (and it only sees the row after the stamp writes it on `main`). \
275 Wrap the code in backticks, or use an absolute URL."
276 ));
277 } else {
278 changelog = Some(value.to_string());
279 }
280 }
281 "closes_rule" => {
282 if saw_closes_rule {
283 errors.push("duplicate frontmatter key `closes_rule`".into());
284 }
285 saw_closes_rule = true;
286 if value.is_empty() {
287 errors.push(
288 "closes_rule must not be empty (omit the key entirely if there's \
289 nothing to cite)"
290 .into(),
291 );
292 } else {
293 for entry in value.split(',') {
294 let entry = entry.trim();
295 if is_rule_id(entry) {
296 closes_rule.push(entry.to_string());
297 } else {
298 errors.push(format!(
299 "closes_rule entry {entry:?} is not a rule id \
300 (expected `R<major>.<minor>`, e.g. `R2.3`)"
301 ));
302 }
303 }
304 }
305 }
306 other => errors.push(format!("unknown frontmatter key {other:?}")),
307 }
308 }
309
310 if !saw_level {
311 errors.push("frontmatter is missing `level`".into());
312 }
313 if !saw_changelog {
314 errors.push("frontmatter is missing `changelog`".into());
315 }
316
317 Some((level, changelog, closes_rule))
318}
319
320pub fn is_rule_id(s: &str) -> bool {
324 let Some(rest) = s.strip_prefix('R') else {
325 return false;
326 };
327 let Some((major, minor)) = rest.split_once('.') else {
328 return false;
329 };
330 !major.is_empty()
331 && major.chars().all(|c| c.is_ascii_digit())
332 && !minor.is_empty()
333 && minor.chars().all(|c| c.is_ascii_digit())
334}
335
336pub fn known_rule_ids(root: &Path) -> Result<std::collections::HashSet<String>, String> {
347 let path = root.join("design/bynk-greenfield-compiler.md");
348 let text =
349 fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
350 let mut ids = std::collections::HashSet::new();
351 let bytes = text.as_bytes();
352 let mut i = 0;
353 while let Some(rel) = text[i..].find("**R") {
354 let start = i + rel + 2; let mut end = start;
356 while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'.') {
357 end += 1;
358 }
359 let candidate = &text[start..end];
360 if is_rule_id(candidate) {
361 ids.insert(candidate.to_string());
362 }
363 i = end.max(start + 1);
364 }
365 Ok(ids)
366}
367
368pub fn pr_number_from_subject(subject: &str) -> Option<u32> {
388 let inner = subject.strip_suffix(')')?.rsplit_once("(#")?.1;
389 inner.parse().ok()
390}
391
392fn parse_adrs(content: &str, errors: &mut Vec<String>) -> Vec<Adr> {
396 let mut fences = 0;
398 let mut body_lines = Vec::new();
399 for line in content.lines() {
400 if fences < 2 {
401 if line.trim_end() == "---" {
402 fences += 1;
403 }
404 continue;
405 }
406 body_lines.push(line);
407 }
408
409 let mut in_fence = false;
414 let is_header: Vec<bool> = body_lines
415 .iter()
416 .map(|line| {
417 if line.trim_start().starts_with("```") {
418 in_fence = !in_fence;
419 false
420 } else {
421 !in_fence && adr_header_slug(line).is_some()
422 }
423 })
424 .collect();
425
426 let mut adrs: Vec<Adr> = Vec::new();
427 let mut i = 0;
428 while i < body_lines.len() {
429 if is_header[i] {
430 let slug = adr_header_slug(body_lines[i])
431 .expect("is_header implies an ADR header")
432 .trim()
433 .to_string();
434 i += 1;
435 let mut block = Vec::new();
436 while i < body_lines.len() && !is_header[i] {
437 block.push(body_lines[i]);
438 i += 1;
439 }
440
441 let mut title = None;
446 let mut summary = None;
447 let mut status = None;
448 let mut body_start = block.len();
449 for (idx, raw) in block.iter().enumerate() {
450 let line = raw.trim();
451 if line.is_empty() && title.is_none() && summary.is_none() && status.is_none() {
452 continue;
453 }
454 if let Some(v) = line.strip_prefix("title:") {
455 title = Some(v.trim().to_string());
456 } else if let Some(v) = line.strip_prefix("summary:") {
457 summary = Some(v.trim().to_string());
458 } else if let Some(v) = line.strip_prefix("status:") {
459 status = Some(v.trim().to_string());
460 } else {
461 body_start = idx;
462 break;
463 }
464 }
465 let body = block[body_start..].join("\n").trim().to_string();
466
467 if !is_kebab(&slug) {
468 errors.push(format!(
469 "ADR slug {slug:?} is not a kebab-case slug (a-z, 0-9, single hyphens)"
470 ));
471 } else if adrs.iter().any(|a| a.slug == slug) {
472 errors.push(format!("duplicate ADR slug {slug:?}"));
473 }
474 match &title {
475 Some(t) if t.is_empty() => {
476 errors.push(format!("ADR {slug:?} has an empty `title:`"))
477 }
478 None => errors.push(format!("ADR {slug:?} is missing a `title:` line")),
479 _ => {}
480 }
481 if body.is_empty() {
482 errors.push(format!("ADR {slug:?} has an empty body"));
483 }
484 adrs.push(Adr {
485 slug,
486 title: title.unwrap_or_default(),
487 summary: summary.filter(|s| !s.is_empty()),
488 status: status.filter(|s| !s.is_empty()),
489 body,
490 });
491 } else {
492 i += 1;
493 }
494 }
495 adrs
496}
497
498fn adr_header_slug(line: &str) -> Option<&str> {
500 line.trim().strip_prefix("## ADR:")
501}
502
503fn is_kebab(s: &str) -> bool {
505 !s.is_empty()
506 && !s.starts_with('-')
507 && !s.ends_with('-')
508 && !s.contains("--")
509 && s.chars()
510 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
511}
512
513fn looks_like_version_prefix(changelog: &str) -> bool {
520 let raw = changelog.split_whitespace().next().unwrap_or("");
521 let had_v = raw.starts_with('v') || raw.starts_with('V');
522 let groups: Vec<&str> = raw.trim_start_matches(['v', 'V']).split('.').collect();
523 let all_numeric = groups.len() >= 2
524 && groups
525 .iter()
526 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
527 all_numeric && (had_v || groups.len() >= 3)
528}
529
530fn relative_markdown_link_in(blurb: &str) -> Option<String> {
547 let bytes = blurb.as_bytes();
548 let mut i = 0;
549 let mut saw_open_bracket = false;
550 while i < bytes.len() {
551 match bytes[i] {
552 b'\\' => i += 1,
554 b'`' => {
561 let fence = bytes[i..].iter().take_while(|&&b| b == b'`').count();
562 let mut j = i + fence;
563 let mut closed = false;
564 while j < bytes.len() {
565 if bytes[j] == b'`' {
566 let run = bytes[j..].iter().take_while(|&&b| b == b'`').count();
567 j += run;
568 if run == fence {
569 closed = true;
570 break;
571 }
572 } else {
573 j += 1;
574 }
575 }
576 i = if closed { j } else { i + fence };
577 continue;
578 }
579 b'[' => saw_open_bracket = true,
580 b']' if saw_open_bracket && bytes.get(i + 1) == Some(&b'(') => {
582 saw_open_bracket = false;
583 let start = i + 2;
584 let Some(len) = bytes[start..].iter().position(|&b| b == b')') else {
585 break;
586 };
587 if let Some(dest) = link_destination(&blurb[start..start + len])
588 && !is_absolute_link_destination(dest)
589 {
590 return Some(dest.to_string());
591 }
592 i = start + len;
593 }
594 b']' => saw_open_bracket = false,
595 _ => {}
596 }
597 i += 1;
598 }
599 None
600}
601
602fn link_destination(tail: &str) -> Option<&str> {
611 let tail = tail.trim();
612 if let Some(rest) = tail.strip_prefix('<') {
613 return rest.split_once('>').map(|(dest, _)| dest);
614 }
615 let (dest, rest) = match tail.split_once(char::is_whitespace) {
616 Some((dest, rest)) => (dest, rest.trim_start()),
617 None => (tail, ""),
618 };
619 if rest.is_empty() || rest.starts_with(['"', '\'', '(']) {
621 Some(dest)
622 } else {
623 None
624 }
625}
626
627fn is_absolute_link_destination(dest: &str) -> bool {
635 dest.starts_with("https://")
636 || dest.starts_with("http://")
637 || dest.starts_with("mailto:")
638 || dest.starts_with('/')
639 || dest.starts_with('#')
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 fn ok(name: &str, content: &str) -> Pending {
647 validate(name, content).unwrap_or_else(|e| panic!("expected valid, got {e:?}"))
648 }
649 fn err(name: &str, content: &str) -> Vec<String> {
650 validate(name, content).expect_err("expected invalid")
651 }
652
653 #[test]
654 fn minimal_no_adr_is_valid() {
655 let p = ok(
656 "add-a-thing.md",
657 "---\nlevel: minor\nchangelog: Add a thing to the language\n---\n",
658 );
659 assert_eq!(p.level, Level::Minor);
660 assert_eq!(p.changelog, "Add a thing to the language");
661 assert!(p.adrs.is_empty());
662 }
663
664 #[test]
665 fn patch_level_is_valid() {
666 assert_eq!(
667 ok(
668 "fix-a-thing.md",
669 "---\nlevel: patch\nchangelog: Fix a non-language thing\n---\n"
670 )
671 .level,
672 Level::Patch
673 );
674 }
675
676 #[test]
677 fn one_adr_parses_slug_title_and_body() {
678 let p = ok(
679 "unit-tier.md",
680 "---\nlevel: minor\nchangelog: Drive a handler at the unit tier\n---\n\n## ADR: unit-tier-service-address\ntitle: A case addresses a handler by surface\n\n**Decision.** A case addresses by surface.\n",
681 );
682 assert_eq!(p.adrs.len(), 1);
683 let adr = &p.adrs[0];
684 assert_eq!(adr.slug, "unit-tier-service-address");
685 assert_eq!(adr.title, "A case addresses a handler by surface");
686 assert_eq!(adr.summary(), adr.title);
688 assert_eq!(adr.status(), "Accepted");
689 assert!(adr.body.contains("addresses by surface"));
690 assert!(!adr.body.contains("title:"), "the title line is not body");
691 }
692
693 #[test]
694 fn adr_summary_and_status_are_parsed() {
695 let p = ok(
696 "x.md",
697 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: a-slug\ntitle: The title\nsummary: The one-line index distillation\nstatus: Proposed\n\nBody.\n",
698 );
699 let adr = &p.adrs[0];
700 assert_eq!(adr.summary(), "The one-line index distillation");
701 assert_eq!(adr.status(), "Proposed");
702 }
703
704 #[test]
705 fn adr_missing_title_rejected() {
706 assert!(
707 err(
708 "x.md",
709 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: a-slug\n\nBody with no title line.\n"
710 )
711 .iter()
712 .any(|e| e.contains("missing a `title:`"))
713 );
714 }
715
716 #[test]
717 fn two_adrs_parse() {
718 let p = ok(
719 "two.md",
720 "---\nlevel: minor\nchangelog: Two decisions\n---\n\n## ADR: first-one\ntitle: First\n\nBody one.\n\n## ADR: second-one\ntitle: Second\n\nBody two.\n",
721 );
722 assert_eq!(p.adrs.len(), 2);
723 assert_eq!(p.adrs[0].slug, "first-one");
724 assert_eq!(p.adrs[1].slug, "second-one");
725 }
726
727 #[test]
728 fn bad_level_rejected() {
729 assert!(
730 err("x.md", "---\nlevel: major\nchangelog: x\n---\n")
731 .iter()
732 .any(|e| e.contains("level must be"))
733 );
734 }
735
736 #[test]
737 fn missing_level_rejected() {
738 assert!(
739 err("x.md", "---\nchangelog: x\n---\n")
740 .iter()
741 .any(|e| e.contains("missing `level`"))
742 );
743 }
744
745 #[test]
746 fn missing_changelog_rejected() {
747 assert!(
748 err("x.md", "---\nlevel: minor\n---\n")
749 .iter()
750 .any(|e| e.contains("missing `changelog`"))
751 );
752 }
753
754 #[test]
755 fn empty_changelog_rejected() {
756 assert!(
757 err("x.md", "---\nlevel: minor\nchangelog: \n---\n")
758 .iter()
759 .any(|e| e.contains("changelog"))
760 );
761 }
762
763 #[test]
764 fn version_prefixed_changelog_rejected() {
765 for cl in ["v0.186 Add a thing", "0.186.0 Add a thing"] {
766 let content = format!("---\nlevel: minor\nchangelog: {cl}\n---\n");
767 assert!(
768 err("x.md", &content)
769 .iter()
770 .any(|e| e.contains("version number")),
771 "expected rejection for {cl:?}"
772 );
773 }
774 }
775
776 #[test]
777 fn plain_changelog_with_a_dot_is_allowed() {
778 ok(
781 "x.md",
782 "---\nlevel: minor\nchangelog: Support semver ranges like 1.2.3\n---\n",
783 );
784 }
785
786 #[test]
787 fn bare_two_group_leading_number_is_allowed() {
788 ok(
791 "x.md",
792 "---\nlevel: minor\nchangelog: 3.0 rendering pipeline added\n---\n",
793 );
794 }
795
796 #[test]
797 fn accidental_relative_markdown_link_in_changelog_rejected() {
798 let content = "---\nlevel: patch\nchangelog: Read Callee to detect an \
803 Events.emit[E](event) call\n---\n";
804 assert!(
805 err("x.md", content)
806 .iter()
807 .any(|e| e.contains("relative destination \"event\"")),
808 "expected rejection, got {:?}",
809 err("x.md", content)
810 );
811 }
812
813 #[test]
814 fn backticked_generic_call_in_changelog_is_allowed() {
815 ok(
817 "x.md",
818 "---\nlevel: patch\nchangelog: Detect an `Events.emit[E](event)` call\n---\n",
819 );
820 }
821
822 #[test]
823 fn absolute_links_in_changelog_are_allowed() {
824 for cl in [
827 "Close [#548](https://github.com/accuser/bynk/issues/548)",
828 "See [the roadmap](/book/about/versioning-and-roadmap/)",
829 "See [below](#notes)",
830 ] {
831 let content = format!("---\nlevel: minor\nchangelog: {cl}\n---\n");
832 ok("x.md", &content);
833 }
834 }
835
836 #[test]
837 fn bracket_without_a_link_in_changelog_is_allowed() {
838 ok(
841 "x.md",
842 "---\nlevel: minor\nchangelog: Widen ts_any [see the probe] (P7.0) for writes\n---\n",
843 );
844 }
845
846 #[test]
847 fn a_half_backticked_changelog_is_still_rejected() {
848 let content = "---\nlevel: patch\nchangelog: Detect an `Events.emit[E](event) call\n---\n";
853 assert!(
854 err("x.md", content)
855 .iter()
856 .any(|e| e.contains("relative destination \"event\"")),
857 "expected rejection, got {:?}",
858 err("x.md", content)
859 );
860 }
861
862 #[test]
863 fn a_stray_backtick_does_not_blind_the_rest_of_the_blurb() {
864 let content = "---\nlevel: patch\nchangelog: A stray `Callee typo, then \
866 Events.emit[E](event) later\n---\n";
867 assert!(
868 err("x.md", content)
869 .iter()
870 .any(|e| e.contains("relative destination \"event\"")),
871 "expected rejection, got {:?}",
872 err("x.md", content)
873 );
874 }
875
876 #[test]
877 fn a_multi_parameter_generic_call_is_not_reported_as_a_link() {
878 ok(
881 "x.md",
882 "---\nlevel: minor\nchangelog: Lift a fn map[K, V](key, value) call\n---\n",
883 );
884 }
885
886 #[test]
887 fn a_titled_link_is_still_read_as_a_link() {
888 let content = "---\nlevel: minor\nchangelog: See [it](there \"a title\")\n---\n";
891 assert!(
892 err("x.md", content)
893 .iter()
894 .any(|e| e.contains("relative destination \"there\"")),
895 "expected rejection, got {:?}",
896 err("x.md", content)
897 );
898 }
899
900 #[test]
901 fn duplicate_frontmatter_key_rejected() {
902 assert!(
903 err(
904 "x.md",
905 "---\nlevel: minor\nlevel: patch\nchangelog: x\n---\n"
906 )
907 .iter()
908 .any(|e| e.contains("duplicate frontmatter key `level`"))
909 );
910 }
911
912 #[test]
913 fn adr_header_inside_a_code_fence_is_not_a_block() {
914 let p = ok(
917 "x.md",
918 "---\nlevel: minor\nchangelog: Document the format\n---\n\n\
919 Example:\n\n```markdown\n## ADR: not-a-real-block\nfenced prose\n```\n\n\
920 ## ADR: the-real-one\ntitle: The real one\n\nReal body.\n",
921 );
922 assert_eq!(p.adrs.len(), 1);
923 assert_eq!(p.adrs[0].slug, "the-real-one");
924 }
925
926 #[test]
927 fn no_frontmatter_rejected() {
928 assert!(
929 err("x.md", "just some text\n")
930 .iter()
931 .any(|e| e.contains("open with a `---`"))
932 );
933 }
934
935 #[test]
936 fn unclosed_frontmatter_rejected() {
937 assert!(
938 err("x.md", "---\nlevel: minor\nchangelog: x\n")
939 .iter()
940 .any(|e| e.contains("not closed"))
941 );
942 }
943
944 #[test]
945 fn unknown_key_rejected() {
946 assert!(
947 err("x.md", "---\nlevel: minor\nchangelog: x\nversion: 9\n---\n")
948 .iter()
949 .any(|e| e.contains("unknown frontmatter key"))
950 );
951 }
952
953 #[test]
956 fn closes_rule_is_optional_and_defaults_empty() {
957 let p = ok("x.md", "---\nlevel: patch\nchangelog: x\n---\n");
958 assert!(p.closes_rule.is_empty());
959 }
960
961 #[test]
962 fn closes_rule_parses_a_single_id() {
963 let p = ok(
964 "x.md",
965 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\n---\n",
966 );
967 assert_eq!(p.closes_rule, vec!["R2.3".to_string()]);
968 }
969
970 #[test]
971 fn closes_rule_parses_a_comma_separated_list_and_trims_whitespace() {
972 let p = ok(
973 "x.md",
974 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3, R2.12 ,R0.1\n---\n",
975 );
976 assert_eq!(
977 p.closes_rule,
978 vec!["R2.3".to_string(), "R2.12".to_string(), "R0.1".to_string()]
979 );
980 }
981
982 #[test]
983 fn closes_rule_rejects_a_malformed_entry() {
984 assert!(
985 err(
986 "x.md",
987 "---\nlevel: patch\nchangelog: x\ncloses_rule: not-a-rule\n---\n"
988 )
989 .iter()
990 .any(|e| e.contains("closes_rule entry") && e.contains("not a rule id"))
991 );
992 }
993
994 #[test]
995 fn closes_rule_rejects_empty_value() {
996 assert!(
997 err(
998 "x.md",
999 "---\nlevel: patch\nchangelog: x\ncloses_rule: \n---\n"
1000 )
1001 .iter()
1002 .any(|e| e.contains("closes_rule must not be empty"))
1003 );
1004 }
1005
1006 #[test]
1007 fn closes_rule_rejects_duplicate_key() {
1008 assert!(
1009 err(
1010 "x.md",
1011 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\ncloses_rule: R2.4\n---\n"
1012 )
1013 .iter()
1014 .any(|e| e.contains("duplicate frontmatter key `closes_rule`"))
1015 );
1016 }
1017
1018 #[test]
1019 fn is_rule_id_accepts_and_rejects() {
1020 assert!(is_rule_id("R2.3"));
1021 assert!(is_rule_id("R0.1"));
1022 assert!(is_rule_id("R12.34"));
1023 assert!(!is_rule_id("2.3"));
1024 assert!(!is_rule_id("R2"));
1025 assert!(!is_rule_id("R2.3.4"));
1026 assert!(!is_rule_id("R.3"));
1027 assert!(!is_rule_id("R2."));
1028 assert!(!is_rule_id("Rx.y"));
1029 }
1030
1031 #[test]
1034 fn pr_number_from_subject_finds_a_trailing_squash_merge_suffix() {
1035 assert_eq!(
1036 pr_number_from_subject("feat(xtask): thing (#1234)"),
1037 Some(1234)
1038 );
1039 }
1040
1041 #[test]
1042 fn pr_number_from_subject_requires_the_suffix_at_the_very_end() {
1043 assert_eq!(pr_number_from_subject("feat: thing (#12) then more"), None);
1045 }
1046
1047 #[test]
1048 fn pr_number_from_subject_is_not_confused_by_earlier_nested_parens() {
1049 assert_eq!(pr_number_from_subject("chore: bump (deps) (#12)"), Some(12));
1050 }
1051
1052 #[test]
1053 fn pr_number_from_subject_rejects_a_non_numeric_hash() {
1054 assert_eq!(pr_number_from_subject("feat: thing (#abc)"), None);
1055 }
1056
1057 #[test]
1058 fn pr_number_from_subject_rejects_a_number_too_large_for_u32() {
1059 assert_eq!(pr_number_from_subject("(#99999999999999)"), None);
1060 }
1061
1062 #[test]
1063 fn pr_number_from_subject_none_without_any_suffix() {
1064 assert_eq!(pr_number_from_subject("Merge branch 'x'"), None);
1065 }
1066
1067 #[test]
1071 fn pr_number_from_subject_cannot_distinguish_an_issue_reference() {
1072 assert_eq!(
1073 pr_number_from_subject("fix: handle empty spans (#1001)"),
1074 Some(1001)
1075 );
1076 }
1077
1078 fn rule_fixture(tag: &str, reference_body: &str) -> PathBuf {
1083 let root = std::env::temp_dir().join(format!("xtask-closes-rule-{tag}"));
1084 let _ = fs::remove_dir_all(&root);
1085 fs::create_dir_all(root.join("design/pending")).unwrap();
1086 fs::write(
1087 root.join("design/bynk-greenfield-compiler.md"),
1088 reference_body,
1089 )
1090 .unwrap();
1091 root
1092 }
1093
1094 #[test]
1095 fn known_rule_ids_finds_bold_rule_headers() {
1096 let dir = rule_fixture(
1097 "finds-bold-headers",
1098 "Some prose.\n\n**R2.3 — A rule about spans.**\n\nMore prose citing **R2.3** again \
1099 in passing, and introducing **R10.11 — a second rule.**\n",
1100 );
1101 let ids = known_rule_ids(&dir).unwrap();
1102 assert_eq!(ids.len(), 2, "expected exactly 2 distinct ids: {ids:?}");
1103 assert!(ids.contains("R2.3"));
1104 assert!(ids.contains("R10.11"));
1105 }
1106
1107 #[test]
1108 fn validated_pending_in_rejects_a_closes_rule_citing_an_unknown_id() {
1109 let dir = rule_fixture("rejects-unknown", "**R2.3 — real.**\n");
1110 fs::write(
1111 dir.join("design/pending/x.md"),
1112 "---\nlevel: patch\nchangelog: x\ncloses_rule: R99.99\n---\n",
1113 )
1114 .unwrap();
1115 let errors = validated_pending_in(&dir).expect_err("R99.99 does not exist");
1116 assert!(
1117 errors
1118 .iter()
1119 .any(|e| e.contains("R99.99") && e.contains("not a rule id in")),
1120 "{errors:?}"
1121 );
1122 }
1123
1124 #[test]
1125 fn validated_pending_in_accepts_a_closes_rule_citing_a_known_id() {
1126 let dir = rule_fixture("accepts-known", "**R2.3 — real.**\n");
1127 fs::write(
1128 dir.join("design/pending/x.md"),
1129 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\n---\n",
1130 )
1131 .unwrap();
1132 let parsed = validated_pending_in(&dir).unwrap();
1133 assert_eq!(parsed.len(), 1);
1134 assert_eq!(parsed[0].1.closes_rule, vec!["R2.3".to_string()]);
1135 }
1136
1137 #[test]
1138 fn non_kebab_adr_slug_rejected() {
1139 assert!(
1140 err(
1141 "x.md",
1142 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: Not_Kebab\ntitle: T\n\nBody.\n"
1143 )
1144 .iter()
1145 .any(|e| e.contains("not a kebab-case slug"))
1146 );
1147 }
1148
1149 #[test]
1150 fn duplicate_adr_slug_rejected() {
1151 assert!(err(
1152 "x.md",
1153 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: dup\ntitle: A\n\nBody a.\n\n## ADR: dup\ntitle: B\n\nBody b.\n"
1154 )
1155 .iter()
1156 .any(|e| e.contains("duplicate ADR slug")));
1157 }
1158
1159 #[test]
1160 fn empty_adr_body_rejected() {
1161 assert!(
1162 err(
1163 "x.md",
1164 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: empty\ntitle: T\n\n## ADR: next\ntitle: N\n\nBody.\n"
1165 )
1166 .iter()
1167 .any(|e| e.contains("empty body"))
1168 );
1169 }
1170
1171 #[test]
1172 fn non_kebab_filename_rejected() {
1173 assert!(
1174 err("Not_A_Slug.md", "---\nlevel: minor\nchangelog: x\n---\n")
1175 .iter()
1176 .any(|e| e.contains("filename stem"))
1177 );
1178 }
1179}