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 `` 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 =
"\n\n\n\n\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("").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()
}
}