Skip to main content

xtask/
lib.rs

1//! The pending-increment format validator (increment-allocation track, Slice 0).
2//!
3//! A feature PR adds one `design/pending/<slug>.md` declaring its bump level, a
4//! one-line changelog blurb, and — when it records a decision — one or more ADR
5//! prose blocks. It writes *no* version and *no* ADR number: those are the two
6//! serial counters that the merge-time stamp assigns on `main`, so that parallel
7//! increments stop conflicting on them. See `design/pending/README.md` and
8//! ADR 0206 (`design/decisions/0206-allocation-on-main.md`).
9//!
10//! This module is the *format contract* between that human-authored file and the
11//! future stamp. It is process tooling, not compiler behaviour, which is why it
12//! lives in the unpublished `xtask` crate rather than in `bynkc`'s test suite.
13//! [`check_all`] is exercised two ways: an integration test (`tests/pending_files.rs`)
14//! runs it over the real `design/pending/**` as a drift guard, and the
15//! `check-pending` binary subcommand exposes it for local runs.
16
17use std::fs;
18use std::path::{Path, PathBuf};
19
20pub mod greenfield_status;
21pub mod manifest_schema;
22pub mod stamp;
23
24/// The bump level an increment declares. The stamp turns this into the next
25/// `X.Y.Z` in merge order; the format never carries a concrete number.
26#[derive(Debug, PartialEq, Eq)]
27pub enum Level {
28    Minor,
29    Patch,
30}
31
32/// One ADR block. The stamp writes `design/decisions/NNNN-<slug>.md` — a
33/// `# NNNN — <title>` heading, a status line, then `body` verbatim — and a
34/// `decisions/README.md` index row (`**<title>** … <summary>`), assigning
35/// `NNNN` at merge. `title` is required (the file heading and the index bold
36/// need it); `summary` defaults to `title`, `status` to `Accepted`.
37#[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    /// The one-line distillation for the index row — the author's `summary`, or
48    /// the title when none was given.
49    pub fn summary(&self) -> &str {
50        self.summary.as_deref().unwrap_or(&self.title)
51    }
52
53    /// The ADR status — the author's `status`, or `Accepted`.
54    pub fn status(&self) -> &str {
55        self.status.as_deref().unwrap_or("Accepted")
56    }
57}
58
59/// A parsed, validated pending-increment file.
60#[derive(Debug, PartialEq, Eq)]
61pub struct Pending {
62    pub level: Level,
63    pub changelog: String,
64    pub adrs: Vec<Adr>,
65    /// Greenfield reference rule ids (`R2.3`) this increment closes (#1001).
66    /// Optional and usually empty — most increments don't close a tracked rule.
67    /// Syntax-checked here (each entry matches `R<major>.<minor>`); whether the
68    /// id actually exists in `design/bynk-greenfield-compiler.md` is checked
69    /// separately by [`known_rule_ids`], which needs the repo root this pure
70    /// parse doesn't have.
71    pub closes_rule: Vec<String>,
72    /// Gated greenfield-status probes this increment moves (see
73    /// `design/pending/README.md`). Acknowledging a probe here is what lets the
74    /// increment change its reading *without* editing the shared
75    /// `design/greenfield-status.md` table: the gate accepts an acknowledged
76    /// disagreement, and the stamp rewrites the table's gated rows on `main` in
77    /// merge order. Optional and usually empty. Each entry is checked against
78    /// [`greenfield_status::GATED_PROBES`].
79    pub probes: Vec<String>,
80}
81
82/// The repo root, resolved from this crate's manifest dir so it's independent
83/// of the working directory (the same trick `decisions_index` uses).
84pub fn repo_root() -> PathBuf {
85    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
86}
87
88/// `design/pending/` under [`repo_root`].
89pub fn pending_dir() -> PathBuf {
90    repo_root().join("design/pending")
91}
92
93/// Validate every `*.md` under [`pending_dir`] except `README.md` (the format
94/// doc, excluded like the decisions index excludes its own README). Returns the
95/// number of pending files validated, or every error found across all files
96/// (each prefixed with its filename) so one run reports the whole picture.
97pub fn check_all() -> Result<usize, Vec<String>> {
98    validated_pending_in(&repo_root()).map(|ps| ps.len())
99}
100
101/// Read and validate the pending files under `root/design/pending` (skipping
102/// `README.md`), sorted by filename. Root-parameterised so the stamp — and its
103/// fixture tests — can target any tree; [`check_all`] is this over the real
104/// [`repo_root`].
105///
106/// Also cross-checks each file's `closes_rule` entries against
107/// [`known_rule_ids`] — a syntactically valid id (`is_rule_id`, checked in
108/// [`validate`]) that names no rule the reference actually has is still an
109/// error, just one that needs `root` to catch, which the pure per-file parse
110/// doesn't have.
111pub 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        // No file cites a rule — skip reading the (large) reference doc at all.
143    } 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
170/// Validate a single pending file's `content`. `filename` is used to check the
171/// stem is a kebab-case slug. Returns every problem found (not just the first),
172/// so a malformed file reports completely.
173pub 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
205/// The header fields [`parse_frontmatter`] recovers: `level`, `changelog`,
206/// `closes_rule`, `probes`.
207type Frontmatter = (Option<Level>, Option<String>, Vec<String>, Vec<String>);
208
209/// Parse and validate the `---`-delimited header. Pushes errors; returns the
210/// fields when present and well-formed (`closes_rule` and `probes` default to
211/// empty rather than `Option` — they're genuinely optional, unlike `level`/`changelog`). Returns
212/// `None` only when the frontmatter block itself is missing/unterminated
213/// (nothing to recover).
214fn 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    // Track key presence separately from a valid value: a key that is present
240    // but malformed reports its own error and must not also be "missing".
241    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
361/// The gated probes acknowledged by every pending file under `root` — the union
362/// of their `probes` keys. The pending files present in a tree are exactly the
363/// increments not yet stamped (the stamp deletes each one as it rewrites the
364/// table), so on a PR's merge ref this covers both the PR's own acknowledgement
365/// and any just-merged increment whose stamp hasn't landed yet. An unreadable or
366/// invalid pending directory acknowledges nothing; `pending_files` reports why.
367pub 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
373/// Is `s` shaped like a greenfield-reference rule id — `R` followed by
374/// `<digits>.<digits>` (e.g. `R2.3`, `R0.1`)? Syntax only; whether the id
375/// actually exists in the reference is [`known_rule_ids`]'s job.
376pub 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
389/// Every rule id (`R2.3`, …) enumerated in the greenfield reference
390/// (`design/bynk-greenfield-compiler.md`) — the existence check for a pending
391/// file's `closes_rule` entries, separate from [`is_rule_id`]'s pure syntax
392/// check because it needs the repo root. Root-parameterised like
393/// `stamp::next_adr_number`, so a fixture tree can supply its own reference doc.
394///
395/// Rules are written inline as `**R2.3 — <title>.**`; this scans every `**R`
396/// occurrence for the dotted id immediately following, rather than requiring a
397/// line-start anchor — the doc's own precedent (`grep -oE '\*\*R[0-9]+\.[0-9]+
398/// —'`) confirmed this finds exactly the 130 rules the reference claims.
399pub 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; // skip `**`, keep the leading `R`
408        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
421/// The PR number a commit `subject` (the first line of its message) names, if
422/// it ends in the `(#NNNN)` GitHub's squash-merge appends to the PR title —
423/// pure and root-independent, so `xtask/src/main.rs`'s `pr_number_from_head`
424/// (the only caller, which runs `git log -1 --format=%s` to get `subject`)
425/// stays a thin wrapper over this, and this half — the part with the
426/// interesting edge cases — is unit-testable without a git tree (#1001,
427/// caught by review as the one piece of new logic with no test coverage).
428///
429/// **Known limitation, stated rather than guarded against:** this matches on
430/// shape alone. Any subject ending `(#NNNN)` is read as the merging PR,
431/// including a hand-written commit that happens to end in an *issue*
432/// reference (`"fix: handle empty spans (#1001)"`, where 1001 names an issue,
433/// not the PR that closes it) — correct on the squash-merge path this exists
434/// for, a silent false positive off it (a hand-run `stamp --apply` on an
435/// unmerged local commit, say). Not guarded against because the fix — only
436/// trust the parse when the run is known to be CI/merge-triggered — would
437/// also suppress the *legitimate* case of manually re-running `stamp --apply`
438/// against an already-merged commit to recover from a failed push (`stamp.yml`
439/// names this as the documented recovery path), which is a worse trade.
440pub fn pr_number_from_subject(subject: &str) -> Option<u32> {
441    let inner = subject.strip_suffix(')')?.rsplit_once("(#")?.1;
442    inner.parse().ok()
443}
444
445/// Parse `## ADR: <slug>` blocks from the body (everything after the closing
446/// frontmatter fence). Zero blocks is valid — an increment may record no
447/// decision. Pushes errors for a non-kebab or duplicate slug, or an empty body.
448fn parse_adrs(content: &str, errors: &mut Vec<String>) -> Vec<Adr> {
449    // Body starts after the second `---` fence.
450    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    // A `## ADR:` line inside a ``` code fence is prose (e.g. a pending file
463    // documenting the format inline), not a block header. Mark each line's
464    // header-ness up front, toggling on backtick-fence delimiters, so both the
465    // outer scan and the body-collecting loop below agree on where blocks start.
466    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            // The block opens with `title:`/`summary:`/`status:` key lines (any
495            // order, `title` required), then a blank line, then the verbatim
496            // body. Consume leading blanks and known keys; the first other line
497            // starts the body.
498            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
551/// The slug text of a `## ADR: <slug>` header line, if this line is one.
552fn adr_header_slug(line: &str) -> Option<&str> {
553    line.trim().strip_prefix("## ADR:")
554}
555
556/// A kebab-case slug: non-empty, `a-z0-9` and single interior hyphens only.
557fn 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
566/// Whether the changelog's first token reads as a repo version the author has
567/// accidentally prefixed — the stamp prepends the number, so the blurb must not
568/// carry one. Matched to the repo's actual spellings: a `v` prefix (`v0.186`,
569/// the banner form) or three-plus numeric groups (`0.186.0`, the Cargo form).
570/// A bare two-group token like `3.0` is *not* a version here, so a blurb such as
571/// "3.0 rendering pipeline added" is allowed.
572fn 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
583/// The destination of the first Markdown inline link in `blurb` that resolves
584/// *relatively*, if there is one.
585///
586/// The blurb is inserted verbatim into a table row in the Book's
587/// `reference/changelog.md`, so `[…](…)` in prose is a real link once it lands —
588/// and an unbackticked generic call like `Events.emit[E](event)` is exactly that
589/// shape, a link to a relative `event`. The docs site's own link-checker
590/// (starlight-links-validator, the `Docs site (astro build)` gate) rejects those,
591/// but it only ever sees the row *after* the stamp writes it on `main`, so that
592/// gate fires post-merge with nothing left to review. This is the pre-merge
593/// stand-in, run against the pending file the PR actually carries.
594///
595/// Deliberate links stay allowed — absolute (`https://…`, the `[#548](…)` issue
596/// citations the corpus is full of), site-root (`/book/…`), and anchors. Code
597/// spans are skipped, which is also the fix the error message asks for: write
598/// ``` `Events.emit[E](event)` ```.
599fn 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            // A backslash escape covers the next byte, whatever it is.
606            b'\\' => i += 1,
607            // Skip a code span: a run of N backticks closes on the next run of
608            // exactly N. An *unmatched* run is literal text in CommonMark and
609            // the rest of the blurb still parses as Markdown, so resume just
610            // after it rather than swallowing the remainder — otherwise a
611            // half-applied fix (`` `Events.emit[E](event) ``, opening backtick
612            // only) would sail through this check and break `main` anyway.
613            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            // `](` is only a link if some `[` opened it.
634            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
655/// The destination inside a `](…)` link tail, or `None` when the tail is not one.
656///
657/// CommonMark accepts `(dest)` and `(dest "title")` (also `'…'` and `(…)` titles),
658/// and a bare destination may not contain spaces unless it is `<…>`-wrapped. So a
659/// multi-parameter generic call in prose — `map[K, V](key, value)` — is genuinely
660/// not a link, and reporting one would assert something the site's own checker
661/// would never do. Backticking it is still the house convention; this check just
662/// does not claim it is a broken link.
663fn 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    // Whatever follows the destination has to be a title, or this is just prose.
673    if rest.is_empty() || rest.starts_with(['"', '\'', '(']) {
674        Some(dest)
675    } else {
676        None
677    }
678}
679
680/// Whether a Markdown link destination resolves without depending on the page it
681/// is written on — the only kind a changelog blurb may carry.
682///
683/// Note this is about *shape*, not existence: the site's link-checker also
684/// resolves site-root paths and anchors, which nothing here can do without the
685/// built site. A hand-written `/book/typo/` still passes this and fails the docs
686/// build. `design/pending/README.md` says so rather than implying a guarantee.
687fn 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        // summary/status default to title/"Accepted" when absent.
740        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        // A blurb ending in a version-like word must not false-positive; only the
832        // *first* token is checked.
833        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        // `3.0` is not a repo version (no `v`, only two groups) — a blurb may
842        // legitimately open with it.
843        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        // The real regression (v0.249.1): an unbackticked generic call in prose
852        // parses as `[E](event)`, a link to a relative `event`, and failed the
853        // docs site's link-checker on `main` — post-merge, after the stamp had
854        // already written the row.
855        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        // The documented fix, and the corpus's own convention for code in prose.
869        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        // Issue citations are the single most common blurb decoration; site-root
878        // links and anchors resolve independently of the page too.
879        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        // `]` with no `(` after it, and `(` with no `[` before it, are ordinary
892        // punctuation — neither forms a link.
893        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        // The likeliest mistake after reading the error message: the author adds
902        // the opening backtick and forgets the closing one. CommonMark treats the
903        // unmatched run as literal text and still links `[E](event)` (confirmed
904        // against the site's own parser), so the guard must not stop scanning.
905        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        // Same failure mode from an unrelated typo earlier in the sentence.
918        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        // `(key, value)` holds a space, so CommonMark does not link it — claiming
932        // otherwise would assert a failure the site checker would never produce.
933        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        // `(dest "title")` is a real link, so the destination is still checked
942        // even though a space follows it.
943        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        // A pending file documenting the format inline must not have its fenced
968        // `## ADR:` example split off into a spurious block.
969        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    // --- probes --------------------------------------------------------------
1007
1008    #[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    // --- closes_rule (#1001) --------------------------------------------------
1058
1059    #[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    // --- pr_number_from_subject (#1001) --------------------------------------
1136
1137    #[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        // Trailing prose after the `)` means this isn't a squash-merge title.
1148        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    /// The documented limitation, pinned so it can't silently change meaning:
1172    /// a hand-written commit ending in an *issue* reference is indistinguishable
1173    /// from a squash-merge PR title by shape alone.
1174    #[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    /// A throwaway fixture tree, named per calling test so parallel runs don't
1183    /// collide — the same convention `xtask/tests/stamp_apply.rs`'s `fixture`
1184    /// uses. Removed and recreated on construction, not cleaned up after (the OS
1185    /// temp dir is not this test's to manage beyond that).
1186    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}