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"]
memberslists member directories. Glob patterns are allowed, socrates/*means every directory directly insidecrates/. Each matched directory must contain aCargo.toml; Cargo reports an error for one that does not, unless it is listed inexclude.excluderemoves directories that a glob would otherwise include. Herecrates/experimentalis not a member.default-memberschooses what plaincargo build,cargo testand similar commands act on when run from the root without-por--workspace. Here that is onlycrates/cli(plus whatever it depends on).resolverselects 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.lockat 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 anopt-levelchange 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 Hide 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 --listprints the files that would go into the upload.cargo publish --dry-runperforms 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:
- When building. Cargo refuses to build a package whose
rust-versionis newer than the running compiler. With rustc 1.98.1 andrust-version = "1.200", the error says the package "requires rustc 1.200".--ignore-rust-versionoverrides the check. - 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 ownrust-versionis 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 differingrust-versionvalues 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
Nonewhere it used to returnSomestill 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
Dropimplementation can break callers: once a type implementsDrop, code that moves a field out of it, such aslet T { a } = t;with aStringfielda, 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.