Skip to content
← All Rust articles

Rust explainer

Workspaces, publishing, MSRV and docs.rs

About 13 min to read. Allow extra time to work through examples.

How a Cargo workspace groups related packages, what publishing to crates.io does and does not let you undo, how rust-version affects builds and dependency resolution, and how docs.rs documents feature-gated items.

This article uses Rust. You can make it your practice mode to browse related content across the site.

This article covers the tooling around a Rust library once it grows past one package and is shared with other people: Cargo workspaces, publishing to crates.io, the minimum supported Rust version (MSRV), documentation on docs.rs, and automated compatibility checking. None of it can be run on this site, because the site compiles a single file with rustc and has no Cargo, no network access and no second package. The Cargo and rustc behavior described below was checked against version 1.98.1; the crates.io, docs.rs and cargo-semver-checks details come from their documentation.

Workspaces

A package is a directory with a Cargo.toml manifest that builds one or more crates. A workspace is a set of packages, called members, that Cargo manages together. The workspace is declared by a [workspace] table in the manifest at the top of the directory tree:

# Cargo.toml at the repository root
[workspace]
resolver = "3"
members = ["crates/*"]
exclude = ["crates/experimental"]
default-members = ["crates/cli"]
  • members lists member directories. Glob patterns are allowed, so crates/* means every directory directly inside crates/. Each matched directory must contain a Cargo.toml; Cargo reports an error for one that does not, unless it is listed in exclude.
  • exclude removes directories that a glob would otherwise include. Here crates/experimental is not a member.
  • default-members chooses what plain cargo build, cargo test and similar commands act on when run from the root without -p or --workspace. Here that is only crates/cli (plus whatever it depends on).
  • resolver selects the dependency resolver version, explained under "MSRV" below.

If the root manifest also has a [package] table, the root is itself a member, called the root package. If it has [workspace] and no [package], it is a virtual manifest: the root only organizes the members. A virtual manifest has no edition from which Cargo could infer the resolver, so it should set resolver explicitly. Without it, Cargo uses resolver 1, and Cargo 1.98.1 prints a warning about this when the members use edition 2021 or later.

What the members share:

  • one Cargo.lock at the root, so each dependency is locked once for the whole workspace,
  • one output directory (target/ at the root by default), so a compiled dependency can be reused by another member when it was built with the same settings,
  • one dependency resolution: Cargo picks versions for all members at once.

Feature unification (Cargo building a dependency once with the union of the features that its users enable) applies to the packages selected for a particular build. In a test workspace where member x enabled feature f1 of a shared dependency and member y enabled f2, cargo build -p x built the dependency with only f1, cargo build --workspace built it with both, and going back to -p x compiled it again. So reuse of compiled dependencies depends on which members you build together.

Some settings are read only from the root manifest:

[patch], [replace] and [profile.*] are recognized only in the workspace root manifest. A [profile.release] in a member's manifest has no effect. Cargo prints a warning ("profiles for the non root package will be ignored, specify profiles at the workspace root"), so check build output when an opt-level change seems to do nothing.

Inheriting shared settings

Since Rust 1.64, members can inherit package metadata and dependency declarations from the root instead of repeating them. Since Rust 1.74 the same applies to lint settings.

# root Cargo.toml (continued)
[workspace.package]
version = "0.4.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.85"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }

[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
# crates/core/Cargo.toml
[package]
name = "thing-core"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true

[dependencies]
serde.workspace = true

[lints]
workspace = true

version.workspace = true means "use the value from [workspace.package]". Each key is opted into separately: a member that leaves out rust-version.workspace = true has no rust-version at all. serde.workspace = true copies the requirement from [workspace.dependencies], and a member may add extra features to it. [lints] workspace = true applies the workspace lint table; here it turns every lint in the clippy::all group (the lints Clippy enables by default) into an error, and priority = -1 lets more specific lint settings override the group.

This keeps a shared version number in one place. It does not force every member onto the same dependency version: a member can still write its own requirement, such as serde = "=1.0.228", instead of serde.workspace = true. The shared lock file then has to satisfy both requirements.

Check your understanding Somebody splits a crate whose clean build takes 40 seconds into six workspace members, and the clean build still takes about 40 seconds. Does that mean splitting was pointless? Show answer

Not necessarily. Splitting a crate changes two different things, and they affect different kinds of builds.

Incremental rebuilds usually benefit. When you edit one member, Cargo recompiles that member and the members that depend on it, and reuses the compiled output of the others.

Clean builds may get faster, stay the same, or get slower. All the same code still has to be compiled. Members that do not depend on each other can be compiled in parallel, which can help. A chain where every member depends on the previous one leaves little to run in parallel, and crate boundaries can reduce how much the optimizer inlines across code unless link-time optimization is enabled. cargo build --timings writes a report showing which crates ran in parallel and how long each took, which is the way to find out for a specific project.

Splitting also has design effects that do not depend on build time: a member can use only what its dependencies make public, and Cargo rejects cycles of ordinary dependencies between packages, so the split enforces a layering.

Publishing

cargo publish packages a crate, builds the packaged copy to check that it compiles, and uploads it to a registry (crates.io by default). crates.io requires a description and either license or license-file; Cargo warns when those are missing, and also when documentation, homepage and repository are all missing. Useful checks before a real upload:

  • cargo package --list prints the files that would go into the upload.
  • cargo publish --dry-run performs every check without uploading.

Facts about crates.io that shape how you publish:

A published version cannot be changed. The files uploaded for a version can never be overwritten, and that version number cannot be uploaded again with different contents. You can publish any number of new versions. The crates.io policy lets an owner delete an entire crate only in limited cases: shortly after it was first published, or when it has a single owner, very few downloads and no other crates depending on it. Plan as if every release is permanent.

Yanking is not deletion. cargo yank --version 0.3.1 (run in the package's directory, or with the crate name as an argument) marks that version as yanked. Cargo will not pick a yanked version when it creates new Cargo.lock entries, but the files stay downloadable and any project whose Cargo.lock already records that version keeps building. cargo yank --version 0.3.1 --undo reverses it. Yanking discourages new use of a broken release; it does not remove the release, and if a version accidentally contained a secret, the secret must be revoked.

A published crate cannot have a path-only dependency. A dependency written as thing-core = { path = "../core" } refers to a directory on your machine, which other users do not have. cargo package and cargo publish reject it ("all dependencies must have a version requirement specified when packaging"). Give the dependency both a path and a version requirement:

thing-core = { path = "../core", version = "0.4.0" }

Builds inside the workspace use the local path. When the crate is packaged, Cargo removes the path and keeps the version requirement, so users downloading it get thing-core from crates.io. That means thing-core 0.4.0 (or a compatible version) must be published before the crate that depends on it. The same pair can live in [workspace.dependencies] and be inherited with thing-core.workspace = true. cargo publish --workspace publishes every publishable member in one command, in dependency order.

Dev-dependencies (used only by tests, examples and benchmarks) may be path-only: Cargo drops a dev-dependency that has no version requirement from the published manifest.

publish = false in a member's [package] table makes cargo publish refuse that package. It suits internal crates, such as shared test helpers, that are never meant for crates.io.

MSRV in practice

The minimum supported Rust version (MSRV) is the oldest compiler a package promises to build with. You declare it with rust-version in [package], such as rust-version = "1.85". Cargo uses it in two places:

  1. When building. Cargo refuses to build a package whose rust-version is newer than the running compiler. With rustc 1.98.1 and rust-version = "1.200", the error says the package "requires rustc 1.200". --ignore-rust-version overrides the check.
  2. When resolving dependencies. Resolver 3, stabilized in Rust 1.84, is the default for any package with edition = "2024". When it chooses a version for a new or updated lock file entry, it prefers dependency versions whose own rust-version is compatible with the workspace's. If no version that matches the requirement is compatible, it falls back to an incompatible one instead of failing, and the build step above may then reject it. With differing rust-version values across members, it uses a heuristic rather than an exact per-member answer.

Whether raising your MSRV needs a major version bump is a policy choice. The Cargo SemVer reference lists it as "possibly breaking", because it breaks users who cannot upgrade their compiler, and generally recommends treating it as a minor change. A common compromise: keep rust-version accurate, mention increases in the changelog, and let MSRV-aware resolution steer users toward older compatible releases of your crate. That only helps users whose Cargo is 1.84 or newer and uses resolver 3 (or is configured to prefer compatible versions); older Cargo ignores your rust-version when resolving.

docs.rs

docs.rs automatically builds documentation for crates published to crates.io, using a nightly Rust compiler in a sandbox with network access blocked. A build can fail, for example when a build script tries to download something. By default it documents only the default features. Settings go in the manifest:

[package.metadata.docs.rs]
all-features = true

all-features = true documents feature-gated items too. Use features = [...] instead if some features cannot be enabled together.

docs.rs also sets the docsrs configuration flag while documenting your crate, so source code can detect it. Many manifests add rustdoc-args = ["--cfg", "docsrs"], which sets the same flag and is harmless.

An item gated by a feature can be labeled with the feature it needs. The doc(cfg(...)) attribute that adds the label is unstable, so it must be enabled with a crate-level attribute that applies only under docsrs, where the nightly compiler accepts it:

// at the top of src/lib.rs
#![cfg_attr(docsrs, feature(doc_cfg))]

#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub async fn fetch() {}

#[cfg(feature = "async")] compiles fetch only when the async feature is enabled. #[cfg_attr(docsrs, ...)] applies the doc(cfg(...)) attribute only when docsrs is set, so ordinary stable builds never see the unstable attribute. Without the crate-level feature(doc_cfg) line, compiling with docsrs set fails with error E0658.

The result on the documentation page is a note reading "Available on crate feature async only." Without all-features (or a matching features list), fetch is missing from the documentation entirely. With all-features but no label, it appears with nothing saying it needs a feature, and users may try to call it without enabling one. In the rustdoc shipped with Rust 1.98, enabling feature(doc_cfg) was enough for the note to appear even without the per-item attribute, because rustdoc now derives it from #[cfg]; the explicit attribute remains accepted.

cargo-semver-checks and its limits

cargo-semver-checks is a third-party Cargo subcommand (run as cargo semver-checks). By default it compares your current code with the latest version published on crates.io and reports public API changes that its lints recognize as requiring a new major or minor version. For example, it has a lint for a public type that stops implementing an auto trait such as Send, which happens when a private field of type Rc<T> is added.

It compares API descriptions generated by rustdoc, not program behavior, so some kinds of breakage are outside what it can check:

  • Behavior changes. A function that starts returning None where it used to return Some still compiles for every caller, but callers that relied on the old result now behave differently.
  • Changes without a lint. Its documentation states that it does not yet have lints for every way to break compatibility, and lists categories it does not detect, such as some changes to generics, lifetimes, and field or parameter types. Changes like these can be subtle. For example, adding a Drop implementation can break callers: once a type implements Drop, code that moves a field out of it, such as let T { a } = t; with a String field a, fails with error E0509. Check the tool's lint list to see whether the version you run covers a given change.
  • Feature combinations. It checks the features it is configured to check, so a break that appears only with a particular subset of features can be missed.

Treat it as an automated check that catches many breaking changes, alongside your own review of what changed and what the API promises.

step 18 of 22

Modules, Visibility, Testing and Docs

Up next #[test], the assert macros, and what a test actually is