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

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 }
// 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());
}

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"

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");
}
// 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");
    }
}

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"

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:

flowchart LR subgraph ws["shelf workspace (one Cargo.lock, one target/)"] core["shelf-core
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"
# 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

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 check while editing, and run tests with cargo nextest for parallelism;

  • cargo build --timings produces 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 debug to "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; mold elsewhere) and sccache in 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.