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 stamp;
22
23/// The bump level an increment declares. The stamp turns this into the next
24/// `X.Y.Z` in merge order; the format never carries a concrete number.
25#[derive(Debug, PartialEq, Eq)]
26pub enum Level {
27    Minor,
28    Patch,
29}
30
31/// One ADR block. The stamp writes `design/decisions/NNNN-<slug>.md` — a
32/// `# NNNN — <title>` heading, a status line, then `body` verbatim — and a
33/// `decisions/README.md` index row (`**<title>** … <summary>`), assigning
34/// `NNNN` at merge. `title` is required (the file heading and the index bold
35/// need it); `summary` defaults to `title`, `status` to `Accepted`.
36#[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    /// The one-line distillation for the index row — the author's `summary`, or
47    /// the title when none was given.
48    pub fn summary(&self) -> &str {
49        self.summary.as_deref().unwrap_or(&self.title)
50    }
51
52    /// The ADR status — the author's `status`, or `Accepted`.
53    pub fn status(&self) -> &str {
54        self.status.as_deref().unwrap_or("Accepted")
55    }
56}
57
58/// A parsed, validated pending-increment file.
59#[derive(Debug, PartialEq, Eq)]
60pub struct Pending {
61    pub level: Level,
62    pub changelog: String,
63    pub adrs: Vec<Adr>,
64    /// Greenfield reference rule ids (`R2.3`) this increment closes (#1001).
65    /// Optional and usually empty — most increments don't close a tracked rule.
66    /// Syntax-checked here (each entry matches `R<major>.<minor>`); whether the
67    /// id actually exists in `design/bynk-greenfield-compiler.md` is checked
68    /// separately by [`known_rule_ids`], which needs the repo root this pure
69    /// parse doesn't have.
70    pub closes_rule: Vec<String>,
71}
72
73/// The repo root, resolved from this crate's manifest dir so it's independent
74/// of the working directory (the same trick `decisions_index` uses).
75pub fn repo_root() -> PathBuf {
76    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
77}
78
79/// `design/pending/` under [`repo_root`].
80pub fn pending_dir() -> PathBuf {
81    repo_root().join("design/pending")
82}
83
84/// Validate every `*.md` under [`pending_dir`] except `README.md` (the format
85/// doc, excluded like the decisions index excludes its own README). Returns the
86/// number of pending files validated, or every error found across all files
87/// (each prefixed with its filename) so one run reports the whole picture.
88pub fn check_all() -> Result<usize, Vec<String>> {
89    validated_pending_in(&repo_root()).map(|ps| ps.len())
90}
91
92/// Read and validate the pending files under `root/design/pending` (skipping
93/// `README.md`), sorted by filename. Root-parameterised so the stamp — and its
94/// fixture tests — can target any tree; [`check_all`] is this over the real
95/// [`repo_root`].
96///
97/// Also cross-checks each file's `closes_rule` entries against
98/// [`known_rule_ids`] — a syntactically valid id (`is_rule_id`, checked in
99/// [`validate`]) that names no rule the reference actually has is still an
100/// error, just one that needs `root` to catch, which the pure per-file parse
101/// doesn't have.
102pub 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        // No file cites a rule — skip reading the (large) reference doc at all.
134    } 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
161/// Validate a single pending file's `content`. `filename` is used to check the
162/// stem is a kebab-case slug. Returns every problem found (not just the first),
163/// so a malformed file reports completely.
164pub 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
195/// Parse and validate the `---`-delimited header. Pushes errors; returns the
196/// fields when present and well-formed (`closes_rule` defaults to empty rather
197/// than `Option` — it's genuinely optional, unlike `level`/`changelog`). Returns
198/// `None` only when the frontmatter block itself is missing/unterminated
199/// (nothing to recover).
200fn 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    // Track key presence separately from a valid value: a key that is present
228    // but malformed reports its own error and must not also be "missing".
229    let mut saw_level = false;
230    let mut saw_changelog = false;
231    let mut saw_closes_rule = false;
232    for raw in header {
233        let line = raw.trim();
234        if line.is_empty() {
235            continue;
236        }
237        let Some((key, value)) = line.split_once(':') else {
238            errors.push(format!("frontmatter line is not `key: value`: {raw:?}"));
239            continue;
240        };
241        let key = key.trim();
242        let value = value.trim();
243        match key {
244            "level" => {
245                if saw_level {
246                    errors.push("duplicate frontmatter key `level`".into());
247                }
248                saw_level = true;
249                level = match value {
250                    "minor" => Some(Level::Minor),
251                    "patch" => Some(Level::Patch),
252                    other => {
253                        errors.push(format!("level must be `minor` or `patch`, got {other:?}"));
254                        None
255                    }
256                };
257            }
258            "changelog" => {
259                if saw_changelog {
260                    errors.push("duplicate frontmatter key `changelog`".into());
261                }
262                saw_changelog = true;
263                if value.is_empty() {
264                    errors.push("changelog must not be empty".into());
265                } else if looks_like_version_prefix(value) {
266                    errors.push(format!(
267                        "changelog must not start with a version number (the stamp adds it): {value:?}"
268                    ));
269                } else if let Some(dest) = relative_markdown_link_in(value) {
270                    errors.push(format!(
271                        "changelog reads as a Markdown link to the relative destination {dest:?} \
272                         — the blurb is inserted verbatim into the Book's changelog table, so a \
273                         bare `x[T](y)` in prose becomes a link the docs site's link-checker \
274                         rejects (and it only sees the row after the stamp writes it on `main`). \
275                         Wrap the code in backticks, or use an absolute URL."
276                    ));
277                } else {
278                    changelog = Some(value.to_string());
279                }
280            }
281            "closes_rule" => {
282                if saw_closes_rule {
283                    errors.push("duplicate frontmatter key `closes_rule`".into());
284                }
285                saw_closes_rule = true;
286                if value.is_empty() {
287                    errors.push(
288                        "closes_rule must not be empty (omit the key entirely if there's \
289                         nothing to cite)"
290                            .into(),
291                    );
292                } else {
293                    for entry in value.split(',') {
294                        let entry = entry.trim();
295                        if is_rule_id(entry) {
296                            closes_rule.push(entry.to_string());
297                        } else {
298                            errors.push(format!(
299                                "closes_rule entry {entry:?} is not a rule id \
300                                 (expected `R<major>.<minor>`, e.g. `R2.3`)"
301                            ));
302                        }
303                    }
304                }
305            }
306            other => errors.push(format!("unknown frontmatter key {other:?}")),
307        }
308    }
309
310    if !saw_level {
311        errors.push("frontmatter is missing `level`".into());
312    }
313    if !saw_changelog {
314        errors.push("frontmatter is missing `changelog`".into());
315    }
316
317    Some((level, changelog, closes_rule))
318}
319
320/// Is `s` shaped like a greenfield-reference rule id — `R` followed by
321/// `<digits>.<digits>` (e.g. `R2.3`, `R0.1`)? Syntax only; whether the id
322/// actually exists in the reference is [`known_rule_ids`]'s job.
323pub fn is_rule_id(s: &str) -> bool {
324    let Some(rest) = s.strip_prefix('R') else {
325        return false;
326    };
327    let Some((major, minor)) = rest.split_once('.') else {
328        return false;
329    };
330    !major.is_empty()
331        && major.chars().all(|c| c.is_ascii_digit())
332        && !minor.is_empty()
333        && minor.chars().all(|c| c.is_ascii_digit())
334}
335
336/// Every rule id (`R2.3`, …) enumerated in the greenfield reference
337/// (`design/bynk-greenfield-compiler.md`) — the existence check for a pending
338/// file's `closes_rule` entries, separate from [`is_rule_id`]'s pure syntax
339/// check because it needs the repo root. Root-parameterised like
340/// `stamp::next_adr_number`, so a fixture tree can supply its own reference doc.
341///
342/// Rules are written inline as `**R2.3 — <title>.**`; this scans every `**R`
343/// occurrence for the dotted id immediately following, rather than requiring a
344/// line-start anchor — the doc's own precedent (`grep -oE '\*\*R[0-9]+\.[0-9]+
345/// —'`) confirmed this finds exactly the 130 rules the reference claims.
346pub fn known_rule_ids(root: &Path) -> Result<std::collections::HashSet<String>, String> {
347    let path = root.join("design/bynk-greenfield-compiler.md");
348    let text =
349        fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
350    let mut ids = std::collections::HashSet::new();
351    let bytes = text.as_bytes();
352    let mut i = 0;
353    while let Some(rel) = text[i..].find("**R") {
354        let start = i + rel + 2; // skip `**`, keep the leading `R`
355        let mut end = start;
356        while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'.') {
357            end += 1;
358        }
359        let candidate = &text[start..end];
360        if is_rule_id(candidate) {
361            ids.insert(candidate.to_string());
362        }
363        i = end.max(start + 1);
364    }
365    Ok(ids)
366}
367
368/// The PR number a commit `subject` (the first line of its message) names, if
369/// it ends in the `(#NNNN)` GitHub's squash-merge appends to the PR title —
370/// pure and root-independent, so `xtask/src/main.rs`'s `pr_number_from_head`
371/// (the only caller, which runs `git log -1 --format=%s` to get `subject`)
372/// stays a thin wrapper over this, and this half — the part with the
373/// interesting edge cases — is unit-testable without a git tree (#1001,
374/// caught by review as the one piece of new logic with no test coverage).
375///
376/// **Known limitation, stated rather than guarded against:** this matches on
377/// shape alone. Any subject ending `(#NNNN)` is read as the merging PR,
378/// including a hand-written commit that happens to end in an *issue*
379/// reference (`"fix: handle empty spans (#1001)"`, where 1001 names an issue,
380/// not the PR that closes it) — correct on the squash-merge path this exists
381/// for, a silent false positive off it (a hand-run `stamp --apply` on an
382/// unmerged local commit, say). Not guarded against because the fix — only
383/// trust the parse when the run is known to be CI/merge-triggered — would
384/// also suppress the *legitimate* case of manually re-running `stamp --apply`
385/// against an already-merged commit to recover from a failed push (`stamp.yml`
386/// names this as the documented recovery path), which is a worse trade.
387pub fn pr_number_from_subject(subject: &str) -> Option<u32> {
388    let inner = subject.strip_suffix(')')?.rsplit_once("(#")?.1;
389    inner.parse().ok()
390}
391
392/// Parse `## ADR: <slug>` blocks from the body (everything after the closing
393/// frontmatter fence). Zero blocks is valid — an increment may record no
394/// decision. Pushes errors for a non-kebab or duplicate slug, or an empty body.
395fn parse_adrs(content: &str, errors: &mut Vec<String>) -> Vec<Adr> {
396    // Body starts after the second `---` fence.
397    let mut fences = 0;
398    let mut body_lines = Vec::new();
399    for line in content.lines() {
400        if fences < 2 {
401            if line.trim_end() == "---" {
402                fences += 1;
403            }
404            continue;
405        }
406        body_lines.push(line);
407    }
408
409    // A `## ADR:` line inside a ``` code fence is prose (e.g. a pending file
410    // documenting the format inline), not a block header. Mark each line's
411    // header-ness up front, toggling on backtick-fence delimiters, so both the
412    // outer scan and the body-collecting loop below agree on where blocks start.
413    let mut in_fence = false;
414    let is_header: Vec<bool> = body_lines
415        .iter()
416        .map(|line| {
417            if line.trim_start().starts_with("```") {
418                in_fence = !in_fence;
419                false
420            } else {
421                !in_fence && adr_header_slug(line).is_some()
422            }
423        })
424        .collect();
425
426    let mut adrs: Vec<Adr> = Vec::new();
427    let mut i = 0;
428    while i < body_lines.len() {
429        if is_header[i] {
430            let slug = adr_header_slug(body_lines[i])
431                .expect("is_header implies an ADR header")
432                .trim()
433                .to_string();
434            i += 1;
435            let mut block = Vec::new();
436            while i < body_lines.len() && !is_header[i] {
437                block.push(body_lines[i]);
438                i += 1;
439            }
440
441            // The block opens with `title:`/`summary:`/`status:` key lines (any
442            // order, `title` required), then a blank line, then the verbatim
443            // body. Consume leading blanks and known keys; the first other line
444            // starts the body.
445            let mut title = None;
446            let mut summary = None;
447            let mut status = None;
448            let mut body_start = block.len();
449            for (idx, raw) in block.iter().enumerate() {
450                let line = raw.trim();
451                if line.is_empty() && title.is_none() && summary.is_none() && status.is_none() {
452                    continue;
453                }
454                if let Some(v) = line.strip_prefix("title:") {
455                    title = Some(v.trim().to_string());
456                } else if let Some(v) = line.strip_prefix("summary:") {
457                    summary = Some(v.trim().to_string());
458                } else if let Some(v) = line.strip_prefix("status:") {
459                    status = Some(v.trim().to_string());
460                } else {
461                    body_start = idx;
462                    break;
463                }
464            }
465            let body = block[body_start..].join("\n").trim().to_string();
466
467            if !is_kebab(&slug) {
468                errors.push(format!(
469                    "ADR slug {slug:?} is not a kebab-case slug (a-z, 0-9, single hyphens)"
470                ));
471            } else if adrs.iter().any(|a| a.slug == slug) {
472                errors.push(format!("duplicate ADR slug {slug:?}"));
473            }
474            match &title {
475                Some(t) if t.is_empty() => {
476                    errors.push(format!("ADR {slug:?} has an empty `title:`"))
477                }
478                None => errors.push(format!("ADR {slug:?} is missing a `title:` line")),
479                _ => {}
480            }
481            if body.is_empty() {
482                errors.push(format!("ADR {slug:?} has an empty body"));
483            }
484            adrs.push(Adr {
485                slug,
486                title: title.unwrap_or_default(),
487                summary: summary.filter(|s| !s.is_empty()),
488                status: status.filter(|s| !s.is_empty()),
489                body,
490            });
491        } else {
492            i += 1;
493        }
494    }
495    adrs
496}
497
498/// The slug text of a `## ADR: <slug>` header line, if this line is one.
499fn adr_header_slug(line: &str) -> Option<&str> {
500    line.trim().strip_prefix("## ADR:")
501}
502
503/// A kebab-case slug: non-empty, `a-z0-9` and single interior hyphens only.
504fn is_kebab(s: &str) -> bool {
505    !s.is_empty()
506        && !s.starts_with('-')
507        && !s.ends_with('-')
508        && !s.contains("--")
509        && s.chars()
510            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
511}
512
513/// Whether the changelog's first token reads as a repo version the author has
514/// accidentally prefixed — the stamp prepends the number, so the blurb must not
515/// carry one. Matched to the repo's actual spellings: a `v` prefix (`v0.186`,
516/// the banner form) or three-plus numeric groups (`0.186.0`, the Cargo form).
517/// A bare two-group token like `3.0` is *not* a version here, so a blurb such as
518/// "3.0 rendering pipeline added" is allowed.
519fn looks_like_version_prefix(changelog: &str) -> bool {
520    let raw = changelog.split_whitespace().next().unwrap_or("");
521    let had_v = raw.starts_with('v') || raw.starts_with('V');
522    let groups: Vec<&str> = raw.trim_start_matches(['v', 'V']).split('.').collect();
523    let all_numeric = groups.len() >= 2
524        && groups
525            .iter()
526            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
527    all_numeric && (had_v || groups.len() >= 3)
528}
529
530/// The destination of the first Markdown inline link in `blurb` that resolves
531/// *relatively*, if there is one.
532///
533/// The blurb is inserted verbatim into a table row in the Book's
534/// `reference/changelog.md`, so `[…](…)` in prose is a real link once it lands —
535/// and an unbackticked generic call like `Events.emit[E](event)` is exactly that
536/// shape, a link to a relative `event`. The docs site's own link-checker
537/// (starlight-links-validator, the `Docs site (astro build)` gate) rejects those,
538/// but it only ever sees the row *after* the stamp writes it on `main`, so that
539/// gate fires post-merge with nothing left to review. This is the pre-merge
540/// stand-in, run against the pending file the PR actually carries.
541///
542/// Deliberate links stay allowed — absolute (`https://…`, the `[#548](…)` issue
543/// citations the corpus is full of), site-root (`/book/…`), and anchors. Code
544/// spans are skipped, which is also the fix the error message asks for: write
545/// ``` `Events.emit[E](event)` ```.
546fn relative_markdown_link_in(blurb: &str) -> Option<String> {
547    let bytes = blurb.as_bytes();
548    let mut i = 0;
549    let mut saw_open_bracket = false;
550    while i < bytes.len() {
551        match bytes[i] {
552            // A backslash escape covers the next byte, whatever it is.
553            b'\\' => i += 1,
554            // Skip a code span: a run of N backticks closes on the next run of
555            // exactly N. An *unmatched* run is literal text in CommonMark and
556            // the rest of the blurb still parses as Markdown, so resume just
557            // after it rather than swallowing the remainder — otherwise a
558            // half-applied fix (`` `Events.emit[E](event) ``, opening backtick
559            // only) would sail through this check and break `main` anyway.
560            b'`' => {
561                let fence = bytes[i..].iter().take_while(|&&b| b == b'`').count();
562                let mut j = i + fence;
563                let mut closed = false;
564                while j < bytes.len() {
565                    if bytes[j] == b'`' {
566                        let run = bytes[j..].iter().take_while(|&&b| b == b'`').count();
567                        j += run;
568                        if run == fence {
569                            closed = true;
570                            break;
571                        }
572                    } else {
573                        j += 1;
574                    }
575                }
576                i = if closed { j } else { i + fence };
577                continue;
578            }
579            b'[' => saw_open_bracket = true,
580            // `](` is only a link if some `[` opened it.
581            b']' if saw_open_bracket && bytes.get(i + 1) == Some(&b'(') => {
582                saw_open_bracket = false;
583                let start = i + 2;
584                let Some(len) = bytes[start..].iter().position(|&b| b == b')') else {
585                    break;
586                };
587                if let Some(dest) = link_destination(&blurb[start..start + len])
588                    && !is_absolute_link_destination(dest)
589                {
590                    return Some(dest.to_string());
591                }
592                i = start + len;
593            }
594            b']' => saw_open_bracket = false,
595            _ => {}
596        }
597        i += 1;
598    }
599    None
600}
601
602/// The destination inside a `](…)` link tail, or `None` when the tail is not one.
603///
604/// CommonMark accepts `(dest)` and `(dest "title")` (also `'…'` and `(…)` titles),
605/// and a bare destination may not contain spaces unless it is `<…>`-wrapped. So a
606/// multi-parameter generic call in prose — `map[K, V](key, value)` — is genuinely
607/// not a link, and reporting one would assert something the site's own checker
608/// would never do. Backticking it is still the house convention; this check just
609/// does not claim it is a broken link.
610fn link_destination(tail: &str) -> Option<&str> {
611    let tail = tail.trim();
612    if let Some(rest) = tail.strip_prefix('<') {
613        return rest.split_once('>').map(|(dest, _)| dest);
614    }
615    let (dest, rest) = match tail.split_once(char::is_whitespace) {
616        Some((dest, rest)) => (dest, rest.trim_start()),
617        None => (tail, ""),
618    };
619    // Whatever follows the destination has to be a title, or this is just prose.
620    if rest.is_empty() || rest.starts_with(['"', '\'', '(']) {
621        Some(dest)
622    } else {
623        None
624    }
625}
626
627/// Whether a Markdown link destination resolves without depending on the page it
628/// is written on — the only kind a changelog blurb may carry.
629///
630/// Note this is about *shape*, not existence: the site's link-checker also
631/// resolves site-root paths and anchors, which nothing here can do without the
632/// built site. A hand-written `/book/typo/` still passes this and fails the docs
633/// build. `design/pending/README.md` says so rather than implying a guarantee.
634fn is_absolute_link_destination(dest: &str) -> bool {
635    dest.starts_with("https://")
636        || dest.starts_with("http://")
637        || dest.starts_with("mailto:")
638        || dest.starts_with('/')
639        || dest.starts_with('#')
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    fn ok(name: &str, content: &str) -> Pending {
647        validate(name, content).unwrap_or_else(|e| panic!("expected valid, got {e:?}"))
648    }
649    fn err(name: &str, content: &str) -> Vec<String> {
650        validate(name, content).expect_err("expected invalid")
651    }
652
653    #[test]
654    fn minimal_no_adr_is_valid() {
655        let p = ok(
656            "add-a-thing.md",
657            "---\nlevel: minor\nchangelog: Add a thing to the language\n---\n",
658        );
659        assert_eq!(p.level, Level::Minor);
660        assert_eq!(p.changelog, "Add a thing to the language");
661        assert!(p.adrs.is_empty());
662    }
663
664    #[test]
665    fn patch_level_is_valid() {
666        assert_eq!(
667            ok(
668                "fix-a-thing.md",
669                "---\nlevel: patch\nchangelog: Fix a non-language thing\n---\n"
670            )
671            .level,
672            Level::Patch
673        );
674    }
675
676    #[test]
677    fn one_adr_parses_slug_title_and_body() {
678        let p = ok(
679            "unit-tier.md",
680            "---\nlevel: minor\nchangelog: Drive a handler at the unit tier\n---\n\n## ADR: unit-tier-service-address\ntitle: A case addresses a handler by surface\n\n**Decision.** A case addresses by surface.\n",
681        );
682        assert_eq!(p.adrs.len(), 1);
683        let adr = &p.adrs[0];
684        assert_eq!(adr.slug, "unit-tier-service-address");
685        assert_eq!(adr.title, "A case addresses a handler by surface");
686        // summary/status default to title/"Accepted" when absent.
687        assert_eq!(adr.summary(), adr.title);
688        assert_eq!(adr.status(), "Accepted");
689        assert!(adr.body.contains("addresses by surface"));
690        assert!(!adr.body.contains("title:"), "the title line is not body");
691    }
692
693    #[test]
694    fn adr_summary_and_status_are_parsed() {
695        let p = ok(
696            "x.md",
697            "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: a-slug\ntitle: The title\nsummary: The one-line index distillation\nstatus: Proposed\n\nBody.\n",
698        );
699        let adr = &p.adrs[0];
700        assert_eq!(adr.summary(), "The one-line index distillation");
701        assert_eq!(adr.status(), "Proposed");
702    }
703
704    #[test]
705    fn adr_missing_title_rejected() {
706        assert!(
707            err(
708                "x.md",
709                "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: a-slug\n\nBody with no title line.\n"
710            )
711            .iter()
712            .any(|e| e.contains("missing a `title:`"))
713        );
714    }
715
716    #[test]
717    fn two_adrs_parse() {
718        let p = ok(
719            "two.md",
720            "---\nlevel: minor\nchangelog: Two decisions\n---\n\n## ADR: first-one\ntitle: First\n\nBody one.\n\n## ADR: second-one\ntitle: Second\n\nBody two.\n",
721        );
722        assert_eq!(p.adrs.len(), 2);
723        assert_eq!(p.adrs[0].slug, "first-one");
724        assert_eq!(p.adrs[1].slug, "second-one");
725    }
726
727    #[test]
728    fn bad_level_rejected() {
729        assert!(
730            err("x.md", "---\nlevel: major\nchangelog: x\n---\n")
731                .iter()
732                .any(|e| e.contains("level must be"))
733        );
734    }
735
736    #[test]
737    fn missing_level_rejected() {
738        assert!(
739            err("x.md", "---\nchangelog: x\n---\n")
740                .iter()
741                .any(|e| e.contains("missing `level`"))
742        );
743    }
744
745    #[test]
746    fn missing_changelog_rejected() {
747        assert!(
748            err("x.md", "---\nlevel: minor\n---\n")
749                .iter()
750                .any(|e| e.contains("missing `changelog`"))
751        );
752    }
753
754    #[test]
755    fn empty_changelog_rejected() {
756        assert!(
757            err("x.md", "---\nlevel: minor\nchangelog:   \n---\n")
758                .iter()
759                .any(|e| e.contains("changelog"))
760        );
761    }
762
763    #[test]
764    fn version_prefixed_changelog_rejected() {
765        for cl in ["v0.186 Add a thing", "0.186.0 Add a thing"] {
766            let content = format!("---\nlevel: minor\nchangelog: {cl}\n---\n");
767            assert!(
768                err("x.md", &content)
769                    .iter()
770                    .any(|e| e.contains("version number")),
771                "expected rejection for {cl:?}"
772            );
773        }
774    }
775
776    #[test]
777    fn plain_changelog_with_a_dot_is_allowed() {
778        // A blurb ending in a version-like word must not false-positive; only the
779        // *first* token is checked.
780        ok(
781            "x.md",
782            "---\nlevel: minor\nchangelog: Support semver ranges like 1.2.3\n---\n",
783        );
784    }
785
786    #[test]
787    fn bare_two_group_leading_number_is_allowed() {
788        // `3.0` is not a repo version (no `v`, only two groups) — a blurb may
789        // legitimately open with it.
790        ok(
791            "x.md",
792            "---\nlevel: minor\nchangelog: 3.0 rendering pipeline added\n---\n",
793        );
794    }
795
796    #[test]
797    fn accidental_relative_markdown_link_in_changelog_rejected() {
798        // The real regression (v0.249.1): an unbackticked generic call in prose
799        // parses as `[E](event)`, a link to a relative `event`, and failed the
800        // docs site's link-checker on `main` — post-merge, after the stamp had
801        // already written the row.
802        let content = "---\nlevel: patch\nchangelog: Read Callee to detect an \
803                       Events.emit[E](event) call\n---\n";
804        assert!(
805            err("x.md", content)
806                .iter()
807                .any(|e| e.contains("relative destination \"event\"")),
808            "expected rejection, got {:?}",
809            err("x.md", content)
810        );
811    }
812
813    #[test]
814    fn backticked_generic_call_in_changelog_is_allowed() {
815        // The documented fix, and the corpus's own convention for code in prose.
816        ok(
817            "x.md",
818            "---\nlevel: patch\nchangelog: Detect an `Events.emit[E](event)` call\n---\n",
819        );
820    }
821
822    #[test]
823    fn absolute_links_in_changelog_are_allowed() {
824        // Issue citations are the single most common blurb decoration; site-root
825        // links and anchors resolve independently of the page too.
826        for cl in [
827            "Close [#548](https://github.com/accuser/bynk/issues/548)",
828            "See [the roadmap](/book/about/versioning-and-roadmap/)",
829            "See [below](#notes)",
830        ] {
831            let content = format!("---\nlevel: minor\nchangelog: {cl}\n---\n");
832            ok("x.md", &content);
833        }
834    }
835
836    #[test]
837    fn bracket_without_a_link_in_changelog_is_allowed() {
838        // `]` with no `(` after it, and `(` with no `[` before it, are ordinary
839        // punctuation — neither forms a link.
840        ok(
841            "x.md",
842            "---\nlevel: minor\nchangelog: Widen ts_any [see the probe] (P7.0) for writes\n---\n",
843        );
844    }
845
846    #[test]
847    fn a_half_backticked_changelog_is_still_rejected() {
848        // The likeliest mistake after reading the error message: the author adds
849        // the opening backtick and forgets the closing one. CommonMark treats the
850        // unmatched run as literal text and still links `[E](event)` (confirmed
851        // against the site's own parser), so the guard must not stop scanning.
852        let content = "---\nlevel: patch\nchangelog: Detect an `Events.emit[E](event) call\n---\n";
853        assert!(
854            err("x.md", content)
855                .iter()
856                .any(|e| e.contains("relative destination \"event\"")),
857            "expected rejection, got {:?}",
858            err("x.md", content)
859        );
860    }
861
862    #[test]
863    fn a_stray_backtick_does_not_blind_the_rest_of_the_blurb() {
864        // Same failure mode from an unrelated typo earlier in the sentence.
865        let content = "---\nlevel: patch\nchangelog: A stray `Callee typo, then \
866                       Events.emit[E](event) later\n---\n";
867        assert!(
868            err("x.md", content)
869                .iter()
870                .any(|e| e.contains("relative destination \"event\"")),
871            "expected rejection, got {:?}",
872            err("x.md", content)
873        );
874    }
875
876    #[test]
877    fn a_multi_parameter_generic_call_is_not_reported_as_a_link() {
878        // `(key, value)` holds a space, so CommonMark does not link it — claiming
879        // otherwise would assert a failure the site checker would never produce.
880        ok(
881            "x.md",
882            "---\nlevel: minor\nchangelog: Lift a fn map[K, V](key, value) call\n---\n",
883        );
884    }
885
886    #[test]
887    fn a_titled_link_is_still_read_as_a_link() {
888        // `(dest "title")` is a real link, so the destination is still checked
889        // even though a space follows it.
890        let content = "---\nlevel: minor\nchangelog: See [it](there \"a title\")\n---\n";
891        assert!(
892            err("x.md", content)
893                .iter()
894                .any(|e| e.contains("relative destination \"there\"")),
895            "expected rejection, got {:?}",
896            err("x.md", content)
897        );
898    }
899
900    #[test]
901    fn duplicate_frontmatter_key_rejected() {
902        assert!(
903            err(
904                "x.md",
905                "---\nlevel: minor\nlevel: patch\nchangelog: x\n---\n"
906            )
907            .iter()
908            .any(|e| e.contains("duplicate frontmatter key `level`"))
909        );
910    }
911
912    #[test]
913    fn adr_header_inside_a_code_fence_is_not_a_block() {
914        // A pending file documenting the format inline must not have its fenced
915        // `## ADR:` example split off into a spurious block.
916        let p = ok(
917            "x.md",
918            "---\nlevel: minor\nchangelog: Document the format\n---\n\n\
919             Example:\n\n```markdown\n## ADR: not-a-real-block\nfenced prose\n```\n\n\
920             ## ADR: the-real-one\ntitle: The real one\n\nReal body.\n",
921        );
922        assert_eq!(p.adrs.len(), 1);
923        assert_eq!(p.adrs[0].slug, "the-real-one");
924    }
925
926    #[test]
927    fn no_frontmatter_rejected() {
928        assert!(
929            err("x.md", "just some text\n")
930                .iter()
931                .any(|e| e.contains("open with a `---`"))
932        );
933    }
934
935    #[test]
936    fn unclosed_frontmatter_rejected() {
937        assert!(
938            err("x.md", "---\nlevel: minor\nchangelog: x\n")
939                .iter()
940                .any(|e| e.contains("not closed"))
941        );
942    }
943
944    #[test]
945    fn unknown_key_rejected() {
946        assert!(
947            err("x.md", "---\nlevel: minor\nchangelog: x\nversion: 9\n---\n")
948                .iter()
949                .any(|e| e.contains("unknown frontmatter key"))
950        );
951    }
952
953    // --- closes_rule (#1001) --------------------------------------------------
954
955    #[test]
956    fn closes_rule_is_optional_and_defaults_empty() {
957        let p = ok("x.md", "---\nlevel: patch\nchangelog: x\n---\n");
958        assert!(p.closes_rule.is_empty());
959    }
960
961    #[test]
962    fn closes_rule_parses_a_single_id() {
963        let p = ok(
964            "x.md",
965            "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\n---\n",
966        );
967        assert_eq!(p.closes_rule, vec!["R2.3".to_string()]);
968    }
969
970    #[test]
971    fn closes_rule_parses_a_comma_separated_list_and_trims_whitespace() {
972        let p = ok(
973            "x.md",
974            "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3,  R2.12 ,R0.1\n---\n",
975        );
976        assert_eq!(
977            p.closes_rule,
978            vec!["R2.3".to_string(), "R2.12".to_string(), "R0.1".to_string()]
979        );
980    }
981
982    #[test]
983    fn closes_rule_rejects_a_malformed_entry() {
984        assert!(
985            err(
986                "x.md",
987                "---\nlevel: patch\nchangelog: x\ncloses_rule: not-a-rule\n---\n"
988            )
989            .iter()
990            .any(|e| e.contains("closes_rule entry") && e.contains("not a rule id"))
991        );
992    }
993
994    #[test]
995    fn closes_rule_rejects_empty_value() {
996        assert!(
997            err(
998                "x.md",
999                "---\nlevel: patch\nchangelog: x\ncloses_rule: \n---\n"
1000            )
1001            .iter()
1002            .any(|e| e.contains("closes_rule must not be empty"))
1003        );
1004    }
1005
1006    #[test]
1007    fn closes_rule_rejects_duplicate_key() {
1008        assert!(
1009            err(
1010                "x.md",
1011                "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\ncloses_rule: R2.4\n---\n"
1012            )
1013            .iter()
1014            .any(|e| e.contains("duplicate frontmatter key `closes_rule`"))
1015        );
1016    }
1017
1018    #[test]
1019    fn is_rule_id_accepts_and_rejects() {
1020        assert!(is_rule_id("R2.3"));
1021        assert!(is_rule_id("R0.1"));
1022        assert!(is_rule_id("R12.34"));
1023        assert!(!is_rule_id("2.3"));
1024        assert!(!is_rule_id("R2"));
1025        assert!(!is_rule_id("R2.3.4"));
1026        assert!(!is_rule_id("R.3"));
1027        assert!(!is_rule_id("R2."));
1028        assert!(!is_rule_id("Rx.y"));
1029    }
1030
1031    // --- pr_number_from_subject (#1001) --------------------------------------
1032
1033    #[test]
1034    fn pr_number_from_subject_finds_a_trailing_squash_merge_suffix() {
1035        assert_eq!(
1036            pr_number_from_subject("feat(xtask): thing (#1234)"),
1037            Some(1234)
1038        );
1039    }
1040
1041    #[test]
1042    fn pr_number_from_subject_requires_the_suffix_at_the_very_end() {
1043        // Trailing prose after the `)` means this isn't a squash-merge title.
1044        assert_eq!(pr_number_from_subject("feat: thing (#12) then more"), None);
1045    }
1046
1047    #[test]
1048    fn pr_number_from_subject_is_not_confused_by_earlier_nested_parens() {
1049        assert_eq!(pr_number_from_subject("chore: bump (deps) (#12)"), Some(12));
1050    }
1051
1052    #[test]
1053    fn pr_number_from_subject_rejects_a_non_numeric_hash() {
1054        assert_eq!(pr_number_from_subject("feat: thing (#abc)"), None);
1055    }
1056
1057    #[test]
1058    fn pr_number_from_subject_rejects_a_number_too_large_for_u32() {
1059        assert_eq!(pr_number_from_subject("(#99999999999999)"), None);
1060    }
1061
1062    #[test]
1063    fn pr_number_from_subject_none_without_any_suffix() {
1064        assert_eq!(pr_number_from_subject("Merge branch 'x'"), None);
1065    }
1066
1067    /// The documented limitation, pinned so it can't silently change meaning:
1068    /// a hand-written commit ending in an *issue* reference is indistinguishable
1069    /// from a squash-merge PR title by shape alone.
1070    #[test]
1071    fn pr_number_from_subject_cannot_distinguish_an_issue_reference() {
1072        assert_eq!(
1073            pr_number_from_subject("fix: handle empty spans (#1001)"),
1074            Some(1001)
1075        );
1076    }
1077
1078    /// A throwaway fixture tree, named per calling test so parallel runs don't
1079    /// collide — the same convention `xtask/tests/stamp_apply.rs`'s `fixture`
1080    /// uses. Removed and recreated on construction, not cleaned up after (the OS
1081    /// temp dir is not this test's to manage beyond that).
1082    fn rule_fixture(tag: &str, reference_body: &str) -> PathBuf {
1083        let root = std::env::temp_dir().join(format!("xtask-closes-rule-{tag}"));
1084        let _ = fs::remove_dir_all(&root);
1085        fs::create_dir_all(root.join("design/pending")).unwrap();
1086        fs::write(
1087            root.join("design/bynk-greenfield-compiler.md"),
1088            reference_body,
1089        )
1090        .unwrap();
1091        root
1092    }
1093
1094    #[test]
1095    fn known_rule_ids_finds_bold_rule_headers() {
1096        let dir = rule_fixture(
1097            "finds-bold-headers",
1098            "Some prose.\n\n**R2.3 — A rule about spans.**\n\nMore prose citing **R2.3** again \
1099             in passing, and introducing **R10.11 — a second rule.**\n",
1100        );
1101        let ids = known_rule_ids(&dir).unwrap();
1102        assert_eq!(ids.len(), 2, "expected exactly 2 distinct ids: {ids:?}");
1103        assert!(ids.contains("R2.3"));
1104        assert!(ids.contains("R10.11"));
1105    }
1106
1107    #[test]
1108    fn validated_pending_in_rejects_a_closes_rule_citing_an_unknown_id() {
1109        let dir = rule_fixture("rejects-unknown", "**R2.3 — real.**\n");
1110        fs::write(
1111            dir.join("design/pending/x.md"),
1112            "---\nlevel: patch\nchangelog: x\ncloses_rule: R99.99\n---\n",
1113        )
1114        .unwrap();
1115        let errors = validated_pending_in(&dir).expect_err("R99.99 does not exist");
1116        assert!(
1117            errors
1118                .iter()
1119                .any(|e| e.contains("R99.99") && e.contains("not a rule id in")),
1120            "{errors:?}"
1121        );
1122    }
1123
1124    #[test]
1125    fn validated_pending_in_accepts_a_closes_rule_citing_a_known_id() {
1126        let dir = rule_fixture("accepts-known", "**R2.3 — real.**\n");
1127        fs::write(
1128            dir.join("design/pending/x.md"),
1129            "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\n---\n",
1130        )
1131        .unwrap();
1132        let parsed = validated_pending_in(&dir).unwrap();
1133        assert_eq!(parsed.len(), 1);
1134        assert_eq!(parsed[0].1.closes_rule, vec!["R2.3".to_string()]);
1135    }
1136
1137    #[test]
1138    fn non_kebab_adr_slug_rejected() {
1139        assert!(
1140            err(
1141                "x.md",
1142                "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: Not_Kebab\ntitle: T\n\nBody.\n"
1143            )
1144            .iter()
1145            .any(|e| e.contains("not a kebab-case slug"))
1146        );
1147    }
1148
1149    #[test]
1150    fn duplicate_adr_slug_rejected() {
1151        assert!(err(
1152            "x.md",
1153            "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: dup\ntitle: A\n\nBody a.\n\n## ADR: dup\ntitle: B\n\nBody b.\n"
1154        )
1155        .iter()
1156        .any(|e| e.contains("duplicate ADR slug")));
1157    }
1158
1159    #[test]
1160    fn empty_adr_body_rejected() {
1161        assert!(
1162            err(
1163                "x.md",
1164                "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: empty\ntitle: T\n\n## ADR: next\ntitle: N\n\nBody.\n"
1165            )
1166            .iter()
1167            .any(|e| e.contains("empty body"))
1168        );
1169    }
1170
1171    #[test]
1172    fn non_kebab_filename_rejected() {
1173        assert!(
1174            err("Not_A_Slug.md", "---\nlevel: minor\nchangelog: x\n---\n")
1175                .iter()
1176                .any(|e| e.contains("filename stem"))
1177        );
1178    }
1179}