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 {
270                    changelog = Some(value.to_string());
271                }
272            }
273            "closes_rule" => {
274                if saw_closes_rule {
275                    errors.push("duplicate frontmatter key `closes_rule`".into());
276                }
277                saw_closes_rule = true;
278                if value.is_empty() {
279                    errors.push(
280                        "closes_rule must not be empty (omit the key entirely if there's \
281                         nothing to cite)"
282                            .into(),
283                    );
284                } else {
285                    for entry in value.split(',') {
286                        let entry = entry.trim();
287                        if is_rule_id(entry) {
288                            closes_rule.push(entry.to_string());
289                        } else {
290                            errors.push(format!(
291                                "closes_rule entry {entry:?} is not a rule id \
292                                 (expected `R<major>.<minor>`, e.g. `R2.3`)"
293                            ));
294                        }
295                    }
296                }
297            }
298            other => errors.push(format!("unknown frontmatter key {other:?}")),
299        }
300    }
301
302    if !saw_level {
303        errors.push("frontmatter is missing `level`".into());
304    }
305    if !saw_changelog {
306        errors.push("frontmatter is missing `changelog`".into());
307    }
308
309    Some((level, changelog, closes_rule))
310}
311
312/// Is `s` shaped like a greenfield-reference rule id — `R` followed by
313/// `<digits>.<digits>` (e.g. `R2.3`, `R0.1`)? Syntax only; whether the id
314/// actually exists in the reference is [`known_rule_ids`]'s job.
315pub fn is_rule_id(s: &str) -> bool {
316    let Some(rest) = s.strip_prefix('R') else {
317        return false;
318    };
319    let Some((major, minor)) = rest.split_once('.') else {
320        return false;
321    };
322    !major.is_empty()
323        && major.chars().all(|c| c.is_ascii_digit())
324        && !minor.is_empty()
325        && minor.chars().all(|c| c.is_ascii_digit())
326}
327
328/// Every rule id (`R2.3`, …) enumerated in the greenfield reference
329/// (`design/bynk-greenfield-compiler.md`) — the existence check for a pending
330/// file's `closes_rule` entries, separate from [`is_rule_id`]'s pure syntax
331/// check because it needs the repo root. Root-parameterised like
332/// `stamp::next_adr_number`, so a fixture tree can supply its own reference doc.
333///
334/// Rules are written inline as `**R2.3 — <title>.**`; this scans every `**R`
335/// occurrence for the dotted id immediately following, rather than requiring a
336/// line-start anchor — the doc's own precedent (`grep -oE '\*\*R[0-9]+\.[0-9]+
337/// —'`) confirmed this finds exactly the 130 rules the reference claims.
338pub fn known_rule_ids(root: &Path) -> Result<std::collections::HashSet<String>, String> {
339    let path = root.join("design/bynk-greenfield-compiler.md");
340    let text =
341        fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
342    let mut ids = std::collections::HashSet::new();
343    let bytes = text.as_bytes();
344    let mut i = 0;
345    while let Some(rel) = text[i..].find("**R") {
346        let start = i + rel + 2; // skip `**`, keep the leading `R`
347        let mut end = start;
348        while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'.') {
349            end += 1;
350        }
351        let candidate = &text[start..end];
352        if is_rule_id(candidate) {
353            ids.insert(candidate.to_string());
354        }
355        i = end.max(start + 1);
356    }
357    Ok(ids)
358}
359
360/// The PR number a commit `subject` (the first line of its message) names, if
361/// it ends in the `(#NNNN)` GitHub's squash-merge appends to the PR title —
362/// pure and root-independent, so `xtask/src/main.rs`'s `pr_number_from_head`
363/// (the only caller, which runs `git log -1 --format=%s` to get `subject`)
364/// stays a thin wrapper over this, and this half — the part with the
365/// interesting edge cases — is unit-testable without a git tree (#1001,
366/// caught by review as the one piece of new logic with no test coverage).
367///
368/// **Known limitation, stated rather than guarded against:** this matches on
369/// shape alone. Any subject ending `(#NNNN)` is read as the merging PR,
370/// including a hand-written commit that happens to end in an *issue*
371/// reference (`"fix: handle empty spans (#1001)"`, where 1001 names an issue,
372/// not the PR that closes it) — correct on the squash-merge path this exists
373/// for, a silent false positive off it (a hand-run `stamp --apply` on an
374/// unmerged local commit, say). Not guarded against because the fix — only
375/// trust the parse when the run is known to be CI/merge-triggered — would
376/// also suppress the *legitimate* case of manually re-running `stamp --apply`
377/// against an already-merged commit to recover from a failed push (`stamp.yml`
378/// names this as the documented recovery path), which is a worse trade.
379pub fn pr_number_from_subject(subject: &str) -> Option<u32> {
380    let inner = subject.strip_suffix(')')?.rsplit_once("(#")?.1;
381    inner.parse().ok()
382}
383
384/// Parse `## ADR: <slug>` blocks from the body (everything after the closing
385/// frontmatter fence). Zero blocks is valid — an increment may record no
386/// decision. Pushes errors for a non-kebab or duplicate slug, or an empty body.
387fn parse_adrs(content: &str, errors: &mut Vec<String>) -> Vec<Adr> {
388    // Body starts after the second `---` fence.
389    let mut fences = 0;
390    let mut body_lines = Vec::new();
391    for line in content.lines() {
392        if fences < 2 {
393            if line.trim_end() == "---" {
394                fences += 1;
395            }
396            continue;
397        }
398        body_lines.push(line);
399    }
400
401    // A `## ADR:` line inside a ``` code fence is prose (e.g. a pending file
402    // documenting the format inline), not a block header. Mark each line's
403    // header-ness up front, toggling on backtick-fence delimiters, so both the
404    // outer scan and the body-collecting loop below agree on where blocks start.
405    let mut in_fence = false;
406    let is_header: Vec<bool> = body_lines
407        .iter()
408        .map(|line| {
409            if line.trim_start().starts_with("```") {
410                in_fence = !in_fence;
411                false
412            } else {
413                !in_fence && adr_header_slug(line).is_some()
414            }
415        })
416        .collect();
417
418    let mut adrs: Vec<Adr> = Vec::new();
419    let mut i = 0;
420    while i < body_lines.len() {
421        if is_header[i] {
422            let slug = adr_header_slug(body_lines[i])
423                .expect("is_header implies an ADR header")
424                .trim()
425                .to_string();
426            i += 1;
427            let mut block = Vec::new();
428            while i < body_lines.len() && !is_header[i] {
429                block.push(body_lines[i]);
430                i += 1;
431            }
432
433            // The block opens with `title:`/`summary:`/`status:` key lines (any
434            // order, `title` required), then a blank line, then the verbatim
435            // body. Consume leading blanks and known keys; the first other line
436            // starts the body.
437            let mut title = None;
438            let mut summary = None;
439            let mut status = None;
440            let mut body_start = block.len();
441            for (idx, raw) in block.iter().enumerate() {
442                let line = raw.trim();
443                if line.is_empty() && title.is_none() && summary.is_none() && status.is_none() {
444                    continue;
445                }
446                if let Some(v) = line.strip_prefix("title:") {
447                    title = Some(v.trim().to_string());
448                } else if let Some(v) = line.strip_prefix("summary:") {
449                    summary = Some(v.trim().to_string());
450                } else if let Some(v) = line.strip_prefix("status:") {
451                    status = Some(v.trim().to_string());
452                } else {
453                    body_start = idx;
454                    break;
455                }
456            }
457            let body = block[body_start..].join("\n").trim().to_string();
458
459            if !is_kebab(&slug) {
460                errors.push(format!(
461                    "ADR slug {slug:?} is not a kebab-case slug (a-z, 0-9, single hyphens)"
462                ));
463            } else if adrs.iter().any(|a| a.slug == slug) {
464                errors.push(format!("duplicate ADR slug {slug:?}"));
465            }
466            match &title {
467                Some(t) if t.is_empty() => {
468                    errors.push(format!("ADR {slug:?} has an empty `title:`"))
469                }
470                None => errors.push(format!("ADR {slug:?} is missing a `title:` line")),
471                _ => {}
472            }
473            if body.is_empty() {
474                errors.push(format!("ADR {slug:?} has an empty body"));
475            }
476            adrs.push(Adr {
477                slug,
478                title: title.unwrap_or_default(),
479                summary: summary.filter(|s| !s.is_empty()),
480                status: status.filter(|s| !s.is_empty()),
481                body,
482            });
483        } else {
484            i += 1;
485        }
486    }
487    adrs
488}
489
490/// The slug text of a `## ADR: <slug>` header line, if this line is one.
491fn adr_header_slug(line: &str) -> Option<&str> {
492    line.trim().strip_prefix("## ADR:")
493}
494
495/// A kebab-case slug: non-empty, `a-z0-9` and single interior hyphens only.
496fn is_kebab(s: &str) -> bool {
497    !s.is_empty()
498        && !s.starts_with('-')
499        && !s.ends_with('-')
500        && !s.contains("--")
501        && s.chars()
502            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
503}
504
505/// Whether the changelog's first token reads as a repo version the author has
506/// accidentally prefixed — the stamp prepends the number, so the blurb must not
507/// carry one. Matched to the repo's actual spellings: a `v` prefix (`v0.186`,
508/// the banner form) or three-plus numeric groups (`0.186.0`, the Cargo form).
509/// A bare two-group token like `3.0` is *not* a version here, so a blurb such as
510/// "3.0 rendering pipeline added" is allowed.
511fn looks_like_version_prefix(changelog: &str) -> bool {
512    let raw = changelog.split_whitespace().next().unwrap_or("");
513    let had_v = raw.starts_with('v') || raw.starts_with('V');
514    let groups: Vec<&str> = raw.trim_start_matches(['v', 'V']).split('.').collect();
515    let all_numeric = groups.len() >= 2
516        && groups
517            .iter()
518            .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
519    all_numeric && (had_v || groups.len() >= 3)
520}
521
522#[cfg(test)]
523mod tests {
524    use super::*;
525
526    fn ok(name: &str, content: &str) -> Pending {
527        validate(name, content).unwrap_or_else(|e| panic!("expected valid, got {e:?}"))
528    }
529    fn err(name: &str, content: &str) -> Vec<String> {
530        validate(name, content).expect_err("expected invalid")
531    }
532
533    #[test]
534    fn minimal_no_adr_is_valid() {
535        let p = ok(
536            "add-a-thing.md",
537            "---\nlevel: minor\nchangelog: Add a thing to the language\n---\n",
538        );
539        assert_eq!(p.level, Level::Minor);
540        assert_eq!(p.changelog, "Add a thing to the language");
541        assert!(p.adrs.is_empty());
542    }
543
544    #[test]
545    fn patch_level_is_valid() {
546        assert_eq!(
547            ok(
548                "fix-a-thing.md",
549                "---\nlevel: patch\nchangelog: Fix a non-language thing\n---\n"
550            )
551            .level,
552            Level::Patch
553        );
554    }
555
556    #[test]
557    fn one_adr_parses_slug_title_and_body() {
558        let p = ok(
559            "unit-tier.md",
560            "---\nlevel: minor\nchangelog: Drive a handler at the unit tier\n---\n\n## ADR: unit-tier-service-address\ntitle: A case addresses a handler by surface\n\n**Decision.** A case addresses by surface.\n",
561        );
562        assert_eq!(p.adrs.len(), 1);
563        let adr = &p.adrs[0];
564        assert_eq!(adr.slug, "unit-tier-service-address");
565        assert_eq!(adr.title, "A case addresses a handler by surface");
566        // summary/status default to title/"Accepted" when absent.
567        assert_eq!(adr.summary(), adr.title);
568        assert_eq!(adr.status(), "Accepted");
569        assert!(adr.body.contains("addresses by surface"));
570        assert!(!adr.body.contains("title:"), "the title line is not body");
571    }
572
573    #[test]
574    fn adr_summary_and_status_are_parsed() {
575        let p = ok(
576            "x.md",
577            "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: a-slug\ntitle: The title\nsummary: The one-line index distillation\nstatus: Proposed\n\nBody.\n",
578        );
579        let adr = &p.adrs[0];
580        assert_eq!(adr.summary(), "The one-line index distillation");
581        assert_eq!(adr.status(), "Proposed");
582    }
583
584    #[test]
585    fn adr_missing_title_rejected() {
586        assert!(
587            err(
588                "x.md",
589                "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: a-slug\n\nBody with no title line.\n"
590            )
591            .iter()
592            .any(|e| e.contains("missing a `title:`"))
593        );
594    }
595
596    #[test]
597    fn two_adrs_parse() {
598        let p = ok(
599            "two.md",
600            "---\nlevel: minor\nchangelog: Two decisions\n---\n\n## ADR: first-one\ntitle: First\n\nBody one.\n\n## ADR: second-one\ntitle: Second\n\nBody two.\n",
601        );
602        assert_eq!(p.adrs.len(), 2);
603        assert_eq!(p.adrs[0].slug, "first-one");
604        assert_eq!(p.adrs[1].slug, "second-one");
605    }
606
607    #[test]
608    fn bad_level_rejected() {
609        assert!(
610            err("x.md", "---\nlevel: major\nchangelog: x\n---\n")
611                .iter()
612                .any(|e| e.contains("level must be"))
613        );
614    }
615
616    #[test]
617    fn missing_level_rejected() {
618        assert!(
619            err("x.md", "---\nchangelog: x\n---\n")
620                .iter()
621                .any(|e| e.contains("missing `level`"))
622        );
623    }
624
625    #[test]
626    fn missing_changelog_rejected() {
627        assert!(
628            err("x.md", "---\nlevel: minor\n---\n")
629                .iter()
630                .any(|e| e.contains("missing `changelog`"))
631        );
632    }
633
634    #[test]
635    fn empty_changelog_rejected() {
636        assert!(
637            err("x.md", "---\nlevel: minor\nchangelog:   \n---\n")
638                .iter()
639                .any(|e| e.contains("changelog"))
640        );
641    }
642
643    #[test]
644    fn version_prefixed_changelog_rejected() {
645        for cl in ["v0.186 Add a thing", "0.186.0 Add a thing"] {
646            let content = format!("---\nlevel: minor\nchangelog: {cl}\n---\n");
647            assert!(
648                err("x.md", &content)
649                    .iter()
650                    .any(|e| e.contains("version number")),
651                "expected rejection for {cl:?}"
652            );
653        }
654    }
655
656    #[test]
657    fn plain_changelog_with_a_dot_is_allowed() {
658        // A blurb ending in a version-like word must not false-positive; only the
659        // *first* token is checked.
660        ok(
661            "x.md",
662            "---\nlevel: minor\nchangelog: Support semver ranges like 1.2.3\n---\n",
663        );
664    }
665
666    #[test]
667    fn bare_two_group_leading_number_is_allowed() {
668        // `3.0` is not a repo version (no `v`, only two groups) — a blurb may
669        // legitimately open with it.
670        ok(
671            "x.md",
672            "---\nlevel: minor\nchangelog: 3.0 rendering pipeline added\n---\n",
673        );
674    }
675
676    #[test]
677    fn duplicate_frontmatter_key_rejected() {
678        assert!(
679            err(
680                "x.md",
681                "---\nlevel: minor\nlevel: patch\nchangelog: x\n---\n"
682            )
683            .iter()
684            .any(|e| e.contains("duplicate frontmatter key `level`"))
685        );
686    }
687
688    #[test]
689    fn adr_header_inside_a_code_fence_is_not_a_block() {
690        // A pending file documenting the format inline must not have its fenced
691        // `## ADR:` example split off into a spurious block.
692        let p = ok(
693            "x.md",
694            "---\nlevel: minor\nchangelog: Document the format\n---\n\n\
695             Example:\n\n```markdown\n## ADR: not-a-real-block\nfenced prose\n```\n\n\
696             ## ADR: the-real-one\ntitle: The real one\n\nReal body.\n",
697        );
698        assert_eq!(p.adrs.len(), 1);
699        assert_eq!(p.adrs[0].slug, "the-real-one");
700    }
701
702    #[test]
703    fn no_frontmatter_rejected() {
704        assert!(
705            err("x.md", "just some text\n")
706                .iter()
707                .any(|e| e.contains("open with a `---`"))
708        );
709    }
710
711    #[test]
712    fn unclosed_frontmatter_rejected() {
713        assert!(
714            err("x.md", "---\nlevel: minor\nchangelog: x\n")
715                .iter()
716                .any(|e| e.contains("not closed"))
717        );
718    }
719
720    #[test]
721    fn unknown_key_rejected() {
722        assert!(
723            err("x.md", "---\nlevel: minor\nchangelog: x\nversion: 9\n---\n")
724                .iter()
725                .any(|e| e.contains("unknown frontmatter key"))
726        );
727    }
728
729    // --- closes_rule (#1001) --------------------------------------------------
730
731    #[test]
732    fn closes_rule_is_optional_and_defaults_empty() {
733        let p = ok("x.md", "---\nlevel: patch\nchangelog: x\n---\n");
734        assert!(p.closes_rule.is_empty());
735    }
736
737    #[test]
738    fn closes_rule_parses_a_single_id() {
739        let p = ok(
740            "x.md",
741            "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\n---\n",
742        );
743        assert_eq!(p.closes_rule, vec!["R2.3".to_string()]);
744    }
745
746    #[test]
747    fn closes_rule_parses_a_comma_separated_list_and_trims_whitespace() {
748        let p = ok(
749            "x.md",
750            "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3,  R2.12 ,R0.1\n---\n",
751        );
752        assert_eq!(
753            p.closes_rule,
754            vec!["R2.3".to_string(), "R2.12".to_string(), "R0.1".to_string()]
755        );
756    }
757
758    #[test]
759    fn closes_rule_rejects_a_malformed_entry() {
760        assert!(
761            err(
762                "x.md",
763                "---\nlevel: patch\nchangelog: x\ncloses_rule: not-a-rule\n---\n"
764            )
765            .iter()
766            .any(|e| e.contains("closes_rule entry") && e.contains("not a rule id"))
767        );
768    }
769
770    #[test]
771    fn closes_rule_rejects_empty_value() {
772        assert!(
773            err(
774                "x.md",
775                "---\nlevel: patch\nchangelog: x\ncloses_rule: \n---\n"
776            )
777            .iter()
778            .any(|e| e.contains("closes_rule must not be empty"))
779        );
780    }
781
782    #[test]
783    fn closes_rule_rejects_duplicate_key() {
784        assert!(
785            err(
786                "x.md",
787                "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\ncloses_rule: R2.4\n---\n"
788            )
789            .iter()
790            .any(|e| e.contains("duplicate frontmatter key `closes_rule`"))
791        );
792    }
793
794    #[test]
795    fn is_rule_id_accepts_and_rejects() {
796        assert!(is_rule_id("R2.3"));
797        assert!(is_rule_id("R0.1"));
798        assert!(is_rule_id("R12.34"));
799        assert!(!is_rule_id("2.3"));
800        assert!(!is_rule_id("R2"));
801        assert!(!is_rule_id("R2.3.4"));
802        assert!(!is_rule_id("R.3"));
803        assert!(!is_rule_id("R2."));
804        assert!(!is_rule_id("Rx.y"));
805    }
806
807    // --- pr_number_from_subject (#1001) --------------------------------------
808
809    #[test]
810    fn pr_number_from_subject_finds_a_trailing_squash_merge_suffix() {
811        assert_eq!(
812            pr_number_from_subject("feat(xtask): thing (#1234)"),
813            Some(1234)
814        );
815    }
816
817    #[test]
818    fn pr_number_from_subject_requires_the_suffix_at_the_very_end() {
819        // Trailing prose after the `)` means this isn't a squash-merge title.
820        assert_eq!(pr_number_from_subject("feat: thing (#12) then more"), None);
821    }
822
823    #[test]
824    fn pr_number_from_subject_is_not_confused_by_earlier_nested_parens() {
825        assert_eq!(pr_number_from_subject("chore: bump (deps) (#12)"), Some(12));
826    }
827
828    #[test]
829    fn pr_number_from_subject_rejects_a_non_numeric_hash() {
830        assert_eq!(pr_number_from_subject("feat: thing (#abc)"), None);
831    }
832
833    #[test]
834    fn pr_number_from_subject_rejects_a_number_too_large_for_u32() {
835        assert_eq!(pr_number_from_subject("(#99999999999999)"), None);
836    }
837
838    #[test]
839    fn pr_number_from_subject_none_without_any_suffix() {
840        assert_eq!(pr_number_from_subject("Merge branch 'x'"), None);
841    }
842
843    /// The documented limitation, pinned so it can't silently change meaning:
844    /// a hand-written commit ending in an *issue* reference is indistinguishable
845    /// from a squash-merge PR title by shape alone.
846    #[test]
847    fn pr_number_from_subject_cannot_distinguish_an_issue_reference() {
848        assert_eq!(
849            pr_number_from_subject("fix: handle empty spans (#1001)"),
850            Some(1001)
851        );
852    }
853
854    /// A throwaway fixture tree, named per calling test so parallel runs don't
855    /// collide — the same convention `xtask/tests/stamp_apply.rs`'s `fixture`
856    /// uses. Removed and recreated on construction, not cleaned up after (the OS
857    /// temp dir is not this test's to manage beyond that).
858    fn rule_fixture(tag: &str, reference_body: &str) -> PathBuf {
859        let root = std::env::temp_dir().join(format!("xtask-closes-rule-{tag}"));
860        let _ = fs::remove_dir_all(&root);
861        fs::create_dir_all(root.join("design/pending")).unwrap();
862        fs::write(
863            root.join("design/bynk-greenfield-compiler.md"),
864            reference_body,
865        )
866        .unwrap();
867        root
868    }
869
870    #[test]
871    fn known_rule_ids_finds_bold_rule_headers() {
872        let dir = rule_fixture(
873            "finds-bold-headers",
874            "Some prose.\n\n**R2.3 — A rule about spans.**\n\nMore prose citing **R2.3** again \
875             in passing, and introducing **R10.11 — a second rule.**\n",
876        );
877        let ids = known_rule_ids(&dir).unwrap();
878        assert_eq!(ids.len(), 2, "expected exactly 2 distinct ids: {ids:?}");
879        assert!(ids.contains("R2.3"));
880        assert!(ids.contains("R10.11"));
881    }
882
883    #[test]
884    fn validated_pending_in_rejects_a_closes_rule_citing_an_unknown_id() {
885        let dir = rule_fixture("rejects-unknown", "**R2.3 — real.**\n");
886        fs::write(
887            dir.join("design/pending/x.md"),
888            "---\nlevel: patch\nchangelog: x\ncloses_rule: R99.99\n---\n",
889        )
890        .unwrap();
891        let errors = validated_pending_in(&dir).expect_err("R99.99 does not exist");
892        assert!(
893            errors
894                .iter()
895                .any(|e| e.contains("R99.99") && e.contains("not a rule id in")),
896            "{errors:?}"
897        );
898    }
899
900    #[test]
901    fn validated_pending_in_accepts_a_closes_rule_citing_a_known_id() {
902        let dir = rule_fixture("accepts-known", "**R2.3 — real.**\n");
903        fs::write(
904            dir.join("design/pending/x.md"),
905            "---\nlevel: patch\nchangelog: x\ncloses_rule: R2.3\n---\n",
906        )
907        .unwrap();
908        let parsed = validated_pending_in(&dir).unwrap();
909        assert_eq!(parsed.len(), 1);
910        assert_eq!(parsed[0].1.closes_rule, vec!["R2.3".to_string()]);
911    }
912
913    #[test]
914    fn non_kebab_adr_slug_rejected() {
915        assert!(
916            err(
917                "x.md",
918                "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: Not_Kebab\ntitle: T\n\nBody.\n"
919            )
920            .iter()
921            .any(|e| e.contains("not a kebab-case slug"))
922        );
923    }
924
925    #[test]
926    fn duplicate_adr_slug_rejected() {
927        assert!(err(
928            "x.md",
929            "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: dup\ntitle: A\n\nBody a.\n\n## ADR: dup\ntitle: B\n\nBody b.\n"
930        )
931        .iter()
932        .any(|e| e.contains("duplicate ADR slug")));
933    }
934
935    #[test]
936    fn empty_adr_body_rejected() {
937        assert!(
938            err(
939                "x.md",
940                "---\nlevel: minor\nchangelog: x\n---\n\n## ADR: empty\ntitle: T\n\n## ADR: next\ntitle: N\n\nBody.\n"
941            )
942            .iter()
943            .any(|e| e.contains("empty body"))
944        );
945    }
946
947    #[test]
948    fn non_kebab_filename_rejected() {
949        assert!(
950            err("Not_A_Slug.md", "---\nlevel: minor\nchangelog: x\n---\n")
951                .iter()
952                .any(|e| e.contains("filename stem"))
953        );
954    }
955}