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

Freeze a Listing

This chapter is reconstructed retrospectively

The Freeze a Listing story landed in a single commit before this book adopted outside-in TDD as its development discipline. As a result, this chapter shows the story’s end state — story, acceptance criteria, final listings, design decisions — without an outside-in narrative walking through slices. Chapters from ch. 2 onward have one narrative section per slice.

Story

As a book author, I want to freeze a source file into my book under a memorable tag so that a later edit to the source file does not silently change what my chapter renders.

Acceptance criteria

  1. When an author freezes a source file under a tag, the file’s bytes are preserved verbatim and become consumable from any chapter by that tag, using mdbook’s existing include machinery (no additional wiring required).
  2. Each freeze is recorded persistently. The record captures the chosen tag, the original source path, the frozen location, and an integrity hash of the frozen bytes.
  3. Re-freezing the same source under the same tag with no change to its bytes does not modify disk state and confirms to the author that nothing changed.
  4. Re-freezing under a tag that already exists, but with different bytes — whether the original source was edited or a different source was supplied — is rejected. No disk state changes.
  5. The author can opt in to overwriting an existing tag’s frozen copy with new bytes. Doing so updates the record and the frozen content, and confirms the replacement to the author.
  6. Tags that could escape the listings area (e.g. containing path separators or relative-path segments) are rejected before any disk state changes.

Acceptance criteria 3, 4, and 5 together are the “idempotency discipline” that keeps this tool honest: the tag is the identity, the bytes are the content, and the author has to be explicit when they want to break the association.

The slice

The slice cuts top-to-bottom through the crate:

FileRoleFrozen tag
tests/freeze.rsAcceptance criteria as CLI-level testsfreeze-tests-v1
src/main.rsclap subcommand handler — the CLI adaptermain-v1
src/freeze.rsCore logic: hash, decide, write, upsertfreeze-v1
src/manifest.rsTOML load / save / upsertmanifest-v1
src/lib.rsPublic module registrationslib-v1

Every file contributing to the slice is frozen as of this commit and embedded below. When later stories edit these files, they freeze a new tag (freeze-v2, etc.) and this chapter keeps pointing at -v1.

Design decisions

Four decisions shape the behaviour of freeze. They are called out explicitly here so that later slices that extend freeze know which invariants they are allowed to break and which they are not.

Manifest format: TOML

The manifest is TOML, matching mdbook’s own book.toml and Cargo’s Cargo.toml/Cargo.lock. The alternatives would have been YAML (more concise for nested structures but breaks ecosystem fit) or JSON (nicer for machines, worse for humans to hand-edit). TOML wins on ecosystem coherence.

Listing identifier: author-chosen tag plus content hash

Listings are named by an author-supplied tag (manifest-v1, freeze-v1, etc.) — the human identifier. The manifest also stores a SHA-256 of the frozen bytes, which is the integrity identifier. This is the pattern Git uses for refs and commits: humans remember the name, machines verify the hash.

Pure content-addressed identifiers (name the file by its SHA) were rejected because {{#include listings/a1b2c3d4.rs}} is unreadable. Auto-derived identifiers (chapter + section + index) were rejected because inserting or reordering a section silently renumbers everything.

Frozen directory layout: <book-root>/src/listings/<tag>.<ext>

Frozen files live under src/listings/ inside the book so the built-in {{#include}} resolver finds them without any path gymnastics. The extension is inherited from the source so syntax highlighting works automatically.

Freeze trigger: manual CLI only (for now)

mdbook-listings freeze is the only way a listing gets frozen. Pre-commit hooks and tag-triggered freezes are on the backlog but deferred — they introduce policy questions (whose tag? which commit?) that are not worth answering until later stories reveal which of those policies authors actually want.

Final state

The four source files and the integration test, as of this commit, all frozen.

tests/freeze.rs — the acceptance criteria as tests

#![allow(unused)]
fn main() {
//! Integration tests for the Freeze a Listing story (ch. 2). These pin the
//! error-path acceptance criteria that aren't covered by the book's own use
//! of the freeze primitive on its own listings.

use std::fs;

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

mod common;
use common::mdbook_listings;

/// `freeze` rejects re-running with the same tag but a now-different source
/// content unless `--force` is given. Without this guard, an author who edits
/// a source file and re-runs `freeze` would silently lose the previously
/// frozen bytes.
#[test]
fn freeze_rejects_conflicting_content_without_force() {
    let tmp = TempDir::new().expect("tempdir");
    let book_root = tmp.path().join("book");
    fs::create_dir_all(&book_root).unwrap();
    let source = tmp.path().join("compose.yaml");
    fs::write(&source, "a: 1\n").unwrap();

    mdbook_listings()
        .args(["freeze", "--tag", "t", "--book-root"])
        .arg(&book_root)
        .arg(&source)
        .assert()
        .success();

    fs::write(&source, "a: 2\n").unwrap();
    mdbook_listings()
        .args(["freeze", "--tag", "t", "--book-root"])
        .arg(&book_root)
        .arg(&source)
        .assert()
        .failure()
        .stderr(contains("already frozen"));
}

/// `freeze` rejects re-running with the same tag but content from an entirely
/// different source file, unless `--force` is given. The tag is the identity;
/// without this guard, an author who accidentally re-uses a tag for a new
/// source would clobber the previously frozen bytes silently.
#[test]
fn freeze_rejects_duplicate_tag_from_different_source() {
    let tmp = TempDir::new().expect("tempdir");
    let book_root = tmp.path().join("book");
    fs::create_dir_all(&book_root).unwrap();
    let source_a = tmp.path().join("a.yaml");
    let source_b = tmp.path().join("b.yaml");
    fs::write(&source_a, "a: 1\n").unwrap();
    fs::write(&source_b, "b: 2\n").unwrap();

    mdbook_listings()
        .args(["freeze", "--tag", "t", "--book-root"])
        .arg(&book_root)
        .arg(&source_a)
        .assert()
        .success();

    mdbook_listings()
        .args(["freeze", "--tag", "t", "--book-root"])
        .arg(&book_root)
        .arg(&source_b)
        .assert()
        .failure()
        .stderr(contains("already frozen"));
}
}

The freeze_rejects_conflicting_content_without_force and freeze_rejects_duplicate_tag_from_different_source tests together pin the two halves of AC 4, which is the one criterion the book itself can’t exercise (because the book only drives the happy paths).

src/main.rs — the CLI adapter

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

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

/// 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: _ }) => {
            anyhow::bail!("`mdbook-listings install` is not yet implemented")
        }
        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 });
}

The no-subcommand arm (preprocess()) is a stub that errors — the preprocessor pipeline belongs to the Show Diffs Between Slices story and isn’t implemented yet. Likewise install and verify are stubs that will fill in later. The supports arm is real and was shipped by the CLI-scaffolding chore, not this story; it’s here because the dispatch table has to mention every subcommand.

src/freeze.rs — the freeze logic

#![allow(unused)]
fn main() {
//! `mdbook-listings freeze`: snapshot a source file into the book-local
//! listings directory and record it in the manifest.

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

use anyhow::{Context, Result, anyhow, bail};
use sha2::{Digest, Sha256};

use crate::manifest::{Listing, Manifest};

/// Relative path from a book root to the frozen-listings directory.
pub const LISTINGS_SUBDIR: &str = "src/listings";

/// Options accepted by [`freeze`]. Mirrors the CLI flags 1:1 so the binary
/// layer stays a thin adapter.
#[derive(Debug)]
pub struct FreezeOptions<'a> {
    pub book_root: &'a Path,
    pub tag: &'a str,
    pub source: &'a Path,
    pub force: bool,
}

/// Outcome of a freeze invocation. Callers use this to render a summary line
/// and to drive tests.
#[derive(Debug, PartialEq, Eq)]
pub enum FreezeOutcome {
    /// New listing with this tag; frozen file and manifest entry created.
    Created,
    /// Tag already existed and the source content is byte-identical to the
    /// frozen copy on disk; nothing was changed.
    Unchanged,
    /// Tag already existed and the source differs; frozen file and manifest
    /// entry were overwritten (only possible with `--force`).
    Replaced,
}

/// Freeze `opts.source` into `<book_root>/src/listings/<tag>.<ext>` and upsert
/// the corresponding entry in `<book_root>/listings.toml`.
pub fn freeze(opts: FreezeOptions<'_>) -> Result<FreezeOutcome> {
    let source_bytes = fs::read(opts.source)
        .with_context(|| format!("reading source file {}", opts.source.display()))?;
    let source_sha = hex_sha256(&source_bytes);

    let frozen_rel = frozen_relative_path(opts.tag, opts.source)?;
    let frozen_abs = opts.book_root.join(&frozen_rel);

    let mut manifest = Manifest::load(opts.book_root)?;

    let outcome = match manifest.find(opts.tag) {
        Some(existing) if existing.sha256 == source_sha && frozen_abs.exists() => {
            FreezeOutcome::Unchanged
        }
        Some(_existing) if !opts.force => {
            bail!(
                "tag `{}` already frozen with different content; re-run with --force to overwrite",
                opts.tag
            )
        }
        Some(_existing) => FreezeOutcome::Replaced,
        None => FreezeOutcome::Created,
    };

    if outcome != FreezeOutcome::Unchanged {
        if let Some(parent) = frozen_abs.parent() {
            fs::create_dir_all(parent).with_context(|| {
                format!("creating frozen-listings directory {}", parent.display())
            })?;
        }
        fs::write(&frozen_abs, &source_bytes)
            .with_context(|| format!("writing frozen file {}", frozen_abs.display()))?;

        let source_rel = relativize(opts.source, opts.book_root);
        manifest.upsert(Listing {
            tag: opts.tag.to_string(),
            source: path_to_string(&source_rel)?,
            frozen: path_to_string(&frozen_rel)?,
            sha256: source_sha,
        });
        manifest.save(opts.book_root)?;
    }

    Ok(outcome)
}

fn frozen_relative_path(tag: &str, source: &Path) -> Result<PathBuf> {
    if tag.is_empty() {
        bail!("tag must be non-empty");
    }
    if tag.contains(['/', '\\', '.']) {
        bail!("tag `{tag}` contains disallowed character (/, \\, or .)");
    }
    let ext = source
        .extension()
        .and_then(|s| s.to_str())
        .ok_or_else(|| anyhow!("source {} has no file extension", source.display()))?;
    Ok(Path::new(LISTINGS_SUBDIR).join(format!("{tag}.{ext}")))
}

fn relativize(path: &Path, base: &Path) -> PathBuf {
    pathdiff(path, base).unwrap_or_else(|| path.to_path_buf())
}

/// Minimal relative-path computation: if `path` is under `base`, strip the
/// prefix; otherwise walk up from `base` with `..` segments.
fn pathdiff(path: &Path, base: &Path) -> Option<PathBuf> {
    let path = path.canonicalize().ok()?;
    let base = base.canonicalize().ok()?;
    let mut path_components: Vec<_> = path.components().collect();
    let mut base_components: Vec<_> = base.components().collect();
    while let (Some(p), Some(b)) = (path_components.first(), base_components.first()) {
        if p == b {
            path_components.remove(0);
            base_components.remove(0);
        } else {
            break;
        }
    }
    let mut result = PathBuf::new();
    for _ in &base_components {
        result.push("..");
    }
    for c in &path_components {
        result.push(c.as_os_str());
    }
    Some(result)
}

fn path_to_string(path: &Path) -> Result<String> {
    path.to_str()
        .map(|s| s.replace('\\', "/"))
        .ok_or_else(|| anyhow!("path {} is not valid UTF-8", path.display()))
}

fn hex_sha256(bytes: &[u8]) -> String {
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    let digest = hasher.finalize();
    let mut out = String::with_capacity(digest.len() * 2);
    for byte in digest {
        use std::fmt::Write;
        let _ = write!(out, "{byte:02x}");
    }
    out
}

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

    #[test]
    fn hex_sha256_matches_known_vector() {
        // Well-known sha256("") per FIPS 180-4.
        assert_eq!(
            hex_sha256(b""),
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
    }

    #[test]
    fn frozen_path_composes_tag_and_extension() {
        let p = frozen_relative_path("compose-v1", Path::new("compose.yaml")).unwrap();
        assert_eq!(p, Path::new("src/listings/compose-v1.yaml"));
    }

    #[test]
    fn frozen_path_rejects_tag_with_slash() {
        assert!(frozen_relative_path("foo/bar", Path::new("x.yaml")).is_err());
    }

    #[test]
    fn frozen_path_rejects_empty_tag() {
        assert!(frozen_relative_path("", Path::new("x.yaml")).is_err());
    }

    #[test]
    fn frozen_path_rejects_extensionless_source() {
        assert!(frozen_relative_path("tag", Path::new("Makefile")).is_err());
    }
}
}

FreezeOutcome carries the Create / Unchanged / Replaced decision up to main.rs purely so the CLI can print a different verb for each case. Everything else — the four-way match on manifest.find(tag), the sha256 comparison, the early return on Unchanged to avoid spurious disk writes — falls directly out of the acceptance criteria.

frozen_relative_path is the guard that rejects tags containing /, \, or . (AC 6). Rejecting these early, before any disk writes, matters: a tag like ../escape would otherwise write outside the listings directory.

src/manifest.rs — the persistence layer

#![allow(unused)]
fn main() {
//! Freeze manifest: the TOML file that records every listing that has been
//! frozen into a book. Lives at `<book_root>/listings.toml`.

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

use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};

/// Current manifest schema version. Bumped when the on-disk layout changes in
/// a way that requires a migration.
pub const MANIFEST_VERSION: u32 = 1;

/// Relative path from a book root to the manifest file.
pub const MANIFEST_FILENAME: &str = "listings.toml";

/// Top-level manifest document.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Manifest {
    pub version: u32,
    #[serde(default, rename = "listing")]
    pub listings: Vec<Listing>,
}

/// One entry per frozen listing.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Listing {
    /// Human-readable identifier chosen by the author. Unique within the
    /// manifest.
    pub tag: String,
    /// Original source path, relative to the book root. Informational — shallow
    /// verify does not re-read this file (deep verify, deferred to a later
    /// release, will).
    pub source: String,
    /// Path to the frozen copy, relative to the book root (e.g.
    /// `src/listings/compose-v1.yaml`).
    pub frozen: String,
    /// Hex-encoded sha256 of the frozen file's byte content. Used by shallow
    /// verify to detect post-freeze tampering.
    pub sha256: String,
}

impl Manifest {
    /// Load the manifest from `<book_root>/listings.toml`. Returns an empty
    /// manifest if the file does not exist.
    pub fn load(book_root: &Path) -> Result<Self> {
        let path = Self::path(book_root);
        if !path.exists() {
            return Ok(Self {
                version: MANIFEST_VERSION,
                listings: Vec::new(),
            });
        }
        let text = fs::read_to_string(&path)
            .with_context(|| format!("reading manifest at {}", path.display()))?;
        let manifest: Manifest = toml::from_str(&text)
            .with_context(|| format!("parsing manifest at {}", path.display()))?;
        if manifest.version != MANIFEST_VERSION {
            return Err(anyhow!(
                "manifest at {} has version {}, expected {}",
                path.display(),
                manifest.version,
                MANIFEST_VERSION
            ));
        }
        Ok(manifest)
    }

    /// Write the manifest to `<book_root>/listings.toml`, creating parent
    /// directories as needed.
    pub fn save(&self, book_root: &Path) -> Result<()> {
        let path = Self::path(book_root);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("creating manifest parent {}", parent.display()))?;
        }
        let text = toml::to_string_pretty(self).context("serializing manifest to TOML")?;
        fs::write(&path, text)
            .with_context(|| format!("writing manifest to {}", path.display()))?;
        Ok(())
    }

    /// Look up a listing by tag.
    pub fn find(&self, tag: &str) -> Option<&Listing> {
        self.listings.iter().find(|l| l.tag == tag)
    }

    /// Insert or replace a listing by tag, keeping the vector in insertion
    /// order (existing entries retain their position).
    pub fn upsert(&mut self, listing: Listing) {
        match self.listings.iter().position(|l| l.tag == listing.tag) {
            Some(idx) => self.listings[idx] = listing,
            None => self.listings.push(listing),
        }
    }

    fn path(book_root: &Path) -> PathBuf {
        book_root.join(MANIFEST_FILENAME)
    }
}

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

    #[test]
    fn load_rejects_unknown_manifest_version() {
        let tmp = TempDir::new().unwrap();
        let manifest_path = tmp.path().join(MANIFEST_FILENAME);
        fs::write(
            &manifest_path,
            "version = 99\n\n[[listing]]\n\
             tag = \"x\"\nsource = \"a\"\nfrozen = \"b\"\nsha256 = \"c\"\n",
        )
        .unwrap();

        let err = Manifest::load(tmp.path()).unwrap_err();
        let msg = format!("{err}");
        assert!(
            msg.contains("version 99") && msg.contains(&MANIFEST_VERSION.to_string()),
            "diagnostic should name both the found and the expected version, got: {msg}",
        );
    }

    #[test]
    fn load_accepts_current_manifest_version() {
        let tmp = TempDir::new().unwrap();
        let manifest_path = tmp.path().join(MANIFEST_FILENAME);
        fs::write(&manifest_path, format!("version = {MANIFEST_VERSION}\n")).unwrap();

        let m = Manifest::load(tmp.path()).expect("current-version manifest should load");
        assert_eq!(m.version, MANIFEST_VERSION);
        assert!(m.listings.is_empty());
    }
}
}

upsert preserves insertion order when replacing an existing entry. That’s invisible in the CLI today but matters for reading the listings.toml diff in code review: a re-freeze of an existing tag should show up as a sha change on one entry, not as a reorder of the whole file.

src/lib.rs — public module registrations

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

pub mod freeze;
pub mod manifest;
}

Nothing to see here; lib.rs exists only so src/main.rs and the integration tests can reach into the crate’s modules as mdbook_listings::freeze::…. Every future story will add one more pub mod line.

What this slice does not solve

  • No drift detection between source and frozen copy. If any of the files above is edited but mdbook-listings freeze is not re-run, the book keeps showing the old bytes. That’s the feature of freezing, but it also means nothing in the tool warns you that your latest refactor didn’t make it into the book. This gap is closed by the Verify Sync with Source story.
  • No way to show evolution within a chapter. This chapter’s final-state section prints every file in full. When later chapters are built outside-in with many slices, doing the same would make them unreadable. The Show Diffs Between Slices story closes that gap.
  • No inline annotations or cross-references. The five frozen listings above are bare code blocks with no way to attach prose to a specific line. The Render Inline Callouts story addresses this, with YAML first.
  • --tag is required. Running mdbook-listings freeze src/manifest.rs (no tag) fails today. Auto-derivation of the tag from the source filename plus the next available -v<N> suffix is a planned ergonomic enhancement and will land as a small follow-up commit — not as a new story, because the behaviour it adds is narrow enough to describe in a single AC amendment to this chapter when it ships.
  • No automatic installation here. Registering the preprocessor in book.toml, shipping the CSS asset, and adding the additional-css entry are the responsibility of ch. 1 (Install the Preprocessor). Freeze itself doesn’t need any of that — it’s a standalone subcommand that operates on a book directory; the preprocessor only matters once you want diffs or callouts.
  • The -v1 listings here are superseded by ch. 1’s freezes. Ch. 1 (Install the Preprocessor) shipped after this chapter in implementation order and modified src/lib.rs, src/main.rs, and added src/install.rs. The current state of those files is captured by ch. 1’s outside-in narrative — lib-v2 in slice 2, main-v2 in slice 6, and install-v8 in the Refactor sub-section. The original consolidated tests/integration.rs was split into per-story files in ch. 1’s first slice; what was here as integration-tests-v1 is now freeze-tests-v1. This chapter keeps pointing at the freeze-story end-of-story snapshots so it shows the code as it actually was when freeze shipped, not the post-install state. Until the diff primitive ships, readers reading both chapters in sequence see overlapping full-file listings.