This is the “you are now a member of the ecosystem” item. Nothing here can be run on this site — there is no cargo, no network and no second package — but everything here is where the last four items stop being theory.
Workspaces
# Cargo.toml at the repository root
[workspace]
resolver = "3"
members = ["crates/*"]
exclude = ["crates/experimental"]
default-members = ["crates/cli"]
A root manifest has a [workspace] table alongside its own [package].
A virtual manifest has [workspace] and no [package] at all — the root
is just an organiser, which is usually what you want.
What the members share:
-
one
Cargo.lock, so every member resolves the same versions, -
one
target/, so a dependency compiled for one member is not recompiled for the next, - one dependency resolution, which means feature unification (10.16) happens across the whole workspace.
What is root-only, and this catches people:
[patch],[replace]and[profile.*]are honoured only in the workspace root. A[profile.release]in a member’s manifest is silently ignored with a warning, and people lose hours wondering why theiropt-levelsetting does nothing.
The inheritance tables
Recent enough that older tutorials still duplicate metadata across a dozen manifests:
# root
[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
[dependencies]
serde.workspace = true
[lints]
workspace = true
Version bumps become one edit instead of twelve, and a dependency can only be at one version across the repo because there is only one place to write it.
💡Somebody splits a 40-second crate into six workspace members and reports that the clean build still takes 40 seconds. Were they lied to? click to reveal
Partly. The claim that gets repeated is “splitting a crate improves compile times”, and the accurate claim is narrower:
Splitting a crate does not by itself make a clean build faster. It makes incremental rebuilds more granular.
A clean build still has to compile all the same code, and it now has crate boundaries that constrain how much can happen in parallel and prevent some cross-crate inlining. What changes is that editing one member only recompiles that member and its dependents, instead of the whole thing. If most of your time goes on repeated edit-rebuild cycles in one area, that is a large win; if you mostly do clean CI builds, it is close to zero.
The other real benefits are architectural — enforced layering, since a member can only use what its dependencies expose — and those are usually the better reason to split.
Publishing
cargo publish needs name, version, description and a license field.
Run cargo publish --dry-run and cargo package --list first, every time.
Three facts about crates.io that shape everything else:
Publishing is immutable. A published version can never be changed or deleted. Not a policy you can appeal — the whole ecosystem’s reproducibility depends on it.
Yanking is not deletion. cargo yank --version 0.3.1 stops new resolutions
from picking that version. It does not remove the files, does not break
existing Cargo.lock files, and anybody who has it pinned keeps building. Yank
a version that shipped a bug; do not expect it to un-ship it.
You cannot publish a path-only dependency. A member that depends on
{ path = "../core" } has nothing crates.io can resolve. The workaround is the
dual form:
thing-core = { path = "../core", version = "0.4.0" }
Local builds use the path, published builds use the version. Every workspace that publishes more than one crate does this.
publish = false in a member’s [package] marks it as never-publishable, which
is the right setting for internal test-support crates.
MSRV in practice
rust-version is now enforced by the resolver rather than merely documented:
the MSRV-aware resolver (resolver 3, implied by edition 2024) prefers older
dependency versions over ones your declared toolchain cannot compile.
Raising your MSRV is treated as a minor bump by convention. It also breaks
real users on pinned toolchains, and the community has never fully agreed
about this. The current compromise is: declare rust-version honestly, treat
raising it as a notable change worth a changelog entry, and let the resolver
do what it can for people who cannot upgrade.
docs.rs
Docs are built automatically on publish, for every version, in a sandbox with no network. Two knobs matter:
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
#[cfg(feature = "async")]
#[cfg_attr(docsrs, doc(cfg(feature = "async")))]
pub async fn fetch() { … }
Without that, a feature-gated item either does not appear at all or appears with no indication that it needs a feature — and your users file an issue saying the function does not exist. With it, docs.rs renders a little “Available on feature async only” badge.
cargo-semver-checks, and its limits
cargo semver-checks compares your working copy against the last published
version and reports breakages, using the rules from 10.17. It is excellent and
you should run it in CI.
It also cannot see:
-
auto-trait changes. Adding an
Rcto a private field can silently make your type notSend, breaking every downstreamthread::spawn. There is no syntax in the diff to point at. -
behavioural changes. A function that starts returning
Nonein a case where it used to returnSomecompiles fine everywhere and breaks everything. -
Dropsemantics. Adding aDropimpl changes when things happen and can even make previously-valid code fail to compile.
So: a mechanical guard that catches many breakages, not all of them. The rules in 10.17 are still something you have to know, because the tool cannot know what your library means.