Skip to main content

xtask/
stamp.rs

1//! The merge-time stamp (increment-allocation track, Slice 1).
2//!
3//! On merge, [`plan`] reads the pending files, assigns the next version(s) in
4//! merge order and the next ADR number(s), and [`apply`] materialises them:
5//! it runs `scripts/bump-version.sh`, inserts the changelog row(s), writes each
6//! `design/decisions/NNNN-<slug>.md` and its index row, and deletes the consumed
7//! pending files. Deleting what it consumes is what makes a re-run a no-op —
8//! which is why version assignment and ADR materialisation are one atomic pass
9//! (the entangled-by-delete finding, proposal #689 DECISION A).
10//!
11//! The version bump is injected (see [`apply`]) so the whole flow is testable on
12//! a fixture tree without running the real, side-effect-heavy bump script.
13
14use crate::{Adr, Level, validated_pending_in};
15use std::fmt;
16use std::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19
20/// A semantic version `MAJOR.MINOR.PATCH`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct Version {
23    pub major: u64,
24    pub minor: u64,
25    pub patch: u64,
26}
27
28impl Version {
29    /// The next version for `level`: `minor` bumps MINOR and resets PATCH,
30    /// `patch` bumps PATCH. (MAJOR is 0 pre-1.0 and never moved here.)
31    pub fn next(self, level: &Level) -> Version {
32        match level {
33            Level::Minor => Version {
34                minor: self.minor + 1,
35                patch: 0,
36                ..self
37            },
38            Level::Patch => Version {
39                patch: self.patch + 1,
40                ..self
41            },
42        }
43    }
44
45    /// Parse an `X.Y.Z` string.
46    pub fn parse(s: &str) -> Option<Version> {
47        let mut it = s.trim().split('.');
48        let major = it.next()?.parse().ok()?;
49        let minor = it.next()?.parse().ok()?;
50        let patch = it.next()?.parse().ok()?;
51        if it.next().is_some() {
52            return None;
53        }
54        Some(Version {
55            major,
56            minor,
57            patch,
58        })
59    }
60}
61
62impl fmt::Display for Version {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
65    }
66}
67
68impl Version {
69    /// The ADR/index version string, following the corpus convention: a MINOR
70    /// increment is `MAJOR.MINOR` (`0.186`), a PATCH increment keeps its patch
71    /// (`0.185.1`, like the existing `v0.29.1`). Since [`next`](Version::next)
72    /// resets PATCH to 0 for a minor bump, `patch == 0` is exactly the minor case.
73    pub fn short(self) -> String {
74        if self.patch == 0 {
75            format!("{}.{}", self.major, self.minor)
76        } else {
77            self.to_string()
78        }
79    }
80}
81
82/// One pending increment stamped with the version it will ship as.
83#[derive(Debug)]
84pub struct Stamped {
85    /// The pending file that produced this (relative name under `design/pending/`).
86    pub file: String,
87    pub version: Version,
88    pub changelog: String,
89    pub adrs: Vec<Adr>,
90    /// Greenfield reference rule ids this increment closes (#1001) — already
91    /// validated (syntax and existence) by `validated_pending_in`.
92    pub closes_rule: Vec<String>,
93    /// Gated greenfield-status probes this increment acknowledges moving — the
94    /// stamp rewrites only these rows of the table.
95    pub probes: Vec<String>,
96}
97
98/// The full stamp plan for the pending files present.
99#[derive(Debug)]
100pub struct Plan {
101    pub base_version: Version,
102    pub increments: Vec<Stamped>,
103    /// The next free ADR number, for materialisation.
104    pub first_adr_number: u32,
105}
106
107impl Plan {
108    /// The version the manifests end on — the last increment's, or the base if
109    /// there is nothing to stamp.
110    pub fn final_version(&self) -> Version {
111        self.increments
112            .last()
113            .map(|s| s.version)
114            .unwrap_or(self.base_version)
115    }
116
117    pub fn is_empty(&self) -> bool {
118        self.increments.is_empty()
119    }
120}
121
122/// Build the plan for `root`: read the workspace version and the pending files,
123/// and assign each pending file (in filename order — a deterministic proxy for
124/// merge order) its own next version. Returns validation errors verbatim.
125pub fn plan(root: &Path) -> Result<Plan, Vec<String>> {
126    let cargo = root.join("Cargo.toml");
127    let cargo_src = fs::read_to_string(&cargo)
128        .map_err(|e| vec![format!("cannot read {}: {e}", cargo.display())])?;
129    let base_version = parse_workspace_version(&cargo_src)
130        .ok_or_else(|| vec![format!("no `version = \"X.Y.Z\"` in {}", cargo.display())])?;
131
132    let pending = validated_pending_in(root)?;
133    let first_adr_number = next_adr_number(root).map_err(|e| {
134        vec![format!(
135            "cannot scan {}: {e}",
136            root.join("design/decisions").display()
137        )]
138    })?;
139
140    let mut version = base_version;
141    let mut increments = Vec::new();
142    for (file, p) in pending {
143        version = version.next(&p.level);
144        increments.push(Stamped {
145            file,
146            version,
147            changelog: p.changelog,
148            adrs: p.adrs,
149            closes_rule: p.closes_rule,
150            probes: p.probes,
151        });
152    }
153
154    Ok(Plan {
155        base_version,
156        increments,
157        first_adr_number,
158    })
159}
160
161/// Apply `plan` to `root`: write ADR files + index rows, insert changelog rows,
162/// run `bump` for the final version, then delete the consumed pending files.
163///
164/// The whole thing is transactional. Every write is *staged* in memory first —
165/// the changelog and index edits are computed, ADR contents rendered, and any
166/// ADR-number collision detected — before a single byte hits disk, so a
167/// malformed table or a stale number fails with nothing written. During the
168/// commit phase the tree is mutated in a fixed order (ADR files, changelog,
169/// index, then the flaky `bump`); if any step fails, the tree is *rolled back*
170/// to its pre-apply state — the edited docs restored and the freshly written
171/// ADR files removed. Only once everything has succeeded are the consumed
172/// pending files deleted.
173///
174/// This is what makes a re-run safe: a failed run (a `bump` that errors, most
175/// likely) leaves the pending files intact *and* no partial changelog/ADR edits
176/// behind, so a retry recomputes the identical plan rather than duplicating rows
177/// or re-numbering ADRs past the files a previous attempt wrote. (`bump`'s own
178/// partial effects on the manifests it rewrites are outside this function's
179/// reach; the CLI additionally refuses to `--apply` on a dirty worktree so that
180/// debris is surfaced too.)
181///
182/// `bump` is injected so tests exercise the whole flow on a fixture tree with a
183/// stub that just rewrites the fixture's `Cargo.toml`. `pr_number` is the PR
184/// whose merge triggered *this run*, if it could be recovered from the
185/// triggering commit message (#1001) — `None` records a ledger row without
186/// one (e.g. a local `stamp --apply` run outside CI) rather than guessing.
187/// Only trusted for a single-increment plan: a multi-increment run (a prior
188/// run's failed push left its pending file for this one to pick up) has no
189/// way to tell which increment the recovered number actually belongs to, so
190/// every row in that case gets a blank cell instead of one confidently wrong
191/// number applied to increments from different PRs.
192pub fn apply(
193    root: &Path,
194    plan: &Plan,
195    pr_number: Option<u32>,
196    bump: impl Fn(Version) -> io::Result<()>,
197) -> io::Result<()> {
198    if plan.is_empty() {
199        return Ok(());
200    }
201
202    let decisions = root.join("design/decisions");
203    let changelog_path = root.join("site/src/content/docs/book/reference/changelog.md");
204    let readme_path = decisions.join("README.md");
205    let rule_ledger_path = root.join("design/greenfield-status-rules.md");
206
207    // `pr_number` names the PR whose merge triggered *this run*, not any one
208    // increment in `plan` — those are usually the same increment, but not
209    // always: a stamp run that exhausts its push retries (`stamp.yml`) exits
210    // without consuming its pending file, so the *next* run's plan can carry
211    // two increments from two different PRs. Attributing both to the second
212    // PR would be a confidently wrong provenance record — worse than the
213    // blank cell this design already accepts for "couldn't recover a PR
214    // number" — so the recovered number is only trusted when it can only mean
215    // one increment.
216    let ledger_pr_number = if plan.increments.len() == 1 {
217        pr_number
218    } else {
219        None
220    };
221
222    // --- Stage: compute every write in memory. Nothing is on disk yet, so a
223    // fault here (a missing table anchor, an ADR-number collision) aborts with
224    // the tree untouched. ---
225    let mut number = plan.first_adr_number;
226    let mut adr_files: Vec<(PathBuf, String)> = Vec::new();
227    let mut changelog_rows = Vec::new();
228    let mut index_rows = Vec::new();
229    let mut rule_ledger_rows_all = Vec::new();
230    for inc in &plan.increments {
231        for adr in &inc.adrs {
232            let path = decisions.join(format!("{number:04}-{}.md", adr.slug));
233            // Refuse to clobber an ADR that already exists: a number that is not
234            // actually free (e.g. debris from a prior partial run) must surface,
235            // not be silently overwritten and the series restarted.
236            if path.exists() {
237                return Err(io::Error::new(
238                    io::ErrorKind::AlreadyExists,
239                    format!(
240                        "ADR {} already exists — refusing to overwrite",
241                        path.display()
242                    ),
243                ));
244            }
245            adr_files.push((path, adr_file_contents(number, adr, inc.version)));
246            index_rows.push(index_row(number, adr, inc.version));
247            number += 1;
248        }
249        changelog_rows.push(changelog_row(inc.version, &inc.changelog));
250        rule_ledger_rows_all.extend(rule_ledger_rows(inc, ledger_pr_number));
251    }
252
253    // Newest on top: the highest version/number is applied last, so reverse the
254    // ascending lists before prepending them as a block.
255    changelog_rows.reverse();
256    index_rows.reverse();
257    rule_ledger_rows_all.reverse();
258
259    let changelog_before = fs::read_to_string(&changelog_path)?;
260    let changelog_after =
261        insert_after_table_separator(&changelog_before, "| Version |", &changelog_rows)
262            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
263
264    // Only touch the index when there are ADRs to add; `None` means "leave it".
265    let readme_edit = if index_rows.is_empty() {
266        None
267    } else {
268        let before = fs::read_to_string(&readme_path)?;
269        let after = insert_after_table_separator(&before, "| # | Decision |", &index_rows)
270            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
271        Some((before, after))
272    };
273
274    // Only touch the ledger when some increment actually closes a rule.
275    // Unlike the changelog/index, this file may not exist yet — no increment
276    // has ever cited `closes_rule` before #1001 — so a missing file synthesises
277    // a fresh header rather than erroring; `ledger_before` stays `None` in that
278    // case so a rollback removes the file instead of restoring stale content.
279    let ledger_existed = rule_ledger_path.exists();
280    let ledger_edit = if rule_ledger_rows_all.is_empty() {
281        None
282    } else {
283        let ledger_before = if ledger_existed {
284            Some(fs::read_to_string(&rule_ledger_path)?)
285        } else {
286            None
287        };
288        let base = ledger_before
289            .clone()
290            .unwrap_or_else(|| RULE_LEDGER_HEADER.to_string());
291        let after = insert_after_table_separator(&base, "| Rule |", &rule_ledger_rows_all)
292            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
293        Some((ledger_before, after))
294    };
295
296    // --- Commit: mutate the tree. Track what we write so a mid-flight failure
297    // (the injected `bump` is the likely one) can be unwound. ---
298    let mut written_adrs: Vec<PathBuf> = Vec::new();
299    let commit = (|| -> io::Result<()> {
300        for (path, contents) in &adr_files {
301            fs::write(path, contents)?;
302            written_adrs.push(path.clone());
303        }
304        fs::write(&changelog_path, &changelog_after)?;
305        if let Some((_, after)) = &readme_edit {
306            fs::write(&readme_path, after)?;
307        }
308        if let Some((_, after)) = &ledger_edit {
309            fs::write(&rule_ledger_path, after)?;
310        }
311        // The flaky step, run last so its failure unwinds cleanly here rather
312        // than stranding a changelog that cites a version the manifests never
313        // reached.
314        bump(plan.final_version())
315    })();
316
317    if let Err(e) = commit {
318        // Roll back, best effort: restore the edited docs to their pre-apply
319        // bytes and delete the ADR files we created. The pending files were not
320        // touched (they are consumed only below), so the run is a clean retry.
321        let _ = fs::write(&changelog_path, &changelog_before);
322        if let Some((before, _)) = &readme_edit {
323            let _ = fs::write(&readme_path, before);
324        }
325        if let Some((before, _)) = &ledger_edit {
326            match before {
327                Some(orig) => {
328                    let _ = fs::write(&rule_ledger_path, orig);
329                }
330                None => {
331                    let _ = fs::remove_file(&rule_ledger_path);
332                }
333            }
334        }
335        for path in &written_adrs {
336            let _ = fs::remove_file(path);
337        }
338        return Err(e);
339    }
340
341    // Consume last: delete each pending file only once everything else has
342    // succeeded, so a re-run finds nothing to do.
343    for inc in &plan.increments {
344        fs::remove_file(root.join("design/pending").join(&inc.file))?;
345    }
346    Ok(())
347}
348
349// --- Pure rendering / parsing helpers (unit-tested without the filesystem) ---
350
351/// The `[workspace.package] version = "X.Y.Z"` from a `Cargo.toml`. Scoped to
352/// the `[workspace.package]` section: the first line-anchored `version = "..."`
353/// *within that table*, not a `version` under some other section (e.g. a stray
354/// top-level `[package]`) nor the inline `version = "..."` inside a dependency
355/// spec — the same value `bump-version.sh` rewrites.
356pub fn parse_workspace_version(cargo_toml: &str) -> Option<Version> {
357    let mut in_section = false;
358    for line in cargo_toml.lines() {
359        let trimmed = line.trim();
360        // A `[section]` header ends the previous table and opens a new one.
361        if trimmed.starts_with('[') && trimmed.ends_with(']') {
362            in_section = trimmed == "[workspace.package]";
363            continue;
364        }
365        if in_section
366            && let Some(value) = line
367                .strip_prefix("version = \"")
368                .and_then(|r| r.strip_suffix('"'))
369        {
370            return Version::parse(value);
371        }
372    }
373    None
374}
375
376/// The next free ADR number: one past the highest `NNNN-*.md` in
377/// `design/decisions`. Errors if the directory can't be read — a swallowed
378/// failure would silently default to `1` and restart the ADR series.
379pub fn next_adr_number(root: &Path) -> io::Result<u32> {
380    let dir = root.join("design/decisions");
381    let mut max = 0;
382    for entry in fs::read_dir(&dir)? {
383        let name = entry?.file_name().to_string_lossy().into_owned();
384        let bytes = name.as_bytes();
385        let is_adr = name.ends_with(".md")
386            && bytes.len() > 5
387            && bytes[4] == b'-'
388            && bytes[..4].iter().all(u8::is_ascii_digit);
389        if is_adr && let Ok(n) = name[..4].parse::<u32>() {
390            max = max.max(n);
391        }
392    }
393    Ok(max + 1)
394}
395
396/// The changelog table row for a stamped increment.
397pub fn changelog_row(version: Version, blurb: &str) -> String {
398    format!("| **v{version}** | {blurb} |")
399}
400
401/// The `design/decisions/NNNN-<slug>.md` file contents: a `# NNNN — <title>`
402/// heading, a status line carrying the version, then the body verbatim.
403pub fn adr_file_contents(number: u32, adr: &Adr, version: Version) -> String {
404    format!(
405        "# {number:04} — {title}\n\n- **Status:** {status} (v{ver})\n\n{body}\n",
406        title = adr.title,
407        status = adr.status(),
408        ver = version.short(),
409        body = adr.body,
410    )
411}
412
413/// The `design/decisions/README.md` index row for a materialised ADR.
414pub fn index_row(number: u32, adr: &Adr, version: Version) -> String {
415    format!(
416        "| [{number:04}]({number:04}-{slug}.md) | **{title}** (v{ver}) — {summary} | {status} (v{ver}) |",
417        slug = adr.slug,
418        title = adr.title,
419        ver = version.short(),
420        summary = adr.summary(),
421        status = adr.status(),
422    )
423}
424
425/// The header `design/greenfield-status-rules.md` is synthesised with the first
426/// time any increment closes a rule — this file has no other reason to exist,
427/// so `apply` doesn't require it pre-created the way it does the changelog and
428/// the decisions index.
429const RULE_LEDGER_HEADER: &str = "<!-- GENERATED, APPEND-ONLY — written by `cargo xtask stamp \
430--apply` (xtask/src/stamp.rs) at merge, one row per rule id an increment cites in its \
431`closes_rule` frontmatter (#1001).\n     Read by `cargo xtask greenfield-status` to populate \
432the reference's rule-citation surface. Do not hand-edit. -->\n\n# Rules closed\n\n\
433| Rule | Version | PR | Changelog |\n|---|---|---|---|\n";
434
435/// One `design/greenfield-status-rules.md` row per rule id `inc` closes — empty
436/// if `inc.closes_rule` is empty. `pr` renders as `#NNNN`, or the empty string
437/// when it couldn't be recovered (kept as an empty cell, not a placeholder like
438/// `"unknown"`, so the table stays a clean Markdown grid either way).
439pub fn rule_ledger_rows(inc: &Stamped, pr: Option<u32>) -> Vec<String> {
440    let pr_cell = pr.map(|n| format!("#{n}")).unwrap_or_default();
441    inc.closes_rule
442        .iter()
443        .map(|rule| {
444            format!(
445                "| {rule} | v{} | {pr_cell} | {} |",
446                inc.version, inc.changelog
447            )
448        })
449        .collect()
450}
451
452/// Insert `rows` (already newest-first) immediately after the separator line of
453/// the table whose header row contains `header_anchor` (e.g. `"| Version |"`).
454/// Anchoring on the header — rather than the first separator in the file — keeps
455/// the insert correct if another table (a legend, say) is ever added above.
456pub fn insert_after_table_separator(
457    md: &str,
458    header_anchor: &str,
459    rows: &[String],
460) -> Result<String, String> {
461    if rows.is_empty() {
462        return Ok(md.to_string());
463    }
464    let mut out = Vec::new();
465    let mut seen_header = false;
466    let mut inserted = false;
467    for line in md.lines() {
468        out.push(line.to_string());
469        if inserted {
470            continue;
471        }
472        if !seen_header {
473            seen_header = line.contains(header_anchor);
474        } else if is_table_separator(line) {
475            out.extend(rows.iter().cloned());
476            inserted = true;
477        }
478    }
479    if !inserted {
480        return Err(format!(
481            "no table with header {header_anchor:?} (and a `|---|` separator) to insert after"
482        ));
483    }
484    // Preserve a trailing newline if the input had one.
485    let mut joined = out.join("\n");
486    if md.ends_with('\n') {
487        joined.push('\n');
488    }
489    Ok(joined)
490}
491
492/// A Markdown table separator row — `|`, `-`, `:` and spaces only, with at least
493/// one `-`.
494fn is_table_separator(line: &str) -> bool {
495    let t = line.trim();
496    t.starts_with('|') && t.contains('-') && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    fn adr(slug: &str, title: &str) -> Adr {
504        Adr {
505            slug: slug.into(),
506            title: title.into(),
507            summary: None,
508            status: None,
509            body: "**Decision.** Do the thing.".into(),
510        }
511    }
512
513    #[test]
514    fn version_next() {
515        let v = Version {
516            major: 0,
517            minor: 185,
518            patch: 0,
519        };
520        assert_eq!(v.next(&Level::Minor).to_string(), "0.186.0");
521        assert_eq!(v.next(&Level::Patch).to_string(), "0.185.1");
522        let p = Version {
523            major: 0,
524            minor: 185,
525            patch: 3,
526        };
527        assert_eq!(p.next(&Level::Minor).to_string(), "0.186.0");
528    }
529
530    #[test]
531    fn parse_workspace_version_ignores_dep_specs() {
532        let cargo = "[workspace.package]\nversion = \"0.185.0\"\n\n[workspace.dependencies]\nbynk = { path = \"bynk\", version = \"0.185.0\" }\n";
533        assert_eq!(
534            parse_workspace_version(cargo).unwrap().to_string(),
535            "0.185.0"
536        );
537    }
538
539    #[test]
540    fn parse_workspace_version_is_scoped_to_the_workspace_package_table() {
541        // A line-anchored `version` outside `[workspace.package]` (here a stray
542        // top-level `[package]`) must not be mistaken for the workspace version.
543        let cargo = "[package]\nname = \"root\"\nversion = \"9.9.9\"\n\n[workspace.package]\nversion = \"0.185.0\"\n";
544        assert_eq!(
545            parse_workspace_version(cargo).unwrap().to_string(),
546            "0.185.0"
547        );
548    }
549
550    #[test]
551    fn parse_workspace_version_none_without_the_table() {
552        // A bare top-level `version` with no `[workspace.package]` table is not
553        // the workspace version.
554        assert!(parse_workspace_version("version = \"1.2.3\"\n").is_none());
555    }
556
557    #[test]
558    fn changelog_row_format() {
559        let v = Version {
560            major: 0,
561            minor: 186,
562            patch: 0,
563        };
564        assert_eq!(
565            changelog_row(v, "Add a thing"),
566            "| **v0.186.0** | Add a thing |"
567        );
568    }
569
570    #[test]
571    fn version_short_follows_the_corpus_convention() {
572        // Minor (patch == 0) → MAJOR.MINOR; patch → full.
573        assert_eq!(
574            Version {
575                major: 0,
576                minor: 186,
577                patch: 0
578            }
579            .short(),
580            "0.186"
581        );
582        assert_eq!(
583            Version {
584                major: 0,
585                minor: 185,
586                patch: 1
587            }
588            .short(),
589            "0.185.1"
590        );
591    }
592
593    #[test]
594    fn adr_file_and_index_row_use_the_short_version() {
595        let v = Version {
596            major: 0,
597            minor: 186,
598            patch: 0,
599        };
600        let a = adr("a-slug", "The title");
601        let file = adr_file_contents(206, &a, v);
602        assert!(file.starts_with("# 0206 — The title\n"));
603        // MINOR-only, matching every existing ADR — not the full 0.186.0.
604        assert!(file.contains("- **Status:** Accepted (v0.186)"));
605        assert!(file.trim_end().ends_with("Do the thing."));
606
607        let row = index_row(206, &a, v);
608        assert_eq!(
609            row,
610            "| [0206](0206-a-slug.md) | **The title** (v0.186) — The title | Accepted (v0.186) |"
611        );
612    }
613
614    #[test]
615    fn patch_increment_index_row_keeps_the_patch() {
616        let v = Version {
617            major: 0,
618            minor: 185,
619            patch: 1,
620        };
621        let a = adr("s", "T");
622        assert!(index_row(9, &a, v).contains("(v0.185.1)"));
623    }
624
625    #[test]
626    fn index_row_uses_explicit_summary_and_status() {
627        let v = Version {
628            major: 0,
629            minor: 1,
630            patch: 0,
631        };
632        let a = Adr {
633            slug: "s".into(),
634            title: "T".into(),
635            summary: Some("the distillation".into()),
636            status: Some("Proposed".into()),
637            body: "b".into(),
638        };
639        assert_eq!(
640            index_row(7, &a, v),
641            "| [0007](0007-s.md) | **T** (v0.1) — the distillation | Proposed (v0.1) |"
642        );
643    }
644
645    #[test]
646    fn insert_prepends_after_the_anchored_table_newest_first() {
647        let md =
648            "## Recent increments\n\n| Version | Highlights |\n|---|---|\n| **v0.185.0** | Old |\n";
649        let out = insert_after_table_separator(
650            md,
651            "| Version |",
652            &["| NEW1 |".into(), "| NEW2 |".into()],
653        )
654        .unwrap();
655        let lines: Vec<&str> = out.lines().collect();
656        let sep = lines.iter().position(|l| *l == "|---|---|").unwrap();
657        assert_eq!(lines[sep + 1], "| NEW1 |");
658        assert_eq!(lines[sep + 2], "| NEW2 |");
659        assert_eq!(lines[sep + 3], "| **v0.185.0** | Old |");
660        assert!(out.ends_with('\n'));
661    }
662
663    #[test]
664    fn insert_skips_an_earlier_unrelated_table() {
665        // A legend table sits above the target; the anchor must route past it.
666        let md = "| Legend | Meaning |\n|---|---|\n| x | y |\n\n\
667                  | Version | Highlights |\n|---|---|\n| **v0.185.0** | Old |\n";
668        let out = insert_after_table_separator(md, "| Version |", &["| NEW |".into()]).unwrap();
669        let lines: Vec<&str> = out.lines().collect();
670        // Inserted below the *second* separator, not the legend's.
671        let target = lines
672            .iter()
673            .position(|l| *l == "| **v0.185.0** | Old |")
674            .unwrap();
675        assert_eq!(lines[target - 1], "| NEW |");
676        assert!(
677            !out.contains("| x | y |\n| NEW |"),
678            "must not touch the legend table"
679        );
680    }
681
682    #[test]
683    fn insert_without_the_anchored_table_errors() {
684        assert!(
685            insert_after_table_separator("no table here\n", "| Version |", &["| r |".into()])
686                .is_err()
687        );
688    }
689}