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 {
270 changelog = Some(value.to_string());
271 }
272 }
273 "closes_rule" => {
274 if saw_closes_rule {
275 errors.push("duplicate frontmatter key `closes_rule`".into());
276 }
277 saw_closes_rule = true;
278 if value.is_empty() {
279 errors.push(
280 "closes_rule must not be empty (omit the key entirely if there's \
281 nothing to cite)"
282 .into(),
283 );
284 } else {
285 for entry in value.split(',') {
286 let entry = entry.trim();
287 if is_rule_id(entry) {
288 closes_rule.push(entry.to_string());
289 } else {
290 errors.push(format!(
291 "closes_rule entry {entry:?} is not a rule id \
292 (expected `R<major>.<minor>`, e.g. `R2.3`)"
293 ));
294 }
295 }
296 }
297 }
298 other => errors.push(format!("unknown frontmatter key {other:?}")),
299 }
300 }
301
302 if !saw_level {
303 errors.push("frontmatter is missing `level`".into());
304 }
305 if !saw_changelog {
306 errors.push("frontmatter is missing `changelog`".into());
307 }
308
309 Some((level, changelog, closes_rule))
310}
311
312pub fn is_rule_id(s: &str) -> bool {
316 let Some(rest) = s.strip_prefix('R') else {
317 return false;
318 };
319 let Some((major, minor)) = rest.split_once('.') else {
320 return false;
321 };
322 !major.is_empty()
323 && major.chars().all(|c| c.is_ascii_digit())
324 && !minor.is_empty()
325 && minor.chars().all(|c| c.is_ascii_digit())
326}
327
328pub fn known_rule_ids(root: &Path) -> Result<std::collections::HashSet<String>, String> {
339 let path = root.join("design/bynk-greenfield-compiler.md");
340 let text =
341 fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
342 let mut ids = std::collections::HashSet::new();
343 let bytes = text.as_bytes();
344 let mut i = 0;
345 while let Some(rel) = text[i..].find("**R") {
346 let start = i + rel + 2; let mut end = start;
348 while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'.') {
349 end += 1;
350 }
351 let candidate = &text[start..end];
352 if is_rule_id(candidate) {
353 ids.insert(candidate.to_string());
354 }
355 i = end.max(start + 1);
356 }
357 Ok(ids)
358}
359
360pub fn pr_number_from_subject(subject: &str) -> Option<u32> {
380 let inner = subject.strip_suffix(')')?.rsplit_once("(#")?.1;
381 inner.parse().ok()
382}
383
384fn parse_adrs(content: &str, errors: &mut Vec<String>) -> Vec<Adr> {
388 let mut fences = 0;
390 let mut body_lines = Vec::new();
391 for line in content.lines() {
392 if fences < 2 {
393 if line.trim_end() == "---" {
394 fences += 1;
395 }
396 continue;
397 }
398 body_lines.push(line);
399 }
400
401 let mut in_fence = false;
406 let is_header: Vec<bool> = body_lines
407 .iter()
408 .map(|line| {
409 if line.trim_start().starts_with("```") {
410 in_fence = !in_fence;
411 false
412 } else {
413 !in_fence && adr_header_slug(line).is_some()
414 }
415 })
416 .collect();
417
418 let mut adrs: Vec<Adr> = Vec::new();
419 let mut i = 0;
420 while i < body_lines.len() {
421 if is_header[i] {
422 let slug = adr_header_slug(body_lines[i])
423 .expect("is_header implies an ADR header")
424 .trim()
425 .to_string();
426 i += 1;
427 let mut block = Vec::new();
428 while i < body_lines.len() && !is_header[i] {
429 block.push(body_lines[i]);
430 i += 1;
431 }
432
433 let mut title = None;
438 let mut summary = None;
439 let mut status = None;
440 let mut body_start = block.len();
441 for (idx, raw) in block.iter().enumerate() {
442 let line = raw.trim();
443 if line.is_empty() && title.is_none() && summary.is_none() && status.is_none() {
444 continue;
445 }
446 if let Some(v) = line.strip_prefix("title:") {
447 title = Some(v.trim().to_string());
448 } else if let Some(v) = line.strip_prefix("summary:") {
449 summary = Some(v.trim().to_string());
450 } else if let Some(v) = line.strip_prefix("status:") {
451 status = Some(v.trim().to_string());
452 } else {
453 body_start = idx;
454 break;
455 }
456 }
457 let body = block[body_start..].join("\n").trim().to_string();
458
459 if !is_kebab(&slug) {
460 errors.push(format!(
461 "ADR slug {slug:?} is not a kebab-case slug (a-z, 0-9, single hyphens)"
462 ));
463 } else if adrs.iter().any(|a| a.slug == slug) {
464 errors.push(format!("duplicate ADR slug {slug:?}"));
465 }
466 match &title {
467 Some(t) if t.is_empty() => {
468 errors.push(format!("ADR {slug:?} has an empty `title:`"))
469 }
470 None => errors.push(format!("ADR {slug:?} is missing a `title:` line")),
471 _ => {}
472 }
473 if body.is_empty() {
474 errors.push(format!("ADR {slug:?} has an empty body"));
475 }
476 adrs.push(Adr {
477 slug,
478 title: title.unwrap_or_default(),
479 summary: summary.filter(|s| !s.is_empty()),
480 status: status.filter(|s| !s.is_empty()),
481 body,
482 });
483 } else {
484 i += 1;
485 }
486 }
487 adrs
488}
489
490fn adr_header_slug(line: &str) -> Option<&str> {
492 line.trim().strip_prefix("## ADR:")
493}
494
495fn is_kebab(s: &str) -> bool {
497 !s.is_empty()
498 && !s.starts_with('-')
499 && !s.ends_with('-')
500 && !s.contains("--")
501 && s.chars()
502 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
503}
504
505fn looks_like_version_prefix(changelog: &str) -> bool {
512 let raw = changelog.split_whitespace().next().unwrap_or("");
513 let had_v = raw.starts_with('v') || raw.starts_with('V');
514 let groups: Vec<&str> = raw.trim_start_matches(['v', 'V']).split('.').collect();
515 let all_numeric = groups.len() >= 2
516 && groups
517 .iter()
518 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
519 all_numeric && (had_v || groups.len() >= 3)
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 fn ok(name: &str, content: &str) -> Pending {
527 validate(name, content).unwrap_or_else(|e| panic!("expected valid, got {e:?}"))
528 }
529 fn err(name: &str, content: &str) -> Vec<String> {
530 validate(name, content).expect_err("expected invalid")
531 }
532
533 #[test]
534 fn minimal_no_adr_is_valid() {
535 let p = ok(
536 "add-a-thing.md",
537 "---\nlevel: minor\nchangelog: Add a thing to the language\n---\n",
538 );
539 assert_eq!(p.level, Level::Minor);
540 assert_eq!(p.changelog, "Add a thing to the language");
541 assert!(p.adrs.is_empty());
542 }
543
544 #[test]
545 fn patch_level_is_valid() {
546 assert_eq!(
547 ok(
548 "fix-a-thing.md",
549 "---\nlevel: patch\nchangelog: Fix a non-language thing\n---\n"
550 )
551 .level,
552 Level::Patch
553 );
554 }
555
556 #[test]
557 fn one_adr_parses_slug_title_and_body() {
558 let p = ok(
559 "unit-tier.md",
560 "---\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",
561 );
562 assert_eq!(p.adrs.len(), 1);
563 let adr = &p.adrs[0];
564 assert_eq!(adr.slug, "unit-tier-service-address");
565 assert_eq!(adr.title, "A case addresses a handler by surface");
566 assert_eq!(adr.summary(), adr.title);
568 assert_eq!(adr.status(), "Accepted");
569 assert!(adr.body.contains("addresses by surface"));
570 assert!(!adr.body.contains("title:"), "the title line is not body");
571 }
572
573 #[test]
574 fn adr_summary_and_status_are_parsed() {
575 let p = ok(
576 "x.md",
577 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: a-slug\ntitle: The title\nsummary: The one-line index distillation\nstatus: Proposed\n\nBody.\n",
578 );
579 let adr = &p.adrs[0];
580 assert_eq!(adr.summary(), "The one-line index distillation");
581 assert_eq!(adr.status(), "Proposed");
582 }
583
584 #[test]
585 fn adr_missing_title_rejected() {
586 assert!(
587 err(
588 "x.md",
589 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: a-slug\n\nBody with no title line.\n"
590 )
591 .iter()
592 .any(|e| e.contains("missing a `title:`"))
593 );
594 }
595
596 #[test]
597 fn two_adrs_parse() {
598 let p = ok(
599 "two.md",
600 "---\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",
601 );
602 assert_eq!(p.adrs.len(), 2);
603 assert_eq!(p.adrs[0].slug, "first-one");
604 assert_eq!(p.adrs[1].slug, "second-one");
605 }
606
607 #[test]
608 fn bad_level_rejected() {
609 assert!(
610 err("x.md", "---\nlevel: major\nchangelog: x\n---\n")
611 .iter()
612 .any(|e| e.contains("level must be"))
613 );
614 }
615
616 #[test]
617 fn missing_level_rejected() {
618 assert!(
619 err("x.md", "---\nchangelog: x\n---\n")
620 .iter()
621 .any(|e| e.contains("missing `level`"))
622 );
623 }
624
625 #[test]
626 fn missing_changelog_rejected() {
627 assert!(
628 err("x.md", "---\nlevel: minor\n---\n")
629 .iter()
630 .any(|e| e.contains("missing `changelog`"))
631 );
632 }
633
634 #[test]
635 fn empty_changelog_rejected() {
636 assert!(
637 err("x.md", "---\nlevel: minor\nchangelog: \n---\n")
638 .iter()
639 .any(|e| e.contains("changelog"))
640 );
641 }
642
643 #[test]
644 fn version_prefixed_changelog_rejected() {
645 for cl in ["v0.186 Add a thing", "0.186.0 Add a thing"] {
646 let content = format!("---\nlevel: minor\nchangelog: {cl}\n---\n");
647 assert!(
648 err("x.md", &content)
649 .iter()
650 .any(|e| e.contains("version number")),
651 "expected rejection for {cl:?}"
652 );
653 }
654 }
655
656 #[test]
657 fn plain_changelog_with_a_dot_is_allowed() {
658 ok(
661 "x.md",
662 "---\nlevel: minor\nchangelog: Support semver ranges like 1.2.3\n---\n",
663 );
664 }
665
666 #[test]
667 fn bare_two_group_leading_number_is_allowed() {
668 ok(
671 "x.md",
672 "---\nlevel: minor\nchangelog: 3.0 rendering pipeline added\n---\n",
673 );
674 }
675
676 #[test]
677 fn duplicate_frontmatter_key_rejected() {
678 assert!(
679 err(
680 "x.md",
681 "---\nlevel: minor\nlevel: patch\nchangelog: x\n---\n"
682 )
683 .iter()
684 .any(|e| e.contains("duplicate frontmatter key `level`"))
685 );
686 }
687
688 #[test]
689 fn adr_header_inside_a_code_fence_is_not_a_block() {
690 let p = ok(
693 "x.md",
694 "---\nlevel: minor\nchangelog: Document the format\n---\n\n\
695 Example:\n\n```markdown\n## ADR: not-a-real-block\nfenced prose\n```\n\n\
696 ## ADR: the-real-one\ntitle: The real one\n\nReal body.\n",
697 );
698 assert_eq!(p.adrs.len(), 1);
699 assert_eq!(p.adrs[0].slug, "the-real-one");
700 }
701
702 #[test]
703 fn no_frontmatter_rejected() {
704 assert!(
705 err("x.md", "just some text\n")
706 .iter()
707 .any(|e| e.contains("open with a `---`"))
708 );
709 }
710
711 #[test]
712 fn unclosed_frontmatter_rejected() {
713 assert!(
714 err("x.md", "---\nlevel: minor\nchangelog: x\n")
715 .iter()
716 .any(|e| e.contains("not closed"))
717 );
718 }
719
720 #[test]
721 fn unknown_key_rejected() {
722 assert!(
723 err("x.md", "---\nlevel: minor\nchangelog: x\nversion: 9\n---\n")
724 .iter()
725 .any(|e| e.contains("unknown frontmatter key"))
726 );
727 }
728
729 #[test]
732 fn closes_rule_is_optional_and_defaults_empty() {
733 let p = ok("x.md", "---\nlevel: patch\nchangelog: x\n---\n");
734 assert!(p.closes_rule.is_empty());
735 }
736
737 #[test]
738 fn closes_rule_parses_a_single_id() {
739 let p = ok(
740 "x.md",
741 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\n---\n",
742 );
743 assert_eq!(p.closes_rule, vec!["R2.3".to_string()]);
744 }
745
746 #[test]
747 fn closes_rule_parses_a_comma_separated_list_and_trims_whitespace() {
748 let p = ok(
749 "x.md",
750 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3, R2.12 ,R0.1\n---\n",
751 );
752 assert_eq!(
753 p.closes_rule,
754 vec!["R2.3".to_string(), "R2.12".to_string(), "R0.1".to_string()]
755 );
756 }
757
758 #[test]
759 fn closes_rule_rejects_a_malformed_entry() {
760 assert!(
761 err(
762 "x.md",
763 "---\nlevel: patch\nchangelog: x\ncloses_rule: not-a-rule\n---\n"
764 )
765 .iter()
766 .any(|e| e.contains("closes_rule entry") && e.contains("not a rule id"))
767 );
768 }
769
770 #[test]
771 fn closes_rule_rejects_empty_value() {
772 assert!(
773 err(
774 "x.md",
775 "---\nlevel: patch\nchangelog: x\ncloses_rule: \n---\n"
776 )
777 .iter()
778 .any(|e| e.contains("closes_rule must not be empty"))
779 );
780 }
781
782 #[test]
783 fn closes_rule_rejects_duplicate_key() {
784 assert!(
785 err(
786 "x.md",
787 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\ncloses_rule: R2.4\n---\n"
788 )
789 .iter()
790 .any(|e| e.contains("duplicate frontmatter key `closes_rule`"))
791 );
792 }
793
794 #[test]
795 fn is_rule_id_accepts_and_rejects() {
796 assert!(is_rule_id("R2.3"));
797 assert!(is_rule_id("R0.1"));
798 assert!(is_rule_id("R12.34"));
799 assert!(!is_rule_id("2.3"));
800 assert!(!is_rule_id("R2"));
801 assert!(!is_rule_id("R2.3.4"));
802 assert!(!is_rule_id("R.3"));
803 assert!(!is_rule_id("R2."));
804 assert!(!is_rule_id("Rx.y"));
805 }
806
807 #[test]
810 fn pr_number_from_subject_finds_a_trailing_squash_merge_suffix() {
811 assert_eq!(
812 pr_number_from_subject("feat(xtask): thing (#1234)"),
813 Some(1234)
814 );
815 }
816
817 #[test]
818 fn pr_number_from_subject_requires_the_suffix_at_the_very_end() {
819 assert_eq!(pr_number_from_subject("feat: thing (#12) then more"), None);
821 }
822
823 #[test]
824 fn pr_number_from_subject_is_not_confused_by_earlier_nested_parens() {
825 assert_eq!(pr_number_from_subject("chore: bump (deps) (#12)"), Some(12));
826 }
827
828 #[test]
829 fn pr_number_from_subject_rejects_a_non_numeric_hash() {
830 assert_eq!(pr_number_from_subject("feat: thing (#abc)"), None);
831 }
832
833 #[test]
834 fn pr_number_from_subject_rejects_a_number_too_large_for_u32() {
835 assert_eq!(pr_number_from_subject("(#99999999999999)"), None);
836 }
837
838 #[test]
839 fn pr_number_from_subject_none_without_any_suffix() {
840 assert_eq!(pr_number_from_subject("Merge branch 'x'"), None);
841 }
842
843 #[test]
847 fn pr_number_from_subject_cannot_distinguish_an_issue_reference() {
848 assert_eq!(
849 pr_number_from_subject("fix: handle empty spans (#1001)"),
850 Some(1001)
851 );
852 }
853
854 fn rule_fixture(tag: &str, reference_body: &str) -> PathBuf {
859 let root = std::env::temp_dir().join(format!("xtask-closes-rule-{tag}"));
860 let _ = fs::remove_dir_all(&root);
861 fs::create_dir_all(root.join("design/pending")).unwrap();
862 fs::write(
863 root.join("design/bynk-greenfield-compiler.md"),
864 reference_body,
865 )
866 .unwrap();
867 root
868 }
869
870 #[test]
871 fn known_rule_ids_finds_bold_rule_headers() {
872 let dir = rule_fixture(
873 "finds-bold-headers",
874 "Some prose.\n\n**R2.3 — A rule about spans.**\n\nMore prose citing **R2.3** again \
875 in passing, and introducing **R10.11 — a second rule.**\n",
876 );
877 let ids = known_rule_ids(&dir).unwrap();
878 assert_eq!(ids.len(), 2, "expected exactly 2 distinct ids: {ids:?}");
879 assert!(ids.contains("R2.3"));
880 assert!(ids.contains("R10.11"));
881 }
882
883 #[test]
884 fn validated_pending_in_rejects_a_closes_rule_citing_an_unknown_id() {
885 let dir = rule_fixture("rejects-unknown", "**R2.3 — real.**\n");
886 fs::write(
887 dir.join("design/pending/x.md"),
888 "---\nlevel: patch\nchangelog: x\ncloses_rule: R99.99\n---\n",
889 )
890 .unwrap();
891 let errors = validated_pending_in(&dir).expect_err("R99.99 does not exist");
892 assert!(
893 errors
894 .iter()
895 .any(|e| e.contains("R99.99") && e.contains("not a rule id in")),
896 "{errors:?}"
897 );
898 }
899
900 #[test]
901 fn validated_pending_in_accepts_a_closes_rule_citing_a_known_id() {
902 let dir = rule_fixture("accepts-known", "**R2.3 — real.**\n");
903 fs::write(
904 dir.join("design/pending/x.md"),
905 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\n---\n",
906 )
907 .unwrap();
908 let parsed = validated_pending_in(&dir).unwrap();
909 assert_eq!(parsed.len(), 1);
910 assert_eq!(parsed[0].1.closes_rule, vec!["R2.3".to_string()]);
911 }
912
913 #[test]
914 fn non_kebab_adr_slug_rejected() {
915 assert!(
916 err(
917 "x.md",
918 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: Not_Kebab\ntitle: T\n\nBody.\n"
919 )
920 .iter()
921 .any(|e| e.contains("not a kebab-case slug"))
922 );
923 }
924
925 #[test]
926 fn duplicate_adr_slug_rejected() {
927 assert!(err(
928 "x.md",
929 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: dup\ntitle: A\n\nBody a.\n\n## ADR: dup\ntitle: B\n\nBody b.\n"
930 )
931 .iter()
932 .any(|e| e.contains("duplicate ADR slug")));
933 }
934
935 #[test]
936 fn empty_adr_body_rejected() {
937 assert!(
938 err(
939 "x.md",
940 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: empty\ntitle: T\n\n## ADR: next\ntitle: N\n\nBody.\n"
941 )
942 .iter()
943 .any(|e| e.contains("empty body"))
944 );
945 }
946
947 #[test]
948 fn non_kebab_filename_rejected() {
949 assert!(
950 err("Not_A_Slug.md", "---\nlevel: minor\nchangelog: x\n---\n")
951 .iter()
952 .any(|e| e.contains("filename stem"))
953 );
954 }
955}