Cargo & Workspaces
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Rust and Cargo 1.99.0 and the 2024 edition, checked on 2026-10-06. Cargo builds Rust
code, resolves and downloads dependencies from crates.io, runs tests, benchmarks and documentation, and publishes
packages. Everything it does is driven by a manifest, Cargo.toml. This page covers the manifest in depth and then the
workspace setup the shelf project uses. Distribution of finished binaries (installers, Homebrew, release archives) is
covered on Packaging & Distribution.
Cargo.toml and Cargo.lock
[package]
name = "shelf" # crate name on crates.io; `-` becomes `_` in code
version = "0.3.1" # SemVer
edition = "2024"
rust-version = "1.85" # MSRV, see the toolchain page
description = "Command-line client for the shelf library catalogue"
license = "MIT OR Apache-2.0"
repository = "https://example.org/shelf"
readme = "README.md"
keywords = ["library", "catalogue", "cli"]
categories = ["command-line-utilities"]
publish = true # false (or a list of registries) to prevent accidental publishing
[dependencies]
shelf-core = { path = "../shelf-core", version = "0.3" }
serde_json = "1.0.151"
anyhow = "1.0.104"
[dev-dependencies] # only for tests, examples and benchmarks
assert_cmd = "2"
[[bin]] # optional: more binaries or a different name
name = "shelf"
path = "src/main.rs"
Cargo.toml states requirements ("serde_json 1.0.151 or any later compatible version"); Cargo.lock, which Cargo
writes, records the exact versions chosen for every crate in the dependency graph, so builds are reproducible. Commit
Cargo.lock for every package (Cargo’s guidance since 2023, for libraries too); update it deliberately with cargo
update (everything) or cargo update -p serde_json (one crate).
Dependencies and SemVer requirements
A bare version string is a caret requirement: "1.0.151" means >=1.0.151, <2.0.0; for 0.x versions the leftmost
non-zero component is the breaking one, so "0.8.9" means >=0.8.9, <0.9.0. Other operators: ~1.2.3 (patch updates
only), =1.2.3 (exact), >=1.2, <1.5 (range), * (any; not allowed on crates.io).
[dependencies]
tokio = { version = "1.53", features = ["rt-multi-thread", "macros"] }
serde = { version = "1.0.229", default-features = false, features = ["derive"] }
rand = { version = "0.10", optional = true } # only built when a feature enables it
shelf-core = { path = "../shelf-core", version = "0.3" } # path for local builds, version for crates.io
isbn-fixtures = { git = "https://example.org/isbn-fixtures.git", tag = "v1.2.0" }
jsonwebtoken = { version = "11.1", package = "jsonwebtoken" } # `package` lets you rename a dependency
[target.'cfg(unix)'.dependencies] # platform-specific
libc = "0.2.190"
[build-dependencies] # used by build.rs only
cc = "1.2"
Cargo follows the SemVer rules described in its SemVer Compatibility chapter: a minor release must not break callers.
When two dependencies require incompatible major versions of the same crate (say tower-http 0.6 and 0.7), Cargo
builds both; the types are then distinct, which is the source of "expected tower_http::cors::CorsLayer, found
`tower_http::cors::CorsLayer`" errors that the axum section warns about.
The command line covers the everyday operations:
cargo add serde --features derive # edit Cargo.toml for you
cargo add --dev assert_cmd
cargo remove rand
cargo info axum # crate metadata, versions, features (1.82)
cargo tree # the resolved dependency graph
cargo tree -i tower-http # who depends on tower-http? (-i = invert)
cargo tree -d # duplicated crates (several versions)
cargo update --dry-run # what would change
Source: The Cargo Book: cargo tree
Features
Features are named, optional parts of a crate, enabled by the crate’s users. They are declared in [features] and can
enable other features and optional dependencies (dep:name, 1.60). Code checks them with #[cfg(feature = "...")]:
[features]
default = ["json"] # enabled unless the user writes default-features = false
json = ["dep:serde_json", "serde"] # enables an optional dependency and another feature
serde = ["dep:serde"]
random-sample = ["dep:rand"]
full = ["json", "random-sample"]
[dependencies]
serde = { version = "1.0.229", features = ["derive"], optional = true }
serde_json = { version = "1.0.151", optional = true }
rand = { version = "0.10", optional = true }
Source: The Cargo Book: Features
// In the library: compile the export module only when the `json` feature is on.
#[cfg(feature = "json")]
pub mod export {
pub fn to_json(titles: &[&str]) -> String {
format!("{titles:?}") // a real implementation would use serde_json
}
}
pub fn features_enabled() -> Vec<&'static str> {
let mut v = Vec::new();
if cfg!(feature = "json") {
v.push("json");
}
v
}
fn main() {
println!("{:?}", features_enabled());
}
Source: The Cargo Book: Features
Features are unified: if two crates in the graph enable different features of the same dependency, it is built once
with the union. Therefore features must be additive — enabling one must never remove functionality or change
behaviour incompatibly — and code must compile with any combination (test with cargo hack --feature-powerset in CI).
Build or test with features using cargo build --features json,random-sample, --all-features or
--no-default-features.
Profiles
Profiles configure the compiler for different purposes. dev (used by cargo build, cargo test) favours compile
speed and debugging; release (--release) favours runtime speed. You can override settings and define custom profiles:
[profile.dev]
opt-level = 0 # default for dev; 1 can make debug builds of heavy code usable
debug = true
[profile.dev.package."*"]
opt-level = 2 # optimise dependencies even in dev builds (they rarely change)
[profile.release]
opt-level = 3 # default for release; "s"/"z" optimise for size
lto = "thin" # link-time optimisation across crates: "fat" (true) is slower to build, a bit faster to run
codegen-units = 1 # fewer units = better optimisation, slower builds (release default: 16)
panic = "abort" # no unwinding: smaller binaries; panics end the process
strip = "symbols" # remove symbols from the binary
debug = "line-tables-only" # keep enough for backtraces and profilers
[profile.profiling] # a custom profile: cargo build --profile profiling
inherits = "release"
debug = true
strip = "none"
Source: The Cargo Book: Profiles
Profile settings in dependencies' manifests are ignored — only the top-level package or workspace root decides. Overflow
checks, debug assertions and incremental compilation are also profile settings (overflow-checks, debug-assertions,
incremental). Performance tuning with these settings is discussed on
Performance & Profiling.
Build scripts
A build.rs file at the package root is compiled and run before the package itself. It can generate code, compile C
sources (with the cc crate), run bindgen, or detect system libraries, and it talks to Cargo by printing
cargo:: instructions on stdout:
// build.rs
use std::env;
use std::fs;
use std::path::Path;
fn main() {
// Re-run only when these inputs change (otherwise: whenever any file in the package changes).
println!("cargo::rerun-if-changed=build.rs");
println!("cargo::rerun-if-env-changed=SHELF_BUILD_ID");
// Generate a Rust source file into OUT_DIR, to be include!d by the crate.
let out_dir = env::var("OUT_DIR").expect("set by cargo");
let build_id = env::var("SHELF_BUILD_ID").unwrap_or_else(|_| "dev".into());
fs::write(Path::new(&out_dir).join("build_info.rs"), format!("pub const BUILD_ID: &str = {build_id:?};\n"))
.expect("write build_info.rs");
// Expose a value to the crate as env!("SHELF_PROFILE"), and set a cfg flag.
println!("cargo::rustc-env=SHELF_PROFILE={}", env::var("PROFILE").unwrap_or_default());
println!("cargo::rustc-check-cfg=cfg(shelf_fast_hash)");
println!("cargo::rustc-cfg=shelf_fast_hash");
// Linking a native library would look like:
// println!("cargo::rustc-link-lib=static=isbn");
// println!("cargo::rustc-link-search=native=/opt/isbn/lib");
}
Source: The Cargo Book: Build Scripts
// src/main.rs
include!(concat!(env!("OUT_DIR"), "/build_info.rs"));
fn main() {
println!("build {BUILD_ID}, profile {}", env!("SHELF_PROFILE"));
if cfg!(shelf_fast_hash) {
println!("fast hash enabled by build.rs");
}
}
Source: The Cargo Book: Build Scripts
The cargo:: prefix (double colon) replaced the older cargo: single-colon form in Rust 1.77; Cargo still accepts the
old form. rustc-check-cfg declares custom cfg names so the unexpected_cfgs lint (on by default since 1.80) does not
warn about them. Mara & Holmes (Refactoring to Rust, chapter 4) use build scripts to compile and link C code; see
FFI & C Interop for a current version with cc and bindgen.
.cargo/config.toml
Cargo reads configuration from .cargo/config.toml files in the current directory and its parents, and from
$CARGO_HOME/config.toml. It sets build flags, aliases, registries and environment variables — settings about how to
build on this machine or in this repository, as opposed to the manifest, which describes what the package is:
# .cargo/config.toml
include = ["ci.toml"] # pull in another config file (stable since 1.94)
[alias]
lint = "clippy --all-targets --all-features -- -D warnings"
[build]
rustflags = ["-C", "target-cpu=native"] # only for binaries that run on the build machine
[env]
SHELF_DATA_DIR = { value = "data", relative = true }
[target.x86_64-unknown-linux-gnu]
linker = "clang"
Source: The Cargo Book: Configuration
Workspaces
A workspace is a set of packages that share one Cargo.lock, one target/ directory and one set of profile settings.
It suits projects split into a library and several binaries, as shelf is:
library: Book, Isbn, Catalog"] cli["shelf
binary: CLI"] web["bookshelf-web
binary: axum app"] mig["bookshelf-migrate
binary: DB migrations"] end cli --> core web --> core mig --> core cli -.-> anyhow["anyhow, serde_json, clap"] web -.-> axum["axum, tokio, sqlx, tower-http"] core -.-> thiserror["thiserror, serde (optional)"]
# Cargo.toml at the workspace root (a "virtual manifest": no [package])
[workspace]
resolver = "3" # default for edition 2024 members; MSRV-aware resolution
members = ["shelf-core", "shelf", "bookshelf-web", "bookshelf-migrate"]
[workspace.package] # values members can inherit
version = "0.3.1"
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
[workspace.dependencies] # one version for the whole workspace
shelf-core = { path = "shelf-core", version = "0.3" }
serde = { version = "1.0.229", features = ["derive"] }
thiserror = "2.0.21"
tokio = { version = "1.53", features = ["full"] }
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
pedantic = { level = "warn", priority = -1 }
[profile.release]
lto = "thin"
Source: The Cargo Book: Workspaces
# shelf-core/Cargo.toml -- inherits from the workspace
[package]
name = "shelf-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true, optional = true } # can add features or `optional` locally
[lints]
workspace = true
Source: The Cargo Book: Workspaces
Workspace commands act on all members (cargo test --workspace, cargo build --workspace) or on one (cargo run -p
shelf — search dune). The Book’s chapter 14.3 walks through creating a workspace step by step.
cargo install and custom subcommands
cargo install ripgrep builds a binary crate from crates.io (in release mode) and puts it in ~/.cargo/bin;
cargo install --path shelf installs a local one; --locked uses the package’s Cargo.lock. Any executable named
cargo-something on the PATH becomes a subcommand cargo something — that is how cargo-nextest, cargo-audit,
cargo-expand and cargo-flamegraph work. cargo-binstall downloads prebuilt binaries instead of compiling.
Publishing to crates.io
cargo login # paste a crates.io API token (stored in ~/.cargo/credentials.toml)
cargo package --list # what would be uploaded (respects `include`/`exclude`)
cargo publish --dry-run
cargo publish # upload one package
cargo publish --workspace # publish all publishable members in dependency order (1.90)
cargo yank --version 0.3.0 # prevent new projects from depending on a broken version
Published versions are permanent: you cannot delete or overwrite them, only yank them. Every dependency of a published
crate must itself be on a registry (a path dependency needs a version too). Before publishing, check the API with
cargo semver-checks to avoid accidental breaking changes.
The [lints] table
Since Rust 1.74, lint levels for rustc, Clippy and rustdoc can be set in Cargo.toml instead of #![...] attributes in
every crate root, and inherited across a workspace (shown above). priority orders group settings before individual
overrides:
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(shelf_fast_hash)'] }
[lints.clippy]
pedantic = { level = "warn", priority = -1 } # the group first...
module_name_repetitions = "allow" # ...then exceptions
unwrap_used = "deny"
Build performance
Cargo’s build performance guide collects the practical levers. The most effective ones for day-to-day work:
-
use
cargo checkwhile editing, and run tests withcargo nextestfor parallelism; -
cargo build --timingsproduces an HTML report of which crates take longest and how parallel the build is; -
split large crates (compilation is parallel across crates, sequential within one), and avoid heavy generic code in hot crates;
-
turn off unused default features of dependencies;
-
lower
debugto"line-tables-only"in dev, optimise dependencies once with[profile.dev.package."*"]; -
use a faster linker (LLD is the default on x86-64 Linux since 1.90;
moldelsewhere) andsccachein CI.
Cargo in the 2024 edition
The 2024 edition changed Cargo’s defaults: the MSRV-aware resolver (resolver = "3") is the default; unused
default-features = false on a workspace-inherited dependency is reported; manifests reject the old underscore spellings
(dev_dependencies, crate_types). See What’s New
& Edition Migration.
Where the books are outdated
Mara & Holmes (Refactoring to Rust, chapters 3-4) use the single-colon cargo: build-script instructions and older
dependency versions (bindgen 0.56, pyo3 0.14); the double-colon cargo:: form (1.77), rustc-check-cfg, the [lints]
table (1.74), workspace inheritance (1.64), cargo info (1.82), cargo publish --workspace (1.90) and config include
(1.94) all postdate their examples.