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.
DocPilot Team
AI documentation research and engineering from Onzira Labs.
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.
- Crate-level docs (
src/lib.rstop of file): the elevator pitch. Why does this crate exist? When should I reach for it? What does the canonical example look like? - Module-level docs (
//!at the top of each module): the architecture map. How are responsibilities split? What invariants hold inside this module? - 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 returnErr, and what do variants mean?# Panics— under what conditions does this panic? Be specific.index < lenis not a panic condition; it's a precondition.# Safety— only forunsafe 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.
| Command | Purpose |
|---|---|
cargo doc --no-deps | Build docs locally; fast iteration |
cargo doc --no-deps -D warnings | Same, with warnings as errors (CI parity) |
cargo test --doc | Run 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.
Read next
api-documentation
The Underrated Power of Well-Structured API Docs
Most API docs fail not because of missing information, but because of missing structure. Here's why architecture beats aesthetics every time.
Read
roadmap
Building a Public Documentation Roadmap: Best Practices
A public documentation roadmap signals that you take your docs seriously. Here's how to build one that earns user trust instead of empty promises.
Read
comparison
The 20 Documentation Tools Comparison: Features, Pricing, Alternatives
A practical, side-by-side comparison of 20 documentation tools engineering teams actually use, with pricing, strengths, and the trade-offs nobody talks about.
Read