1use std::fs;
18use std::path::{Path, PathBuf};
19
20pub mod greenfield_status;
21pub mod manifest_schema;
22pub mod stamp;
23
24#[derive(Debug, PartialEq, Eq)]
27pub enum Level {
28 Minor,
29 Patch,
30}
31
32#[derive(Debug, PartialEq, Eq)]
38pub struct Adr {
39 pub slug: String,
40 pub title: String,
41 pub summary: Option<String>,
42 pub status: Option<String>,
43 pub body: String,
44}
45
46impl Adr {
47 pub fn summary(&self) -> &str {
50 self.summary.as_deref().unwrap_or(&self.title)
51 }
52
53 pub fn status(&self) -> &str {
55 self.status.as_deref().unwrap_or("Accepted")
56 }
57}
58
59#[derive(Debug, PartialEq, Eq)]
61pub struct Pending {
62 pub level: Level,
63 pub changelog: String,
64 pub adrs: Vec<Adr>,
65 pub closes_rule: Vec<String>,
72 pub probes: Vec<String>,
80}
81
82pub fn repo_root() -> PathBuf {
85 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
86}
87
88pub fn pending_dir() -> PathBuf {
90 repo_root().join("design/pending")
91}
92
93pub fn check_all() -> Result<usize, Vec<String>> {
98 validated_pending_in(&repo_root()).map(|ps| ps.len())
99}
100
101pub fn validated_pending_in(root: &Path) -> Result<Vec<(String, Pending)>, Vec<String>> {
112 let dir = root.join("design/pending");
113 let entries = match fs::read_dir(&dir) {
114 Ok(e) => e,
115 Err(err) => return Err(vec![format!("cannot read {}: {err}", dir.display())]),
116 };
117
118 let mut names: Vec<String> = entries
119 .filter_map(Result::ok)
120 .map(|e| e.file_name().to_string_lossy().into_owned())
121 .filter(|n| n.ends_with(".md") && n != "README.md")
122 .collect();
123 names.sort();
124
125 let mut parsed = Vec::new();
126 let mut errors = Vec::new();
127 for name in names {
128 let content = match fs::read_to_string(dir.join(&name)) {
129 Ok(c) => c,
130 Err(err) => {
131 errors.push(format!("{name}: cannot read: {err}"));
132 continue;
133 }
134 };
135 match validate(&name, &content) {
136 Ok(p) => parsed.push((name, p)),
137 Err(errs) => errors.extend(errs.into_iter().map(|e| format!("{name}: {e}"))),
138 }
139 }
140
141 if !parsed.iter().any(|(_, p)| !p.closes_rule.is_empty()) {
142 } else {
144 match known_rule_ids(root) {
145 Ok(known) => {
146 for (name, pending) in &parsed {
147 for rule in &pending.closes_rule {
148 if !known.contains(rule) {
149 errors.push(format!(
150 "{name}: closes_rule cites {rule:?}, which is not a rule id in \
151 design/bynk-greenfield-compiler.md"
152 ));
153 }
154 }
155 }
156 }
157 Err(e) => errors.push(format!(
158 "cannot validate closes_rule entries against the reference: {e}"
159 )),
160 }
161 }
162
163 if errors.is_empty() {
164 Ok(parsed)
165 } else {
166 Err(errors)
167 }
168}
169
170pub fn validate(filename: &str, content: &str) -> Result<Pending, Vec<String>> {
174 let mut errors = Vec::new();
175
176 let stem = Path::new(filename)
177 .file_stem()
178 .map(|s| s.to_string_lossy().into_owned())
179 .unwrap_or_default();
180 if !is_kebab(&stem) {
181 errors.push(format!(
182 "filename stem {stem:?} is not a kebab-case slug (a-z, 0-9, single hyphens)"
183 ));
184 }
185
186 let (level, changelog, closes_rule, probes) = match parse_frontmatter(content, &mut errors) {
187 Some(fm) => fm,
188 None => return Err(errors),
189 };
190 let adrs = parse_adrs(content, &mut errors);
191
192 if errors.is_empty() {
193 Ok(Pending {
194 level: level.expect("no errors implies a level"),
195 changelog: changelog.expect("no errors implies a changelog"),
196 adrs,
197 closes_rule,
198 probes,
199 })
200 } else {
201 Err(errors)
202 }
203}
204
205type Frontmatter = (Option<Level>, Option<String>, Vec<String>, Vec<String>);
208
209fn parse_frontmatter(content: &str, errors: &mut Vec<String>) -> Option<Frontmatter> {
215 let mut lines = content.lines();
216 if lines.next().map(str::trim_end) != Some("---") {
217 errors.push("must open with a `---` frontmatter fence on line 1".into());
218 return None;
219 }
220
221 let mut header = Vec::new();
222 let mut closed = false;
223 for line in lines {
224 if line.trim_end() == "---" {
225 closed = true;
226 break;
227 }
228 header.push(line);
229 }
230 if !closed {
231 errors.push("frontmatter is not closed with a `---` fence".into());
232 return None;
233 }
234
235 let mut level = None;
236 let mut changelog = None;
237 let mut closes_rule = Vec::new();
238 let mut probes = Vec::new();
239 let mut saw_level = false;
242 let mut saw_changelog = false;
243 let mut saw_closes_rule = false;
244 let mut saw_probes = false;
245 for raw in header {
246 let line = raw.trim();
247 if line.is_empty() {
248 continue;
249 }
250 let Some((key, value)) = line.split_once(':') else {
251 errors.push(format!("frontmatter line is not `key: value`: {raw:?}"));
252 continue;
253 };
254 let key = key.trim();
255 let value = value.trim();
256 match key {
257 "level" => {
258 if saw_level {
259 errors.push("duplicate frontmatter key `level`".into());
260 }
261 saw_level = true;
262 level = match value {
263 "minor" => Some(Level::Minor),
264 "patch" => Some(Level::Patch),
265 other => {
266 errors.push(format!("level must be `minor` or `patch`, got {other:?}"));
267 None
268 }
269 };
270 }
271 "changelog" => {
272 if saw_changelog {
273 errors.push("duplicate frontmatter key `changelog`".into());
274 }
275 saw_changelog = true;
276 if value.is_empty() {
277 errors.push("changelog must not be empty".into());
278 } else if looks_like_version_prefix(value) {
279 errors.push(format!(
280 "changelog must not start with a version number (the stamp adds it): {value:?}"
281 ));
282 } else if let Some(dest) = relative_markdown_link_in(value) {
283 errors.push(format!(
284 "changelog reads as a Markdown link to the relative destination {dest:?} \
285 — the blurb is inserted verbatim into the Book's changelog table, so a \
286 bare `x[T](y)` in prose becomes a link the docs site's link-checker \
287 rejects (and it only sees the row after the stamp writes it on `main`). \
288 Wrap the code in backticks, or use an absolute URL."
289 ));
290 } else {
291 changelog = Some(value.to_string());
292 }
293 }
294 "closes_rule" => {
295 if saw_closes_rule {
296 errors.push("duplicate frontmatter key `closes_rule`".into());
297 }
298 saw_closes_rule = true;
299 if value.is_empty() {
300 errors.push(
301 "closes_rule must not be empty (omit the key entirely if there's \
302 nothing to cite)"
303 .into(),
304 );
305 } else {
306 for entry in value.split(',') {
307 let entry = entry.trim();
308 if is_rule_id(entry) {
309 closes_rule.push(entry.to_string());
310 } else {
311 errors.push(format!(
312 "closes_rule entry {entry:?} is not a rule id \
313 (expected `R<major>.<minor>`, e.g. `R2.3`)"
314 ));
315 }
316 }
317 }
318 }
319 "probes" => {
320 if saw_probes {
321 errors.push("duplicate frontmatter key `probes`".into());
322 }
323 saw_probes = true;
324 if value.is_empty() {
325 errors.push(
326 "probes must not be empty (omit the key entirely if no gated probe \
327 moves)"
328 .into(),
329 );
330 } else {
331 for entry in value.split(',') {
332 let entry = entry.trim().trim_matches('`');
333 if greenfield_status::GATED_PROBES.contains(&entry) {
334 if !probes.iter().any(|p| p == entry) {
335 probes.push(entry.to_string());
336 }
337 } else {
338 errors.push(format!(
339 "probes entry {entry:?} is not a gated greenfield-status probe \
340 (one of: {})",
341 greenfield_status::GATED_PROBES.join(", ")
342 ));
343 }
344 }
345 }
346 }
347 other => errors.push(format!("unknown frontmatter key {other:?}")),
348 }
349 }
350
351 if !saw_level {
352 errors.push("frontmatter is missing `level`".into());
353 }
354 if !saw_changelog {
355 errors.push("frontmatter is missing `changelog`".into());
356 }
357
358 Some((level, changelog, closes_rule, probes))
359}
360
361pub fn acknowledged_probes(root: &Path) -> std::collections::BTreeSet<String> {
368 validated_pending_in(root)
369 .map(|ps| ps.into_iter().flat_map(|(_, p)| p.probes).collect())
370 .unwrap_or_default()
371}
372
373pub fn is_rule_id(s: &str) -> bool {
377 let Some(rest) = s.strip_prefix('R') else {
378 return false;
379 };
380 let Some((major, minor)) = rest.split_once('.') else {
381 return false;
382 };
383 !major.is_empty()
384 && major.chars().all(|c| c.is_ascii_digit())
385 && !minor.is_empty()
386 && minor.chars().all(|c| c.is_ascii_digit())
387}
388
389pub fn known_rule_ids(root: &Path) -> Result<std::collections::HashSet<String>, String> {
400 let path = root.join("design/bynk-greenfield-compiler.md");
401 let text =
402 fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
403 let mut ids = std::collections::HashSet::new();
404 let bytes = text.as_bytes();
405 let mut i = 0;
406 while let Some(rel) = text[i..].find("**R") {
407 let start = i + rel + 2; let mut end = start;
409 while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'.') {
410 end += 1;
411 }
412 let candidate = &text[start..end];
413 if is_rule_id(candidate) {
414 ids.insert(candidate.to_string());
415 }
416 i = end.max(start + 1);
417 }
418 Ok(ids)
419}
420
421pub fn pr_number_from_subject(subject: &str) -> Option<u32> {
441 let inner = subject.strip_suffix(')')?.rsplit_once("(#")?.1;
442 inner.parse().ok()
443}
444
445fn parse_adrs(content: &str, errors: &mut Vec<String>) -> Vec<Adr> {
449 let mut fences = 0;
451 let mut body_lines = Vec::new();
452 for line in content.lines() {
453 if fences < 2 {
454 if line.trim_end() == "---" {
455 fences += 1;
456 }
457 continue;
458 }
459 body_lines.push(line);
460 }
461
462 let mut in_fence = false;
467 let is_header: Vec<bool> = body_lines
468 .iter()
469 .map(|line| {
470 if line.trim_start().starts_with("```") {
471 in_fence = !in_fence;
472 false
473 } else {
474 !in_fence && adr_header_slug(line).is_some()
475 }
476 })
477 .collect();
478
479 let mut adrs: Vec<Adr> = Vec::new();
480 let mut i = 0;
481 while i < body_lines.len() {
482 if is_header[i] {
483 let slug = adr_header_slug(body_lines[i])
484 .expect("is_header implies an ADR header")
485 .trim()
486 .to_string();
487 i += 1;
488 let mut block = Vec::new();
489 while i < body_lines.len() && !is_header[i] {
490 block.push(body_lines[i]);
491 i += 1;
492 }
493
494 let mut title = None;
499 let mut summary = None;
500 let mut status = None;
501 let mut body_start = block.len();
502 for (idx, raw) in block.iter().enumerate() {
503 let line = raw.trim();
504 if line.is_empty() && title.is_none() && summary.is_none() && status.is_none() {
505 continue;
506 }
507 if let Some(v) = line.strip_prefix("title:") {
508 title = Some(v.trim().to_string());
509 } else if let Some(v) = line.strip_prefix("summary:") {
510 summary = Some(v.trim().to_string());
511 } else if let Some(v) = line.strip_prefix("status:") {
512 status = Some(v.trim().to_string());
513 } else {
514 body_start = idx;
515 break;
516 }
517 }
518 let body = block[body_start..].join("\n").trim().to_string();
519
520 if !is_kebab(&slug) {
521 errors.push(format!(
522 "ADR slug {slug:?} is not a kebab-case slug (a-z, 0-9, single hyphens)"
523 ));
524 } else if adrs.iter().any(|a| a.slug == slug) {
525 errors.push(format!("duplicate ADR slug {slug:?}"));
526 }
527 match &title {
528 Some(t) if t.is_empty() => {
529 errors.push(format!("ADR {slug:?} has an empty `title:`"))
530 }
531 None => errors.push(format!("ADR {slug:?} is missing a `title:` line")),
532 _ => {}
533 }
534 if body.is_empty() {
535 errors.push(format!("ADR {slug:?} has an empty body"));
536 }
537 adrs.push(Adr {
538 slug,
539 title: title.unwrap_or_default(),
540 summary: summary.filter(|s| !s.is_empty()),
541 status: status.filter(|s| !s.is_empty()),
542 body,
543 });
544 } else {
545 i += 1;
546 }
547 }
548 adrs
549}
550
551fn adr_header_slug(line: &str) -> Option<&str> {
553 line.trim().strip_prefix("## ADR:")
554}
555
556fn is_kebab(s: &str) -> bool {
558 !s.is_empty()
559 && !s.starts_with('-')
560 && !s.ends_with('-')
561 && !s.contains("--")
562 && s.chars()
563 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
564}
565
566fn looks_like_version_prefix(changelog: &str) -> bool {
573 let raw = changelog.split_whitespace().next().unwrap_or("");
574 let had_v = raw.starts_with('v') || raw.starts_with('V');
575 let groups: Vec<&str> = raw.trim_start_matches(['v', 'V']).split('.').collect();
576 let all_numeric = groups.len() >= 2
577 && groups
578 .iter()
579 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
580 all_numeric && (had_v || groups.len() >= 3)
581}
582
583fn relative_markdown_link_in(blurb: &str) -> Option<String> {
600 let bytes = blurb.as_bytes();
601 let mut i = 0;
602 let mut saw_open_bracket = false;
603 while i < bytes.len() {
604 match bytes[i] {
605 b'\\' => i += 1,
607 b'`' => {
614 let fence = bytes[i..].iter().take_while(|&&b| b == b'`').count();
615 let mut j = i + fence;
616 let mut closed = false;
617 while j < bytes.len() {
618 if bytes[j] == b'`' {
619 let run = bytes[j..].iter().take_while(|&&b| b == b'`').count();
620 j += run;
621 if run == fence {
622 closed = true;
623 break;
624 }
625 } else {
626 j += 1;
627 }
628 }
629 i = if closed { j } else { i + fence };
630 continue;
631 }
632 b'[' => saw_open_bracket = true,
633 b']' if saw_open_bracket && bytes.get(i + 1) == Some(&b'(') => {
635 saw_open_bracket = false;
636 let start = i + 2;
637 let Some(len) = bytes[start..].iter().position(|&b| b == b')') else {
638 break;
639 };
640 if let Some(dest) = link_destination(&blurb[start..start + len])
641 && !is_absolute_link_destination(dest)
642 {
643 return Some(dest.to_string());
644 }
645 i = start + len;
646 }
647 b']' => saw_open_bracket = false,
648 _ => {}
649 }
650 i += 1;
651 }
652 None
653}
654
655fn link_destination(tail: &str) -> Option<&str> {
664 let tail = tail.trim();
665 if let Some(rest) = tail.strip_prefix('<') {
666 return rest.split_once('>').map(|(dest, _)| dest);
667 }
668 let (dest, rest) = match tail.split_once(char::is_whitespace) {
669 Some((dest, rest)) => (dest, rest.trim_start()),
670 None => (tail, ""),
671 };
672 if rest.is_empty() || rest.starts_with(['"', '\'', '(']) {
674 Some(dest)
675 } else {
676 None
677 }
678}
679
680fn is_absolute_link_destination(dest: &str) -> bool {
688 dest.starts_with("https://")
689 || dest.starts_with("http://")
690 || dest.starts_with("mailto:")
691 || dest.starts_with('/')
692 || dest.starts_with('#')
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698
699 fn ok(name: &str, content: &str) -> Pending {
700 validate(name, content).unwrap_or_else(|e| panic!("expected valid, got {e:?}"))
701 }
702 fn err(name: &str, content: &str) -> Vec<String> {
703 validate(name, content).expect_err("expected invalid")
704 }
705
706 #[test]
707 fn minimal_no_adr_is_valid() {
708 let p = ok(
709 "add-a-thing.md",
710 "---\nlevel: minor\nchangelog: Add a thing to the language\n---\n",
711 );
712 assert_eq!(p.level, Level::Minor);
713 assert_eq!(p.changelog, "Add a thing to the language");
714 assert!(p.adrs.is_empty());
715 }
716
717 #[test]
718 fn patch_level_is_valid() {
719 assert_eq!(
720 ok(
721 "fix-a-thing.md",
722 "---\nlevel: patch\nchangelog: Fix a non-language thing\n---\n"
723 )
724 .level,
725 Level::Patch
726 );
727 }
728
729 #[test]
730 fn one_adr_parses_slug_title_and_body() {
731 let p = ok(
732 "unit-tier.md",
733 "---\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",
734 );
735 assert_eq!(p.adrs.len(), 1);
736 let adr = &p.adrs[0];
737 assert_eq!(adr.slug, "unit-tier-service-address");
738 assert_eq!(adr.title, "A case addresses a handler by surface");
739 assert_eq!(adr.summary(), adr.title);
741 assert_eq!(adr.status(), "Accepted");
742 assert!(adr.body.contains("addresses by surface"));
743 assert!(!adr.body.contains("title:"), "the title line is not body");
744 }
745
746 #[test]
747 fn adr_summary_and_status_are_parsed() {
748 let p = ok(
749 "x.md",
750 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: a-slug\ntitle: The title\nsummary: The one-line index distillation\nstatus: Proposed\n\nBody.\n",
751 );
752 let adr = &p.adrs[0];
753 assert_eq!(adr.summary(), "The one-line index distillation");
754 assert_eq!(adr.status(), "Proposed");
755 }
756
757 #[test]
758 fn adr_missing_title_rejected() {
759 assert!(
760 err(
761 "x.md",
762 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: a-slug\n\nBody with no title line.\n"
763 )
764 .iter()
765 .any(|e| e.contains("missing a `title:`"))
766 );
767 }
768
769 #[test]
770 fn two_adrs_parse() {
771 let p = ok(
772 "two.md",
773 "---\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",
774 );
775 assert_eq!(p.adrs.len(), 2);
776 assert_eq!(p.adrs[0].slug, "first-one");
777 assert_eq!(p.adrs[1].slug, "second-one");
778 }
779
780 #[test]
781 fn bad_level_rejected() {
782 assert!(
783 err("x.md", "---\nlevel: major\nchangelog: x\n---\n")
784 .iter()
785 .any(|e| e.contains("level must be"))
786 );
787 }
788
789 #[test]
790 fn missing_level_rejected() {
791 assert!(
792 err("x.md", "---\nchangelog: x\n---\n")
793 .iter()
794 .any(|e| e.contains("missing `level`"))
795 );
796 }
797
798 #[test]
799 fn missing_changelog_rejected() {
800 assert!(
801 err("x.md", "---\nlevel: minor\n---\n")
802 .iter()
803 .any(|e| e.contains("missing `changelog`"))
804 );
805 }
806
807 #[test]
808 fn empty_changelog_rejected() {
809 assert!(
810 err("x.md", "---\nlevel: minor\nchangelog: \n---\n")
811 .iter()
812 .any(|e| e.contains("changelog"))
813 );
814 }
815
816 #[test]
817 fn version_prefixed_changelog_rejected() {
818 for cl in ["v0.186 Add a thing", "0.186.0 Add a thing"] {
819 let content = format!("---\nlevel: minor\nchangelog: {cl}\n---\n");
820 assert!(
821 err("x.md", &content)
822 .iter()
823 .any(|e| e.contains("version number")),
824 "expected rejection for {cl:?}"
825 );
826 }
827 }
828
829 #[test]
830 fn plain_changelog_with_a_dot_is_allowed() {
831 ok(
834 "x.md",
835 "---\nlevel: minor\nchangelog: Support semver ranges like 1.2.3\n---\n",
836 );
837 }
838
839 #[test]
840 fn bare_two_group_leading_number_is_allowed() {
841 ok(
844 "x.md",
845 "---\nlevel: minor\nchangelog: 3.0 rendering pipeline added\n---\n",
846 );
847 }
848
849 #[test]
850 fn accidental_relative_markdown_link_in_changelog_rejected() {
851 let content = "---\nlevel: patch\nchangelog: Read Callee to detect an \
856 Events.emit[E](event) call\n---\n";
857 assert!(
858 err("x.md", content)
859 .iter()
860 .any(|e| e.contains("relative destination \"event\"")),
861 "expected rejection, got {:?}",
862 err("x.md", content)
863 );
864 }
865
866 #[test]
867 fn backticked_generic_call_in_changelog_is_allowed() {
868 ok(
870 "x.md",
871 "---\nlevel: patch\nchangelog: Detect an `Events.emit[E](event)` call\n---\n",
872 );
873 }
874
875 #[test]
876 fn absolute_links_in_changelog_are_allowed() {
877 for cl in [
880 "Close [#548](https://github.com/accuser/bynk/issues/548)",
881 "See [the roadmap](/book/about/versioning-and-roadmap/)",
882 "See [below](#notes)",
883 ] {
884 let content = format!("---\nlevel: minor\nchangelog: {cl}\n---\n");
885 ok("x.md", &content);
886 }
887 }
888
889 #[test]
890 fn bracket_without_a_link_in_changelog_is_allowed() {
891 ok(
894 "x.md",
895 "---\nlevel: minor\nchangelog: Widen ts_any [see the probe] (P7.0) for writes\n---\n",
896 );
897 }
898
899 #[test]
900 fn a_half_backticked_changelog_is_still_rejected() {
901 let content = "---\nlevel: patch\nchangelog: Detect an `Events.emit[E](event) call\n---\n";
906 assert!(
907 err("x.md", content)
908 .iter()
909 .any(|e| e.contains("relative destination \"event\"")),
910 "expected rejection, got {:?}",
911 err("x.md", content)
912 );
913 }
914
915 #[test]
916 fn a_stray_backtick_does_not_blind_the_rest_of_the_blurb() {
917 let content = "---\nlevel: patch\nchangelog: A stray `Callee typo, then \
919 Events.emit[E](event) later\n---\n";
920 assert!(
921 err("x.md", content)
922 .iter()
923 .any(|e| e.contains("relative destination \"event\"")),
924 "expected rejection, got {:?}",
925 err("x.md", content)
926 );
927 }
928
929 #[test]
930 fn a_multi_parameter_generic_call_is_not_reported_as_a_link() {
931 ok(
934 "x.md",
935 "---\nlevel: minor\nchangelog: Lift a fn map[K, V](key, value) call\n---\n",
936 );
937 }
938
939 #[test]
940 fn a_titled_link_is_still_read_as_a_link() {
941 let content = "---\nlevel: minor\nchangelog: See [it](there \"a title\")\n---\n";
944 assert!(
945 err("x.md", content)
946 .iter()
947 .any(|e| e.contains("relative destination \"there\"")),
948 "expected rejection, got {:?}",
949 err("x.md", content)
950 );
951 }
952
953 #[test]
954 fn duplicate_frontmatter_key_rejected() {
955 assert!(
956 err(
957 "x.md",
958 "---\nlevel: minor\nlevel: patch\nchangelog: x\n---\n"
959 )
960 .iter()
961 .any(|e| e.contains("duplicate frontmatter key `level`"))
962 );
963 }
964
965 #[test]
966 fn adr_header_inside_a_code_fence_is_not_a_block() {
967 let p = ok(
970 "x.md",
971 "---\nlevel: minor\nchangelog: Document the format\n---\n\n\
972 Example:\n\n```markdown\n## ADR: not-a-real-block\nfenced prose\n```\n\n\
973 ## ADR: the-real-one\ntitle: The real one\n\nReal body.\n",
974 );
975 assert_eq!(p.adrs.len(), 1);
976 assert_eq!(p.adrs[0].slug, "the-real-one");
977 }
978
979 #[test]
980 fn no_frontmatter_rejected() {
981 assert!(
982 err("x.md", "just some text\n")
983 .iter()
984 .any(|e| e.contains("open with a `---`"))
985 );
986 }
987
988 #[test]
989 fn unclosed_frontmatter_rejected() {
990 assert!(
991 err("x.md", "---\nlevel: minor\nchangelog: x\n")
992 .iter()
993 .any(|e| e.contains("not closed"))
994 );
995 }
996
997 #[test]
998 fn unknown_key_rejected() {
999 assert!(
1000 err("x.md", "---\nlevel: minor\nchangelog: x\nversion: 9\n---\n")
1001 .iter()
1002 .any(|e| e.contains("unknown frontmatter key"))
1003 );
1004 }
1005
1006 #[test]
1009 fn probes_is_optional_and_defaults_empty() {
1010 let p = ok("x.md", "---\nlevel: patch\nchangelog: x\n---\n");
1011 assert!(p.probes.is_empty());
1012 }
1013
1014 #[test]
1015 fn probes_parses_a_list_trims_backticks_and_dedupes() {
1016 let p = ok(
1017 "x.md",
1018 "---\nlevel: patch\nchangelog: x\nprobes: `ts_writes`, diagnostic_coverage ,ts_writes\n---\n",
1019 );
1020 assert_eq!(
1021 p.probes,
1022 vec!["ts_writes".to_string(), "diagnostic_coverage".to_string()]
1023 );
1024 }
1025
1026 #[test]
1027 fn probes_rejects_a_trend_or_unknown_probe() {
1028 let errs = err(
1029 "x.md",
1030 "---\nlevel: patch\nchangelog: x\nprobes: wildcard_arms, nope\n---\n",
1031 );
1032 assert_eq!(
1033 errs.iter()
1034 .filter(|e| e.contains("is not a gated greenfield-status probe"))
1035 .count(),
1036 2
1037 );
1038 }
1039
1040 #[test]
1041 fn probes_rejects_empty_and_duplicate_keys() {
1042 assert!(
1043 err("x.md", "---\nlevel: patch\nchangelog: x\nprobes:\n---\n")
1044 .iter()
1045 .any(|e| e.contains("probes must not be empty"))
1046 );
1047 assert!(
1048 err(
1049 "x.md",
1050 "---\nlevel: patch\nchangelog: x\nprobes: ts_any\nprobes: ts_any\n---\n"
1051 )
1052 .iter()
1053 .any(|e| e.contains("duplicate frontmatter key `probes`"))
1054 );
1055 }
1056
1057 #[test]
1060 fn closes_rule_is_optional_and_defaults_empty() {
1061 let p = ok("x.md", "---\nlevel: patch\nchangelog: x\n---\n");
1062 assert!(p.closes_rule.is_empty());
1063 }
1064
1065 #[test]
1066 fn closes_rule_parses_a_single_id() {
1067 let p = ok(
1068 "x.md",
1069 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\n---\n",
1070 );
1071 assert_eq!(p.closes_rule, vec!["R2.3".to_string()]);
1072 }
1073
1074 #[test]
1075 fn closes_rule_parses_a_comma_separated_list_and_trims_whitespace() {
1076 let p = ok(
1077 "x.md",
1078 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3, R2.12 ,R0.1\n---\n",
1079 );
1080 assert_eq!(
1081 p.closes_rule,
1082 vec!["R2.3".to_string(), "R2.12".to_string(), "R0.1".to_string()]
1083 );
1084 }
1085
1086 #[test]
1087 fn closes_rule_rejects_a_malformed_entry() {
1088 assert!(
1089 err(
1090 "x.md",
1091 "---\nlevel: patch\nchangelog: x\ncloses_rule: not-a-rule\n---\n"
1092 )
1093 .iter()
1094 .any(|e| e.contains("closes_rule entry") && e.contains("not a rule id"))
1095 );
1096 }
1097
1098 #[test]
1099 fn closes_rule_rejects_empty_value() {
1100 assert!(
1101 err(
1102 "x.md",
1103 "---\nlevel: patch\nchangelog: x\ncloses_rule: \n---\n"
1104 )
1105 .iter()
1106 .any(|e| e.contains("closes_rule must not be empty"))
1107 );
1108 }
1109
1110 #[test]
1111 fn closes_rule_rejects_duplicate_key() {
1112 assert!(
1113 err(
1114 "x.md",
1115 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\ncloses_rule: R2.4\n---\n"
1116 )
1117 .iter()
1118 .any(|e| e.contains("duplicate frontmatter key `closes_rule`"))
1119 );
1120 }
1121
1122 #[test]
1123 fn is_rule_id_accepts_and_rejects() {
1124 assert!(is_rule_id("R2.3"));
1125 assert!(is_rule_id("R0.1"));
1126 assert!(is_rule_id("R12.34"));
1127 assert!(!is_rule_id("2.3"));
1128 assert!(!is_rule_id("R2"));
1129 assert!(!is_rule_id("R2.3.4"));
1130 assert!(!is_rule_id("R.3"));
1131 assert!(!is_rule_id("R2."));
1132 assert!(!is_rule_id("Rx.y"));
1133 }
1134
1135 #[test]
1138 fn pr_number_from_subject_finds_a_trailing_squash_merge_suffix() {
1139 assert_eq!(
1140 pr_number_from_subject("feat(xtask): thing (#1234)"),
1141 Some(1234)
1142 );
1143 }
1144
1145 #[test]
1146 fn pr_number_from_subject_requires_the_suffix_at_the_very_end() {
1147 assert_eq!(pr_number_from_subject("feat: thing (#12) then more"), None);
1149 }
1150
1151 #[test]
1152 fn pr_number_from_subject_is_not_confused_by_earlier_nested_parens() {
1153 assert_eq!(pr_number_from_subject("chore: bump (deps) (#12)"), Some(12));
1154 }
1155
1156 #[test]
1157 fn pr_number_from_subject_rejects_a_non_numeric_hash() {
1158 assert_eq!(pr_number_from_subject("feat: thing (#abc)"), None);
1159 }
1160
1161 #[test]
1162 fn pr_number_from_subject_rejects_a_number_too_large_for_u32() {
1163 assert_eq!(pr_number_from_subject("(#99999999999999)"), None);
1164 }
1165
1166 #[test]
1167 fn pr_number_from_subject_none_without_any_suffix() {
1168 assert_eq!(pr_number_from_subject("Merge branch 'x'"), None);
1169 }
1170
1171 #[test]
1175 fn pr_number_from_subject_cannot_distinguish_an_issue_reference() {
1176 assert_eq!(
1177 pr_number_from_subject("fix: handle empty spans (#1001)"),
1178 Some(1001)
1179 );
1180 }
1181
1182 fn rule_fixture(tag: &str, reference_body: &str) -> PathBuf {
1187 let root = std::env::temp_dir().join(format!("xtask-closes-rule-{tag}"));
1188 let _ = fs::remove_dir_all(&root);
1189 fs::create_dir_all(root.join("design/pending")).unwrap();
1190 fs::write(
1191 root.join("design/bynk-greenfield-compiler.md"),
1192 reference_body,
1193 )
1194 .unwrap();
1195 root
1196 }
1197
1198 #[test]
1199 fn acknowledged_probes_is_the_union_across_pending_files() {
1200 let dir = rule_fixture("ack-union", "");
1201 let pending = dir.join("design/pending");
1202 fs::write(
1203 pending.join("a.md"),
1204 "---\nlevel: patch\nchangelog: a\nprobes: ts_writes, ts_any\n---\n",
1205 )
1206 .unwrap();
1207 fs::write(
1208 pending.join("b.md"),
1209 "---\nlevel: patch\nchangelog: b\nprobes: ts_any, diagnostic_coverage\n---\n",
1210 )
1211 .unwrap();
1212 fs::write(
1213 pending.join("c.md"),
1214 "---\nlevel: patch\nchangelog: c\n---\n",
1215 )
1216 .unwrap();
1217 fs::write(pending.join("README.md"), "not a pending file").unwrap();
1218 let ack: Vec<String> = acknowledged_probes(&dir).into_iter().collect();
1219 assert_eq!(ack, ["diagnostic_coverage", "ts_any", "ts_writes"]);
1220 }
1221
1222 #[test]
1223 fn acknowledged_probes_is_empty_when_any_pending_file_is_invalid() {
1224 let dir = rule_fixture("ack-invalid", "");
1225 let pending = dir.join("design/pending");
1226 fs::write(
1227 pending.join("a.md"),
1228 "---\nlevel: patch\nchangelog: a\nprobes: ts_writes\n---\n",
1229 )
1230 .unwrap();
1231 fs::write(
1232 pending.join("b.md"),
1233 "---\nlevel: nope\nchangelog: b\n---\n",
1234 )
1235 .unwrap();
1236 assert!(acknowledged_probes(&dir).is_empty());
1237 }
1238
1239 #[test]
1240 fn acknowledged_probes_is_empty_without_a_pending_dir() {
1241 let dir = std::env::temp_dir().join("xtask-ack-no-pending-dir");
1242 let _ = fs::remove_dir_all(&dir);
1243 fs::create_dir_all(&dir).unwrap();
1244 assert!(acknowledged_probes(&dir).is_empty());
1245 }
1246
1247 #[test]
1248 fn known_rule_ids_finds_bold_rule_headers() {
1249 let dir = rule_fixture(
1250 "finds-bold-headers",
1251 "Some prose.\n\n**R2.3 — A rule about spans.**\n\nMore prose citing **R2.3** again \
1252 in passing, and introducing **R10.11 — a second rule.**\n",
1253 );
1254 let ids = known_rule_ids(&dir).unwrap();
1255 assert_eq!(ids.len(), 2, "expected exactly 2 distinct ids: {ids:?}");
1256 assert!(ids.contains("R2.3"));
1257 assert!(ids.contains("R10.11"));
1258 }
1259
1260 #[test]
1261 fn validated_pending_in_rejects_a_closes_rule_citing_an_unknown_id() {
1262 let dir = rule_fixture("rejects-unknown", "**R2.3 — real.**\n");
1263 fs::write(
1264 dir.join("design/pending/x.md"),
1265 "---\nlevel: patch\nchangelog: x\ncloses_rule: R99.99\n---\n",
1266 )
1267 .unwrap();
1268 let errors = validated_pending_in(&dir).expect_err("R99.99 does not exist");
1269 assert!(
1270 errors
1271 .iter()
1272 .any(|e| e.contains("R99.99") && e.contains("not a rule id in")),
1273 "{errors:?}"
1274 );
1275 }
1276
1277 #[test]
1278 fn validated_pending_in_accepts_a_closes_rule_citing_a_known_id() {
1279 let dir = rule_fixture("accepts-known", "**R2.3 — real.**\n");
1280 fs::write(
1281 dir.join("design/pending/x.md"),
1282 "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\n---\n",
1283 )
1284 .unwrap();
1285 let parsed = validated_pending_in(&dir).unwrap();
1286 assert_eq!(parsed.len(), 1);
1287 assert_eq!(parsed[0].1.closes_rule, vec!["R2.3".to_string()]);
1288 }
1289
1290 #[test]
1291 fn non_kebab_adr_slug_rejected() {
1292 assert!(
1293 err(
1294 "x.md",
1295 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: Not_Kebab\ntitle: T\n\nBody.\n"
1296 )
1297 .iter()
1298 .any(|e| e.contains("not a kebab-case slug"))
1299 );
1300 }
1301
1302 #[test]
1303 fn duplicate_adr_slug_rejected() {
1304 assert!(err(
1305 "x.md",
1306 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: dup\ntitle: A\n\nBody a.\n\n## ADR: dup\ntitle: B\n\nBody b.\n"
1307 )
1308 .iter()
1309 .any(|e| e.contains("duplicate ADR slug")));
1310 }
1311
1312 #[test]
1313 fn empty_adr_body_rejected() {
1314 assert!(
1315 err(
1316 "x.md",
1317 "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: empty\ntitle: T\n\n## ADR: next\ntitle: N\n\nBody.\n"
1318 )
1319 .iter()
1320 .any(|e| e.contains("empty body"))
1321 );
1322 }
1323
1324 #[test]
1325 fn non_kebab_filename_rejected() {
1326 assert!(
1327 err("Not_A_Slug.md", "---\nlevel: minor\nchangelog: x\n---\n")
1328 .iter()
1329 .any(|e| e.contains("filename stem"))
1330 );
1331 }
1332}