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