Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

xtask/src/docsbook.rs

//! `cargo xtask docs` — the `docs/*.pdf` long form, assembled from `.nornir/`.
//!
//! # Why this exists
//!
//! `nornir docs export <repo>` renders exactly one document: the repo's
//! generated `README.md`. That is the right front page and the wrong manual —
//! the front page is 39 lines and the material behind it is 860 KB of markdown
//! in `.nornir/`. `nornir docs book` is the verb that would assemble the whole
//! set, and it hangs, so it is not used here and must not be reintroduced
//! (`release/docs-nornir.toml` carries the same warning).
//!
//! So this task assembles the chapters itself and then feeds the result back
//! through the *supported* verb. It stages a throwaway tree that looks like a
//! repo to nornir — `Cargo.toml` for the title and version, `release/docs-nornir.toml`
//! for the config, `.nornir/README.md` holding the assembled book, `assets/` for
//! the images — and runs `nornir docs export` against that. The renderer is
//! nornir's, unmodified; only the choice and order of chapters is ours.
//!
//! # A generated document that cannot be regenerated is a liability
//!
//! Nothing here is hand-edited output. [`BOOKS`] is the whole editorial
//! decision: which `.nornir/*.md` file goes in which book, in what order. Rerun
//! `cargo xtask docs` after any change to those sources and the PDFs follow.
//!
//! # The checks are the point
//!
//! A PDF with a cover page and no chapters is still a valid PDF, and it commits
//! quietly. [`verify_pdf`] refuses one: it asserts the header, the `%%EOF`
//! trailer, a page count above the cover-plus-contents floor, and one
//! `/Subtype /Image` XObject for every **raster** the assembled markdown
//! referenced.
//!
//! Rasters and SVGs need different evidence, and conflating them produces a
//! false red — this module's own first run failed that way. typst embeds a
//! raster as an image XObject, but it *inlines* an SVG as vector drawing
//! operators, so a document with a correctly rendered SVG and no raster has
//! **zero** image XObjects. [`raster_targets`] is therefore what
//! [`verify_pdf`] counts against.
//!
//! What covers the SVG is the pair of checks in front of it, both red-proven on
//! the desktop 2026-08-04 against this repo's own `gunnar-znippy-cycle.svg`:
//!
//! * [`smudge_check`] runs before typst. Any staged asset whose first bytes are
//!   `version https://git-lfs.github.com/spec/v1` is an unsmudged pointer, and
//!   the run stops there rather than embedding it.
//! * typst refuses the pointer on its own if the check is removed — as a
//!   `.webp` it is `Format error decoding WebP: Invalid Chunk header`, as an
//!   `.svg` it is `failed to parse SVG (unknown token at 1:1)`. Either way the
//!   compile fails and no PDF is written.
//!
//! So a pointer cannot reach a PDF, but what this module does **not** assert is
//! that a *well-formed* SVG drew what it was supposed to draw. That would need
//! the content streams inflated, and `xtask` has no zlib because it has no
//! dependencies. Stated rather than papered over — and answered outside the
//! crate instead, because typst turns an SVG's `<text>` into real PDF glyphs,
//! so a phrase that exists only inside a generated SVG is proof the SVG drew:
//!
//! ```text
//! python3 -c 'import pypdf,sys
//! t="".join(p.extract_text() or "" for p in pypdf.PdfReader(sys.argv[1]).pages[:12])
//! print([(s, s in t) for s in ["FORGEJO IS NOT A THIRD ENGINE","NEVER EMITTED HERE",
//!                              "THIS STRING IS NOWHERE IN ANY SVG"]])' docs/gunnar-manual.pdf
//! ```
//!
//! Run on the desktop 2026-08-04 against both books: the first two `True`, the third
//! `False`. The negative control is the point — a probe that cannot say no is
//! not a probe.
//!
//! # Zero dependencies
//!
//! `xtask` has none by construction (`xtask/Cargo.toml`), so the assembly, the
//! staging and the PDF inspection are `std` alone, and `nornir` is invoked as a
//! tool: repository tooling is invoked as a tool, never as a dependency.

use std::collections::BTreeSet;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

/// The Git-LFS pointer preamble. A staged asset starting with this is a pointer
/// the working tree never smudged, not the image it stands for.
pub const LFS_POINTER_MAGIC: &str = "version https://git-lfs.github.com/spec/v1";

/// One chapter: a source under `.nornir/` and the title it gets in the book.
///
/// The title is carried here rather than taken from the file's own first
/// heading because several sources open with a heading that reads as a section
/// of a larger whole ("Defect record", "Stomach") and the table of contents is the one
/// place a reader has to be able to navigate from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Chapter {
    /// Path under `.nornir/`, e.g. `design.md`.
    pub source: &'static str,
    /// Chapter title in the assembled book.
    pub title: &'static str,
}

impl Chapter {
    const fn new(source: &'static str, title: &'static str) -> Self {
        Self { source, title }
    }
}

/// One output PDF.
#[derive(Debug, Clone, Copy)]
pub struct Book {
    /// `cargo xtask docs <name>` selects one book; no argument builds all.
    pub name: &'static str,
    /// Output path relative to the repo root.
    pub out: &'static str,
    /// Title on the cover page.
    pub title: &'static str,
    /// One paragraph under the title, plain.
    pub subtitle: &'static str,
    pub chapters: &'static [Chapter],
}

/// The editorial decision, in full.
///
/// Two books rather than one because the two halves are read for different
/// reasons and only one of them is a manual: `manual` is how gunnar is built
/// and why it is shaped the way it is; `record` is the decision log and the
/// open audit, which is a reference and not a read-through.
///
/// Both open on `README.md` deliberately. The three deployment shapes with
/// where each was measured, the sentence that no physical hardware has run any
/// of it, and the list of what is built but not wired are the spine of every
/// document here — and they are *numbers*, which means there must be exactly
/// one copy of them. Restating them on a cover page would be a second copy free
/// to drift (one implementation, not two). The chapter is the same `.nornir/README.md` the front page
/// is generated from.
pub const BOOKS: &[Book] = &[
    Book {
        name: "guide",
        out: "docs/gunnar-fast-setup.pdf",
        title: "Gunnar \u{2014} fast setup",
        subtitle: "Four lists of commands, one picture per setup: one server, the console beside \
                   it, a twin and the two flips, building gunnar. The same \
                   `.nornir/gunnar-quickstart.md` the README links, burned by `cargo xtask docs`.",
        chapters: &[Chapter::new("gunnar-quickstart.md", "The guide")],
    },
    Book {
        name: "manual",
        out: "docs/gunnar-manual.pdf",
        title: "Gunnar — the manual",
        subtitle: "The design: what the server is, how it is deployed, what it stores and how it \
                   speaks. Assembled from `.nornir/` by `cargo xtask docs`.",
        chapters: &[
            Chapter::new(
                "README.md",
                "What this is, and where each number was measured",
            ),
            Chapter::new(
                "detail.md",
                "The reference: every number and where it was measured",
            ),
            Chapter::new("gunnar-quickstart.md", "The guide"),
            // ── THREE CHAPTERS REMOVED 2026-09-10 ───────────────────────────
            //
            // `forge-bakeoff-benchmarks.md`, `design.md` and `deployment.md`
            // are the design record, and the design record left this tree — see
            // [`NOT_IN_THIS_TREE`]. `.nornir/` here is exactly the five things
            // `xtask/src/publish.rs`'s `KEPT` names, and `detail.md`, which was
            // never in this list at all, is one of them: the reference page the
            // front page links. So the manual is now its three pages, and all
            // three open.
            // ── EIGHT CHAPTERS REMOVED 2026-08-13 ───────────────────────────
            //
            // `appliance-design.md`, `wire-design.md`,
            // `transport-encoding-design.md`, `storage-reasoning.md`,
            // `write-read-cycle.md`, `cold-tier-decision.md`,
            // `policy-design.md` and `real-metal-checklist.md` were all named
            // here and NONE of them is in `.nornir/`. `appliance-design.md`
            // went in `abfe43cd`; the rest are older.
            //
            // The book therefore could not render at all, and its guard had
            // been red for so long that it read as background noise. A manual
            // that lists chapters nobody can open is not a smaller manual, it
            // is a broken one — so the list is now exactly what is on disk, and
            // the guard is the thing that keeps it that way.
            //
            // Restore any of them by writing the file; the guard will tell you
            // the moment a name and a file disagree again.
        ],
    },
];

/// Books this tree **cannot** assemble, and the reason, so `cargo xtask docs
/// <name>` refuses BY NAME.
///
/// `.nornir/` in this repository is exactly the five things
/// `xtask/src/publish.rs`'s `KEPT` names: the front page's source, the
/// reference page it links, the build-variant declaration, the operator's quick
/// start, and the image assets. The design record itself — the triage
/// documents, the decision log, the audit — is not here.
///
/// `record` was nothing BUT those documents (`performance-triage.md`,
/// `znippy-fast-and-robust.md`, `best-results-benchmarks.md`,
/// `defect-record.md`), so leaving it in [`BOOKS`] made
/// [`tests::every_book_chapter_exists_on_disk`] permanently red and made
/// `cargo xtask docs` fail on its second book. Deleting the name outright would
/// be worse in the one way that matters: an operator who types `cargo xtask
/// docs record` would be told "no book named `record`", which is a lie about
/// WHY. This list is how the verb says the true thing instead.
///
/// A name belongs here only while its sources really are absent —
/// [`tests::a_book_named_absent_is_actually_absent`] holds that.
pub const NOT_IN_THIS_TREE: &[(&str, &str, &[&str])] = &[(
    "record",
    "its chapters are the design record, and this tree carries only the five \
     `.nornir/` files that are product; assemble it where the record lives",
    &[
        "performance-triage.md",
        "znippy-fast-and-robust.md",
        "best-results-benchmarks.md",
        "defect-record.md",
    ],
)];

/// What a build of one book produced, for the caller to print.
#[derive(Debug, Clone)]
pub struct BookReport {
    pub name: String,
    pub out: PathBuf,
    pub bytes: usize,
    pub pages: usize,
    pub images: usize,
    pub chapters: usize,
    pub markdown_bytes: usize,
}

/// The repo root, from `xtask/`'s own manifest dir.
pub fn workspace_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .expect("xtask/ has a parent")
        .to_path_buf()
}

/// `cargo xtask docs [name]`.
///
/// With no name, every book in [`BOOKS`]. Returns one report per book built.
pub fn run(args: &[String]) -> Result<Vec<BookReport>, String> {
    let root = workspace_root();
    let wanted: Vec<&Book> = match args.first() {
        None => BOOKS.iter().collect(),
        Some(name) => {
            // Refuse BY NAME before "no such book": a book this tree cannot
            // assemble is a different fact from a typo, and the operator needs
            // the difference. See [`NOT_IN_THIS_TREE`].
            if let Some((_, why, sources)) = NOT_IN_THIS_TREE.iter().find(|(n, _, _)| *n == name) {
                return Err(format!(
                    "`{name}` cannot be assembled in this tree: {why}. Its chapters are {}",
                    sources.join(", ")
                ));
            }
            let hit: Vec<&Book> = BOOKS.iter().filter(|b| b.name == name).collect();
            if hit.is_empty() {
                let names: Vec<&str> = BOOKS.iter().map(|b| b.name).collect();
                return Err(format!(
                    "no book named `{name}`; have: {}",
                    names.join(", ")
                ));
            }
            hit
        }
    };
    let mut out = Vec::with_capacity(wanted.len());
    for book in wanted {
        out.push(build(&root, book)?);
    }
    Ok(out)
}

/// Assemble, render and verify one book.
pub fn build(root: &Path, book: &Book) -> Result<BookReport, String> {
    let markdown = assemble(root, book)?;
    let stage = root.join("target/docsbook").join(book.name).join("gunnar");
    stage_tree(root, &stage, &markdown)?;

    let images = image_targets(&markdown);
    smudge_check(&stage, &images)?;

    let pdf = render(&stage)?;
    verify_pdf(&pdf, raster_targets(&images).len(), MIN_PAGES)?;

    let out = root.join(book.out);
    if let Some(parent) = out.parent() {
        fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?;
    }
    fs::write(&out, &pdf).map_err(|e| format!("write {}: {e}", out.display()))?;

    Ok(BookReport {
        name: book.name.to_string(),
        out,
        bytes: pdf.len(),
        pages: page_count(&pdf),
        images: image_xobjects(&pdf),
        chapters: book.chapters.len(),
        markdown_bytes: markdown.len(),
    })
}

/// A cover page and a contents page are produced whether or not a single
/// chapter made it in, so anything at or below this floor is an empty book.
pub const MIN_PAGES: usize = 3;

// ════════════════════════════════════════════════════════════════════════════
// Assembly
// ════════════════════════════════════════════════════════════════════════════

/// Concatenate the chapters into one markdown document.
///
/// Every chapter's headings are demoted one level so the chapter title is the
/// only `#` in its span and nornir's table of contents nests correctly. The
/// demotion is skipped inside fenced code blocks, where a leading `#` is a
/// comment and not a heading — getting that wrong turns every shell comment in
/// the sources into a chapter.
pub fn assemble(root: &Path, book: &Book) -> Result<String, String> {
    let mut out = String::with_capacity(1 << 20);
    let _ = writeln!(out, "<!-- nornir:class: readme -->");
    let _ = writeln!(out);
    let _ = writeln!(out, "# {}", book.title);
    let _ = writeln!(out);
    let _ = writeln!(out, "{}", book.subtitle);
    let _ = writeln!(out);
    let _ = writeln!(
        out,
        "Sources: {}. Regenerate with `cargo xtask docs {}`.",
        book.chapters
            .iter()
            .map(|c| format!("`.nornir/{}`", c.source))
            .collect::<Vec<_>>()
            .join(", "),
        book.name
    );
    let _ = writeln!(out);

    for chapter in book.chapters {
        let path = root.join(".nornir").join(chapter.source);
        let body =
            fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
        if body.trim().is_empty() {
            return Err(format!(
                "{} is empty — refusing to ship a blank chapter",
                path.display()
            ));
        }
        let _ = writeln!(out, "# {}", chapter.title);
        let _ = writeln!(out);
        let _ = writeln!(out, "{}", demote_headings(&body));
        let _ = writeln!(out);
    }
    Ok(out)
}

/// Demote every ATX heading by one level, outside fenced code blocks.
///
/// `######` is already the deepest level markdown has, so it stays put rather
/// than becoming a seventh level that renders as literal `#######`.
pub fn demote_headings(md: &str) -> String {
    let mut out = String::with_capacity(md.len() + md.len() / 32);
    let mut fence: Option<String> = None;
    for line in md.lines() {
        let trimmed = line.trim_start();
        match &fence {
            Some(open) => {
                if trimmed.starts_with(open.as_str()) {
                    fence = None;
                }
            }
            None => {
                if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
                    let marker: String = trimmed
                        .chars()
                        .take_while(|c| *c == '`' || *c == '~')
                        .collect();
                    fence = Some(marker);
                } else if trimmed.starts_with('#') {
                    let hashes = trimmed.chars().take_while(|c| *c == '#').count();
                    let is_heading = trimmed
                        .chars()
                        .nth(hashes)
                        .map(|c| c == ' ' || c == '\t')
                        .unwrap_or(false);
                    if is_heading && hashes < 6 {
                        let indent = &line[..line.len() - trimmed.len()];
                        out.push_str(indent);
                        out.push('#');
                        out.push_str(trimmed);
                        out.push('\n');
                        continue;
                    }
                }
            }
        }
        out.push_str(line);
        out.push('\n');
    }
    out
}

/// Every image target the assembled markdown references, in source order,
/// deduplicated. Only local paths — an `http(s)://` target is not staged and
/// typst is not asked to fetch it.
pub fn image_targets(md: &str) -> Vec<String> {
    let mut seen = BTreeSet::new();
    let mut out = Vec::new();
    let bytes: Vec<char> = md.chars().collect();
    let mut i = 0;
    // Inline code spans are PROSE ABOUT markdown, not markdown. `.nornir/README.md`
    // explains the logo convention with a literal `![alt](target)` inside
    // backticks, and this scanner read it as a reference to a file called
    // `target` — so the guard demanded a `target` image, which no repository
    // will ever have. Measured 2026-08-13.
    //
    // Tracked with a toggle rather than by stripping the text first, so the
    // reported source offsets stay the offsets of the real document.
    let mut in_code = false;
    while i + 1 < bytes.len() {
        if bytes[i] == '`' {
            in_code = !in_code;
            i += 1;
            continue;
        }
        if in_code {
            i += 1;
            continue;
        }
        if bytes[i] == '!' && bytes[i + 1] == '[' {
            // Skip the alt text to its closing bracket.
            let mut j = i + 2;
            while j < bytes.len() && bytes[j] != ']' {
                j += 1;
            }
            if j + 1 < bytes.len() && bytes[j + 1] == '(' {
                let mut k = j + 2;
                let mut target = String::new();
                while k < bytes.len() && bytes[k] != ')' {
                    target.push(bytes[k]);
                    k += 1;
                }
                let target = target.split_whitespace().next().unwrap_or("").to_string();
                if !target.is_empty()
                    && !target.starts_with("http://")
                    && !target.starts_with("https://")
                    && seen.insert(target.clone())
                {
                    out.push(target);
                }
                i = k;
                continue;
            }
        }
        i += 1;
    }
    out
}

/// Every local `<img src="…">` target in a source, in source order,
/// deduplicated.
///
/// Separate from [`image_targets`] and deliberately NOT folded into it: that
/// list drives staging and the XObject count, and typst does not render raw
/// HTML, so an `<img>` counted there would be an XObject the PDF is right not
/// to have. This list exists for the one guard that asks a different question —
/// does the path an author wrote actually resolve? — and both figures this
/// repository ships are embedded with `<img>` rather than `![…](…)`, because
/// both need a width attribute markdown has no syntax for. Before this
/// existed, [`tests::every_referenced_image_resolves`] saw NO images at all in
/// the pages that remain and its own blindness assert was the only thing
/// standing between it and passing on nothing.
pub fn html_image_targets(md: &str) -> Vec<String> {
    let mut seen = BTreeSet::new();
    let mut out = Vec::new();
    for (i, _) in md.match_indices("<img ") {
        // Bounded to THIS tag: an `<img>` with no `src` must not reach forward
        // and claim the next one's.
        let tag = &md[i..];
        let tag = &tag[..tag.find('>').map(|e| e + 1).unwrap_or(tag.len())];
        let Some((_, rest)) = tag.split_once("src=\"") else {
            continue;
        };
        let Some((target, _)) = rest.split_once('"') else {
            continue;
        };
        if !target.is_empty()
            && !target.starts_with("http://")
            && !target.starts_with("https://")
            && seen.insert(target.to_string())
        {
            out.push(target.to_string());
        }
    }
    out
}

/// The subset of [`image_targets`] typst turns into a PDF image XObject.
///
/// Everything else it inlines as vector operators — an SVG most of all — which
/// is why the XObject count is checked against this list and not against every
/// image reference.
pub fn raster_targets(images: &[String]) -> Vec<String> {
    const RASTER: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff"];
    images
        .iter()
        .filter(|t| {
            let lower = t.to_ascii_lowercase();
            RASTER.iter().any(|ext| lower.ends_with(ext))
        })
        .cloned()
        .collect()
}

// ════════════════════════════════════════════════════════════════════════════
// Staging
// ════════════════════════════════════════════════════════════════════════════

/// Build the throwaway tree `nornir docs export` will be pointed at.
///
/// `.nornir/assets/` is flattened into the staged `assets/` alongside the repo
/// root's own, because the chapters were written to sit in `.nornir/` and refer
/// to `assets/…` from there, while the assembled book sits at the staged root.
fn stage_tree(root: &Path, stage: &Path, markdown: &str) -> Result<(), String> {
    let _ = fs::remove_dir_all(stage);
    for sub in [".nornir", "release", "assets"] {
        let d = stage.join(sub);
        fs::create_dir_all(&d).map_err(|e| format!("create {}: {e}", d.display()))?;
    }
    copy(&root.join("Cargo.toml"), &stage.join("Cargo.toml"))?;
    fs::write(stage.join("release/nornir.toml"), STAGE_CONFIG)
        .map_err(|e| format!("write staged nornir.toml: {e}"))?;
    fs::write(stage.join(".nornir/README.md"), markdown)
        .map_err(|e| format!("write staged README source: {e}"))?;
    for dir in [root.join("assets"), root.join(".nornir/assets")] {
        copy_files(&dir, &stage.join("assets"))?;
    }
    Ok(())
}

/// The staged tree's own nornir config. `[repo.gunnar]` resolves to
/// `<workspace_root>/gunnar`, and `workspace_root` is the config's
/// grandparent — which is why the staging dir is named `gunnar`.
const STAGE_CONFIG: &str = "\
# GENERATED by `cargo xtask docs` (xtask/src/docsbook.rs). Throwaway.
[repo.gunnar]
private = true
";

fn copy(from: &Path, to: &Path) -> Result<(), String> {
    fs::copy(from, to)
        .map(|_| ())
        .map_err(|e| format!("copy {} -> {}: {e}", from.display(), to.display()))
}

/// Copy the regular files of `from` into `to`. A missing source dir is not an
/// error: not every repo carries both asset roots.
fn copy_files(from: &Path, to: &Path) -> Result<(), String> {
    let entries = match fs::read_dir(from) {
        Ok(e) => e,
        Err(_) => return Ok(()),
    };
    for entry in entries {
        let entry = entry.map_err(|e| format!("read {}: {e}", from.display()))?;
        if entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
            copy(&entry.path(), &to.join(entry.file_name()))?;
        }
    }
    Ok(())
}

/// Refuse to render while any referenced asset is an unsmudged Git-LFS pointer.
///
/// This runs against the *staged* copies, which is the tree typst will read.
pub fn smudge_check(stage: &Path, images: &[String]) -> Result<(), String> {
    for target in images {
        let path = stage.join(target);
        let meta = fs::metadata(&path)
            .map_err(|e| format!("referenced image {} is not staged: {e}", path.display()))?;
        if meta.len() == 0 {
            return Err(format!("referenced image {} is empty", path.display()));
        }
        let head = read_head(&path, LFS_POINTER_MAGIC.len())?;
        if head == LFS_POINTER_MAGIC.as_bytes() {
            return Err(format!(
                "{} is an unsmudged Git-LFS pointer ({} bytes), not an image — run `git lfs pull` \
                 before rendering",
                path.display(),
                meta.len()
            ));
        }
    }
    Ok(())
}

fn read_head(path: &Path, n: usize) -> Result<Vec<u8>, String> {
    use std::io::Read as _;
    let mut f = fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;
    let mut buf = vec![0u8; n];
    let mut filled = 0;
    while filled < n {
        match f.read(&mut buf[filled..]) {
            Ok(0) => break,
            Ok(k) => filled += k,
            Err(e) => return Err(format!("read {}: {e}", path.display())),
        }
    }
    buf.truncate(filled);
    Ok(buf)
}

// ════════════════════════════════════════════════════════════════════════════
// Render
// ════════════════════════════════════════════════════════════════════════════

/// Environment override for the renderer binary.
///
/// `~/.cargo/bin/nornir` is an installed copy and has been months behind the
/// source before now, so a run that has to be current points this at a
/// freshly built `target/release/nornir` rather than trusting `PATH`.
pub const NORNIR_BIN_ENV: &str = "GUNNAR_NORNIR_BIN";

/// Run `nornir docs export` against the staged tree and read back the bytes.
///
/// `docs export` — never `docs book`, which hangs.
fn render(stage: &Path) -> Result<Vec<u8>, String> {
    let config = stage.join("release/nornir.toml");
    let bin = std::env::var(NORNIR_BIN_ENV).unwrap_or_else(|_| "nornir".to_string());
    let output = Command::new(&bin)
        .arg("docs")
        .arg("export")
        .arg("gunnar")
        .arg("--config")
        .arg(&config)
        .arg("--format")
        .arg("pdf")
        .current_dir(stage)
        .output()
        .map_err(|e| {
            format!("run `{bin} docs export`: {e} (is nornir on PATH, or {NORNIR_BIN_ENV} set?)")
        })?;
    if !output.status.success() {
        return Err(format!(
            "`{bin} docs export` failed ({}):\n{}{}",
            output.status,
            String::from_utf8_lossy(&output.stdout),
            String::from_utf8_lossy(&output.stderr),
        ));
    }
    let produced = stage.join("docs/README.pdf");
    fs::read(&produced).map_err(|e| format!("read {}: {e}", produced.display()))
}

// ════════════════════════════════════════════════════════════════════════════
// Verification
// ════════════════════════════════════════════════════════════════════════════

/// Reject a PDF that is truncated, empty of chapters, or missing an image the
/// markdown asked for.
pub fn verify_pdf(pdf: &[u8], want_images: usize, min_pages: usize) -> Result<(), String> {
    if pdf.len() < 1024 {
        return Err(format!(
            "PDF is {} bytes — that is not a document",
            pdf.len()
        ));
    }
    if !pdf.starts_with(b"%PDF-") {
        return Err("output does not start with %PDF- — not a PDF".to_string());
    }
    let tail_from = pdf.len().saturating_sub(64);
    if !contains(&pdf[tail_from..], b"%%EOF") {
        return Err("PDF has no %%EOF trailer — truncated".to_string());
    }
    let pages = page_count(pdf);
    if pages < min_pages {
        return Err(format!(
            "PDF has {pages} page(s), floor is {min_pages} — the chapters did not render"
        ));
    }
    let got = image_xobjects(pdf);
    if got < want_images {
        return Err(format!(
            "markdown referenced {want_images} image(s), the PDF embeds {got} — an image was \
             dropped or was a pointer file"
        ));
    }
    Ok(())
}

/// Page count, read from the page-tree root's `/Count`.
///
/// The largest `/Count` in the file is the root of the tree: an intermediate
/// node's count is a subset of its parent's by construction.
pub fn page_count(pdf: &[u8]) -> usize {
    let mut best = 0usize;
    let needle = b"/Count";
    let mut i = 0;
    while let Some(p) = find(&pdf[i..], needle) {
        let mut j = i + p + needle.len();
        while j < pdf.len() && pdf[j].is_ascii_whitespace() {
            j += 1;
        }
        let start = j;
        while j < pdf.len() && pdf[j].is_ascii_digit() {
            j += 1;
        }
        if j > start {
            if let Ok(n) = std::str::from_utf8(&pdf[start..j])
                .unwrap_or("")
                .parse::<usize>()
            {
                best = best.max(n);
            }
        }
        i = i + p + needle.len();
    }
    best
}

/// Count the image XObjects. typst writes `/Subtype /Image` for a raster and
/// rasterises an SVG into one too, so both kinds land here.
pub fn image_xobjects(pdf: &[u8]) -> usize {
    let mut n = 0;
    let mut i = 0;
    while let Some(p) = find(&pdf[i..], b"/Image") {
        // `/Subtype /Image` or `/Subtype/Image`, allowing whitespace.
        let abs = i + p;
        let mut j = abs;
        while j > 0 && pdf[j - 1].is_ascii_whitespace() {
            j -= 1;
        }
        if j >= 8 && &pdf[j - 8..j] == b"/Subtype" {
            n += 1;
        }
        i = abs + 6;
    }
    n
}

fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
    if needle.is_empty() || hay.len() < needle.len() {
        return None;
    }
    hay.windows(needle.len()).position(|w| w == needle)
}

fn contains(hay: &[u8], needle: &[u8]) -> bool {
    find(hay, needle).is_some()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn headings_demote_outside_fences_only() {
        let md = "# Top\n\n```sh\n# not a heading\n```\n\n## Sub\n";
        let out = demote_headings(md);
        assert!(out.contains("## Top"), "{out}");
        assert!(
            out.contains("\n# not a heading\n"),
            "fence content moved: {out}"
        );
        assert!(out.contains("### Sub"), "{out}");
    }

    #[test]
    fn deepest_heading_does_not_grow_a_seventh_level() {
        assert_eq!(demote_headings("###### six\n"), "###### six\n");
    }

    #[test]
    fn image_targets_are_found_and_remote_ones_skipped() {
        let md =
            "![a](assets/one.svg)\n\n![b](https://example.invalid/x.png)\n\n![c](assets/one.svg)\n";
        assert_eq!(image_targets(md), vec!["assets/one.svg".to_string()]);
    }

    /// The false red this module shipped with on its first run: an SVG renders
    /// as inline vector operators and produces **no** image XObject, so
    /// counting it as one fails a document that is in fact correct.
    #[test]
    fn only_rasters_are_expected_to_become_image_xobjects() {
        let all = vec![
            "assets/cycle.svg".to_string(),
            "assets/gunnar.webp".to_string(),
            "assets/shot.PNG".to_string(),
        ];
        assert_eq!(
            raster_targets(&all),
            vec![
                "assets/gunnar.webp".to_string(),
                "assets/shot.PNG".to_string()
            ]
        );
        assert!(raster_targets(&["a/b.svg".to_string()]).is_empty());
    }

    /// Red first: each rejection must be reachable, and for its own
    /// reason. A verifier that only ever returns `Ok` is the failure mode this
    /// whole module exists to prevent.
    #[test]
    fn verify_rejects_every_way_a_pdf_can_be_hollow() {
        let ok = synthetic_pdf(4, 1);
        verify_pdf(&ok, 1, MIN_PAGES).expect("the synthetic good PDF must pass");

        let err = verify_pdf(b"not a pdf at all", 0, MIN_PAGES).unwrap_err();
        assert!(err.contains("not a document"), "{err}");

        let mut wrong_magic = synthetic_pdf(4, 1);
        wrong_magic[..5].copy_from_slice(b"%XDF-");
        let err = verify_pdf(&wrong_magic, 1, MIN_PAGES).unwrap_err();
        assert!(err.contains("not a PDF"), "{err}");

        let good = synthetic_pdf(4, 1);
        let truncated = &good[..good.len() - 8];
        let err = verify_pdf(truncated, 1, MIN_PAGES).unwrap_err();
        assert!(err.contains("truncated"), "{err}");

        let thin = synthetic_pdf(2, 1);
        let err = verify_pdf(&thin, 1, MIN_PAGES).unwrap_err();
        assert!(err.contains("floor is"), "{err}");

        let no_image = synthetic_pdf(4, 0);
        let err = verify_pdf(&no_image, 1, MIN_PAGES).unwrap_err();
        assert!(err.contains("pointer file"), "{err}");
    }

    /// The two readers must read, not guess: a different page count and a
    /// different image count have to come back different.
    #[test]
    fn readers_track_what_they_read() {
        assert_eq!(page_count(&synthetic_pdf(4, 0)), 4);
        assert_eq!(page_count(&synthetic_pdf(11, 0)), 11);
        assert_eq!(image_xobjects(&synthetic_pdf(4, 0)), 0);
        assert_eq!(image_xobjects(&synthetic_pdf(4, 3)), 3);
    }

    #[test]
    fn smudge_check_names_the_pointer() {
        let dir = std::env::temp_dir().join(format!("gunnar-docsbook-{}", std::process::id()));
        let assets = dir.join("assets");
        fs::create_dir_all(&assets).unwrap();
        let img = assets.join("x.svg");

        fs::write(
            &img,
            format!("{LFS_POINTER_MAGIC}\noid sha256:00\nsize 1\n"),
        )
        .unwrap();
        let err = smudge_check(&dir, &["assets/x.svg".to_string()]).unwrap_err();
        assert!(err.contains("unsmudged Git-LFS pointer"), "{err}");

        fs::write(&img, "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>").unwrap();
        smudge_check(&dir, &["assets/x.svg".to_string()]).expect("a real SVG must pass");

        fs::write(&img, "").unwrap();
        let err = smudge_check(&dir, &["assets/x.svg".to_string()]).unwrap_err();
        assert!(err.contains("is empty"), "{err}");

        let err = smudge_check(&dir, &["assets/missing.svg".to_string()]).unwrap_err();
        assert!(err.contains("not staged"), "{err}");

        let _ = fs::remove_dir_all(&dir);
    }

    /// Every image a `.nornir/` source references has to resolve from BOTH
    /// roots, because one string is read from two places: `.nornir/README.md`
    /// is rendered verbatim into the repo-root `README.md`, where
    /// `assets/x.svg` means `<root>/assets/x.svg`, and the same line beside its
    /// source means `<root>/.nornir/assets/x.svg`.
    ///
    /// Red-proven on the desktop 2026-08-04: before `cycle_svg::publish_asset`, the
    /// root README's two SVG links were broken images on the forge and nothing
    /// said so — `smudge_check` only ever looks at the staged tree, where both
    /// asset dirs have already been flattened into one.
    #[test]
    fn every_referenced_image_resolves() {
        let root = workspace_root();
        let mut targets: BTreeSet<String> = BTreeSet::new();
        for book in BOOKS {
            for c in book.chapters {
                let body = fs::read_to_string(root.join(".nornir").join(c.source))
                    .unwrap_or_else(|e| panic!("{}: {e}", c.source));
                targets.extend(image_targets(&body));
                targets.extend(html_image_targets(&body));
            }
        }
        assert!(
            !targets.is_empty(),
            "no chapter references any image — this test is blind"
        );
        // Two forms, two rules, and the path itself says which:
        //
        //  * `assets/x` is RELATIVE, and the one string is read from two
        //    places, so it has to resolve from both roots.
        //  * `.nornir/assets/x` is anchored at the repository root on purpose:
        //    `.nornir/README.md` is rendered VERBATIM into the root
        //    `README.md`, which is the page the forge shows, and a relative
        //    `assets/…` there would have to be rewritten in between. It
        //    resolves from the root and only from the root — asking it to
        //    resolve from `.nornir/` too would be asking for
        //    `.nornir/.nornir/assets/`.
        let mut relative = 0usize;
        let mut anchored = 0usize;
        for t in &targets {
            if let Some(rest) = t.strip_prefix(".nornir/") {
                anchored += 1;
                let p = root.join(".nornir").join(rest);
                assert!(
                    p.is_file(),
                    "{t} is root-anchored by a .nornir source but {} does not exist",
                    p.display()
                );
            } else {
                relative += 1;
                for base in ["", ".nornir"] {
                    let p = root.join(base).join(t);
                    assert!(
                        p.is_file(),
                        "{t} is referenced by a .nornir source but {} does not exist — the \
                         generated root README.md and the source both use this one path",
                        p.display()
                    );
                }
            }
        }
        // The control: both rules have to be exercised, or one of them is
        // dead code that nothing would notice going wrong.
        assert!(relative > 0, "no relative image target was checked");
        assert!(anchored > 0, "no root-anchored image target was checked");
    }

    /// The `<img>` reader is bounded to its own tag and skips remote targets.
    #[test]
    fn html_image_targets_are_found_and_bounded() {
        let md = "<img src=\"assets/a.svg\" width=\"10\">\n\
                  <img alt=\"no src here\">\n\
                  <img src=\"https://example.invalid/x.png\">\n\
                  <img src=\".nornir/assets/b.webp\">\n\
                  <img src=\"assets/a.svg\">\n";
        assert_eq!(
            html_image_targets(md),
            vec![
                "assets/a.svg".to_string(),
                ".nornir/assets/b.webp".to_string()
            ]
        );
        assert!(html_image_targets("![a](assets/one.svg)").is_empty());
    }

    /// A name in [`NOT_IN_THIS_TREE`] is refused, by its own name, with the
    /// reason — and NOT with "no book named …", which is what a typo gets.
    ///
    /// Red-first: put `record` back in [`BOOKS`] and this fails, because the
    /// verb would then try to build it.
    #[test]
    fn a_book_the_record_owns_is_refused_by_name() {
        for (name, _, _) in NOT_IN_THIS_TREE {
            assert!(
                !BOOKS.iter().any(|b| b.name == *name),
                "`{name}` is in BOOKS and in NOT_IN_THIS_TREE at once"
            );
            let err = run(&[(*name).to_string()]).unwrap_err();
            assert!(
                err.contains(name) && err.contains("cannot be assembled"),
                "{err}"
            );
            assert!(!err.contains("no book named"), "{err}");
        }
        let err = run(&["nonesuch".to_string()]).unwrap_err();
        assert!(err.contains("no book named"), "{err}");
    }

    /// A book is only "not in this tree" while its sources really are absent.
    /// Write one of them back and this says so, so the entry cannot outlive its
    /// reason.
    #[test]
    fn a_book_named_absent_is_actually_absent() {
        let root = workspace_root();
        for (name, _, sources) in NOT_IN_THIS_TREE {
            assert!(!sources.is_empty(), "{name} names no chapters at all");
            for s in *sources {
                let p = root.join(".nornir").join(s);
                assert!(
                    !p.is_file(),
                    "{} is back on disk — `{name}` belongs in BOOKS again, not in \
                     NOT_IN_THIS_TREE",
                    p.display()
                );
            }
        }
    }

    #[test]
    fn every_book_chapter_exists_on_disk() {
        let root = workspace_root();
        for book in BOOKS {
            assert!(!book.chapters.is_empty(), "{} has no chapters", book.name);
            for c in book.chapters {
                let p = root.join(".nornir").join(c.source);
                assert!(
                    p.is_file(),
                    "{} names a missing source {}",
                    book.name,
                    p.display()
                );
            }
        }
    }

    /// A minimal PDF shaped like the parts the verifier reads.
    fn synthetic_pdf(pages: usize, images: usize) -> Vec<u8> {
        let mut s = String::from("%PDF-1.7\n");
        let _ = writeln!(s, "1 0 obj << /Type /Pages /Count {pages} >> endobj");
        for i in 0..images {
            let _ = writeln!(
                s,
                "{} 0 obj << /Type /XObject /Subtype /Image /Width 8 /Height 8 >> endobj",
                i + 2
            );
        }
        // Pad past the 1024-byte floor so size is not what the test is asserting.
        s.push_str(&"% padding\n".repeat(120));
        s.push_str("trailer\n%%EOF\n");
        s.into_bytes()
    }
}