Skip to main content

choir_cli/
docs.rs

1//! `choir docs`: the book, and the API documentation inside it.
2//!
3//! Two renderers over one set of files. `docs/*.md` is the book's source
4//! and is also pulled into the crates with `#![doc = include_str!]`, so a
5//! page cannot say one thing in the book and another in `cargo doc`.
6//!
7//! rustdoc is copied to `book/api/` rather than left in the target
8//! directory. That is the whole reason this is one command instead of
9//! two: a book whose API links point outside the tree it was built into
10//! is a book whose API links 404 the moment it is copied anywhere.
11//!
12//! # Why this is not a shell script
13//!
14//! It was one. The script computed the documentation directory as
15//! `${CARGO_TARGET_DIR:-target}/doc`, which is right when the variable
16//! is exported, right when nothing sets it, and wrong when `target-dir`
17//! comes from a `[build]` table in a `.cargo/config.toml`, which cargo
18//! reads from every *ancestor* of the working directory. `cargo doc`
19//! then succeeded and the copy after it reported that cargo had produced
20//! nothing. [`target_dir_from_metadata`] asks cargo instead, and is
21//! tested against the shape cargo actually emits.
22
23use std::path::{Path, PathBuf};
24
25/// What a successful build produced.
26#[derive(Debug)]
27pub struct Built {
28    /// The checkout the book was built from.
29    pub root: PathBuf,
30    /// The rendered book.
31    pub book: PathBuf,
32    /// The API documentation, inside the book.
33    pub api: PathBuf,
34    /// Crate pages rustdoc produced, in the order the index lists them.
35    pub crates: Vec<String>,
36}
37
38/// Why a build did not happen, phrased as the thing to do about it.
39#[derive(Debug)]
40pub enum Failure {
41    /// No `book.toml` in this directory or any parent.
42    NotACheckout,
43    /// A tool this needs is not on `PATH`.
44    MissingTool {
45        /// The executable that was not found.
46        tool: &'static str,
47        /// The command that installs it.
48        install: &'static str,
49    },
50    /// A stage ran and exited nonzero. Its own output has already been
51    /// written to the terminal, so this carries no captured text: the
52    /// compiler's errors belong on the screen, not inside a JSON string.
53    Stage {
54        /// Which stage.
55        name: &'static str,
56        /// Its exit code, or `None` when a signal killed it.
57        code: Option<i32>,
58    },
59    /// Something failed that is nobody's fault but the filesystem's.
60    Io(String),
61}
62
63impl std::fmt::Display for Failure {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        match self {
66            Self::NotACheckout => write!(
67                f,
68                "no book.toml in this directory or any parent.\n\
69                 `choir docs` builds this repository's own documentation, \
70                 so it has to be run inside a checkout of it."
71            ),
72            Self::MissingTool { tool, install } => {
73                write!(f, "{tool} is not on PATH.\n  {install}")
74            }
75            Self::Stage { name, code } => match code {
76                Some(code) => write!(f, "{name} exited {code}"),
77                None => write!(f, "{name} was killed by a signal"),
78            },
79            Self::Io(message) => write!(f, "{message}"),
80        }
81    }
82}
83
84/// The checkout `from` sits in, found by the file that defines the book.
85///
86/// `book.toml` rather than `Cargo.toml`: this workspace has sixteen
87/// manifests and exactly one book, so walking up for a manifest would
88/// stop at whichever crate the caller happened to be standing in.
89#[must_use]
90pub fn find_root(from: &Path) -> Option<PathBuf> {
91    let mut dir = from.to_path_buf();
92    loop {
93        if dir.join("book.toml").is_file() {
94            return Some(dir);
95        }
96        if !dir.pop() {
97            return None;
98        }
99    }
100}
101
102/// Cargo's target directory, read out of `cargo metadata` output.
103///
104/// A string scan rather than a JSON dependency: `target_directory` is a
105/// top-level string in a document this crate never otherwise parses, and
106/// the alternative is asking every consumer of this CLI to build serde's
107/// derive for one field.
108///
109/// Returns `None` when the field is absent, which is the honest answer
110/// for output this did not understand: the caller falls back rather
111/// than guessing a path and reporting somebody else's bug.
112#[must_use]
113pub fn target_dir_from_metadata(json: &str) -> Option<&str> {
114    const KEY: &str = "\"target_directory\":";
115    let rest = &json[json.find(KEY)? + KEY.len()..];
116    let rest = rest.trim_start();
117    let rest = rest.strip_prefix('"')?;
118    let end = rest.find('"')?;
119    Some(&rest[..end])
120}
121
122/// The landing page rustdoc does not write.
123///
124/// rustdoc emits a root `index.html` only when it has a single root
125/// crate to point at; documenting a workspace with `--no-deps` leaves
126/// that root empty, so the book's `/api/` link would land on nothing.
127///
128/// Styled from `theme/choir-tokens.css`, which the build copies in
129/// beside it, so this page and the book are the same colours rather than
130/// merely adjacent. It carries no script and no external request, which
131/// is the same rule the node's own pages follow.
132#[must_use]
133pub fn api_index(crates: &[String]) -> String {
134    let mut items = String::new();
135    for name in crates {
136        items.push_str(&format!(
137            "  <li><a href=\"{name}/index.html\"><code>{name}</code></a></li>\n"
138        ));
139    }
140    format!(
141        r#"<!doctype html>
142<html lang="en">
143<meta charset="utf-8">
144<meta name="viewport" content="width=device-width,initial-scale=1">
145<title>choir: API documentation</title>
146<link rel="stylesheet" href="choir-tokens.css">
147<style>
148  :root {{ font-size: 17px; }}
149  body {{ margin: 0; background: var(--ground); color: var(--ink);
150         font-family: var(--font-sans); letter-spacing: var(--tr-body);
151         line-height: var(--lh-copy); }}
152  main {{ max-width: var(--measure); margin: 0 auto;
153         padding: var(--sp-16) var(--sp-5); }}
154  h1 {{ font-size: var(--fs-900); line-height: var(--lh-tight);
155       letter-spacing: var(--tr-display); color: var(--strong);
156       margin: 0 0 var(--sp-3); }}
157  p.lede {{ font-size: var(--fs-600); color: var(--muted);
158           margin: 0 0 var(--sp-10); }}
159  a {{ color: var(--accent-ink); text-decoration-color: var(--accent-line);
160      text-underline-offset: .18em; }}
161  a:hover {{ color: var(--accent-hover); }}
162  ul {{ list-style: none; padding: 0; margin: 0; display: grid;
163       gap: var(--sp-2);
164       grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); }}
165  li a {{ display: block; background: var(--card); border: var(--border);
166         border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4);
167         text-decoration: none; color: var(--ink); }}
168  li a code {{ font-family: var(--font-mono); font-size: var(--fs-300); }}
169  li a:hover {{ border-color: var(--accent-line); background: var(--accent-tint);
170               color: var(--accent-ink); }}
171  footer {{ margin-top: var(--sp-12); padding-top: var(--sp-5);
172           border-top: var(--bw-hair) solid var(--line);
173           color: var(--faint); font-size: var(--fs-300); }}
174</style>
175<main>
176<h1>choir</h1>
177<p class="lede">API documentation, one page per crate. The prose that
178explains how they fit is in <a href="../index.html">the book</a>.</p>
179<ul>
180{items}</ul>
181<footer>Written by <code>choir docs</code>. Rebuild with
182<code>choir docs --open</code>.</footer>
183</main>
184</html>
185"#
186    )
187}
188
189/// The marker `docs/README.md` carries where a link to the node belongs.
190///
191/// An HTML comment, so a build that does not know the node's address
192/// renders nothing at all rather than a placeholder somebody has to
193/// explain. mdBook passes raw HTML through, and a comment is the one
194/// shape of it that is invisible until something replaces it.
195pub const NODE_LINK_MARKER: &str = "<!--node-link-->";
196
197/// Repoints the links that leave the book.
198///
199/// `docs/*.md` is read by three people (`book.toml` says so): somebody
200/// browsing the checkout, somebody reading the book, and `cargo doc`.
201/// The first and third want `../README.md`, because in a checkout that
202/// file is right there. The second gets a 404: mdBook renders `src` and
203/// nothing above it, so `../README.html` names a file the published
204/// artifact does not contain — twenty-one such links, from four pages.
205///
206/// Rather than making the pages worse for two readers to fix the third,
207/// the escaping links are repointed at the repository itself at publish
208/// time. `base` is a browsable tree URL (the workflow builds one from the
209/// commit it is publishing, so the link is pinned rather than tracking a
210/// branch), and it never appears in a tracked file — the same rule
211/// `site-url` follows.
212///
213/// `depth` is how many directories below the book root the page sits, so
214/// `index.html` is 0 and `using/workflow.html` is 1. A link escapes when
215/// it climbs further than that, and the first `..` past the book root is
216/// the step from `book/` to the checkout — which is why one is stripped
217/// before the rest becomes a repository path.
218///
219/// `.html` goes back to `.md` because mdBook rewrote it on the way in.
220/// Anything that was not a markdown link is left exactly as it was: a
221/// rewriter that guessed here would break the links that work.
222#[must_use]
223pub fn repoint_escaping_links(html: &str, depth: usize, base: &str) -> String {
224    let mut out = String::with_capacity(html.len());
225    let mut rest = html;
226    while let Some(at) = rest.find("href=\"") {
227        out.push_str(&rest[..at + 6]);
228        rest = &rest[at + 6..];
229        let Some(end) = rest.find('"') else { break };
230        let href = &rest[..end];
231        match repoint_one(href, depth, base) {
232            Some(moved) => out.push_str(&moved),
233            None => out.push_str(href),
234        }
235        rest = &rest[end..];
236    }
237    out.push_str(rest);
238    out
239}
240
241/// One `href`, or `None` when it stays where it is.
242fn repoint_one(href: &str, depth: usize, base: &str) -> Option<String> {
243    let (path, tail) = match href.find(['#', '?']) {
244        Some(at) => (&href[..at], &href[at..]),
245        None => (href, ""),
246    };
247    let climbs = path.split('/').take_while(|seg| *seg == "..").count();
248    // Not a climb, or a climb the book contains: nothing to do. The
249    // `depth + 1` is the book root itself — a page at depth 0 may climb
250    // once before it has left, and that once lands on the checkout.
251    if climbs == 0 || climbs <= depth || climbs > depth + 1 {
252        return None;
253    }
254    let target = path.split('/').skip(climbs).collect::<Vec<_>>().join("/");
255    let target = target.strip_suffix(".html")?;
256    Some(format!("{}/{target}.md{tail}", base.trim_end_matches('/')))
257}
258
259/// Puts a link to the node into the page that carries
260/// [`NODE_LINK_MARKER`].
261///
262/// The two halves of the site are on two hosts (D76), so neither can
263/// reach the other with a relative path and neither may name the other in
264/// a tracked file. The marker is tracked; the address is not.
265#[must_use]
266pub fn stamp_node_link(html: &str, node: &str) -> String {
267    let node = node.trim_end_matches('/');
268    html.replace(
269        NODE_LINK_MARKER,
270        &format!("<a class=\"api-link\" href=\"{node}\">The node itself &rarr;</a>"),
271    )
272}
273
274/// Applies both publish-time rewrites to every page mdBook rendered.
275///
276/// **`api/` is skipped by name**, not by running first. It is rustdoc's
277/// output, whose thousands of pages are full of `../` links that are
278/// already right; a rewriter with an opinion about them would be a
279/// second bug. Ordering alone does not skip them, because this runs
280/// against `book/` in place and a previous build's `api/` is still
281/// sitting there — the copy that replaces it happens further down.
282fn rewrite_pages(book: &Path, base: Option<&str>, node: Option<&str>) -> std::io::Result<()> {
283    fn walk(
284        dir: &Path,
285        depth: usize,
286        base: Option<&str>,
287        node: Option<&str>,
288    ) -> std::io::Result<()> {
289        for entry in std::fs::read_dir(dir)? {
290            let entry = entry?;
291            let path = entry.path();
292            if entry.file_type()?.is_dir() {
293                if depth == 0 && entry.file_name() == "api" {
294                    continue;
295                }
296                walk(&path, depth + 1, base, node)?;
297                continue;
298            }
299            if path.extension().is_none_or(|ext| ext != "html") {
300                continue;
301            }
302            let before = std::fs::read_to_string(&path)?;
303            let mut after = match base {
304                Some(base) => repoint_escaping_links(&before, depth, base),
305                None => before.clone(),
306            };
307            if let Some(node) = node {
308                after = stamp_node_link(&after, node);
309            }
310            if after != before {
311                std::fs::write(&path, after)?;
312            }
313        }
314        Ok(())
315    }
316    walk(book, 0, base, node)
317}
318
319fn tool_exists(tool: &str) -> bool {
320    std::process::Command::new(tool)
321        .arg("--version")
322        .stdout(std::process::Stdio::null())
323        .stderr(std::process::Stdio::null())
324        .status()
325        .is_ok_and(|s| s.success())
326}
327
328/// Runs one stage, letting its output reach the terminal.
329fn stage(name: &'static str, mut command: std::process::Command) -> Result<(), Failure> {
330    let status = command
331        .status()
332        .map_err(|e| Failure::Io(format!("cannot run {name}: {e}")))?;
333    if status.success() {
334        return Ok(());
335    }
336    Err(Failure::Stage {
337        name,
338        code: status.code(),
339    })
340}
341
342/// Copies a directory's *contents* into `dst`.
343///
344/// Contents rather than the directory itself, which is the difference
345/// between `book/api/index.html` and `book/api/doc/index.html`.
346fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
347    std::fs::create_dir_all(dst)?;
348    for entry in std::fs::read_dir(src)? {
349        let entry = entry?;
350        let target = dst.join(entry.file_name());
351        if entry.file_type()?.is_dir() {
352            copy_tree(&entry.path(), &target)?;
353        } else {
354            std::fs::copy(entry.path(), &target)?;
355        }
356    }
357    Ok(())
358}
359
360/// Builds the book and the API documentation inside it.
361///
362/// # Errors
363///
364/// [`Failure`], which is written for a person to read: a missing tool
365/// names the command that installs it, and a stage that failed has
366/// already put its own errors on the terminal.
367pub fn build(root: &Path) -> Result<Built, Failure> {
368    if !tool_exists("cargo") {
369        return Err(Failure::MissingTool {
370            tool: "cargo",
371            install: "install Rust from https://rustup.rs/",
372        });
373    }
374    if !tool_exists("mdbook") {
375        return Err(Failure::MissingTool {
376            tool: "mdbook",
377            install: "cargo install mdbook --locked",
378        });
379    }
380
381    // The same flags the gate's rustdoc stage uses. A doc build that
382    // warns here and is denied there is a difference nobody wants to
383    // find at commit time, and `docs/` is compiled by both.
384    let mut doc = std::process::Command::new("cargo");
385    doc.current_dir(root)
386        .env("RUSTDOCFLAGS", "-Dwarnings")
387        .args(["doc", "--workspace", "--no-deps"]);
388    stage("cargo doc", doc)?;
389
390    let mut book = std::process::Command::new("mdbook");
391    book.current_dir(root).arg("build");
392    stage("mdbook build", book)?;
393
394    // The two publish-time rewrites (D76). Both come from the
395    // environment for the reason `site-url` does: they name hosts, and a
396    // host in a tracked file is the thing this repository does not do.
397    // Absent, which is every local build, the pages are left exactly as
398    // mdBook rendered them.
399    let base = std::env::var("CHOIR_DOCS_REPO_BASE").ok();
400    let node = std::env::var("CHOIR_DOCS_NODE_URL").ok();
401    rewrite_pages(
402        &root.join("book"),
403        base.as_deref().filter(|s| !s.is_empty()),
404        node.as_deref().filter(|s| !s.is_empty()),
405    )
406    .map_err(|e| Failure::Io(format!("cannot rewrite the book's links: {e}")))?;
407
408    let metadata = std::process::Command::new("cargo")
409        .current_dir(root)
410        .args(["metadata", "--format-version", "1", "--no-deps"])
411        .output()
412        .map_err(|e| Failure::Io(format!("cannot run cargo metadata: {e}")))?;
413    let metadata = String::from_utf8_lossy(&metadata.stdout);
414    let target = target_dir_from_metadata(&metadata)
415        .map(PathBuf::from)
416        .or_else(|| std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from))
417        .unwrap_or_else(|| root.join("target"));
418
419    let from = target.join("doc");
420    if !from.is_dir() {
421        return Err(Failure::Io(format!(
422            "cargo doc reported success but {} does not exist\n\
423             cargo says its target directory is: {}",
424            from.display(),
425            target.display()
426        )));
427    }
428
429    let book_dir = root.join("book");
430    let api = book_dir.join("api");
431    if let Err(e) = std::fs::remove_dir_all(&api) {
432        if e.kind() != std::io::ErrorKind::NotFound {
433            return Err(Failure::Io(format!("cannot clear {}: {e}", api.display())));
434        }
435    }
436    copy_tree(&from, &api).map_err(|e| {
437        Failure::Io(format!(
438            "cannot copy {} to {}: {e}",
439            from.display(),
440            api.display()
441        ))
442    })?;
443
444    // Every directory rustdoc produced a page for. Binary targets are in
445    // this list because they are real documentation pages, not because
446    // it failed to filter them out.
447    let mut crates = Vec::new();
448    if let Ok(entries) = std::fs::read_dir(&api) {
449        for entry in entries.flatten() {
450            if entry.path().join("index.html").is_file() {
451                crates.push(entry.file_name().to_string_lossy().into_owned());
452            }
453        }
454    }
455    crates.sort();
456
457    // Only when rustdoc did not write one: if a future cargo starts
458    // emitting a workspace index, its page is better than this one.
459    let index = api.join("index.html");
460    if !index.is_file() {
461        let tokens = root.join("theme/choir-tokens.css");
462        std::fs::copy(&tokens, api.join("choir-tokens.css"))
463            .map_err(|e| Failure::Io(format!("cannot copy {}: {e}", tokens.display())))?;
464        std::fs::write(&index, api_index(&crates))
465            .map_err(|e| Failure::Io(format!("cannot write {}: {e}", index.display())))?;
466    }
467
468    Ok(Built {
469        root: root.to_path_buf(),
470        book: book_dir,
471        api,
472        crates,
473    })
474}
475
476/// Opens a built page in whatever the desktop uses, if anything does.
477///
478/// Best-effort on purpose: a build that succeeded and could not open a
479/// browser has still built the documentation, and reporting it as a
480/// failure would make `--open` less reliable than not passing it.
481pub fn open(path: &Path) -> bool {
482    for opener in ["open", "xdg-open"] {
483        let opened = std::process::Command::new(opener)
484            .arg(path)
485            .stdout(std::process::Stdio::null())
486            .stderr(std::process::Stdio::null())
487            .status()
488            .is_ok_and(|s| s.success());
489        if opened {
490            return true;
491        }
492    }
493    false
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    #[test]
501    fn target_directory_is_read_out_of_cargos_own_shape() {
502        // The shape `cargo metadata --format-version 1` emits: one line,
503        // compact, the field among many others.
504        let json = r#"{"packages":[],"workspace_members":[],"resolve":null,
505            "target_directory":"/w/shared-target","version":1,"workspace_root":"/w"}"#;
506        assert_eq!(
507            target_dir_from_metadata(json),
508            Some("/w/shared-target"),
509            "the field cargo actually emits must be readable"
510        );
511    }
512
513    #[test]
514    fn unreadable_metadata_is_none_rather_than_a_guess() {
515        assert_eq!(target_dir_from_metadata("{}"), None);
516        assert_eq!(target_dir_from_metadata(""), None);
517        // Truncated mid-value: a scan that returned the rest of the
518        // buffer would produce a path, and a wrong path is worse here
519        // than no answer, because the caller has a correct fallback.
520        assert_eq!(target_dir_from_metadata(r#"{"target_directory":"/w"#), None);
521    }
522
523    #[test]
524    fn the_root_is_the_directory_holding_the_book() {
525        let dir = std::env::temp_dir().join("choir-docs-find-root");
526        let deep = dir.join("crates/choir-cli/src");
527        std::fs::create_dir_all(&deep).expect("scratch tree");
528        std::fs::write(dir.join("book.toml"), "").expect("book.toml");
529        // A manifest in between must not stop the walk: this workspace
530        // has fifteen of them and one book.
531        std::fs::write(dir.join("crates/choir-cli/Cargo.toml"), "").expect("manifest");
532
533        assert_eq!(find_root(&deep).as_deref(), Some(dir.as_path()));
534        std::fs::remove_dir_all(&dir).ok();
535    }
536
537    #[test]
538    fn a_tree_with_no_book_has_no_root() {
539        let dir = std::env::temp_dir().join("choir-docs-no-book");
540        std::fs::create_dir_all(&dir).expect("scratch tree");
541        // `/` has no book.toml either, so the walk ends at None rather
542        // than looping.
543        assert!(find_root(&dir).is_none());
544        std::fs::remove_dir_all(&dir).ok();
545    }
546
547    #[test]
548    fn the_index_links_every_crate_and_nothing_else() {
549        let html = api_index(&["choir_node".to_string(), "choir_view".to_string()]);
550        assert!(html.contains("href=\"choir_node/index.html\""));
551        assert!(html.contains("href=\"choir_view/index.html\""));
552        assert!(
553            html.contains("href=\"../index.html\""),
554            "no way back to the book"
555        );
556        assert!(
557            html.contains("choir-tokens.css"),
558            "the index must use the book's palette"
559        );
560        // The rule the node's own pages follow, and the reason this page
561        // works from a file:// URL with no network.
562        assert!(!html.contains("<script"), "the index must run nothing");
563        assert!(!html.contains("http://"), "no external request");
564    }
565
566    #[test]
567    fn an_empty_workspace_still_renders_a_page() {
568        let html = api_index(&[]);
569        assert!(html.contains("<ul>"), "the list is still well-formed");
570        assert!(html.contains("</html>"));
571    }
572
573    const BASE: &str = "https://forge.invalid/o/choir/blob/abc123";
574
575    /// The four shapes the book actually contains, taken from the pages
576    /// that were broken: a climb from the root, a climb from one level
577    /// down, one with a fragment, and one that stays inside.
578    #[test]
579    fn a_link_that_leaves_the_book_is_repointed_at_the_repository() {
580        assert_eq!(
581            repoint_one("../DECISIONS.html", 0, BASE).as_deref(),
582            Some("https://forge.invalid/o/choir/blob/abc123/DECISIONS.md")
583        );
584        assert_eq!(
585            repoint_one("../../templates/README.html", 1, BASE).as_deref(),
586            Some("https://forge.invalid/o/choir/blob/abc123/templates/README.md")
587        );
588        assert_eq!(
589            repoint_one("../ERRORS.html#e_no_such_ref", 0, BASE).as_deref(),
590            Some("https://forge.invalid/o/choir/blob/abc123/ERRORS.md#e_no_such_ref")
591        );
592    }
593
594    /// A link inside the book is the common case and must not move. The
595    /// second of these is the one that would break a whole chapter: from
596    /// `using/workflow.html`, `../architecture.html` is a page the book
597    /// does contain.
598    #[test]
599    fn a_link_that_stays_inside_the_book_is_left_alone() {
600        assert_eq!(repoint_one("architecture.html", 0, BASE), None);
601        assert_eq!(repoint_one("../architecture.html", 1, BASE), None);
602        assert_eq!(repoint_one("operating/limits.html", 0, BASE), None);
603        assert_eq!(repoint_one("#start-here", 0, BASE), None);
604        assert_eq!(repoint_one("https://example.invalid/x", 0, BASE), None);
605    }
606
607    /// A climb past the checkout is not a repository path, and guessing
608    /// one would be a link to somewhere nobody named.
609    #[test]
610    fn a_link_that_leaves_the_checkout_too_is_left_alone() {
611        assert_eq!(repoint_one("../../../elsewhere.html", 0, BASE), None);
612    }
613
614    /// Only what mdBook rewrote on the way in comes back. A stylesheet or
615    /// an image that climbs is not a markdown page.
616    #[test]
617    fn only_a_page_mdbook_renamed_is_renamed_back() {
618        assert_eq!(repoint_one("../theme/custom.css", 0, BASE), None);
619    }
620
621    #[test]
622    fn the_rewrite_touches_hrefs_and_nothing_else() {
623        let html = "<p>see ../DECISIONS.html</p><a href=\"../DECISIONS.html\">x</a>";
624        let out = repoint_escaping_links(html, 0, BASE);
625        assert!(
626            out.contains("<p>see ../DECISIONS.html</p>"),
627            "prose was rewritten: {out}"
628        );
629        assert!(
630            out.contains("href=\"https://forge.invalid/o/choir/blob/abc123/DECISIONS.md\""),
631            "the link was not rewritten: {out}"
632        );
633    }
634
635    #[test]
636    fn the_node_link_replaces_its_marker_and_nothing_when_unmarked() {
637        let stamped = stamp_node_link(&format!("<p>{NODE_LINK_MARKER}</p>"), "https://n.invalid/");
638        assert!(
639            stamped.contains("href=\"https://n.invalid\""),
640            "the trailing slash survived: {stamped}"
641        );
642        assert!(!stamped.contains(NODE_LINK_MARKER));
643        // A page without the marker is returned unchanged, which is what
644        // makes it safe to run over every page in the book.
645        assert_eq!(stamp_node_link("<p>x</p>", "https://n.invalid"), "<p>x</p>");
646    }
647}