Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Install the Preprocessor

This chapter has shipped

The story shipped across eight slices plus a small refactor and a wrap-up chore. Every Acceptance criterion is exercised by at least one test in the suite. Read the chapter top-to-bottom for the methodology view; the Outside-in narrative sub-sections embed each frozen tag at the slice that introduced it, so the latest version of each file is in the slice that touched it last (Refactor for src/install.rs, slice 8 for tests/install.rs, slice 6 for src/main.rs, slice 3 for Cargo.toml, slice 2 for src/lib.rs and assets/mdbook-listings.css).

Story

As a book author, I want one command that wires mdbook-listings into my book so that I don’t have to hand-edit configuration or hunt down assets to start using the tool.

Acceptance criteria

  1. After install runs successfully against a book, building that book invokes mdbook-listings as a preprocessor without further author intervention.
  2. After install runs successfully against a book, the HTML build picks up the CSS asset that styles mdbook-listings’s output.
  3. Install is idempotent: a second run on an already-installed book makes no further changes and confirms to the author that nothing changed.
  4. Install preserves the rest of the book’s existing configuration — comments, formatting, and the order of any already-registered preprocessors and outputs are untouched; only entries relevant to mdbook-listings are added.
  5. Install run in a directory without a valid book configuration is rejected with a diagnostic identifying what was expected and not found.
  6. If mdbook-admonish is also registered in the book, install places mdbook-listings before it in the preprocessor chain so the callout → admonish-note pipeline produces correctly styled PDF output.

The slice — outside-in narrative outline

The story shipped as eight slices plus a refactor:

SliceWhat it adds
1/8Failing integration test asserting ACs 1+2 via post-install disk state: a minimal fixture book’s book.toml gains a [preprocessor.listings] entry, references the bundled CSS asset in [output.html].additional-css, and the asset itself is written to the book root. Fails because install is a stub. (Asserting AC 1 by actually running mdbook build is deferred — it would couple the test to having mdbook on PATH at test time.)
2/8Bundle the CSS asset into the binary at compile time (include_bytes!). Unit test: asset is non-empty + matches an expected sentinel. CSS contents stay a placeholder until ch. 4 (Callouts) settles the badge styling.
3/8TOML round-trip primitive (read book.toml, mutate, write back preserving comments + ordering, via toml_edit). Unit-tested on synthetic input strings — no filesystem.
4/8Add the [preprocessor.listings] registration. Unit test for AC 3 (idempotency) on top of slice 3.
5/8Copy the CSS asset to <book-root>/mdbook-listings.css and add it to [output.html].additional-css. Unit test for the additional-css addition (AC 2 in the synthetic-config form).
6/8Wire slices 2–5 into the install CLI handler. Slice 1’s integration test now passes for ACs 1+2. AC 3 (idempotency) is pinned by slice 4’s unit test.
7/8Reject missing book config with a diagnostic (AC 5). New integration test.
8/8Enforce ordering relative to mdbook-admonish if present (AC 6). Unit test on synthetic configs with admonish present / absent / already-correctly-ordered. Integration test in a fixture book with admonish registered after a stub preprocessor.
refactorOptional.

Outside-in narrative

Slice 1 — failing integration test

The first slice introduces a CLI-level integration test that drives install against a minimal fixture book. The test body delegates setup and assertions to a MinimalFixtureBook helper so it reads as the scenario rather than the mechanics:

#![allow(unused)]
fn main() {
//! Integration tests for the Install the Preprocessor story (ch. 1).

use std::fs;
use std::path::{Path, PathBuf};

use tempfile::TempDir;

mod common;
use common::mdbook_listings;

#[test]
#[ignore = "passes once the install command is wired up to do real work"]
fn install_registers_preprocessor_and_writes_css() {
    let book = MinimalFixtureBook::new();

    mdbook_listings()
        .args(["install", "--book-root"])
        .arg(book.root())
        .assert()
        .success();

    book.assert_preprocessor_registered();
    book.assert_css_asset_present();
}

/// The smallest mdbook that's still a valid book: a `book.toml` declaring
/// just the `[book]` table with a title, materialised in a TempDir whose
/// lifetime is tied to this struct so the filesystem clean-up is automatic.
struct MinimalFixtureBook {
    _tmp: TempDir,
    root: PathBuf,
}

impl MinimalFixtureBook {
    fn new() -> Self {
        let tmp = TempDir::new().expect("tempdir");
        let root = tmp.path().to_path_buf();
        fs::write(root.join("book.toml"), "[book]\ntitle = \"Test\"\n").unwrap();
        Self { _tmp: tmp, root }
    }

    fn root(&self) -> &Path {
        &self.root
    }

    fn assert_preprocessor_registered(&self) {
        let book_toml = fs::read_to_string(self.root.join("book.toml")).unwrap();
        assert!(
            book_toml.contains("[preprocessor.listings]"),
            "book.toml should register the preprocessor; got:\n{book_toml}",
        );
        assert!(
            book_toml.contains("mdbook-listings.css"),
            "book.toml should reference the CSS asset; got:\n{book_toml}",
        );
    }

    fn assert_css_asset_present(&self) {
        assert!(
            self.root.join("mdbook-listings.css").exists(),
            "CSS asset should be written to the book root",
        );
    }
}
}

The test is #[ignore]’d so the green-build pre-commit chain stays passing while install is still a stub. It was run once locally first and confirmed to fail at the install invocation (error: 'mdbook-listings install' is not yet implemented); the ignore reason names the condition for unskipping. A later slice wires up the install handler and removes the ignore.

Slice 2 — bundle the CSS asset

Slice 2 introduces the first piece of code the integration test will eventually need: the CSS bytes that install will copy to the book root. The asset is compiled into the binary via [include_bytes!] so a cargo install mdbook-listings produces a self-contained binary with nothing external to fetch.

A new install module declares the constant and a sentinel string that unit tests assert is present in the bundled bytes (so a build that strips or replaces the asset fails loudly):

#![allow(unused)]
fn main() {
//! `install` subcommand: configures an existing book to use mdbook-listings.

/// Compiled in so `cargo install mdbook-listings` produces a self-contained
/// binary with nothing external to fetch at install time.
pub const CSS_ASSET: &[u8] = include_bytes!("../assets/mdbook-listings.css");

/// Catches builds that stripped or replaced the asset — a missing sentinel
/// means the bundled bytes are not the expected build-time asset.
pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v1";

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

    #[test]
    fn css_asset_is_non_empty() {
        assert!(!CSS_ASSET.is_empty(), "bundled CSS asset must not be empty");
    }

    #[test]
    fn css_asset_contains_sentinel() {
        let contents = std::str::from_utf8(CSS_ASSET).expect("CSS asset must be UTF-8");
        assert!(
            contents.contains(CSS_ASSET_SENTINEL),
            "bundled CSS asset must contain sentinel `{CSS_ASSET_SENTINEL}`; got:\n{contents}",
        );
    }
}
}

The asset itself is intentionally a placeholder — real callout styling depends on choices the Render Inline Callouts story (ch. 4) hasn’t made yet. The placeholder carries only the sentinel string the unit tests look for:

/* mdbook-listings — placeholder CSS.
 *
 * The Render Inline Callouts story (ch. 4) will replace this with the real
 * styling for callout badges and <details> blocks. Until then the asset
 * exists only so the install pipeline has something to ship and so the
 * build-time bundling can be smoke-tested.
 *
 * Sentinel string used by unit tests to confirm the bundled bytes are the
 * expected build-time asset: mdbook-listings-css-v1
 */

src/lib.rs gains one line — pub mod install; — so the rest of the crate can reach the new module:

#![allow(unused)]
fn main() {
//! Managed code listings for mdbook.

pub mod freeze;
pub mod install;
pub mod manifest;
}

The unit tests run as part of the regular suite and pass; the integration test from slice 1 is still #[ignore]’d because install doesn’t yet do anything with the bundled asset.

Slice 3 — TOML round-trip primitive

Slice 3 stands up the primitive that lets later slices mutate book.toml while preserving its formatting: a BookConfig newtype around toml_edit::DocumentMut. Two unit tests pin the guarantees the wrapper has to keep — round-tripping a config without mutation is byte-identical to the input (preserving comments and entry ordering), and invalid TOML is rejected with a diagnostic.

Cargo.toml gains toml_edit as a runtime dep:

[package]
name = "mdbook-listings"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
license = "MIT"
description = "Managed code listings for mdbook: inline callouts, freezing, and verification"
repository = "https://github.com/padamson/mdbook-listings"
categories = ["command-line-utilities", "text-processing"]
keywords = ["mdbook", "preprocessor", "documentation", "code-listing"]

[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
sha2 = "0.11"
toml = "1.1"
toml_edit = "0.22"

[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"

The install module now declares the primitive alongside the CSS asset bundling from slice 2. What’s new in install-v2 compared to install-v1: the BookConfig struct (with #[derive(Debug)] so test failures format readably), its parse and render methods, two new tests (book_config_round_trip_preserves_comments_and_ordering and book_config_parse_rejects_invalid_toml), and the imports those need (anyhow::{Context, Result}, toml_edit::DocumentMut). Everything else — the CSS constants and their tests — is unchanged from install-v1.

#![allow(unused)]
fn main() {
//! `install` subcommand: configures an existing book to use mdbook-listings.

use anyhow::{Context, Result};
use toml_edit::DocumentMut;

/// Compiled in so `cargo install mdbook-listings` produces a self-contained
/// binary with nothing external to fetch at install time.
pub const CSS_ASSET: &[u8] = include_bytes!("../assets/mdbook-listings.css");

/// Catches builds that stripped or replaced the asset — a missing sentinel
/// means the bundled bytes are not the expected build-time asset.
pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v1";

/// Newtype over [`toml_edit::DocumentMut`] so future install methods
/// (register preprocessor, add additional-css) have a domain type to
/// attach to and so callers don't depend on `toml_edit` directly.
#[derive(Debug)]
pub struct BookConfig(DocumentMut);

impl BookConfig {
    pub fn parse(s: &str) -> Result<Self> {
        s.parse::<DocumentMut>()
            .map(BookConfig)
            .context("book config is not valid TOML")
    }

    pub fn render(&self) -> String {
        self.0.to_string()
    }
}

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

    #[test]
    fn css_asset_is_non_empty() {
        assert!(!CSS_ASSET.is_empty(), "bundled CSS asset must not be empty");
    }

    #[test]
    fn css_asset_contains_sentinel() {
        let contents = std::str::from_utf8(CSS_ASSET).expect("CSS asset must be UTF-8");
        assert!(
            contents.contains(CSS_ASSET_SENTINEL),
            "bundled CSS asset must contain sentinel `{CSS_ASSET_SENTINEL}`; got:\n{contents}",
        );
    }

    #[test]
    fn book_config_round_trip_preserves_comments_and_ordering() {
        let input = "\
top comment
[book]
title = \"Test\"

preprocessor comment
[preprocessor.admonish]
command = \"mdbook-admonish\"

[output.html]
";
        let cfg = BookConfig::parse(input).expect("parse");
        assert_eq!(cfg.render(), input);
    }

    #[test]
    fn book_config_parse_rejects_invalid_toml() {
        let err = BookConfig::parse("[book\nbroken = ").unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("not valid TOML"),
            "diagnostic should name the failure mode; got: {msg}"
        );
    }
}
}

The integration test from slice 1 is still #[ignore]’d. BookConfig is plumbing — slice 4 wires it up to add the [preprocessor.listings] registration that satisfies the test’s first assertion.

Slice 4 — register the [preprocessor.listings] entry

Slice 4 adds the BookConfig method that satisfies the chunk of AC 1 visible from book.toml: a [preprocessor.listings] entry with command = "mdbook-listings". Two unit tests pin (a) that the entry is added with the right command value and (b) that the operation is idempotent — a second call on an already-registered config produces identical rendered output (this is the unit-test form of AC 3).

What’s new in install-v3 compared to install-v2: the register_listings_preprocessor method on BookConfig, the Item, Table imports it needs from toml_edit, and two new tests (book_config_register_listings_preprocessor_adds_entry and book_config_register_listings_preprocessor_is_idempotent). Everything else — the CSS constants, the BookConfig parse and render methods, and their tests — is unchanged from install-v2.

#![allow(unused)]
fn main() {
//! `install` subcommand: configures an existing book to use mdbook-listings.

use anyhow::{Context, Result};
use toml_edit::{DocumentMut, Item, Table};

/// Compiled in so `cargo install mdbook-listings` produces a self-contained
/// binary with nothing external to fetch at install time.
pub const CSS_ASSET: &[u8] = include_bytes!("../assets/mdbook-listings.css");

/// Catches builds that stripped or replaced the asset — a missing sentinel
/// means the bundled bytes are not the expected build-time asset.
pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v1";

/// Newtype over [`toml_edit::DocumentMut`] so future install methods
/// (register preprocessor, add additional-css) have a domain type to
/// attach to and so callers don't depend on `toml_edit` directly.
#[derive(Debug)]
pub struct BookConfig(DocumentMut);

impl BookConfig {
    pub fn parse(s: &str) -> Result<Self> {
        s.parse::<DocumentMut>()
            .map(BookConfig)
            .context("book config is not valid TOML")
    }

    pub fn render(&self) -> String {
        self.0.to_string()
    }

    /// Add (or confirm the presence of) `[preprocessor.listings]` with
    /// `command = "mdbook-listings"`. Idempotent — a second call on an
    /// already-registered config produces identical rendered output.
    pub fn register_listings_preprocessor(&mut self) {
        let preprocessor = self
            .0
            .as_table_mut()
            .entry("preprocessor")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[preprocessor] must be a table");
        let listings = preprocessor
            .entry("listings")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[preprocessor.listings] must be a table");
        listings["command"] = toml_edit::value("mdbook-listings");
    }
}

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

    #[test]
    fn css_asset_is_non_empty() {
        assert!(!CSS_ASSET.is_empty(), "bundled CSS asset must not be empty");
    }

    #[test]
    fn css_asset_contains_sentinel() {
        let contents = std::str::from_utf8(CSS_ASSET).expect("CSS asset must be UTF-8");
        assert!(
            contents.contains(CSS_ASSET_SENTINEL),
            "bundled CSS asset must contain sentinel `{CSS_ASSET_SENTINEL}`; got:\n{contents}",
        );
    }

    #[test]
    fn book_config_round_trip_preserves_comments_and_ordering() {
        let input = "\
top comment
[book]
title = \"Test\"

preprocessor comment
[preprocessor.admonish]
command = \"mdbook-admonish\"

[output.html]
";
        let cfg = BookConfig::parse(input).expect("parse");
        assert_eq!(cfg.render(), input);
    }

    #[test]
    fn book_config_parse_rejects_invalid_toml() {
        let err = BookConfig::parse("[book\nbroken = ").unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("not valid TOML"),
            "diagnostic should name the failure mode; got: {msg}"
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_preprocessor();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[preprocessor.listings]"),
            "rendered config should declare [preprocessor.listings]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"command = "mdbook-listings""#),
            "rendered config should set command = \"mdbook-listings\"; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_preprocessor();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_preprocessor();
        let after_second = cfg2.render();

        assert_eq!(after_first, after_second, "register must be idempotent");
    }
}
}

The integration test from slice 1 is still #[ignore]’d. The register method handles the [preprocessor.listings] half of the post-install disk state; slice 5 adds the matching additional-css registration for the CSS asset, and slice 6 wires both into the install handler so the integration test goes green.

Slice 5 — copy the CSS asset and register it

Slice 5 covers the other half of AC 2: [output.html] gets additional-css = ["./mdbook-listings.css"] so mdbook’s HTML build picks the asset up, and the on-disk copy of the asset itself lands at <book-root>/mdbook-listings.css.

The TOML mutation is a BookConfig::register_listings_css method on the same newtype as the preprocessor registration; the file copy is a free function write_css_asset(book_root) that writes [CSS_ASSET] (from slice 2) to the conventional filename. A new CSS_ASSET_FILENAME constant ties the two together so they can’t drift out of sync. Two unit tests pin the additional-css side: the entry is added with the right relative path, and the operation is idempotent (no duplicate entries on a second call).

What’s new in install-v4 compared to install-v3: the CSS_ASSET_FILENAME constant, the write_css_asset free function, the register_listings_css method on BookConfig, two new tests (book_config_register_listings_css_adds_entry, book_config_register_listings_css_is_idempotent), and the imports they need (std::fs, std::path::Path, plus toml_edit::{Array, Value} added to the existing import line). Everything else — the CSS constants, the parse/render methods, the preprocessor-registration method, and their tests — is unchanged from install-v3.

#![allow(unused)]
fn main() {
//! `install` subcommand: configures an existing book to use mdbook-listings.

use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use toml_edit::{Array, DocumentMut, Item, Table, Value};

/// Compiled in so `cargo install mdbook-listings` produces a self-contained
/// binary with nothing external to fetch at install time.
pub const CSS_ASSET: &[u8] = include_bytes!("../assets/mdbook-listings.css");

/// Catches builds that stripped or replaced the asset — a missing sentinel
/// means the bundled bytes are not the expected build-time asset.
pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v1";

/// Filename the CSS asset is written under at install time. Shared between
/// the file-copy side and the `[output.html].additional-css` entry so the
/// two can't drift.
pub const CSS_ASSET_FILENAME: &str = "mdbook-listings.css";

/// Write the bundled [`CSS_ASSET`] to `<book_root>/<CSS_ASSET_FILENAME>`,
/// creating or overwriting the file. Existing content is replaced
/// unconditionally — this is the "ship the version this binary was
/// built with" semantics the install command wants.
pub fn write_css_asset(book_root: &Path) -> Result<()> {
    let path = book_root.join(CSS_ASSET_FILENAME);
    fs::write(&path, CSS_ASSET).with_context(|| format!("writing CSS asset to {}", path.display()))
}

/// Newtype over [`toml_edit::DocumentMut`] so future install methods
/// (register preprocessor, add additional-css) have a domain type to
/// attach to and so callers don't depend on `toml_edit` directly.
#[derive(Debug)]
pub struct BookConfig(DocumentMut);

impl BookConfig {
    pub fn parse(s: &str) -> Result<Self> {
        s.parse::<DocumentMut>()
            .map(BookConfig)
            .context("book config is not valid TOML")
    }

    pub fn render(&self) -> String {
        self.0.to_string()
    }

    /// Add (or confirm the presence of) `[preprocessor.listings]` with
    /// `command = "mdbook-listings"`. Idempotent — a second call on an
    /// already-registered config produces identical rendered output.
    pub fn register_listings_preprocessor(&mut self) {
        let preprocessor = self
            .0
            .as_table_mut()
            .entry("preprocessor")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[preprocessor] must be a table");
        let listings = preprocessor
            .entry("listings")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[preprocessor.listings] must be a table");
        listings["command"] = toml_edit::value("mdbook-listings");
    }

    /// Add `./<CSS_ASSET_FILENAME>` to `[output.html].additional-css`,
    /// creating the section + array as needed. Idempotent — duplicate
    /// entries are not appended.
    pub fn register_listings_css(&mut self) {
        let entry = format!("./{CSS_ASSET_FILENAME}");
        let array = self
            .0
            .as_table_mut()
            .entry("output")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[output] must be a table")
            .entry("html")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[output.html] must be a table")
            .entry("additional-css")
            .or_insert_with(|| Item::Value(Value::Array(Array::new())))
            .as_value_mut()
            .expect("additional-css must be a value")
            .as_array_mut()
            .expect("additional-css must be an array");
        if !array.iter().any(|v| v.as_str() == Some(entry.as_str())) {
            array.push(entry);
        }
    }
}

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

    #[test]
    fn css_asset_is_non_empty() {
        assert!(!CSS_ASSET.is_empty(), "bundled CSS asset must not be empty");
    }

    #[test]
    fn css_asset_contains_sentinel() {
        let contents = std::str::from_utf8(CSS_ASSET).expect("CSS asset must be UTF-8");
        assert!(
            contents.contains(CSS_ASSET_SENTINEL),
            "bundled CSS asset must contain sentinel `{CSS_ASSET_SENTINEL}`; got:\n{contents}",
        );
    }

    #[test]
    fn book_config_round_trip_preserves_comments_and_ordering() {
        let input = "\
top comment
[book]
title = \"Test\"

preprocessor comment
[preprocessor.admonish]
command = \"mdbook-admonish\"

[output.html]
";
        let cfg = BookConfig::parse(input).expect("parse");
        assert_eq!(cfg.render(), input);
    }

    #[test]
    fn book_config_parse_rejects_invalid_toml() {
        let err = BookConfig::parse("[book\nbroken = ").unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("not valid TOML"),
            "diagnostic should name the failure mode; got: {msg}"
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_preprocessor();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[preprocessor.listings]"),
            "rendered config should declare [preprocessor.listings]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"command = "mdbook-listings""#),
            "rendered config should set command = \"mdbook-listings\"; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_preprocessor();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_preprocessor();
        let after_second = cfg2.render();

        assert_eq!(after_first, after_second, "register must be idempotent");
    }

    #[test]
    fn book_config_register_listings_css_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_css();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[output.html]"),
            "rendered config should declare [output.html]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"additional-css = ["./mdbook-listings.css"]"#),
            "rendered config should reference the CSS asset; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_css_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_css();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_css();
        let after_second = cfg2.render();

        assert_eq!(
            after_first, after_second,
            "register_listings_css must be idempotent"
        );
    }
}
}

The integration test from slice 1 is still #[ignore]’d. Slice 5 finishes the building blocks; slice 6 sequences BookConfig::parse → register_listings_preprocessor → register_listings_css → render → write back to book.toml, plus a write_css_asset call, behind the install CLI handler — at which point the integration test passes for ACs 1+2.

Slice 6 — wire the install handler

Slice 6 sequences the building blocks from slices 2–5 behind the CLI: an install(book_root) orchestrator function in src/install.rs reads book.toml, parses it, calls register_listings_preprocessor and register_listings_css, writes the file back if anything changed, and writes the CSS asset if the on-disk copy differs from the bundled bytes. It returns an InstallOutcome enum (Installed or Unchanged) so the CLI can confirm to the author whether the run was a no-op (the user-visible half of AC 3).

src/main.rs’s Command::Install arm calls into the orchestrator and prints either “installed mdbook-listings into …” or “mdbook-listings already installed in …; nothing changed” based on the outcome.

tests/install.rs drops the #[ignore] attribute on install_registers_preprocessor_and_writes_css. The test now runs as part of the regular suite and passes — confirming ACs 1+2 end-to-end.

What’s new in install-v5 compared to install-v4: the install orchestrator function and the InstallOutcome enum. The bodies of constants, write_css_asset, the BookConfig methods, and all the existing tests are unchanged. Doc comments throughout were trimmed to a why-only style — restating function names or describing the body in prose is dropped — but the code itself is the same as install-v4.

#![allow(unused)]
fn main() {
//! `install` subcommand: configures an existing book to use mdbook-listings.

use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use toml_edit::{Array, DocumentMut, Item, Table, Value};

/// Compiled in so `cargo install mdbook-listings` produces a self-contained
/// binary with nothing external to fetch at install time.
pub const CSS_ASSET: &[u8] = include_bytes!("../assets/mdbook-listings.css");

/// Catches builds that stripped or replaced the asset — a missing sentinel
/// means the bundled bytes are not the expected build-time asset.
pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v1";

/// Shared between [`write_css_asset`] and
/// [`BookConfig::register_listings_css`] so the two can't drift.
pub const CSS_ASSET_FILENAME: &str = "mdbook-listings.css";

/// Always overwrites — install ships the bundled bytes, not whatever a
/// stale on-disk copy happens to contain.
pub fn write_css_asset(book_root: &Path) -> Result<()> {
    let path = book_root.join(CSS_ASSET_FILENAME);
    fs::write(&path, CSS_ASSET).with_context(|| format!("writing CSS asset to {}", path.display()))
}

/// Idempotent: book.toml and the CSS asset on disk are only rewritten if
/// they differ from what install would produce.
pub fn install(book_root: &Path) -> Result<InstallOutcome> {
    let book_toml_path = book_root.join("book.toml");
    let original = fs::read_to_string(&book_toml_path)
        .with_context(|| format!("reading book config at {}", book_toml_path.display()))?;
    let mut config = BookConfig::parse(&original)?;
    config.register_listings_preprocessor();
    config.register_listings_css();
    let new = config.render();

    let css_path = book_root.join(CSS_ASSET_FILENAME);
    let css_already_correct = fs::read(&css_path)
        .ok()
        .is_some_and(|bytes| bytes.as_slice() == CSS_ASSET);

    let toml_changed = new != original;
    if toml_changed {
        fs::write(&book_toml_path, new)
            .with_context(|| format!("writing book config at {}", book_toml_path.display()))?;
    }
    if !css_already_correct {
        write_css_asset(book_root)?;
    }

    Ok(if toml_changed || !css_already_correct {
        InstallOutcome::Installed
    } else {
        InstallOutcome::Unchanged
    })
}

/// Lets the CLI tell the author whether a re-install was a no-op (AC 3).
#[derive(Debug, PartialEq, Eq)]
pub enum InstallOutcome {
    Installed,
    Unchanged,
}

/// Newtype over [`toml_edit::DocumentMut`] so callers don't depend on
/// `toml_edit` directly and the `register_*` methods have a domain type
/// to attach to.
#[derive(Debug)]
pub struct BookConfig(DocumentMut);

impl BookConfig {
    pub fn parse(s: &str) -> Result<Self> {
        s.parse::<DocumentMut>()
            .map(BookConfig)
            .context("book config is not valid TOML")
    }

    pub fn render(&self) -> String {
        self.0.to_string()
    }

    /// Idempotent: a second call on an already-registered config is a no-op
    /// in the rendered output.
    pub fn register_listings_preprocessor(&mut self) {
        let preprocessor = self
            .0
            .as_table_mut()
            .entry("preprocessor")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[preprocessor] must be a table");
        let listings = preprocessor
            .entry("listings")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[preprocessor.listings] must be a table");
        listings["command"] = toml_edit::value("mdbook-listings");
    }

    /// Idempotent: duplicate entries are not appended.
    pub fn register_listings_css(&mut self) {
        let entry = format!("./{CSS_ASSET_FILENAME}");
        let array = self
            .0
            .as_table_mut()
            .entry("output")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[output] must be a table")
            .entry("html")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[output.html] must be a table")
            .entry("additional-css")
            .or_insert_with(|| Item::Value(Value::Array(Array::new())))
            .as_value_mut()
            .expect("additional-css must be a value")
            .as_array_mut()
            .expect("additional-css must be an array");
        if !array.iter().any(|v| v.as_str() == Some(entry.as_str())) {
            array.push(entry);
        }
    }
}

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

    #[test]
    fn css_asset_is_non_empty() {
        assert!(!CSS_ASSET.is_empty(), "bundled CSS asset must not be empty");
    }

    #[test]
    fn css_asset_contains_sentinel() {
        let contents = std::str::from_utf8(CSS_ASSET).expect("CSS asset must be UTF-8");
        assert!(
            contents.contains(CSS_ASSET_SENTINEL),
            "bundled CSS asset must contain sentinel `{CSS_ASSET_SENTINEL}`; got:\n{contents}",
        );
    }

    #[test]
    fn book_config_round_trip_preserves_comments_and_ordering() {
        let input = "\
top comment
[book]
title = \"Test\"

preprocessor comment
[preprocessor.admonish]
command = \"mdbook-admonish\"

[output.html]
";
        let cfg = BookConfig::parse(input).expect("parse");
        assert_eq!(cfg.render(), input);
    }

    #[test]
    fn book_config_parse_rejects_invalid_toml() {
        let err = BookConfig::parse("[book\nbroken = ").unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("not valid TOML"),
            "diagnostic should name the failure mode; got: {msg}"
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_preprocessor();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[preprocessor.listings]"),
            "rendered config should declare [preprocessor.listings]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"command = "mdbook-listings""#),
            "rendered config should set command = \"mdbook-listings\"; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_preprocessor();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_preprocessor();
        let after_second = cfg2.render();

        assert_eq!(after_first, after_second, "register must be idempotent");
    }

    #[test]
    fn book_config_register_listings_css_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_css();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[output.html]"),
            "rendered config should declare [output.html]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"additional-css = ["./mdbook-listings.css"]"#),
            "rendered config should reference the CSS asset; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_css_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_css();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_css();
        let after_second = cfg2.render();

        assert_eq!(
            after_first, after_second,
            "register_listings_css must be idempotent"
        );
    }
}
}

What’s new in main-v2 compared to main-v1: the Command::Install arm now calls mdbook_listings::install::install, branches on InstallOutcome, and prints one of two messages. The use mdbook_listings::install::{InstallOutcome, install}; import is added. Everything else — the other subcommands (Supports, Freeze, Verify, the no-subcommand preprocessor stub) and the main/run/supports functions — is unchanged from main-v1.

use std::path::PathBuf;
use std::process;

use anyhow::Result;
use clap::{Parser, Subcommand};
use mdbook_listings::freeze::{FreezeOptions, FreezeOutcome, freeze};
use mdbook_listings::install::{InstallOutcome, install};

/// Managed code listings for mdbook: inline callouts, freezing, and verification.
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Subcommand)]
enum Command {
    /// Check whether a renderer is supported by this preprocessor.
    ///
    /// Invoked by mdbook during the build to decide whether to pipe the book
    /// through this preprocessor for a given renderer. Exits 0 if supported,
    /// 1 otherwise.
    Supports {
        /// Name of the renderer mdbook is asking about (e.g. `html`, `typst-pdf`).
        renderer: String,
    },

    /// Install preprocessor assets and register mdbook-listings in `book.toml`.
    Install {
        /// Root directory of the book (contains `book.toml`). Defaults to the
        /// current directory.
        #[arg(long)]
        book_root: Option<PathBuf>,
    },

    /// Freeze a source file into the book's listings directory and update
    /// the manifest.
    Freeze {
        /// Human-readable tag used as the frozen filename and as the manifest
        /// entry key. Should be unique within the book.
        #[arg(long)]
        tag: String,

        /// Root directory of the book. Defaults to the current directory.
        #[arg(long)]
        book_root: Option<PathBuf>,

        /// Overwrite an existing frozen copy with the same tag.
        #[arg(long)]
        force: bool,

        /// Path to the source file to freeze.
        source: PathBuf,
    },

    /// Verify consistency between the manifest, frozen listings, and `{{#include}}`
    /// references in the book's markdown.
    Verify {
        /// Root directory of the book. Defaults to the current directory.
        #[arg(long)]
        book_root: Option<PathBuf>,
    },
}

fn main() {
    if let Err(err) = run() {
        eprintln!("error: {err:?}");
        process::exit(1);
    }
}

fn run() -> Result<()> {
    let cli = Cli::parse();
    match cli.command {
        None => preprocess(),
        Some(Command::Supports { renderer }) => supports(&renderer),
        Some(Command::Install { book_root }) => {
            let book_root = book_root.unwrap_or_else(|| PathBuf::from("."));
            match install(&book_root)? {
                InstallOutcome::Installed => {
                    println!("installed mdbook-listings into {}", book_root.display());
                }
                InstallOutcome::Unchanged => {
                    println!(
                        "mdbook-listings already installed in {}; nothing changed",
                        book_root.display(),
                    );
                }
            }
            Ok(())
        }
        Some(Command::Freeze {
            tag,
            book_root,
            force,
            source,
        }) => {
            let book_root = book_root.unwrap_or_else(|| PathBuf::from("."));
            let outcome = freeze(FreezeOptions {
                book_root: &book_root,
                tag: &tag,
                source: &source,
                force,
            })?;
            let verb = match outcome {
                FreezeOutcome::Created => "created",
                FreezeOutcome::Unchanged => "unchanged",
                FreezeOutcome::Replaced => "replaced",
            };
            println!("{verb}: {tag}");
            Ok(())
        }
        Some(Command::Verify { book_root: _ }) => {
            anyhow::bail!("`mdbook-listings verify` is not yet implemented")
        }
    }
}

/// Default mode: read an mdbook preprocessor JSON payload from stdin, emit the
/// transformed payload on stdout.
fn preprocess() -> Result<()> {
    anyhow::bail!("mdbook-listings preprocessor mode is not yet implemented")
}

/// Answer mdbook's renderer-support probe by exiting 0 (supported) or 1
/// (unsupported). We do not return from this function.
fn supports(renderer: &str) -> ! {
    let supported = matches!(renderer, "html" | "typst-pdf");
    process::exit(if supported { 0 } else { 1 });
}

What’s new in install-tests-v2 compared to install-tests-v1: the #[ignore = "..."] attribute is removed; the #[test] attribute and the body are unchanged.

#![allow(unused)]
fn main() {
//! Integration tests for the Install the Preprocessor story (ch. 1).

use std::fs;
use std::path::{Path, PathBuf};

use tempfile::TempDir;

mod common;
use common::mdbook_listings;

#[test]
fn install_registers_preprocessor_and_writes_css() {
    let book = MinimalFixtureBook::new();

    mdbook_listings()
        .args(["install", "--book-root"])
        .arg(book.root())
        .assert()
        .success();

    book.assert_preprocessor_registered();
    book.assert_css_asset_present();
}

/// The smallest mdbook that's still a valid book: a `book.toml` declaring
/// just the `[book]` table with a title, materialised in a TempDir whose
/// lifetime is tied to this struct so the filesystem clean-up is automatic.
struct MinimalFixtureBook {
    _tmp: TempDir,
    root: PathBuf,
}

impl MinimalFixtureBook {
    fn new() -> Self {
        let tmp = TempDir::new().expect("tempdir");
        let root = tmp.path().to_path_buf();
        fs::write(root.join("book.toml"), "[book]\ntitle = \"Test\"\n").unwrap();
        Self { _tmp: tmp, root }
    }

    fn root(&self) -> &Path {
        &self.root
    }

    fn assert_preprocessor_registered(&self) {
        let book_toml = fs::read_to_string(self.root.join("book.toml")).unwrap();
        assert!(
            book_toml.contains("[preprocessor.listings]"),
            "book.toml should register the preprocessor; got:\n{book_toml}",
        );
        assert!(
            book_toml.contains("mdbook-listings.css"),
            "book.toml should reference the CSS asset; got:\n{book_toml}",
        );
    }

    fn assert_css_asset_present(&self) {
        assert!(
            self.root.join("mdbook-listings.css").exists(),
            "CSS asset should be written to the book root",
        );
    }
}
}

The integration test from slice 1 is no longer ignored. Slices 7 and 8 add the remaining ACs (missing-config diagnostic for AC 5, mdbook-admonish ordering for AC 6).

Slice 7 — reject missing book config

Slice 7 makes the missing-book.toml case fail with a helpful diagnostic instead of a generic “reading book config” wrapper. Before slice 7, running install in a directory with no book.toml produced something like:

error: reading book config at /path/to/book.toml
Caused by: No such file or directory (os error 2)

After slice 7:

error: book.toml not found at /path/to/book.toml — install requires
an existing mdbook book directory; run `mdbook init` first.

A new integration test install_rejects_missing_book_config runs install against a fresh TempDir (no book.toml) and asserts the binary exits non-zero with book.toml not found in stderr.

What’s new in install-v6 compared to install-v5: the fs::read_to_string call inside install is now a match that special-cases io::ErrorKind::NotFound with a tailored diagnostic; other I/O errors still go through the existing with_context path. Everything else — every other function, constant, struct, and test — is unchanged.

#![allow(unused)]
fn main() {
//! `install` subcommand: configures an existing book to use mdbook-listings.

use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use toml_edit::{Array, DocumentMut, Item, Table, Value};

/// Compiled in so `cargo install mdbook-listings` produces a self-contained
/// binary with nothing external to fetch at install time.
pub const CSS_ASSET: &[u8] = include_bytes!("../assets/mdbook-listings.css");

/// Catches builds that stripped or replaced the asset — a missing sentinel
/// means the bundled bytes are not the expected build-time asset.
pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v1";

/// Shared between [`write_css_asset`] and
/// [`BookConfig::register_listings_css`] so the two can't drift.
pub const CSS_ASSET_FILENAME: &str = "mdbook-listings.css";

/// Always overwrites — install ships the bundled bytes, not whatever a
/// stale on-disk copy happens to contain.
pub fn write_css_asset(book_root: &Path) -> Result<()> {
    let path = book_root.join(CSS_ASSET_FILENAME);
    fs::write(&path, CSS_ASSET).with_context(|| format!("writing CSS asset to {}", path.display()))
}

/// Idempotent: book.toml and the CSS asset on disk are only rewritten if
/// they differ from what install would produce.
pub fn install(book_root: &Path) -> Result<InstallOutcome> {
    let book_toml_path = book_root.join("book.toml");
    let original = match fs::read_to_string(&book_toml_path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            anyhow::bail!(
                "book.toml not found at {} — install requires an existing mdbook book directory; run `mdbook init` first.",
                book_toml_path.display(),
            );
        }
        Err(e) => {
            return Err(anyhow::Error::from(e))
                .with_context(|| format!("reading book config at {}", book_toml_path.display()));
        }
    };
    let mut config = BookConfig::parse(&original)?;
    config.register_listings_preprocessor();
    config.register_listings_css();
    let new = config.render();

    let css_path = book_root.join(CSS_ASSET_FILENAME);
    let css_already_correct = fs::read(&css_path)
        .ok()
        .is_some_and(|bytes| bytes.as_slice() == CSS_ASSET);

    let toml_changed = new != original;
    if toml_changed {
        fs::write(&book_toml_path, new)
            .with_context(|| format!("writing book config at {}", book_toml_path.display()))?;
    }
    if !css_already_correct {
        write_css_asset(book_root)?;
    }

    Ok(if toml_changed || !css_already_correct {
        InstallOutcome::Installed
    } else {
        InstallOutcome::Unchanged
    })
}

/// Lets the CLI tell the author whether a re-install was a no-op (AC 3).
#[derive(Debug, PartialEq, Eq)]
pub enum InstallOutcome {
    Installed,
    Unchanged,
}

/// Newtype over [`toml_edit::DocumentMut`] so callers don't depend on
/// `toml_edit` directly and the `register_*` methods have a domain type
/// to attach to.
#[derive(Debug)]
pub struct BookConfig(DocumentMut);

impl BookConfig {
    pub fn parse(s: &str) -> Result<Self> {
        s.parse::<DocumentMut>()
            .map(BookConfig)
            .context("book config is not valid TOML")
    }

    pub fn render(&self) -> String {
        self.0.to_string()
    }

    /// Idempotent: a second call on an already-registered config is a no-op
    /// in the rendered output.
    pub fn register_listings_preprocessor(&mut self) {
        let preprocessor = self
            .0
            .as_table_mut()
            .entry("preprocessor")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[preprocessor] must be a table");
        let listings = preprocessor
            .entry("listings")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[preprocessor.listings] must be a table");
        listings["command"] = toml_edit::value("mdbook-listings");
    }

    /// Idempotent: duplicate entries are not appended.
    pub fn register_listings_css(&mut self) {
        let entry = format!("./{CSS_ASSET_FILENAME}");
        let array = self
            .0
            .as_table_mut()
            .entry("output")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[output] must be a table")
            .entry("html")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[output.html] must be a table")
            .entry("additional-css")
            .or_insert_with(|| Item::Value(Value::Array(Array::new())))
            .as_value_mut()
            .expect("additional-css must be a value")
            .as_array_mut()
            .expect("additional-css must be an array");
        if !array.iter().any(|v| v.as_str() == Some(entry.as_str())) {
            array.push(entry);
        }
    }
}

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

    #[test]
    fn css_asset_is_non_empty() {
        assert!(!CSS_ASSET.is_empty(), "bundled CSS asset must not be empty");
    }

    #[test]
    fn css_asset_contains_sentinel() {
        let contents = std::str::from_utf8(CSS_ASSET).expect("CSS asset must be UTF-8");
        assert!(
            contents.contains(CSS_ASSET_SENTINEL),
            "bundled CSS asset must contain sentinel `{CSS_ASSET_SENTINEL}`; got:\n{contents}",
        );
    }

    #[test]
    fn book_config_round_trip_preserves_comments_and_ordering() {
        let input = "\
top comment
[book]
title = \"Test\"

preprocessor comment
[preprocessor.admonish]
command = \"mdbook-admonish\"

[output.html]
";
        let cfg = BookConfig::parse(input).expect("parse");
        assert_eq!(cfg.render(), input);
    }

    #[test]
    fn book_config_parse_rejects_invalid_toml() {
        let err = BookConfig::parse("[book\nbroken = ").unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("not valid TOML"),
            "diagnostic should name the failure mode; got: {msg}"
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_preprocessor();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[preprocessor.listings]"),
            "rendered config should declare [preprocessor.listings]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"command = "mdbook-listings""#),
            "rendered config should set command = \"mdbook-listings\"; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_preprocessor();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_preprocessor();
        let after_second = cfg2.render();

        assert_eq!(after_first, after_second, "register must be idempotent");
    }

    #[test]
    fn book_config_register_listings_css_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_css();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[output.html]"),
            "rendered config should declare [output.html]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"additional-css = ["./mdbook-listings.css"]"#),
            "rendered config should reference the CSS asset; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_css_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_css();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_css();
        let after_second = cfg2.render();

        assert_eq!(
            after_first, after_second,
            "register_listings_css must be idempotent"
        );
    }
}
}

What’s new in install-tests-v3 compared to install-tests-v2: the predicates::str::contains import and the new install_rejects_missing_book_config test. The existing test (install_registers_preprocessor_and_writes_css) and the MinimalFixtureBook helper are unchanged.

#![allow(unused)]
fn main() {
//! Integration tests for the Install the Preprocessor story (ch. 1).

use std::fs;
use std::path::{Path, PathBuf};

use predicates::str::contains;
use tempfile::TempDir;

mod common;
use common::mdbook_listings;

#[test]
fn install_registers_preprocessor_and_writes_css() {
    let book = MinimalFixtureBook::new();

    mdbook_listings()
        .args(["install", "--book-root"])
        .arg(book.root())
        .assert()
        .success();

    book.assert_preprocessor_registered();
    book.assert_css_asset_present();
}

/// The smallest mdbook that's still a valid book: a `book.toml` declaring
/// just the `[book]` table with a title, materialised in a TempDir whose
/// lifetime is tied to this struct so the filesystem clean-up is automatic.
struct MinimalFixtureBook {
    _tmp: TempDir,
    root: PathBuf,
}

impl MinimalFixtureBook {
    fn new() -> Self {
        let tmp = TempDir::new().expect("tempdir");
        let root = tmp.path().to_path_buf();
        fs::write(root.join("book.toml"), "[book]\ntitle = \"Test\"\n").unwrap();
        Self { _tmp: tmp, root }
    }

    fn root(&self) -> &Path {
        &self.root
    }

    fn assert_preprocessor_registered(&self) {
        let book_toml = fs::read_to_string(self.root.join("book.toml")).unwrap();
        assert!(
            book_toml.contains("[preprocessor.listings]"),
            "book.toml should register the preprocessor; got:\n{book_toml}",
        );
        assert!(
            book_toml.contains("mdbook-listings.css"),
            "book.toml should reference the CSS asset; got:\n{book_toml}",
        );
    }

    fn assert_css_asset_present(&self) {
        assert!(
            self.root.join("mdbook-listings.css").exists(),
            "CSS asset should be written to the book root",
        );
    }
}

/// `install` against a directory with no `book.toml` exits non-zero with a
/// diagnostic identifying what was expected (AC 5).
#[test]
fn install_rejects_missing_book_config() {
    let tmp = TempDir::new().expect("tempdir");

    mdbook_listings()
        .args(["install", "--book-root"])
        .arg(tmp.path())
        .assert()
        .failure()
        .stderr(contains("book.toml not found"));
}
}

The suite now runs 24 tests (10 install-related, 14 from other modules). Slice 8 takes care of AC 6 (mdbook-admonish ordering).

Slice 8 — order before mdbook-admonish

Slice 8 satisfies AC 6: when [preprocessor.admonish] is already registered in book.toml, install adds before = ["admonish"] to the new [preprocessor.listings] entry so mdbook runs listings first. The callout → admonish-note pipeline (which the Render Inline Callouts story will rely on for PDF output) requires this ordering.

The behaviour is conditional: if admonish is absent, the before field is not added — keeping the registered listings entry minimal for books that don’t use admonish.

Three new unit tests cover the synthetic-config cases (admonish present, admonish absent, idempotent re-run with admonish present); a new integration test install_orders_before_admonish_when_admonish_is_registered sets up a fixture book with [preprocessor.admonish], runs install, and asserts the resulting book.toml has the before = ["admonish"] ordering plus both preprocessor entries.

What’s new in install-v7 compared to install-v6: the register_listings_preprocessor method now snapshots whether "admonish" is a key in the preprocessor table before adding listings, then appends before = ["admonish"] to the listings entry if so. Three new tests in the unit-test module. The method’s existing behaviour (preprocessor entry with command) and idempotency are unchanged.

#![allow(unused)]
fn main() {
//! `install` subcommand: configures an existing book to use mdbook-listings.

use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use toml_edit::{Array, DocumentMut, Item, Table, Value};

/// Compiled in so `cargo install mdbook-listings` produces a self-contained
/// binary with nothing external to fetch at install time.
pub const CSS_ASSET: &[u8] = include_bytes!("../assets/mdbook-listings.css");

/// Catches builds that stripped or replaced the asset — a missing sentinel
/// means the bundled bytes are not the expected build-time asset.
pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v1";

/// Shared between [`write_css_asset`] and
/// [`BookConfig::register_listings_css`] so the two can't drift.
pub const CSS_ASSET_FILENAME: &str = "mdbook-listings.css";

/// Always overwrites — install ships the bundled bytes, not whatever a
/// stale on-disk copy happens to contain.
pub fn write_css_asset(book_root: &Path) -> Result<()> {
    let path = book_root.join(CSS_ASSET_FILENAME);
    fs::write(&path, CSS_ASSET).with_context(|| format!("writing CSS asset to {}", path.display()))
}

/// Idempotent: book.toml and the CSS asset on disk are only rewritten if
/// they differ from what install would produce.
pub fn install(book_root: &Path) -> Result<InstallOutcome> {
    let book_toml_path = book_root.join("book.toml");
    let original = match fs::read_to_string(&book_toml_path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            anyhow::bail!(
                "book.toml not found at {} — install requires an existing mdbook book directory; run `mdbook init` first.",
                book_toml_path.display(),
            );
        }
        Err(e) => {
            return Err(anyhow::Error::from(e))
                .with_context(|| format!("reading book config at {}", book_toml_path.display()));
        }
    };
    let mut config = BookConfig::parse(&original)?;
    config.register_listings_preprocessor();
    config.register_listings_css();
    let new = config.render();

    let css_path = book_root.join(CSS_ASSET_FILENAME);
    let css_already_correct = fs::read(&css_path)
        .ok()
        .is_some_and(|bytes| bytes.as_slice() == CSS_ASSET);

    let toml_changed = new != original;
    if toml_changed {
        fs::write(&book_toml_path, new)
            .with_context(|| format!("writing book config at {}", book_toml_path.display()))?;
    }
    if !css_already_correct {
        write_css_asset(book_root)?;
    }

    Ok(if toml_changed || !css_already_correct {
        InstallOutcome::Installed
    } else {
        InstallOutcome::Unchanged
    })
}

/// Lets the CLI tell the author whether a re-install was a no-op (AC 3).
#[derive(Debug, PartialEq, Eq)]
pub enum InstallOutcome {
    Installed,
    Unchanged,
}

/// Newtype over [`toml_edit::DocumentMut`] so callers don't depend on
/// `toml_edit` directly and the `register_*` methods have a domain type
/// to attach to.
#[derive(Debug)]
pub struct BookConfig(DocumentMut);

impl BookConfig {
    pub fn parse(s: &str) -> Result<Self> {
        s.parse::<DocumentMut>()
            .map(BookConfig)
            .context("book config is not valid TOML")
    }

    pub fn render(&self) -> String {
        self.0.to_string()
    }

    /// Idempotent: a second call on an already-registered config is a no-op
    /// in the rendered output. If `[preprocessor.admonish]` is registered,
    /// the listings entry gets `before = ["admonish"]` so the
    /// callout → admonish-note pipeline produces correctly styled PDF
    /// output.
    pub fn register_listings_preprocessor(&mut self) {
        let preprocessor = self
            .0
            .as_table_mut()
            .entry("preprocessor")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[preprocessor] must be a table");
        let admonish_present = preprocessor.contains_key("admonish");
        let listings = preprocessor
            .entry("listings")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[preprocessor.listings] must be a table");
        listings["command"] = toml_edit::value("mdbook-listings");
        if admonish_present {
            let mut before = Array::new();
            before.push("admonish");
            listings["before"] = toml_edit::value(before);
        }
    }

    /// Idempotent: duplicate entries are not appended.
    pub fn register_listings_css(&mut self) {
        let entry = format!("./{CSS_ASSET_FILENAME}");
        let array = self
            .0
            .as_table_mut()
            .entry("output")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[output] must be a table")
            .entry("html")
            .or_insert_with(|| Item::Table(Table::new()))
            .as_table_mut()
            .expect("[output.html] must be a table")
            .entry("additional-css")
            .or_insert_with(|| Item::Value(Value::Array(Array::new())))
            .as_value_mut()
            .expect("additional-css must be a value")
            .as_array_mut()
            .expect("additional-css must be an array");
        if !array.iter().any(|v| v.as_str() == Some(entry.as_str())) {
            array.push(entry);
        }
    }
}

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

    #[test]
    fn css_asset_is_non_empty() {
        assert!(!CSS_ASSET.is_empty(), "bundled CSS asset must not be empty");
    }

    #[test]
    fn css_asset_contains_sentinel() {
        let contents = std::str::from_utf8(CSS_ASSET).expect("CSS asset must be UTF-8");
        assert!(
            contents.contains(CSS_ASSET_SENTINEL),
            "bundled CSS asset must contain sentinel `{CSS_ASSET_SENTINEL}`; got:\n{contents}",
        );
    }

    #[test]
    fn book_config_round_trip_preserves_comments_and_ordering() {
        let input = "\
top comment
[book]
title = \"Test\"

preprocessor comment
[preprocessor.admonish]
command = \"mdbook-admonish\"

[output.html]
";
        let cfg = BookConfig::parse(input).expect("parse");
        assert_eq!(cfg.render(), input);
    }

    #[test]
    fn book_config_parse_rejects_invalid_toml() {
        let err = BookConfig::parse("[book\nbroken = ").unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("not valid TOML"),
            "diagnostic should name the failure mode; got: {msg}"
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_preprocessor();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[preprocessor.listings]"),
            "rendered config should declare [preprocessor.listings]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"command = "mdbook-listings""#),
            "rendered config should set command = \"mdbook-listings\"; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_preprocessor();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_preprocessor();
        let after_second = cfg2.render();

        assert_eq!(after_first, after_second, "register must be idempotent");
    }

    #[test]
    fn book_config_register_listings_css_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_css();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[output.html]"),
            "rendered config should declare [output.html]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"additional-css = ["./mdbook-listings.css"]"#),
            "rendered config should reference the CSS asset; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_orders_before_admonish_when_present() {
        let input = "[preprocessor.admonish]\ncommand = \"mdbook-admonish\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_preprocessor();
        let rendered = cfg.render();
        assert!(
            rendered.contains(r#"before = ["admonish"]"#),
            "listings should declare before = [\"admonish\"]; got:\n{rendered}",
        );
        assert!(
            rendered.contains("[preprocessor.admonish]"),
            "admonish should still be registered; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_skips_before_when_admonish_absent() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_preprocessor();
        let rendered = cfg.render();
        assert!(
            !rendered.contains("before"),
            "listings should not declare a before field when admonish is absent; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_idempotent_with_admonish_present() {
        let input = "[preprocessor.admonish]\ncommand = \"mdbook-admonish\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_preprocessor();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_preprocessor();
        let after_second = cfg2.render();

        assert_eq!(
            after_first, after_second,
            "register must be idempotent when admonish is present"
        );
    }

    #[test]
    fn book_config_register_listings_css_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_css();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_css();
        let after_second = cfg2.render();

        assert_eq!(
            after_first, after_second,
            "register_listings_css must be idempotent"
        );
    }
}
}

What’s new in install-tests-v4 compared to install-tests-v3: the new install_orders_before_admonish_when_admonish_is_registered integration test. The other tests and helper struct are unchanged.

#![allow(unused)]
fn main() {
//! Integration tests for the Install the Preprocessor story (ch. 1).

use std::fs;
use std::path::{Path, PathBuf};

use predicates::str::contains;
use tempfile::TempDir;

mod common;
use common::mdbook_listings;

#[test]
fn install_registers_preprocessor_and_writes_css() {
    let book = MinimalFixtureBook::new();

    mdbook_listings()
        .args(["install", "--book-root"])
        .arg(book.root())
        .assert()
        .success();

    book.assert_preprocessor_registered();
    book.assert_css_asset_present();
}

/// The smallest mdbook that's still a valid book: a `book.toml` declaring
/// just the `[book]` table with a title, materialised in a TempDir whose
/// lifetime is tied to this struct so the filesystem clean-up is automatic.
struct MinimalFixtureBook {
    _tmp: TempDir,
    root: PathBuf,
}

impl MinimalFixtureBook {
    fn new() -> Self {
        let tmp = TempDir::new().expect("tempdir");
        let root = tmp.path().to_path_buf();
        fs::write(root.join("book.toml"), "[book]\ntitle = \"Test\"\n").unwrap();
        Self { _tmp: tmp, root }
    }

    fn root(&self) -> &Path {
        &self.root
    }

    fn assert_preprocessor_registered(&self) {
        let book_toml = fs::read_to_string(self.root.join("book.toml")).unwrap();
        assert!(
            book_toml.contains("[preprocessor.listings]"),
            "book.toml should register the preprocessor; got:\n{book_toml}",
        );
        assert!(
            book_toml.contains("mdbook-listings.css"),
            "book.toml should reference the CSS asset; got:\n{book_toml}",
        );
    }

    fn assert_css_asset_present(&self) {
        assert!(
            self.root.join("mdbook-listings.css").exists(),
            "CSS asset should be written to the book root",
        );
    }
}

/// `install` against a book that already has `[preprocessor.admonish]`
/// registers listings with `before = ["admonish"]` so the preprocessor
/// chain runs in the right order for PDF output (AC 6).
#[test]
fn install_orders_before_admonish_when_admonish_is_registered() {
    let tmp = TempDir::new().expect("tempdir");
    let book_root = tmp.path();
    fs::write(
        book_root.join("book.toml"),
        "[book]\ntitle = \"Test\"\n\n[preprocessor.admonish]\ncommand = \"mdbook-admonish\"\n",
    )
    .unwrap();

    mdbook_listings()
        .args(["install", "--book-root"])
        .arg(book_root)
        .assert()
        .success();

    let book_toml = fs::read_to_string(book_root.join("book.toml")).unwrap();
    assert!(
        book_toml.contains(r#"before = ["admonish"]"#),
        "listings should be ordered before admonish; got:\n{book_toml}",
    );
    assert!(
        book_toml.contains("[preprocessor.listings]"),
        "listings preprocessor should still be registered; got:\n{book_toml}",
    );
    assert!(
        book_toml.contains("[preprocessor.admonish]"),
        "admonish should still be registered (untouched); got:\n{book_toml}",
    );
}

/// `install` against a directory with no `book.toml` exits non-zero with a
/// diagnostic identifying what was expected (AC 5).
#[test]
fn install_rejects_missing_book_config() {
    let tmp = TempDir::new().expect("tempdir");

    mdbook_listings()
        .args(["install", "--book-root"])
        .arg(tmp.path())
        .assert()
        .failure()
        .stderr(contains("book.toml not found"));
}
}

The suite now runs 27 tests (14 install-related, 13 from other modules). Every Acceptance criterion has at least one test covering it. The story is feature-complete; the optional refactor slice that follows tidies a small repetition that accumulated across slices 4–8, and the wrap-up chore after that promotes the remaining HTML-comment scaffold to chapter body.

Refactor

With every test green, the refactor commit tidies a small repetition that accumulated during slices 4–8 and was deliberately not addressed during the red-green slices: register_listings_preprocessor and register_listings_css both walked into nested [preprocessor] and [output.html] tables via the same six-line entry().or_insert_with(...).as_table_mut().expect(...) chain. Extracted into a subtable_mut(parent, key) helper so each call site shrinks from six lines to one.

The chapter has this section on purpose — the methodology in ch. 0 calls out the refactor commit as part of the outside-in cycle, and shipping a tiny one here makes that visible. Larger refactors are still possible later (e.g., splitting install.rs once it grows substantially); none felt load-bearing right now.

What’s new in install-v8 compared to install-v7: the private subtable_mut helper at module scope, and both register_* methods rewritten to use it. No public API change; no behaviour change. The full test suite (14 install tests, 27 overall) passes byte-for-byte the same as before.

#![allow(unused)]
fn main() {
//! `install` subcommand: configures an existing book to use mdbook-listings.

use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
use toml_edit::{Array, DocumentMut, Item, Table, Value};

/// Compiled in so `cargo install mdbook-listings` produces a self-contained
/// binary with nothing external to fetch at install time.
pub const CSS_ASSET: &[u8] = include_bytes!("../assets/mdbook-listings.css");

/// Catches builds that stripped or replaced the asset — a missing sentinel
/// means the bundled bytes are not the expected build-time asset.
pub const CSS_ASSET_SENTINEL: &str = "mdbook-listings-css-v1";

/// Shared between [`write_css_asset`] and
/// [`BookConfig::register_listings_css`] so the two can't drift.
pub const CSS_ASSET_FILENAME: &str = "mdbook-listings.css";

/// Always overwrites — install ships the bundled bytes, not whatever a
/// stale on-disk copy happens to contain.
pub fn write_css_asset(book_root: &Path) -> Result<()> {
    let path = book_root.join(CSS_ASSET_FILENAME);
    fs::write(&path, CSS_ASSET).with_context(|| format!("writing CSS asset to {}", path.display()))
}

/// Idempotent: book.toml and the CSS asset on disk are only rewritten if
/// they differ from what install would produce.
pub fn install(book_root: &Path) -> Result<InstallOutcome> {
    let book_toml_path = book_root.join("book.toml");
    let original = match fs::read_to_string(&book_toml_path) {
        Ok(s) => s,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
            anyhow::bail!(
                "book.toml not found at {} — install requires an existing mdbook book directory; run `mdbook init` first.",
                book_toml_path.display(),
            );
        }
        Err(e) => {
            return Err(anyhow::Error::from(e))
                .with_context(|| format!("reading book config at {}", book_toml_path.display()));
        }
    };
    let mut config = BookConfig::parse(&original)?;
    config.register_listings_preprocessor();
    config.register_listings_css();
    let new = config.render();

    let css_path = book_root.join(CSS_ASSET_FILENAME);
    let css_already_correct = fs::read(&css_path)
        .ok()
        .is_some_and(|bytes| bytes.as_slice() == CSS_ASSET);

    let toml_changed = new != original;
    if toml_changed {
        fs::write(&book_toml_path, new)
            .with_context(|| format!("writing book config at {}", book_toml_path.display()))?;
    }
    if !css_already_correct {
        write_css_asset(book_root)?;
    }

    Ok(if toml_changed || !css_already_correct {
        InstallOutcome::Installed
    } else {
        InstallOutcome::Unchanged
    })
}

/// Lets the CLI tell the author whether a re-install was a no-op (AC 3).
#[derive(Debug, PartialEq, Eq)]
pub enum InstallOutcome {
    Installed,
    Unchanged,
}

/// Newtype over [`toml_edit::DocumentMut`] so callers don't depend on
/// `toml_edit` directly and the `register_*` methods have a domain type
/// to attach to.
#[derive(Debug)]
pub struct BookConfig(DocumentMut);

impl BookConfig {
    pub fn parse(s: &str) -> Result<Self> {
        s.parse::<DocumentMut>()
            .map(BookConfig)
            .context("book config is not valid TOML")
    }

    pub fn render(&self) -> String {
        self.0.to_string()
    }

    /// Idempotent: a second call on an already-registered config is a no-op
    /// in the rendered output. If `[preprocessor.admonish]` is registered,
    /// the listings entry gets `before = ["admonish"]` so the
    /// callout → admonish-note pipeline produces correctly styled PDF
    /// output.
    pub fn register_listings_preprocessor(&mut self) {
        let preprocessor = subtable_mut(self.0.as_table_mut(), "preprocessor");
        let admonish_present = preprocessor.contains_key("admonish");
        let listings = subtable_mut(preprocessor, "listings");
        listings["command"] = toml_edit::value("mdbook-listings");
        if admonish_present {
            let mut before = Array::new();
            before.push("admonish");
            listings["before"] = toml_edit::value(before);
        }
    }

    /// Idempotent: duplicate entries are not appended.
    pub fn register_listings_css(&mut self) {
        let entry = format!("./{CSS_ASSET_FILENAME}");
        let html = subtable_mut(subtable_mut(self.0.as_table_mut(), "output"), "html");
        let array = html
            .entry("additional-css")
            .or_insert_with(|| Item::Value(Value::Array(Array::new())))
            .as_value_mut()
            .expect("additional-css must be a value")
            .as_array_mut()
            .expect("additional-css must be an array");
        if !array.iter().any(|v| v.as_str() == Some(entry.as_str())) {
            array.push(entry);
        }
    }
}

/// Get a mutable reference to `parent[key]` as a `Table`, creating the
/// child table if absent. Replaces the open-coded
/// `entry().or_insert_with(...).as_table_mut().expect(...)` chain.
fn subtable_mut<'a>(parent: &'a mut Table, key: &str) -> &'a mut Table {
    parent
        .entry(key)
        .or_insert_with(|| Item::Table(Table::new()))
        .as_table_mut()
        .expect("entry must be a table")
}

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

    #[test]
    fn css_asset_is_non_empty() {
        assert!(!CSS_ASSET.is_empty(), "bundled CSS asset must not be empty");
    }

    #[test]
    fn css_asset_contains_sentinel() {
        let contents = std::str::from_utf8(CSS_ASSET).expect("CSS asset must be UTF-8");
        assert!(
            contents.contains(CSS_ASSET_SENTINEL),
            "bundled CSS asset must contain sentinel `{CSS_ASSET_SENTINEL}`; got:\n{contents}",
        );
    }

    #[test]
    fn book_config_round_trip_preserves_comments_and_ordering() {
        let input = "\
top comment
[book]
title = \"Test\"

preprocessor comment
[preprocessor.admonish]
command = \"mdbook-admonish\"

[output.html]
";
        let cfg = BookConfig::parse(input).expect("parse");
        assert_eq!(cfg.render(), input);
    }

    #[test]
    fn book_config_parse_rejects_invalid_toml() {
        let err = BookConfig::parse("[book\nbroken = ").unwrap_err();
        let msg = format!("{err:#}");
        assert!(
            msg.contains("not valid TOML"),
            "diagnostic should name the failure mode; got: {msg}"
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_preprocessor();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[preprocessor.listings]"),
            "rendered config should declare [preprocessor.listings]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"command = "mdbook-listings""#),
            "rendered config should set command = \"mdbook-listings\"; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_preprocessor();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_preprocessor();
        let after_second = cfg2.render();

        assert_eq!(after_first, after_second, "register must be idempotent");
    }

    #[test]
    fn book_config_register_listings_css_adds_entry() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_css();
        let rendered = cfg.render();
        assert!(
            rendered.contains("[output.html]"),
            "rendered config should declare [output.html]; got:\n{rendered}",
        );
        assert!(
            rendered.contains(r#"additional-css = ["./mdbook-listings.css"]"#),
            "rendered config should reference the CSS asset; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_orders_before_admonish_when_present() {
        let input = "[preprocessor.admonish]\ncommand = \"mdbook-admonish\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_preprocessor();
        let rendered = cfg.render();
        assert!(
            rendered.contains(r#"before = ["admonish"]"#),
            "listings should declare before = [\"admonish\"]; got:\n{rendered}",
        );
        assert!(
            rendered.contains("[preprocessor.admonish]"),
            "admonish should still be registered; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_skips_before_when_admonish_absent() {
        let mut cfg = BookConfig::parse("[book]\ntitle = \"Test\"\n").unwrap();
        cfg.register_listings_preprocessor();
        let rendered = cfg.render();
        assert!(
            !rendered.contains("before"),
            "listings should not declare a before field when admonish is absent; got:\n{rendered}",
        );
    }

    #[test]
    fn book_config_register_listings_preprocessor_idempotent_with_admonish_present() {
        let input = "[preprocessor.admonish]\ncommand = \"mdbook-admonish\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_preprocessor();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_preprocessor();
        let after_second = cfg2.render();

        assert_eq!(
            after_first, after_second,
            "register must be idempotent when admonish is present"
        );
    }

    #[test]
    fn book_config_register_listings_css_is_idempotent() {
        let input = "[book]\ntitle = \"Test\"\n";
        let mut cfg = BookConfig::parse(input).unwrap();
        cfg.register_listings_css();
        let after_first = cfg.render();

        let mut cfg2 = BookConfig::parse(&after_first).unwrap();
        cfg2.register_listings_css();
        let after_second = cfg2.render();

        assert_eq!(
            after_first, after_second,
            "register_listings_css must be idempotent"
        );
    }
}
}

The chapter is feature- and quality-complete. The wrap-up chore lifts the remaining scaffold sections out of the HTML comment.

Notes for implementers

  • toml_edit is the standard crate for read-modify-write of a TOML file while preserving comments and ordering. mdbook-admonish’s own install is a good reference implementation; we studied it while writing the Freeze chapter.
  • The CSS file content is a placeholder until the Render Inline Callouts story (ch. 4) settles the badge styling. The CSS_ASSET_SENTINEL constant exists so a build that strips or replaces the asset fails loudly rather than silently shipping wrong content.
  • Listing overlap with ch. 2 (Freeze a Listing). This story modifies src/lib.rs and src/main.rs — files frozen by ch. 2 under -v1 tags — and adds src/install.rs. Per the per-slice freeze discipline, each slice that touches one of these files freezes a new -vN tag (the latest are catalogued in Final state below). Until the Show Diffs Between Slices primitive ships, readers of ch. 1 and ch. 2 in sequence see overlapping full-file listings; the duplication goes away as a one-line cleanup once diffs are available.

What this slice does not solve

  • No uninstall command. Authors who want to remove mdbook-listings edit book.toml by hand.
  • No upgrade flow. When the bundled CSS asset version bumps, authors re-run install, which overwrites the asset.
  • No detection of pre-existing conflicting configurations. If the book already has a different preprocessor named listings, install silently overwrites its command value.