All articles

Rust Documentation: The rustdoc Playbook for Serious Projects

A practical rustdoc playbook covering comment conventions, doctests, intra-doc links, custom CSS, and CI workflows that turn docs into a first-class artifact.

D

DocPilot Team

AI documentation research and engineering from Onzira Labs.

7 min read

Documentation is the difference between a Rust crate that gets adopted and one that gets abandoned. The compiler is famously strict, but the documentation layer is forgiving to a fault: it will build whatever you throw at it, including nonsense. That forgiveness is a trap. Without a deliberate playbook, your cargo doc output drifts toward the kind of prose that explains the obvious and hides the dangerous.

This guide is the playbook we use on serious Rust projects. It is opinionated, terse where it can be, and focused on what actually ships: readable API docs, executable examples, broken-link detection in CI, and a docs site that doesn't look like raw cargo output.

The Mental Model: Three Layers of Rust Documentation

Before touching syntax, lock in the layers. Most crates that ship poor docs confuse them.

  1. Crate-level docs (src/lib.rs top of file): the elevator pitch. Why does this crate exist? When should I reach for it? What does the canonical example look like?
  2. Module-level docs (//! at the top of each module): the architecture map. How are responsibilities split? What invariants hold inside this module?
  3. Item-level docs (/// above functions, structs, enums, traits): the contract. Inputs, outputs, panics, errors, edge cases. Examples where the API is non-obvious.

If you only write one of these, write the crate-level docs. They are what every visitor lands on at docs.rs/your-crate.

//! `tinyvec` provides stack-allocated vectors with heap-vector ergonomics.
//!
//! Use this when you need `Vec<T>` semantics but cannot afford an allocator
//! in the hot path. For everything else, prefer the standard library.
//!
//! ```
//! use tinyvec::TinyVec;
//! let mut v: TinyVec<u8, 4> = TinyVec::new();
//! v.push(1);
//! assert_eq!(v.as_slice(), &[1]);
//! ```

Comment Conventions That Survive Code Review

The /// and //! syntax is necessary but not sufficient. The conventions below are the ones that hold up across a team of more than one.

Lead with the verb, not the noun

Bad:

/// A connection pool.

Good:

/// Manages a fixed pool of reusable network connections.

The first is a label. The second tells the reader what the type does, which is the question they actually have.

Document failure modes explicitly

Three sections earn their keep on every public function:

  • # Errors — when do you return Err, and what do variants mean?
  • # Panics — under what conditions does this panic? Be specific. index < len is not a panic condition; it's a precondition.
  • # Safety — only for unsafe fn, but mandatory when present.

rustdoc renders these as separate sections in the output. Use them.

/// Reads a frame from the socket.
///
/// # Errors
///
/// Returns `Io::WouldBlock` if the socket is in non-blocking mode and no
/// data is available. Returns `Io::Closed` if the peer has half-closed.
///
/// # Panics
///
/// Panics if `self.buffer` has fewer than 32 bytes of remaining capacity.
/// Callers must ensure `with_capacity(32)` was used at construction.
pub fn read_frame(&mut self) -> Result<Frame, Io> { /* ... */ }

Examples are documentation, not tests of convenience

A doctest that does not exercise a code path is decoration. The convention we enforce: every public function with non-trivial behavior gets at least one doctest that exercises the happy path and one that exercises the documented error or panic. Doctests run with cargo test --doc, which means broken examples break CI.

/// Divides numerator by denominator.
///
/// ```
/// assert_eq!(safe_div(10, 2)?, 5);
/// # Ok::<(), Error>(())
/// ```
///
/// Returns `DivisionByZero` rather than panicking:
///
/// ```
/// assert_eq!(safe_div(1, 0), Err(Error::DivisionByZero));
/// ```
pub fn safe_div(numerator: i64, denominator: i64) -> Result<i64, Error> { /* ... */ }

Intra-Doc Links: The rustdoc Feature That Pays for Itself

rustdoc resolves [Type] style links against your crate and its dependencies. Used well, they turn a wall of prose into a navigable graph. Used badly, they rot.

Prefer intra-doc links over raw URLs

/// See [`std::io::Error`] for the underlying error type.
///
/// Use [`Self::flush`] before dropping the writer.

The first form is searchable and refactor-safe. The second survives method renames if the rename is in the same module.

Detect broken links before they ship

RUSTDOCFLAGS="-D warnings" cargo doc --no-deps will fail the build on broken intra-doc links, missing doc comments, and a handful of other papercuts. Add it to CI. The first run will be painful; the second will be clean.

Doctests Beyond the Defaults

Default doctests are too forgiving in one specific way: they share state through the documentation test binary. If one example defines a struct Foo, the next can see it. This masks real bugs.

Isolate each example

RUSTDOCFLAGS="--test-runner=libtest-json" does not help here. What helps is reviewing examples as if they were standalone files. If your example depends on something defined in another example, rewrite it.

For the occasional case where you genuinely need shared setup, use hidden lines:

/// ```
/// # use my_crate::*;
/// let conn = Pool::connect("localhost")?;
/// let rows = conn.query("SELECT 1")?;
/// assert_eq!(rows.len(), 1);
/// # Ok::<(), Error>(())
/// ```

Lines starting with # are compiled but not rendered. This is how you keep examples readable while including setup.

Compile-fail and should-panic doctests

Two flags cover cases the happy-path syntax cannot:

/// ```compile_fail
/// let x: i32 = "not a number"; // does not compile
/// ```
/// ```should_panic
/// divide_by_zero();
/// ```

Use compile_fail when documenting a type-level invariant ("this trait cannot be implemented for foreign types"). Use should_panic sparingly — panics are usually bugs.

The cargo doc Workflow

Three commands earn muscle memory.

CommandPurpose
cargo doc --no-depsBuild docs locally; fast iteration
cargo doc --no-deps -D warningsSame, with warnings as errors (CI parity)
cargo test --docRun all doctests

Add --document-private-items when you're writing internal docs that should still be browsable. Internal teams benefit more from this than external users do.

Beyond Default Styling

The default rustdoc theme is functional and ugly. Two paths exist beyond it.

docs.rs

If you publish to crates.io, docs.rs builds your documentation on every release with zero configuration. Verify your metadata is correct in Cargo.toml:

[package]
documentation = "https://github.com/you/your-crate/blob/main/README.md"

The documentation field is what shows up on crates.io. The default behavior on docs.rs is good; fight it only when you have a reason.

mdBook for guides, rustdoc for API

Long-form prose — tutorials, architecture explanations, design decisions — does not belong in rustdoc. It belongs in mdBook, in a docs/ directory at the repository root, and on a separate page from your API reference. The two should link to each other but not intermingle.

CI: Make Documentation a First-Class Artifact

A docs job in CI that does four things will catch 90% of doc rot:

name: docs
on: [push, pull_request]
jobs:
  rustdoc:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: rustup component add rust-docs
      - name: Build with warnings as errors
        run: RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
      - name: Run doctests
        run: cargo test --doc --all-features
      - name: Check for broken links
        run: |
          cargo doc --no-deps --all-features -q
          find target/doc -name '*.html' -print0 \
            | xargs -0 -I{} grep -l 'class="struct"' {} \
            | xargs grep -L 'href' || true

Adjust the broken-link check to your taste; the point is that link rot is detectable, and detection should be automated.

Common Failure Modes

Three patterns show up in nearly every Rust project that has let documentation drift.

Doc comments on impl blocks without re-exports. A method on impl Foo is not reachable from the public docs unless Foo is the public type. If the canonical entry point is foo::Builder::new(), document it on Builder, not on the impl block.

Missing # Examples sections on generic types. Generic constraints are the hardest thing to read from a signature. If T: Clone is required, say so in prose. The compiler enforces the constraint; the reader needs to know it exists.

Crate-level docs that read like README sections. The lib.rs opening is not a marketing page. It is a contract: what this crate does, what it does not do, and the minimum example. A README can be long. A crate doc should fit on one screen.

The Payoff

Documentation written to a playbook compiles into something your users can navigate, search, and trust. Doctests prove the examples still work on every release. Intra-doc links survive renames. CI catches rot before users do. None of this is exotic — it is the same machinery every serious Rust crate uses; the difference is whether the discipline is enforced.

DocPilot fits naturally into this workflow if your project mixes Rust with other languages. Connect your GitHub repo, and DocPilot keeps README, setup, architecture, and API reference docs in sync with code changes via webhooks. For multi-language monorepos where the Rust API is one surface among several, that synchronization matters more than any single rustdoc trick. Start with the free tier at DocPilot and let the docs keep up with the code.

Ship documentation that keeps up

Try DocPilot on your repository — free for one repo.

Connect your first repository in about a minute. No credit card. DocPilot writes and maintains the documentation from your code.

Read next