Install the Preprocessor
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
- After install runs successfully against a book, building that book invokes mdbook-listings as a preprocessor without further author intervention.
- After install runs successfully against a book, the HTML build picks up the CSS asset that styles mdbook-listings’s output.
- Install is idempotent: a second run on an already-installed book makes no further changes and confirms to the author that nothing changed.
- 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.
- Install run in a directory without a valid book configuration is rejected with a diagnostic identifying what was expected and not found.
- 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:
| Slice | What it adds |
|---|---|
| 1/8 | Failing 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/8 | Bundle 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/8 | TOML round-trip primitive (read book.toml, mutate, write back preserving comments + ordering, via toml_edit). Unit-tested on synthetic input strings — no filesystem. |
| 4/8 | Add the [preprocessor.listings] registration. Unit test for AC 3 (idempotency) on top of slice 3. |
| 5/8 | Copy 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/8 | Wire 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/8 | Reject missing book config with a diagnostic (AC 5). New integration test. |
| 8/8 | Enforce 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. |
| refactor | Optional. |
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_editis 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_SENTINELconstant 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.rsandsrc/main.rs— files frozen by ch. 2 under-v1tags — and addssrc/install.rs. Per the per-slice freeze discipline, each slice that touches one of these files freezes a new-vNtag (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.tomlby 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 itscommandvalue.