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

nordisk/edda

crates/nornir-docgen/src/book.rs

//! Whole-documentation "book" — assemble *every* markdown source in a repo
//! into one typst-rendered document (PDF / HTML / MD).
//!
//! Where [`super::export::export_repo`] renders a single file (the generated
//! `README.md`), the book gathers the repo's entire doc set:
//!
//!   1. every `*.md` under `.nornir/` (the editable sources — README, CHANGELOG,
//!      design notes, this crate's docs-generation reference, …);
//!   2. every other top-level `<repo>/*.md` (e.g. `plan.md`), *excluding* the
//!      generated managed artifacts (`README.md` / `CHANGELOG.md`) whose source
//!      already comes from `.nornir/`, so nothing is duplicated.
//!
//! Each file becomes a chapter. Marker sections (`<!-- nornir:gen:start:… -->`)
//! are expanded per file via [`super::sections`], so bench tables / dep-graphs
//! are fresh. The chapters are concatenated into one markdown string and handed
//! to the existing typst pipeline with the repo root mounted as the filesystem
//! root, so local images (`.nornir/assets/…`) embed.
//!
//! Everything goes through **typst** — no second rendering engine.
//!
//! Cargo feature: `export` (nornir maps its `docs-export` onto it).

use std::collections::HashSet;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};

use super::export::{self, ChapterInput, DocFormat, ExportMeta};
use super::layout::MANAGED_DOCS;
use super::sections::{Ctx, rewrite_str};

/// One assembled chapter of the book.
#[derive(Debug)]
pub struct Chapter {
    /// Source file the chapter came from.
    pub source: PathBuf,
    /// Display title (the file's own leading `# H1`, or derived from its name).
    pub title: String,
    /// Markdown body with marker sections already expanded.
    pub markdown: String,
    /// True for a synthetic section-divider (a manifest section header) rather
    /// than a real source file — see [`book_plan`].
    pub is_section: bool,
}

/// The catch-all back-matter section: every `.nornir/*.md` not placed by the
/// manifest lands here. Working notes / musings, explicitly non-authoritative.
const TANKEGANGAR: &str = "Tankegångar";
/// Section heading for the per-crate cards gathered from non-root `.nornir/`
/// directories (cards that travel with their crate).
const CRATES_SECTION: &str = "Crates";

/// One entry in the ordered book plan: either a section-divider page or a real
/// source file.
enum PlanItem {
    Divider(String),
    File(PathBuf),
}

/// A `.nornir/book.toml` manifest: ordered curated sections. Any `.nornir/*.md`
/// not named by a section falls through to [`TANKEGANGAR`], so nothing is lost
/// and nothing aspirational leaks into the curated body.
#[derive(serde::Deserialize)]
struct BookManifest {
    #[serde(default)]
    section: Vec<ManifestSection>,
}

#[derive(serde::Deserialize)]
struct ManifestSection {
    title: String,
    #[serde(default)]
    files: Vec<String>,
}

/// Discover and assemble every doc source in `repo_root` into ordered chapters.
///
/// Order: `.nornir/README.md`, `.nornir/CHANGELOG.md`, then the remaining
/// `.nornir/*.md` alphabetically, then the non-generated top-level `*.md`
/// alphabetically.
pub fn collect_chapters(repo_root: &Path, ctx: &Ctx) -> Result<Vec<Chapter>> {
    let mut chapters = Vec::new();
    for item in book_plan(repo_root, ctx.manual) {
        let path = match item {
            PlanItem::Divider(title) => {
                // A synthetic section cover page. Tankegångar carries an explicit
                // "not authoritative" caveat so the reader knows the curated body
                // (Reference + Design) is the source of truth, not these notes.
                let markdown = if title == TANKEGANGAR {
                    format!(
                        "# {title}\n\n_Working notes, design musings and history — \
                         **not authoritative**. The source of truth is the code and \
                         the curated chapters above._\n"
                    )
                } else {
                    format!("# {title}\n")
                };
                chapters.push(Chapter {
                    source: repo_root.join(".nornir"),
                    title,
                    markdown,
                    is_section: true,
                });
                continue;
            }
            PlanItem::File(path) => path,
        };
        chapters.push(build_chapter(&path, repo_root, ctx)?);
    }
    Ok(chapters)
}

/// Read one source file into a [`Chapter`]: expand its marker sections, rewrite
/// its image paths to repo-root-relative, and derive its title. Shared by the
/// sectioned book ([`collect_chapters`]) and the class-routed books
/// ([`build_books`]).
fn build_chapter(path: &Path, repo_root: &Path, ctx: &Ctx) -> Result<Chapter> {
    let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
    // Expand marker sections. A file that isn't a nornir source (no markers,
    // or a stray marker-looking line) must not sink the whole book — fall
    // back to its raw text on any rewrite error.
    let body = match rewrite_str(&raw, ctx) {
        Ok((filled, _)) => filled,
        Err(_) => raw,
    };
    // Resolve image paths so they embed regardless of which convention a
    // doc uses. The book mounts repo-root as the typst FS root, so image
    // paths must be ROOT-relative — but a `.nornir/*.md` file may reference
    // an asset SOURCE-relative (`assets/x.svg`, the markdown/web convention,
    // = `.nornir/assets/x.svg`) OR already root-relative (`.nornir/assets/x.svg`).
    // Try source-relative first, then root-relative, and emit whichever
    // exists as a root-relative path. Fixes nornir's `assets/…` refs without
    // breaking holger's `.nornir/assets/…` refs.
    let body = rewrite_image_paths(&body, path, repo_root);
    let title = chapter_title(path, &body);
    Ok(Chapter {
        source: path.to_path_buf(),
        title,
        markdown: body,
        is_section: false,
    })
}

/// The ordered book plan. With a `.nornir/book.toml` manifest the book is
/// **sectioned** — README/CHANGELOG front matter, then each curated section, then
/// a `Tankegångar` appendix of everything unlisted. Without a manifest it stays
/// the legacy **flat** discovery (so only repos that opt in change).
fn book_plan(repo_root: &Path, manual: bool) -> Vec<PlanItem> {
    match load_manifest(repo_root) {
        Some(sections) if !sections.is_empty() => sectioned_plan(repo_root, &sections, manual),
        _ => discover_sources(repo_root)
            .into_iter()
            .filter(|p| !manual || is_manual_chapter(p))
            .map(PlanItem::File)
            .collect(),
    }
}

/// In **manual** (thin-book) mode, whether a source file belongs in the shipped
/// manual. Front matter (README/CHANGELOG) and design/guide/benchmark chapters
/// stay; the non-manual classes — reasoning, history, unsorted, the idiot/dummy
/// guides, and the `docs-generation` doctrine reference — are excluded so the
/// `docs/book.pdf` stays thin. Nothing is deleted; the sources remain in `.nornir/`.
fn is_manual_chapter(path: &Path) -> bool {
    let name = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();
    if name == "docs-generation.md" {
        return false;
    }
    const NON_MANUAL_SUFFIXES: &[&str] = &[
        "-reasoning.md",
        "-history.md",
        "-unsorted.md",
        "-for-idiots.md",
        "-for-dummies.md",
        "-idiots.md",
        "-idiot-guide.md",
    ];
    if let Some(stem) = name.strip_suffix(".md") {
        // Bare class files: reasoning.md / history.md / unsorted.md.
        if matches!(stem, "reasoning" | "history" | "unsorted") {
            return false;
        }
    }
    !NON_MANUAL_SUFFIXES.iter().any(|s| name.ends_with(s))
}

/// Build the sectioned plan from a manifest: front matter (README, CHANGELOG),
/// each non-empty section under a divider (files in manifest order), then every
/// remaining `.nornir/*.md` and non-generated top-level `*.md` under `Tankegångar`.
fn sectioned_plan(repo_root: &Path, sections: &[ManifestSection], manual: bool) -> Vec<PlanItem> {
    let nornir_md = list_md(&repo_root.join(".nornir"));
    let root_md: Vec<PathBuf> = list_md(repo_root)
        .into_iter()
        .filter(|p| {
            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
            !MANAGED_DOCS.contains(&name) && is_book_chapter(name)
        })
        .collect();

    let find = |name: &str| -> Option<PathBuf> {
        nornir_md
            .iter()
            .find(|p| p.file_name().and_then(|n| n.to_str()) == Some(name))
            .cloned()
    };

    let mut plan = Vec::new();
    let mut used: HashSet<PathBuf> = HashSet::new();

    // Front matter first, no divider.
    for lead in MANAGED_DOCS {
        if let Some(p) = find(lead) {
            used.insert(p.clone());
            plan.push(PlanItem::File(p));
        }
    }

    // Curated sections, in manifest order. A section with no resolvable files
    // emits nothing (no empty divider).
    for sec in sections {
        let mut files = Vec::new();
        for fname in &sec.files {
            if let Some(p) = find(fname) {
                if manual && !is_manual_chapter(&p) {
                    continue; // thin manual: drop reasoning/history/etc. even if curated
                }
                if used.insert(p.clone()) {
                    files.push(p);
                }
            }
        }
        if !files.is_empty() {
            plan.push(PlanItem::Divider(sec.title.clone()));
            plan.extend(files.into_iter().map(PlanItem::File));
        }
    }

    // Crates: per-crate cards from non-root `.nornir/` dirs, their own section.
    let mut cards: Vec<PathBuf> = discover_crate_cards(repo_root)
        .into_iter()
        .filter(|p| !used.contains(p))
        .filter(|p| !manual || is_manual_chapter(p))
        .collect();
    cards.sort();
    if !cards.is_empty() {
        plan.push(PlanItem::Divider(CRATES_SECTION.to_string()));
        for c in &cards {
            used.insert(c.clone());
        }
        plan.extend(cards.into_iter().map(PlanItem::File));
    }

    // Tankegångar: whatever the manifest didn't claim. Skipped entirely in manual
    // (thin-book) mode — the catch-all back-matter is exactly what the manual drops.
    if !manual {
        let mut leftover: Vec<PathBuf> = nornir_md
            .into_iter()
            .chain(root_md)
            .filter(|p| !used.contains(p))
            .collect();
        leftover.sort();
        if !leftover.is_empty() {
            plan.push(PlanItem::Divider(TANKEGANGAR.to_string()));
            plan.extend(leftover.into_iter().map(PlanItem::File));
        }
    }

    plan
}

/// Load `.nornir/book.toml` if present. A missing file → `None` (flat book); a
/// malformed one is logged and treated as absent so a typo never breaks the book.
fn load_manifest(repo_root: &Path) -> Option<Vec<ManifestSection>> {
    let path = repo_root.join(".nornir/book.toml");
    let text = std::fs::read_to_string(&path).ok()?;
    match toml::from_str::<BookManifest>(&text) {
        Ok(m) => Some(m.section),
        Err(e) => {
            eprintln!("nornir docs: ignoring malformed .nornir/book.toml: {e}");
            None
        }
    }
}

/// Rewrite each markdown image's relative path to a repo-root-relative path that
/// actually exists on disk: source-relative first (web semantics), then
/// root-relative (the as-written value). Absolute / `http(s):` / `data:` refs
/// and non-existent paths are left untouched.
fn rewrite_image_paths(body: &str, source: &Path, repo_root: &Path) -> String {
    let src_dir = source.parent().unwrap_or(repo_root);
    let mut out = String::with_capacity(body.len());
    let bytes = body.as_bytes();
    let mut i = 0;
    while i < body.len() {
        // Find the next image start `![`.
        if bytes[i] == b'!' && i + 1 < body.len() && bytes[i + 1] == b'[' {
            if let Some(close_alt) = body[i..].find("](") {
                let lp = i + close_alt + 2; // index just after "]("
                if let Some(rel_close) = body[lp..].find(')') {
                    let inner = &body[lp..lp + rel_close]; // path [+ optional "title"]
                    let (raw_path, title) = match inner.find(char::is_whitespace) {
                        Some(sp) => (&inner[..sp], &inner[sp..]),
                        None => (inner, ""),
                    };
                    let skip = raw_path.is_empty()
                        || raw_path.starts_with('/')
                        || raw_path.starts_with("http://")
                        || raw_path.starts_with("https://")
                        || raw_path.starts_with("data:");
                    let resolved = if skip {
                        None
                    } else {
                        let src_rel = src_dir.join(raw_path);
                        let root_rel = repo_root.join(raw_path);
                        if src_rel.is_file() {
                            src_rel
                                .strip_prefix(repo_root)
                                .ok()
                                .map(|p| p.to_string_lossy().replace('\\', "/"))
                        } else if root_rel.is_file() {
                            None // already correct as-written
                        } else {
                            None // unknown — leave it
                        }
                    };
                    if let Some(newp) = resolved {
                        out.push_str(&body[i..lp]); // through "]("
                        out.push_str(&newp);
                        out.push_str(title);
                        out.push(')');
                        i = lp + rel_close + 1;
                        continue;
                    }
                }
            }
        }
        let ch = body[i..].chars().next().unwrap();
        out.push(ch);
        i += ch.len_utf8();
    }
    out
}

/// Concatenate chapters into one markdown document. Each chapter that does not
/// already begin with a level-1 heading gets one prepended (so every chapter is
/// a top-level section / page break in the rendered output).
pub fn assemble_markdown(chapters: &[Chapter]) -> String {
    let mut out = String::new();
    for ch in chapters {
        if !starts_with_h1(&ch.markdown) {
            out.push_str("# ");
            out.push_str(&ch.title);
            out.push_str("\n\n");
        }
        out.push_str(ch.markdown.trim_end());
        out.push_str("\n\n");
    }
    out
}

// ----- class routing → N books (the docs.toml "three books") -----------------

/// One rendered book produced by [`build_books`] — a routing entry realised.
pub struct BuiltBook {
    /// Routing-entry name (`manual`, `idiot_guide`, `unsorted`, …).
    pub name: String,
    /// Output path from the `[book.*].out` key, with its extension swapped to
    /// the render format; repo-root-relative exactly as written in `docs.toml`.
    pub out: PathBuf,
    /// Rendered document bytes.
    pub bytes: Vec<u8>,
    /// The real source files routed into this book, in assembly order.
    pub sources: Vec<PathBuf>,
}

/// A `[book.<name>]` routing entry: which section classes it collects and where
/// its PDF lands.
#[derive(Debug, Clone)]
pub struct BookRoute {
    pub name: String,
    pub classes: Vec<String>,
    pub out: String,
}

#[derive(serde::Deserialize)]
struct DocsRoutingToml {
    #[serde(default)]
    book: std::collections::BTreeMap<String, BookRouteRaw>,
}

#[derive(serde::Deserialize)]
struct BookRouteRaw {
    #[serde(default)]
    classes: Vec<String>,
    out: String,
}

/// The routing *class* of a doc source, for [`build_books`]. Read from an
/// explicit `<!-- nornir:class: X -->` marker if present, else the filename
/// **suffix** (`-design.md` → `design`, `-for-idiots.md` → `idiot`, …), else the
/// bare class / front-matter name (`design.md` → `design`, `README.md` →
/// `readme`), else `"other"` (which no book claims → the file is omitted and the
/// omission is logged). Suffixes and names per `.nornir/docs-generation.md`.
pub fn classify(path: &Path, body: &str) -> &'static str {
    // 1. An explicit in-file override wins over any filename convention.
    if let Some(c) = explicit_class(body) {
        return c;
    }
    let name = path
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();
    // 2. Filename suffix (the primary convention).
    const SUFFIX: &[(&str, &str)] = &[
        ("-design.md", "design"),
        ("-guide.md", "guide"),
        ("-for-idiots.md", "idiot"),
        ("-for-dummies.md", "idiot"),
        ("-idiots.md", "idiot"),
        ("-idiot-guide.md", "idiot"),
        ("-reasoning.md", "reasoning"),
        ("-history.md", "history"),
        ("-unsorted.md", "unsorted"),
        ("-benchmarks.md", "benchmarks"),
        ("-bench.md", "benchmarks"),
    ];
    for (suf, class) in SUFFIX {
        if name.ends_with(suf) {
            return class;
        }
    }
    // 3. Bare class-name / front-matter files (the `f0` files + README/CHANGELOG).
    if let Some(stem) = name.strip_suffix(".md") {
        if let Some(c) = canonical_class(stem) {
            return c;
        }
        if stem == "readme" {
            return "readme";
        }
        if stem == "changelog" {
            return "changelog";
        }
    }
    "other"
}

/// Canonicalise a class token (from a marker or a bare filename) to one of the
/// known static class names, or `None` if it names no class.
fn canonical_class(s: &str) -> Option<&'static str> {
    Some(match s.to_ascii_lowercase().as_str() {
        "design" => "design",
        "guide" => "guide",
        "idiot" | "for-idiots" | "for-dummies" => "idiot",
        "reasoning" => "reasoning",
        "history" => "history",
        "unsorted" => "unsorted",
        "benchmarks" | "bench" => "benchmarks",
        "readme" => "readme",
        "changelog" => "changelog",
        _ => return None,
    })
}

/// Parse an explicit class override from the head of a file: any
/// `nornir:class: <name>` / `nornir:class = "<name>"` token in the first ~600
/// bytes (typically inside a leading `<!-- … -->` comment). Returns the
/// canonical class, or `None` if absent/unknown.
fn explicit_class(body: &str) -> Option<&'static str> {
    // Clamp to a char boundary so a multibyte char (e.g. `→`) straddling byte
    // 600 can't panic the whole book build.
    let mut cut = body.len().min(600);
    while cut > 0 && !body.is_char_boundary(cut) {
        cut -= 1;
    }
    let head = &body[..cut];
    let idx = head.find("nornir:class")?;
    let rest = &head[idx + "nornir:class".len()..];
    let val: String = rest
        .trim_start_matches(|c: char| c == ':' || c == '=' || c == '"' || c.is_whitespace())
        .chars()
        .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
        .collect();
    canonical_class(&val)
}

/// Load `.nornir/docs.toml` routing, or the built-in three-book default when the
/// file is absent, empty, or malformed (a typo never breaks the docs build).
pub fn load_routing(repo_root: &Path) -> Vec<BookRoute> {
    let path = repo_root.join(".nornir/docs.toml");
    if let Ok(text) = std::fs::read_to_string(&path) {
        match toml::from_str::<DocsRoutingToml>(&text) {
            Ok(cfg) if !cfg.book.is_empty() => {
                return cfg
                    .book
                    .into_iter()
                    .map(|(name, r)| BookRoute {
                        name,
                        classes: r.classes,
                        out: r.out,
                    })
                    .collect();
            }
            Ok(_) => {}
            Err(e) => eprintln!("nornir docs: ignoring malformed .nornir/docs.toml: {e}"),
        }
    }
    default_routing()
}

/// The doctrine's three-book default (see `.nornir/docs-generation.md`): a crisp
/// `manual.pdf`, a standalone `idiot_guide.pdf`, and the `unsorted.pdf` holding
/// pen — used verbatim when a repo has no `.nornir/docs.toml`.
pub fn default_routing() -> Vec<BookRoute> {
    let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect();
    vec![
        BookRoute {
            name: "manual".into(),
            classes: v(&["readme", "changelog", "design", "guide", "benchmarks"]),
            out: "docs/manual.pdf".into(),
        },
        BookRoute {
            name: "idiot_guide".into(),
            classes: v(&["idiot"]),
            out: "docs/idiot_guide.pdf".into(),
        },
        BookRoute {
            name: "unsorted".into(),
            classes: v(&["reasoning", "history", "unsorted"]),
            out: "docs/unsorted.pdf".into(),
        },
    ]
}

/// Render **every** routed book for `repo_root` per `.nornir/docs.toml` (or the
/// three-book default). Each `[book.*]` entry selects sources by class and emits
/// one document; a book whose class set matches no source is skipped (no empty
/// PDF). Sources whose class no book claims are logged, never silently dropped.
///
/// This is the multi-PDF successor to [`build_book`]: `manual` + `idiot_guide` +
/// `unsorted` together are the whole doc set, split by audience.
pub fn build_books(repo_root: &Path, ctx: &Ctx, format: DocFormat) -> Result<Vec<BuiltBook>> {
    // ★ THE CLAIM GATE. The book binds every `.nornir/*.md` source into ONE
    // publication, so a false claimed sentence in any of them refuses the whole
    // build rather than shipping a PDF that re-blesses it. See `super::claims`.
    super::claims::gate_repo(repo_root)?;
    let routing = load_routing(repo_root);

    // Classify every discovered source once (read + expand markers + rewrite
    // images), preserving discovery order (README first, CHANGELOG, then alpha).
    let mut classed: Vec<(&'static str, Chapter)> = Vec::new();
    for path in discover_sources(repo_root) {
        let ch = build_chapter(&path, repo_root, ctx)?;
        let class = classify(&path, &ch.markdown);
        classed.push((class, ch));
    }

    // No silent caps: warn about any source that no book routes.
    let routed: HashSet<&str> = routing
        .iter()
        .flat_map(|b| b.classes.iter().map(|s| s.as_str()))
        .collect();
    for (class, ch) in &classed {
        if !routed.contains(class) {
            eprintln!(
                "nornir docs: {} (class `{class}`) is routed to no book — omitted",
                ch.source
                    .strip_prefix(repo_root)
                    .unwrap_or(&ch.source)
                    .display(),
            );
        }
    }

    let (title, version) = read_meta(repo_root);
    let cover_image = detect_cover_image(repo_root).unwrap_or_default();
    let cache_dir = repo_root.join(".nornir/cache/images");
    let ext = format.extension();

    let mut out_books = Vec::new();
    for route in &routing {
        let wanted: HashSet<&str> = route.classes.iter().map(|s| s.as_str()).collect();
        let chapters: Vec<&Chapter> = classed
            .iter()
            .filter(|(c, _)| wanted.contains(c))
            .map(|(_, ch)| ch)
            .collect();
        if chapters.is_empty() {
            continue; // never emit an empty PDF for an unmatched route
        }
        let md = assemble_markdown_refs(&chapters);
        let meta = ExportMeta {
            title: format!("{title} — {}", titleize(&route.name)),
            version: version.clone(),
            generated: chrono::Utc::now().format("%Y-%m-%d").to_string(),
            cover_image: cover_image.clone(),
        };
        let bytes = export::export(&md, &meta, format, Some(&cache_dir), Some(repo_root))?;
        let out = PathBuf::from(&route.out).with_extension(ext);
        let sources = chapters.iter().map(|c| c.source.clone()).collect();
        out_books.push(BuiltBook {
            name: route.name.clone(),
            out,
            bytes,
            sources,
        });
    }
    Ok(out_books)
}

/// Like [`assemble_markdown`] but over borrowed chapters (the routed subset).
fn assemble_markdown_refs(chapters: &[&Chapter]) -> String {
    let mut out = String::new();
    for ch in chapters {
        if !starts_with_h1(&ch.markdown) {
            out.push_str("# ");
            out.push_str(&ch.title);
            out.push_str("\n\n");
        }
        out.push_str(ch.markdown.trim_end());
        out.push_str("\n\n");
    }
    out
}

/// Assemble + render the whole book for `repo_root` to `format`.
///
/// Returns the rendered bytes plus the list of source files that went into it
/// (in order), for reporting. `ctx` supplies the marker-render context
/// (repo/workspace roots + optional bench run).
pub fn build_book(
    repo_root: &Path,
    ctx: &Ctx,
    format: DocFormat,
) -> Result<(Vec<u8>, Vec<PathBuf>)> {
    // ★ THE CLAIM GATE. The book binds every `.nornir/*.md` source into ONE
    // publication, so a false claimed sentence in any of them refuses the whole
    // build rather than shipping a PDF that re-blesses it. See `super::claims`.
    super::claims::gate_repo(repo_root)?;
    let (md, meta, sources, _titles) = assemble_book(repo_root, ctx)?;
    let cache_dir = repo_root.join(".nornir/cache/images");
    // Mount repo root so local `.nornir/assets/…` images resolve and embed.
    let bytes = export::export(&md, &meta, format, Some(&cache_dir), Some(repo_root))?;
    Ok((bytes, sources))
}

/// Build the whole-doc book as **one crisp SVG per page** plus the chapter titles
/// (a flat TOC, in chapter order) and the source list. Feeds the manual viewer
/// (`facett-docview`) via the on-disk `docs/book-svg/` contract written by
/// `nornir docs book --format svg`.
///
/// Renders **per chapter** — each chapter is compiled as its own small typst
/// document and the resulting SVG pages are concatenated in chapter order (see
/// [`export::export_svg_pages_per_chapter`]). This keeps every typst input small,
/// so the total cost is ~linear in chapters instead of superlinear in one giant
/// document (a big repo's ~600 KB book dropped from ~11 min to seconds). The page
/// set is equivalent to the single-doc path: cover, TOC, then each chapter on its
/// own fresh page, numbered continuously.
///
/// The 4th return, `page_texts`, is the per-page plain-text search sidecar (same
/// length/order as the pages): the SVG pages carry only outlined glyph paths (no
/// `<text>`), so facett-docview search reads this instead. Each chapter's text is
/// attributed to its first page; front-matter and continuation pages are `None`.
pub fn build_book_svg_pages(
    repo_root: &Path,
    ctx: &Ctx,
) -> Result<(Vec<String>, Vec<String>, Vec<PathBuf>, Vec<Option<String>>)> {
    // ★ THE CLAIM GATE. The book binds every `.nornir/*.md` source into ONE
    // publication, so a false claimed sentence in any of them refuses the whole
    // build rather than shipping a PDF that re-blesses it. See `super::claims`.
    super::claims::gate_repo(repo_root)?;
    // Collect ONCE — a second `collect_chapters` would re-run every marker
    // (depgraph `cargo metadata`, per-file bench probes) a whole extra time.
    let chapters = collect_chapters(repo_root, ctx)?;
    // Report only real files, not synthetic section dividers.
    let sources: Vec<PathBuf> = chapters
        .iter()
        .filter(|c| !c.is_section)
        .map(|c| c.source.clone())
        .collect();
    // Flat TOC in chapter order (titles of every chapter, dividers included).
    let chapter_titles: Vec<String> = chapters.iter().map(|c| c.title.clone()).collect();
    // One `ChapterInput` per chapter: the body with its leading H1 ensured (the
    // exact fragment the single-doc `assemble_markdown` would emit for it), plus a
    // plain-text rendering of that body for the search sidecar.
    let inputs: Vec<ChapterInput> = chapters
        .iter()
        .map(|c| {
            let markdown = chapter_body_markdown(c);
            let text = chapter_plaintext(&markdown);
            ChapterInput {
                title: c.title.clone(),
                markdown,
                text,
            }
        })
        .collect();

    let meta = book_meta(repo_root);
    let cache_dir = repo_root.join(".nornir/cache/images");
    let (pages, page_texts) =
        export::export_svg_pages_per_chapter(&inputs, &meta, Some(&cache_dir), Some(repo_root))?;
    Ok((pages, chapter_titles, sources, page_texts))
}

/// A chapter's markdown reduced to plain, searchable text: markdown structure and
/// syntax stripped via a pulldown-cmark pass (headings, emphasis, links, code and
/// image ALT all contribute their text; fences keep their code). Returns `None`
/// when nothing textual remains (an image-only page) so search honestly skips it
/// rather than matching empty text.
fn chapter_plaintext(markdown: &str) -> Option<String> {
    use pulldown_cmark::{Event, Options, Parser};
    let mut out = String::new();
    for ev in Parser::new_ext(markdown, Options::empty()) {
        match ev {
            Event::Text(t) | Event::Code(t) => out.push_str(&t),
            Event::SoftBreak | Event::HardBreak | Event::End(_) => out.push(' '),
            _ => {}
        }
    }
    let trimmed = out.split_whitespace().collect::<Vec<_>>().join(" ");
    if trimmed.is_empty() {
        None
    } else {
        Some(trimmed)
    }
}

/// One chapter's markdown as the single-doc assembler would emit it: its own
/// leading `# H1` prepended only when missing (mirrors [`assemble_markdown`]), so
/// the per-chapter render matches the concatenated render page-for-page.
fn chapter_body_markdown(ch: &Chapter) -> String {
    let mut out = String::new();
    if !starts_with_h1(&ch.markdown) {
        out.push_str("# ");
        out.push_str(&ch.title);
        out.push_str("\n\n");
    }
    out.push_str(ch.markdown.trim_end());
    out.push('\n');
    out
}

/// Shared assembly: collect chapters → markdown + the export metadata (title,
/// version, cover image) + the flat chapter-title TOC. Used by both the paged
/// (PDF/SVG) and per-page-SVG paths. Collects (and marker-expands) exactly once.
fn assemble_book(
    repo_root: &Path,
    ctx: &Ctx,
) -> Result<(String, ExportMeta, Vec<PathBuf>, Vec<String>)> {
    let chapters = collect_chapters(repo_root, ctx)?;
    // Report only real files, not synthetic section dividers.
    let sources: Vec<PathBuf> = chapters
        .iter()
        .filter(|c| !c.is_section)
        .map(|c| c.source.clone())
        .collect();
    // Flat TOC in chapter order (titles of every chapter, dividers included).
    let chapter_titles: Vec<String> = chapters.iter().map(|c| c.title.clone()).collect();
    let md = assemble_markdown(&chapters);
    Ok((md, book_meta(repo_root), sources, chapter_titles))
}

/// The book's export metadata: title (`<name> — documentation`), version, the
/// generation date, and an auto-detected cover/hero image under `.nornir/assets/`
/// (`<reponame>.<ext>` or `cover.<ext>`, repo-root-relative so the mounted typst
/// root resolves it). Shared by the paged (PDF), single-doc SVG, and per-chapter
/// SVG paths so every book carries identical front-matter.
fn book_meta(repo_root: &Path) -> ExportMeta {
    let (title, version) = read_meta(repo_root);
    let cover_image = detect_cover_image(repo_root).unwrap_or_default();
    ExportMeta {
        title: format!("{title} — documentation"),
        version,
        generated: chrono::Utc::now().format("%Y-%m-%d").to_string(),
        cover_image,
    }
}

/// Find a cover/hero image under `.nornir/assets/`: `<reponame>.<ext>` (e.g.
/// `nornir.webp`) or `cover.<ext>`, ext ∈ {svg,png,webp,jpg,jpeg}. Returns it
/// repo-root-relative (forward slashes) so the typst root mount resolves it.
fn detect_cover_image(repo_root: &Path) -> Option<String> {
    let assets = repo_root.join(".nornir/assets");
    let name = repo_root.file_name().and_then(|n| n.to_str()).unwrap_or("");
    let exts = ["svg", "png", "webp", "jpg", "jpeg"];
    let mut stems = Vec::new();
    if !name.is_empty() {
        stems.push(name.to_string());
    }
    stems.push("cover".to_string());
    for stem in stems {
        for ext in exts {
            let p = assets.join(format!("{stem}.{ext}"));
            if p.is_file() {
                return p
                    .strip_prefix(repo_root)
                    .ok()
                    .map(|r| r.to_string_lossy().replace('\\', "/"));
            }
        }
    }
    None
}

// ----- discovery -------------------------------------------------------------

fn discover_sources(repo_root: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();

    // .nornir/*.md — the editable sources.
    let nornir_dir = repo_root.join(".nornir");
    let mut nornir_md = list_md(&nornir_dir);
    // README first, CHANGELOG second, then the rest alphabetically.
    nornir_md.sort_by(|a, b| source_rank(a).cmp(&source_rank(b)).then_with(|| a.cmp(b)));
    out.extend(nornir_md);

    // <repo>/*.md, minus the generated managed artifacts (their source is in
    // .nornir/ and is already included above) and minus non-doc scratch /
    // agent-instruction files (see `is_book_chapter`).
    let mut root_md: Vec<PathBuf> = list_md(repo_root)
        .into_iter()
        .filter(|p| {
            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
            !MANAGED_DOCS.contains(&name) && is_book_chapter(name)
        })
        .collect();
    root_md.sort();
    out.extend(root_md);

    // Per-crate cards from non-root `.nornir/` dirs (cards that travel with their
    // crate) — appended after the root docs so the flat book still includes them.
    out.extend(discover_crate_cards(repo_root));

    out
}

/// Whether a top-level `<repo>/<name>.md` belongs in the rendered doc-book.
///
/// Discovery is otherwise "every top-level `*.md`", which sweeps in files that
/// are not documentation: `CLAUDE.md` (agent instructions) and `*-inbox.md`
/// scratch pads. These are skipped by convention so the book is the project's
/// actual docs, not its working notes. (`.nornir/*.md` sources are always
/// included — that directory is nornir's curated namespace.)
fn is_book_chapter(name: &str) -> bool {
    let lower = name.to_ascii_lowercase();
    if lower == "claude.md" {
        return false;
    }
    // `*-inbox.md` / `*_inbox.md` and a bare `inbox.md`.
    if let Some(stem) = lower.strip_suffix(".md") {
        if stem == "inbox" || stem.ends_with("-inbox") || stem.ends_with("_inbox") {
            return false;
        }
    }
    true
}

/// Sort key so README.md sorts first, CHANGELOG.md second, everything else after.
fn source_rank(p: &Path) -> u8 {
    match p.file_name().and_then(|n| n.to_str()) {
        Some("README.md") => 0,
        Some("CHANGELOG.md") => 1,
        _ => 2,
    }
}

/// Top-level `*.md` files in `dir` (non-recursive), as sorted absolute paths.
/// Per-crate doc cards: every `*.md` under a **non-root** `.nornir/` directory
/// (e.g. `crates/nornir-saga/.nornir/…`, or a workspace member's own `.nornir/`).
/// The root `.nornir/` is handled by normal source discovery; this recurses the
/// rest of the tree so a card that travels with its crate is still swept into the
/// book. Prunes `target/`, `node_modules/`, and any other hidden dir (but NOT
/// `.nornir` itself), and does not descend into `.nornir/` subdirs (assets/cache).
fn discover_crate_cards(repo_root: &Path) -> Vec<PathBuf> {
    let root_nornir = repo_root.join(".nornir");
    let mut out = Vec::new();
    let mut stack = vec![repo_root.to_path_buf()];
    while let Some(dir) = stack.pop() {
        let Ok(rd) = std::fs::read_dir(&dir) else {
            continue;
        };
        for entry in rd.flatten() {
            let path = entry.path();
            // `file_type()` reads the dirent itself (lstat semantics), so a
            // symlinked directory is NOT a dir here and is never descended —
            // a `.claude/worktrees/<repo> → <repo>` self-symlink can't send the
            // walk into a stat storm. (A `.nornir` handled below is a real dir.)
            let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
            if !is_dir {
                continue;
            }
            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
            if name == ".nornir" {
                if path != root_nornir {
                    out.extend(list_md(&path));
                }
                continue; // never descend into a .nornir/ (assets/cache live there)
            }
            // Prune build outputs (`target`, `target-flight`/`target-*`),
            // node_modules, and every hidden dir (`.git`, `.claude` worktree
            // farms, …) — none carry per-crate doc cards.
            if name == "target"
                || name.starts_with("target-")
                || name == "node_modules"
                || name.starts_with('.')
            {
                continue;
            }
            stack.push(path);
        }
    }
    out.sort();
    out
}

fn list_md(dir: &Path) -> Vec<PathBuf> {
    let mut v = Vec::new();
    let Ok(rd) = std::fs::read_dir(dir) else {
        return v;
    };
    for entry in rd.flatten() {
        let path = entry.path();
        if path.is_file()
            && path
                .extension()
                .and_then(|e| e.to_str())
                .map(|e| e.eq_ignore_ascii_case("md"))
                .unwrap_or(false)
        {
            v.push(path);
        }
    }
    v.sort();
    v
}

// ----- chapter helpers -------------------------------------------------------

fn starts_with_h1(md: &str) -> bool {
    md.lines()
        .map(str::trim_start)
        .find(|l| !l.is_empty())
        .map(|l| l.starts_with("# "))
        .unwrap_or(false)
}

/// A chapter's title: the file's own leading `# H1` if present, else a name
/// derived from the file stem (`docs-generation` → `Docs Generation`).
fn chapter_title(path: &Path, body: &str) -> String {
    if let Some(line) = body.lines().map(str::trim_start).find(|l| !l.is_empty()) {
        if let Some(h) = line.strip_prefix("# ") {
            return h.trim().to_string();
        }
    }
    let stem = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or("untitled");
    titleize(stem)
}

fn titleize(stem: &str) -> String {
    stem.split(['-', '_'])
        .filter(|w| !w.is_empty())
        .map(|w| {
            let mut c = w.chars();
            match c.next() {
                Some(first) => first.to_uppercase().collect::<String>() + c.as_str(),
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

/// Read `(name, version)` from the repo's `Cargo.toml`; falls back to the
/// directory name and [`resolve_version`].
fn read_meta(repo_root: &Path) -> (String, String) {
    let dir_name = repo_root
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("project")
        .to_string();
    let name = std::fs::read_to_string(repo_root.join("Cargo.toml"))
        .ok()
        .and_then(|c| toml::from_str::<toml::Value>(&c).ok())
        .and_then(|p| {
            p.get("package")
                .or_else(|| p.get("workspace").and_then(|w| w.get("package")))
                .and_then(|p| p.get("name"))
                .and_then(|v| v.as_str())
                .map(str::to_string)
        })
        .unwrap_or(dir_name);
    (name, resolve_version(repo_root))
}

/// Resolve a display version for a repo from its root `Cargo.toml`.
///
/// Order: `[package].version`, then `[workspace.package].version`. A **virtual
/// workspace** (e.g. holger, znippy) declares neither, so we fall back to the
/// *modal* version across its members — the version most of the crates share is
/// the workspace's release version. Returns `0.0.0` only when nothing resolves.
pub fn resolve_version(repo_root: &Path) -> String {
    let Ok(content) = std::fs::read_to_string(repo_root.join("Cargo.toml")) else {
        return "0.0.0".to_string();
    };
    let Ok(parsed) = toml::from_str::<toml::Value>(&content) else {
        return "0.0.0".to_string();
    };
    // Direct package / workspace-package version.
    if let Some(v) = parsed
        .get("package")
        .or_else(|| parsed.get("workspace").and_then(|w| w.get("package")))
        .and_then(|p| p.get("version"))
        .and_then(|v| v.as_str())
    {
        return v.to_string();
    }
    // Virtual workspace: take the modal member version.
    let ws_pkg_version = parsed
        .get("workspace")
        .and_then(|w| w.get("package"))
        .and_then(|p| p.get("version"))
        .and_then(|v| v.as_str());
    let members = parsed
        .get("workspace")
        .and_then(|w| w.get("members"))
        .and_then(|m| m.as_array())
        .map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
        .unwrap_or_default();
    modal_member_version(repo_root, &members, ws_pkg_version).unwrap_or_else(|| "0.0.0".into())
}

/// The most frequent concrete version across workspace `members` (each a path,
/// possibly a trailing-`/*` glob). `version.workspace = true` resolves to
/// `ws_pkg_version`. Ties break toward the version seen first in member order.
fn modal_member_version(
    repo_root: &Path,
    members: &[&str],
    ws_pkg_version: Option<&str>,
) -> Option<String> {
    let mut counts: Vec<(String, usize)> = Vec::new();
    let mut bump = |v: String| {
        if let Some(e) = counts.iter_mut().find(|(k, _)| *k == v) {
            e.1 += 1;
        } else {
            counts.push((v, 1));
        }
    };
    for m in members {
        let dirs = if let Some(prefix) = m.strip_suffix("/*") {
            // Expand a simple trailing glob one level deep.
            std::fs::read_dir(repo_root.join(prefix))
                .map(|rd| {
                    rd.flatten()
                        .map(|e| e.path())
                        .filter(|p| p.is_dir())
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default()
        } else {
            vec![repo_root.join(m)]
        };
        for dir in dirs {
            let Ok(c) = std::fs::read_to_string(dir.join("Cargo.toml")) else {
                continue;
            };
            let Ok(p) = toml::from_str::<toml::Value>(&c) else {
                continue;
            };
            let ver = p.get("package").and_then(|pkg| pkg.get("version"));
            let resolved = match ver {
                Some(toml::Value::String(s)) => Some(s.clone()),
                // `version.workspace = true`
                Some(toml::Value::Table(t)) if t.get("workspace").is_some() => {
                    ws_pkg_version.map(str::to_string)
                }
                _ => None,
            };
            if let Some(v) = resolved {
                bump(v);
            }
        }
    }
    counts.into_iter().max_by_key(|(_, n)| *n).map(|(v, _)| v)
}

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

    fn write(p: &Path, s: &str) {
        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
        std::fs::write(p, s).unwrap();
    }

    #[test]
    fn crate_cards_recurse_but_skip_root_and_target() {
        let t = tempfile::tempdir().unwrap();
        let root = t.path();
        // Root .nornir is handled elsewhere — must NOT be returned here.
        write(&root.join(".nornir/design.md"), "# Design\n");
        // A per-crate card in a non-root .nornir — MUST be found.
        write(&root.join("crates/foo/.nornir/foo.md"), "# Foo\n");
        write(&root.join("member/bar/.nornir/bar.md"), "# Bar\n");
        // A card under target/ — MUST be pruned.
        write(&root.join("target/pkg/.nornir/junk.md"), "# Junk\n");
        // assets under a .nornir — must NOT be descended into.
        write(&root.join("crates/foo/.nornir/assets/skip.md"), "# Skip\n");

        let got: Vec<String> = discover_crate_cards(root)
            .iter()
            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
            .collect();
        assert!(
            got.contains(&"foo.md".to_string()),
            "per-crate card found: {got:?}"
        );
        assert!(
            got.contains(&"bar.md".to_string()),
            "workspace-member card found: {got:?}"
        );
        assert!(
            !got.contains(&"design.md".to_string()),
            "root .nornir excluded: {got:?}"
        );
        assert!(
            !got.contains(&"junk.md".to_string()),
            "target/ pruned: {got:?}"
        );
        assert!(
            !got.contains(&"skip.md".to_string()),
            ".nornir/assets not descended: {got:?}"
        );
    }

    #[test]
    fn discovers_and_orders_sources() {
        let t = tempfile::tempdir().unwrap();
        let root = t.path();
        write(&root.join(".nornir/README.md"), "# Readme\n\nbody\n");
        write(&root.join(".nornir/CHANGELOG.md"), "# Changelog\n");
        write(&root.join(".nornir/design.md"), "# Design\n");
        write(&root.join("plan.md"), "# Plan\n");
        // Generated managed artifact at the root — must be skipped (dedup).
        write(&root.join("README.md"), "generated\n");
        // Non-doc files — must be skipped by the chapter convention.
        write(&root.join("CLAUDE.md"), "agent instructions\n");
        write(&root.join("znippy-inbox.md"), "scratch\n");

        let got: Vec<String> = discover_sources(root)
            .iter()
            .map(|p| {
                let parent = p.parent().unwrap().file_name().unwrap().to_str().unwrap();
                let name = p.file_name().unwrap().to_str().unwrap();
                format!("{parent}/{name}")
            })
            .collect();
        let root_name = root.file_name().unwrap().to_str().unwrap();
        assert_eq!(
            got,
            vec![
                ".nornir/README.md".to_string(),
                ".nornir/CHANGELOG.md".to_string(),
                ".nornir/design.md".to_string(),
                // top-level README.md excluded; plan.md kept
                format!("{root_name}/plan.md"),
            ]
        );
    }

    #[test]
    fn is_book_chapter_skips_non_docs() {
        assert!(!is_book_chapter("CLAUDE.md"));
        assert!(!is_book_chapter("claude.md"));
        assert!(!is_book_chapter("znippy-inbox.md"));
        assert!(!is_book_chapter("inbox.md"));
        assert!(!is_book_chapter("notes_inbox.md"));
        // Real docs stay.
        assert!(is_book_chapter("WORKSPACE.md"));
        assert!(is_book_chapter("plan.md"));
        assert!(is_book_chapter("design.md"));
    }

    #[test]
    fn assemble_prepends_h1_only_when_missing() {
        let chapters = vec![
            Chapter {
                source: "a.md".into(),
                title: "Alpha".into(),
                markdown: "# Alpha\n\nhas its own h1\n".into(),
                is_section: false,
            },
            Chapter {
                source: "b.md".into(),
                title: "Beta".into(),
                markdown: "## sub only\n\nno h1\n".into(),
                is_section: false,
            },
        ];
        let md = assemble_markdown(&chapters);
        // First chapter keeps its single H1 (not doubled).
        assert_eq!(md.matches("# Alpha").count(), 1);
        // Second chapter gets a synthesized H1 from its title.
        assert!(md.contains("# Beta"));
        assert!(md.contains("## sub only"));
    }

    #[test]
    fn resolve_version_prefers_package_then_modal_member() {
        // Concrete [package].version wins outright.
        let t = tempfile::tempdir().unwrap();
        write(
            &t.path().join("Cargo.toml"),
            "[package]\nname='x'\nversion='1.2.3'\n",
        );
        assert_eq!(resolve_version(t.path()), "1.2.3");

        // Virtual workspace: modal member version (3×0.9.0 vs 1×0.1.0).
        let w = tempfile::tempdir().unwrap();
        write(
            &w.path().join("Cargo.toml"),
            "[workspace]\nmembers=['a','b','c','d']\n",
        );
        for (m, v) in [
            ("a", "0.9.0"),
            ("b", "0.9.0"),
            ("c", "0.9.0"),
            ("d", "0.1.0"),
        ] {
            write(
                &w.path().join(m).join("Cargo.toml"),
                &format!("[package]\nname='{m}'\nversion='{v}'\n"),
            );
        }
        assert_eq!(resolve_version(w.path()), "0.9.0");

        // Nothing resolvable → 0.0.0.
        let e = tempfile::tempdir().unwrap();
        write(&e.path().join("Cargo.toml"), "[workspace]\nmembers=[]\n");
        assert_eq!(resolve_version(e.path()), "0.0.0");
    }

    #[test]
    fn titleize_basic() {
        assert_eq!(titleize("docs-generation"), "Docs Generation");
        assert_eq!(titleize("design_notes"), "Design Notes");
    }

    #[test]
    fn classify_by_suffix_bare_and_marker() {
        let p = |n: &str| PathBuf::from(n);
        // Suffix (the function-axis class files).
        assert_eq!(classify(&p("release-design.md"), ""), "design");
        assert_eq!(classify(&p("warehouse-guide.md"), ""), "guide");
        assert_eq!(classify(&p("build-for-idiots.md"), ""), "idiot");
        assert_eq!(classify(&p("map-reasoning.md"), ""), "reasoning");
        assert_eq!(classify(&p("test-history.md"), ""), "history");
        assert_eq!(classify(&p("jobs-unsorted.md"), ""), "unsorted");
        assert_eq!(classify(&p("znippy-benchmarks.md"), ""), "benchmarks");
        // Bare `f0` files + front matter.
        assert_eq!(classify(&p("design.md"), ""), "design");
        assert_eq!(classify(&p("for-idiots.md"), ""), "idiot");
        assert_eq!(classify(&p("README.md"), ""), "readme");
        assert_eq!(classify(&p("CHANGELOG.md"), ""), "changelog");
        // Explicit marker overrides the filename.
        assert_eq!(
            classify(
                &p("notes.md"),
                "<!-- nornir:class: reasoning -->\n# Notes\n"
            ),
            "reasoning"
        );
        assert_eq!(
            classify(
                &p("release-design.md"),
                "<!-- nornir:class = \"guide\" -->\n"
            ),
            "guide"
        );
        // Unknown → other (routed to no book).
        assert_eq!(classify(&p("docs-generation.md"), ""), "other");
    }

    #[test]
    fn load_routing_falls_back_to_default() {
        let t = tempfile::tempdir().unwrap();
        // No docs.toml → the three-book default.
        let names: Vec<String> = load_routing(t.path()).into_iter().map(|b| b.name).collect();
        assert_eq!(names, vec!["manual", "idiot_guide", "unsorted"]);

        // A docs.toml overrides it (alpha-ordered by name).
        write(
            &t.path().join(".nornir/docs.toml"),
            "[book.manual]\nclasses=[\"design\"]\nout=\"docs/m.pdf\"\n\
             [book.zzz]\nclasses=[\"guide\"]\nout=\"docs/z.pdf\"\n",
        );
        let routes = load_routing(t.path());
        assert_eq!(routes.len(), 2);
        assert_eq!(routes[0].name, "manual");
        assert_eq!(routes[0].out, "docs/m.pdf");
        assert_eq!(routes[1].name, "zzz");
    }

    /// The heart of the multi-PDF doctrine: a repo's classed sources route into
    /// distinct books — design/guide → manual, idiot → idiot_guide, reasoning →
    /// unsorted — and a class no book claims is omitted (not crashed on).
    #[cfg(feature = "export")]
    #[test]
    fn build_books_routes_by_class_into_distinct_pdfs() {
        let t = tempfile::tempdir().unwrap();
        let root = t.path();
        write(
            &root.join("Cargo.toml"),
            "[package]\nname='demo'\nversion='1.0.0'\n",
        );
        let body = "Lorem ipsum dolor sit amet. ".repeat(60);
        write(
            &root.join(".nornir/README.md"),
            &format!("# Demo\n\n{body}"),
        );
        write(
            &root.join(".nornir/release-design.md"),
            &format!("# Release design\n\n{body}"),
        );
        write(
            &root.join(".nornir/release-guide.md"),
            &format!("# Release guide\n\n{body}"),
        );
        write(
            &root.join(".nornir/release-for-idiots.md"),
            &format!("# Release idiot\n\n{body}"),
        );
        write(
            &root.join(".nornir/release-reasoning.md"),
            &format!("# Release why\n\n{body}"),
        );
        // `other` class — must be omitted from every book, not crash the build.
        write(
            &root.join(".nornir/docs-generation.md"),
            &format!("# Doctrine\n\n{body}"),
        );

        let ctx = Ctx::new(root, root, None);
        let books = build_books(root, &ctx, DocFormat::parse("md").unwrap()).unwrap();

        let by = |name: &str| books.iter().find(|b| b.name == name).unwrap();
        // Exactly the three default books, each non-empty.
        let mut names: Vec<&str> = books.iter().map(|b| b.name.as_str()).collect();
        names.sort();
        assert_eq!(names, vec!["idiot_guide", "manual", "unsorted"]);

        // Manual = README + design + guide (readme/design/guide classes).
        let manual = String::from_utf8(by("manual").bytes.clone()).unwrap();
        assert!(manual.contains("Release design"), "manual has design");
        assert!(manual.contains("Release guide"), "manual has guide");
        assert!(!manual.contains("Release idiot"), "manual excludes idiot");
        assert!(!manual.contains("Release why"), "manual excludes reasoning");
        assert!(!manual.contains("Doctrine"), "manual excludes `other`");

        // Idiot guide = only the idiot class.
        let idiot = String::from_utf8(by("idiot_guide").bytes.clone()).unwrap();
        assert!(idiot.contains("Release idiot"));
        assert!(!idiot.contains("Release design"));

        // Unsorted = reasoning (+ history/unsorted).
        let unsorted = String::from_utf8(by("unsorted").bytes.clone()).unwrap();
        assert!(unsorted.contains("Release why"));
        assert!(!unsorted.contains("Release guide"));

        // Output paths are the doctrine defaults with the md extension.
        assert_eq!(by("manual").out, PathBuf::from("docs/manual.md"));
        assert_eq!(by("idiot_guide").out, PathBuf::from("docs/idiot_guide.md"));
    }

    /// The per-chapter SVG book must produce the **same page set** as the
    /// single-doc path: rendering each chapter as its own small typst document and
    /// concatenating the pages yields the same count, in the same order (cover,
    /// TOC, then one fresh page per chapter), as feeding typst one concatenated
    /// document. This is the invariant that lets the split kill typst's
    /// superlinearity without changing what the manual viewer reads.
    #[cfg(feature = "export")]
    #[test]
    fn per_chapter_svg_matches_single_doc_page_set() {
        let t = tempfile::tempdir().unwrap();
        let root = t.path();
        write(
            &root.join("Cargo.toml"),
            "[package]\nname='demo'\nversion='1.0.0'\n",
        );
        // H1-only chapters (no sub-headings) so the single-doc depth-3 `#outline`
        // and the per-chapter chapter-level TOC list the same entries → identical
        // front-matter page count, and the comparison is about the split itself.
        let body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ".repeat(40);
        write(
            &root.join(".nornir/README.md"),
            &format!("# Readme\n\n{body}"),
        );
        write(
            &root.join(".nornir/design.md"),
            &format!("# Design\n\n{body}"),
        );
        write(
            &root.join(".nornir/guide.md"),
            &format!("# Guide\n\n{body}"),
        );

        let ctx = Ctx::new(root, root, None);

        // Per-chapter path — exactly what `nornir docs book --format svg` uses.
        let (per_pages, titles, sources, page_texts) = build_book_svg_pages(root, &ctx).unwrap();
        // Single-doc path — assemble everything, ONE typst compile.
        let (md, meta, _s, _t) = assemble_book(root, &ctx).unwrap();
        let single = export::export_svg_pages(&md, &meta, None, Some(root)).unwrap();

        // Same page SET: identical count, same order. cover(1) + TOC(1) + 3 chapters.
        assert_eq!(
            per_pages.len(),
            single.len(),
            "per-chapter ({}) vs single-doc ({}) page count must match",
            per_pages.len(),
            single.len(),
        );
        // cover + TOC + one fresh page per chapter (H1-only bodies keep the
        // depth-3 outline and the chapter-level TOC the same length → same count).
        assert!(
            per_pages.len() >= 5,
            "cover + TOC + ≥3 chapters, got {}",
            per_pages.len()
        );
        // Every page is a well-formed SVG carrying outlined vector paths (glyphs).
        for (i, p) in per_pages.iter().enumerate() {
            assert!(p.contains("<svg"), "page {i} is an <svg>");
            assert!(p.contains("</svg>"), "page {i} is closed");
            assert!(p.contains("<path"), "page {i} carries vector paths");
        }
        // Titles + sources track the three chapters, in order.
        assert_eq!(titles, vec!["Readme", "Design", "Guide"]);
        assert_eq!(sources.len(), 3);

        // Search sidecar: one plain-text page per chapter (its first), front
        // matter carries none; aligned to the page vector.
        assert_eq!(
            page_texts.len(),
            per_pages.len(),
            "page_texts aligns with pages"
        );
        assert_eq!(
            page_texts.iter().filter(|t| t.is_some()).count(),
            3,
            "one text page per chapter",
        );
        assert!(
            page_texts[0].is_none() && page_texts[1].is_none(),
            "front matter has no text"
        );
        assert!(
            page_texts[2].as_deref().unwrap_or("").contains("Readme"),
            "chapter text present"
        );
    }

    /// Regression: rendering the book for a repo whose key matches the running
    /// self-repo (`nornir`) but whose checkout lives in a differently-named dir
    /// must land the PDF at the *configured* `<repo>/docs/book.pdf` — the real
    /// path — assembling the repo's real `.nornir/*.md`, NOT an empty stub under
    /// a temp dir.
    ///
    /// The historical bug: `[repo.nornir] path = "nornir-orch"` was ignored, so
    /// `docs book nornir` resolved by-name to the wrong (or absent) checkout and
    /// wrote an empty ~16KB `book.pdf`. This drives the same seam end-to-end:
    /// `repo_dir_for` (override) → `build_book` (real sources) →
    /// `RepoLayout::export_path` (real `docs/`), asserting a non-trivial PDF at
    /// the real path and explicitly NOT under `/tmp`.
    #[cfg(feature = "export")]
    #[test]
    fn self_repo_book_lands_at_real_docs_path_not_tmp() {
        use nornir_config::{Nornir, Repo};
        use crate::RepoLayout;

        let ws = tempfile::tempdir().unwrap();
        let ws_root = ws.path();
        // The self-repo's *key* is `nornir`, but its checkout dir is named
        // `nornir-orch` — exactly the dogfooding case. By-name resolution would
        // miss it; the `path` override must redirect to it.
        let repo_dir = ws_root.join("nornir-orch");
        write(
            &repo_dir.join("Cargo.toml"),
            "[package]\nname='nornir'\nversion='9.9.9'\n",
        );
        // Several real `.nornir/*.md` sources, each with real prose, so the
        // assembled book is non-trivial (many pages) rather than an empty stub.
        let body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. \
            Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. \
            Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.\n\n"
            .repeat(40);
        for (name, title) in [
            ("README.md", "Nornir"),
            ("design.md", "Design"),
            ("guide.md", "Guide"),
            ("bencher.md", "Bencher"),
            ("warehouse.md", "Warehouse"),
        ] {
            write(
                &repo_dir.join(format!(".nornir/{name}")),
                &format!("# {title}\n\n{body}"),
            );
        }

        let mut nornir = Nornir::default();
        nornir.repo.insert(
            "nornir".into(),
            Repo {
                path: "nornir-orch".into(),
                ..Default::default()
            },
        );

        // Resolve through the same seam the CLI handler uses.
        let resolved = nornir.repo_dir_for(ws_root, "nornir");
        assert_eq!(
            resolved, repo_dir,
            "override must redirect `nornir` → nornir-orch dir"
        );

        // Build the book PDF from the resolved (real) checkout and write it to
        // the repo's `docs/` via the layout — the exact CLI write path.
        let ctx = Ctx::new(&resolved, ws_root, None);
        let format = DocFormat::parse("pdf").unwrap();
        let (bytes, sources) = build_book(&resolved, &ctx, format).unwrap();

        let layout = RepoLayout::new(&resolved);
        let out = layout.export_path("book", format.extension());
        std::fs::create_dir_all(out.parent().unwrap()).unwrap();
        std::fs::write(&out, &bytes).unwrap();

        // Lands at the configured repo's real docs/book.pdf — under the
        // resolved repo root, NOT at the global self-stub the bug produced
        // (`<temp_dir>/nornir/docs/book.pdf`).
        assert_eq!(out, repo_dir.join("docs/book.pdf"));
        assert!(
            out.starts_with(&repo_dir),
            "book must land under the resolved repo root {}, got {}",
            repo_dir.display(),
            out.display(),
        );
        let self_stub = std::env::temp_dir().join("nornir/docs/book.pdf");
        assert_ne!(
            out, self_stub,
            "book must not be written to the global self-stub"
        );
        // Assembled from the real multi-source set, not an empty stub.
        assert!(
            sources.len() > 1,
            "expected >1 source, got {}",
            sources.len()
        );
        assert!(out.is_file(), "book.pdf must exist at {}", out.display());
        let size = std::fs::metadata(&out).unwrap().len();

        // Baseline: the same pipeline over an *empty* repo (no `.nornir/*.md`)
        // is the "empty-stub" the bug produced. The real book must dwarf it.
        let empty = tempfile::tempdir().unwrap();
        write(
            &empty.path().join("Cargo.toml"),
            "[package]\nname='nornir'\nversion='9.9.9'\n",
        );
        let empty_ctx = Ctx::new(empty.path(), empty.path(), None);
        let (empty_bytes, empty_sources) = build_book(empty.path(), &empty_ctx, format).unwrap();
        assert!(empty_sources.is_empty(), "empty repo must yield no sources");
        assert!(
            size as usize > empty_bytes.len() * 2,
            "book.pdf ({size} bytes) must dwarf the empty stub ({} bytes) — \
             empty-stub regression",
            empty_bytes.len(),
        );
        assert!(
            size > 50_000,
            "book.pdf must be non-trivial, got {size} bytes — empty-stub regression",
        );
    }
}