Skip to content

Cargo (Rust)

Rust's official build system and package manager, whose first-class [workspace] model — a single root manifest, one shared Cargo.lock, one shared target/ directory, and a topologically-scheduled, fingerprint-cached build graph — is the canonical "language package manager that is also a monorepo engine," and the most direct precedent for the workspace feature proposed for dub.

FieldValue
LanguageRust (the tool itself is written in Rust; manifests are TOML)
LicenseMIT OR Apache-2.0 (dual)
Repositoryrust-lang/cargo
DocumentationThe Cargo Book · doc.rust-lang.org/cargo
CategoryLanguage Package Manager / Build System
Workspace modelRoot manifest with a [workspace] table; root-package or virtual workspace; glob members
First releasedCargo 1.0.0 with Rust 1.0 (May 15, 2015); [workspace] landed in Rust 1.12 (Sept 2016)
Latest releaseCargo 1.96.0 (with Rust 1.96.0)

Latest release: Cargo ships in lockstep with the compiler on the six-week train, so its version always tracks rustc's. As of June 5, 2026 the latest stable is Cargo 1.96.0 (Rust 1.96.0, released May 28, 2026). All source citations below are against the development tree on master, which self-reports version = "0.99.0" in Cargo.toml (the pre-1.0-style internal crate version that is renumbered to match the shipping rustc at release).


Overview

What it solves

Cargo unifies three concerns that other ecosystems split across separate tools: dependency resolution (a SAT-style version solver writing a Cargo.lock), building (orchestrating rustc invocations as a DAG), and project organization (the workspace). A Rust monorepo is not a bolt-on: it is the same Cargo.toml mechanism a single crate uses, extended with one extra table. The unit of code is a crate (one rustc compilation; one lib or bin target); the unit of distribution and versioning is a package (one Cargo.toml, one or more targets); and the unit of monorepo grouping is a workspace (one root Cargo.toml with a [workspace] table, N member packages).

The workspace exists to make many packages behave like one project. From the core type's own documentation (src/cargo/core/workspace.rs):

"The core abstraction in Cargo for working with a workspace of crates. A workspace is often created very early on and then threaded through all other functions. It's typically through this object that the current package is loaded and/or learned about."

Concretely, members of one workspace share: a single Cargo.lock (one resolution, no per-package version drift), a single target/ build directory (so a library compiled once is reused by every dependent — no redundant local compilation), the [patch]/[replace]/[profile] tables, and (since Rust 1.64) an inheritable [workspace.package] + [workspace.dependencies] registry.

Design philosophy

Cargo's monorepo design rests on a few load-bearing decisions, all observable in the source:

  1. The workspace is discovered, not configured per-invocation. Running any command from anywhere inside the tree walks up the filesystem to find the root manifest, then walks down (or globs) to enumerate members. Workspace::new "will construct the entire workspace by determining the root and all member packages … Ok is only returned for valid workspaces" (workspace.rs).
  2. Two topologies, one mechanism. A workspace root may itself be a buildable package (a root-package workspace) or a manifest with only a [workspace] table and no [package] (a virtual workspace). The same WorkspaceRootConfig drives both.
  3. One lock, one target dir. The whole workspace resolves to a single Cargo.lock (LOCKFILE_NAME = "Cargo.lock", ops/lockfile.rs) and shares one target/ ("None if the default path of root/target should be used", workspace.rs).
  4. Change tracking over content addressing. Cargo decides what to rebuild with per-unit fingerprints + filesystem mtimes, not a content-addressed cache — a deliberate "balance of performance, simplicity, and completeness" (fingerprint/mod.rs).
  5. No remote execution. Cargo has a local fingerprint cache and a global package cache, but ships no remote build cache or REAPI backend; remote/shared caching is delegated to external wrappers (sccache).

Within this survey Cargo is the reference language-native monorepo: contrast it with the JS managers (pnpm, yarn-berry) that hoist/symlink a node_modules store, with go-work's lockfile-free multi-module overlay, and with the polyglot engines (bazel, buck2) that add content-addressed remote caching Cargo lacks. For the D analogue under improvement see dub.


How it works

A Cargo invocation proceeds: discover the workspace → resolve dependencies into Cargo.lockbuild the unit graphschedule & execute it with fingerprint-driven freshness. The five dimensions below trace each stage.

Workspace declaration & topology

The declaration surface is a single [workspace] table. A virtual workspace has no [package] at all:

toml
# Cargo.toml (virtual workspace root)
[workspace]
resolver = "3"
members = ["crates/*", "apps/*"]
exclude = ["crates/experimental"]
default-members = ["apps/cli"]

A root-package workspace is a normal package that also carries the table:

toml
# Cargo.toml (root is itself a buildable package)
[package]
name = "app"
version = "0.1.0"

[workspace]
members = ["crates/*"]

members, default-members, and exclude are all glob-capable arrays of path patterns. They are held in WorkspaceRootConfig (workspace.rs):

rust
// src/cargo/core/workspace.rs
pub struct WorkspaceRootConfig {
    root_dir: PathBuf,
    members: Option<Vec<String>>,
    default_members: Option<Vec<String>>,
    exclude: Vec<String>,
    inheritable_fields: InheritableFields,
    custom_metadata: Option<toml::Value>,
}

Discovery is bidirectional. Workspace::new calls find_root then find_members. find_root checks whether the invoked manifest is a root, and otherwise walks ancestors via find_workspace_root_with_loader until it finds a [workspace] whose members/exclude claim this path. find_members then materializes the membership (workspace.rs):

"If the workspace.members configuration is present, then this just verifies that those are all valid packages to point to. Otherwise, this will transitively follow all path dependencies looking for members of the workspace."

So membership is the union of two sources: the explicit (globbed) members list, and the transitive closure of path = "..." dependencies reachable from the root (find_path_deps). A package can be a member implicitly just by being a path dependency of another member. Globs are expanded by members_pathsexpand_member_path, which delegates to the glob crate and then filters to directories (so a stray .DS_Store is not mistaken for a member):

rust
// src/cargo/core/workspace.rs — expand_member_path (abridged)
let res = glob(path).with_context(|| format!("could not parse pattern `{}`", &path))?;

exclude subtracts paths from that union, with the subtlety (encoded in is_excluded) that an explicitly listed member always wins over an exclude prefix match. default-members selects the subset that bare commands act on when neither --workspace nor -p is given; for a virtual workspace with no default-members, the default is all members.

Dependency handling & isolation

Cargo does not isolate per-package dependency trees the way the JS world does; there is no hoisting, no symlink farm, no virtual store. Instead the entire workspace resolves together into a single Cargo.lock. One version of each (name, source) is chosen for the whole graph (subject to semver-compatible de-duplication), so two members can never silently use two patch versions of the same crate — the source of "version drift" the proposal aims to kill in dub.

Three mechanisms make members reference each other and shared upstreams cleanly:

  1. Local path dependencies. A member depends on a sibling with a relative path:

    toml
    [dependencies]
    greeter = { path = "../greeter" }

    Such a dependency is a normal edge in the build graph; the sibling is compiled once into the shared target/ and reused. Because the resolver follows path deps, the sibling is automatically pulled into membership.

  2. The central dependency registry ([workspace.dependencies], Rust 1.64+). Declare a dependency once at the root, then each member opts in with dep.workspace = true:

    toml
    # root Cargo.toml
    [workspace.dependencies]
    serde = { version = "1", features = ["derive"] }
    greeter = { path = "crates/greeter" }
    toml
    # member Cargo.toml
    [dependencies]
    serde.workspace = true          # version + features inherited from root
    greeter.workspace = true        # the path is inherited too

    InheritableFields::get_dependency (util/toml/mod.rs) resolves the name against the root table, and for a path dependency rewrites the path relative to the member"update the path to be relative to the workspace root instead." This is Cargo's equivalent of Yarn's workspace: protocol.

  3. Field inheritance ([workspace.package]). Metadata fields are inherited with field.workspace = true. The inheritable set is fixed in InheritableFields (util/toml/mod.rs):

    rust
    // src/cargo/util/toml/mod.rs — package_field_getter! (abridged)
    ("authors", authors -> Vec<String>),  ("edition", edition -> String),
    ("license", license -> String),       ("repository", repository -> String),
    ("rust-version", rust_version -> RustVersion), ("version", version -> semver::Version),
    // ...also categories, description, documentation, homepage, keywords, publish, ...

NOTE

Isolation in Cargo is achieved at the resolution layer (one lock, one chosen version per crate), not at the filesystem layer (no per-package store). This is the opposite end of the spectrum from pnpm's content-addressed node_modules and yarn-berry's zero-installs PnP — and a much closer fit to what a compiled language like D needs.

Task orchestration & scheduling

Cargo builds a DAG of "units" and executes it concurrently. A unit (core/compiler/unit.rs) is one rustc/build-script/doc invocation — roughly a (package, target, profile, features, kind) tuple. Units are connected by unit_dependencies into a graph, then handed to the job queue.

The scheduler is JobQueue (job_queue/mod.rs). Its own header states the model plainly:

"This module implements a job queue. A job here represents a unit of work, which is roughly a rustc invocation, a build script run, or just a no-op. … Spawns concurrent jobs … Controls the number of concurrency. It allocates and manages jobserver tokens to each spawned off rustc and build scripts."

Ordering is a DependencyQueue (util/dependency_queue.rs) — a graph that only releases a node once all its dependencies have finished:

"A graph-like structure used to represent a set of dependencies and in what order they should be built … to figure out when a dependency should be built."

Priority is cost-based: when the graph is finalized, each node's priority is the sum of its own cost plus the transitive cost of its dependencies, so long critical-path chains start first (queue_finished / dequeue, dependency_queue.rs). The job-queue docs are candid that "the current scheduling algorithm is not really polished … the cost is just passed as a fixed placeholder," with future PGO-style historical-timing prioritization noted as an idea.

Concurrency is governed by a GNU-make-compatible jobserver: Cargo is one process handing out N tokens (-j N / --jobs, default = CPU count) to many rustc children, so build scripts that shell out to make cooperate on the same token pool — "the jobserver relationship among Cargo and rustc processes is 1 cargo to N rustc." Cargo also pipelines: a dependent can start as soon as its dependency emits its .rmeta (metadata) file, before the dependency's .rlib (codegen) is finished, overlapping compilation along the DAG (compiler/mod.rs).

Change detection is the Fingerprint (fingerprint/mod.rs). Each unit is "dirty" or "fresh"; a fresh unit is skipped:

rust
// src/cargo/core/compiler/job_queue/job.rs
pub enum Freshness {
    Fresh,
    Dirty(DirtyReason),
}

A fingerprint is a hash (persisted in target/.../.fingerprint/) over rustc version, profile, compile mode, target kind, enabled+declared features, immediate dependency fingerprints, RUSTFLAGS, the [lints] table, source mtimes, and more (a full matrix is tabulated in fingerprint/mod.rs). A change in any dependency's fingerprint propagates "dirty" upward through the DAG, and source mtimes are compared against a dep-info anchor file. This is affected-package detection, but driven by hashes + mtimes rather than VCS diffs.

Caching & remote execution

Cargo has two local caches and no native remote execution:

  1. The build cache is the shared target/ directory plus its .fingerprint/ metadata. Reuse is per-unit: a member library compiled once is not recompiled for the next dependent in the same target/. This is local and machine-specific; mtimes make it non-portable.
  2. The global package cache under CARGO_HOME (~/.cargo): the registry index, downloaded .crate source tarballs, and git checkouts, shared across all workspaces on the machine. A SQLite-backed GlobalCacheTracker (global_cache_tracker.rs) records last-use timestamps and sizes to drive automatic garbage collection of stale downloads — "Tracking of cache files is stored in a sqlite database which contains a timestamp of the last time the file was used, as well as the size of the file."

IMPORTANT

There is no remote/shared build cache and no remote-execution in Cargo itself. The fingerprint model is explicitly not content-addressed — the source notes that "hashing file contents, tracking every file access … would ensure more reliable and reproducible builds at the cost of being complex, slow, and platform-dependent" (fingerprint/mod.rs). Shared/remote caching is achieved out-of-tree by setting build.rustc-wrapper to sccache, which can back onto S3/GCS/Redis. This is the single biggest gap versus bazel/buck2/turborepo (and the nativelink/buildbarnREAPI backends), and a deliberate one.

CLI / UX ergonomics

Cargo's command boundary for monorepos is a small, consistent set of package-selection flags layered on every build-like subcommand (build, check, test, bench, doc, run, publish). They are defined once in command_prelude.rs (command_prelude.rs) and reduce to four states in the Packages enum (ops/cargo_compile/packages.rs):

Flag formPackages variantSelects
(none)Packages::Defaultdefault-members (all members for a virtual root)
-p <spec> / --package <spec>Packages::Packageshand-picked members (repeatable; globs allowed)
--workspace (alias --all)Packages::Allevery member
--workspace --exclude <spec>Packages::OptOutevery member minus the excluded specs
rust
// src/cargo/ops/cargo_compile/packages.rs — from_flags (abridged)
pub fn from_flags(all: bool, exclude: Vec<String>, package: Vec<String>) -> CargoResult<Self> {
    Ok(match (all, exclude.len(), package.len()) {
        (false, 0, 0) => Packages::Default,
        (false, 0, _) => Packages::Packages(package),
        (false, _, _) => bail!("--exclude can only be used together with --workspace"),
        (true, 0, _)  => Packages::All(package),
        (true, _, _)  => Packages::OptOut(exclude),
    })
}

So the developer ergonomics are: global broadcast with --workspace, targeted selection with -p (e.g. cargo test -p greeter), and subtractive selection with --workspace --exclude. -p accepts a PackageIdSpec (a name, or name@version, or a URL), and bare patterns expand against the member list. The -j/--jobs N and --keep-going flags (command_prelude.rs) tune concurrency and failure behavior.

Cargo has no built-in --filter-by-glob, no --since <git-ref> affected-package selection, and no topological foreach: there is no cargo workspaces foreach-style loop in core (that role is filled by the third-party cargo-workspaces / [cargo-hakari] plugins). Custom workflows are usually wired up as aliases in .cargo/config.toml or as an xtask-pattern member binary:

toml
# .cargo/config.toml
[alias]
ci = ["test", "--workspace"]

Sample workspace

A minimal, runnable two-member workspace lives under ./sample/. It demonstrates every dimension above in ~40 lines of TOML:

  • a virtual root (Cargo.toml with only [workspace], resolver = "3", members = ["crates/*"], default-members = ["crates/cli"]);
  • a central registry ([workspace.dependencies]) and field inheritance ([workspace.package] consumed by members via version.workspace = true);
  • a local cross-reference: cli depends on the sibling greeter via greeter.workspace = true, whose path = "crates/greeter" is declared once at the root;
  • a task: a .cargo/config.toml [alias] (cargo cicargo test --workspace).

cargo test --workspace (or cargo ci) builds greeter once, reuses it for cli, and runs both members' tests in topological order. cargo run -p cli -- Cargo runs just the binary.


Strengths

  • Workspaces are native and zero-ceremony. One extra [workspace] table turns N packages into a coherent monorepo — same manifest format, no separate tool. Glob members scale to large trees.
  • One lock, one resolution, no drift. A single Cargo.lock for the whole workspace guarantees every member sees the same version of every crate.
  • Shared target/ eliminates redundant local compilation. A local library is compiled once and reused by every dependent in the workspace.
  • First-class DAG scheduling with pipelining + jobserver. Concurrent, cost-prioritized, .rmeta-pipelined builds that cooperate with make on parallelism tokens.
  • Field + dependency inheritance ([workspace.package] / [workspace.dependencies]) keeps versions and metadata DRY.
  • Consistent, minimal selection flags (-p, --workspace, --exclude) on every subcommand.
  • Automatic GC of the global cache via a SQLite last-use tracker.

Weaknesses

  • No remote/shared build cache, no remote execution. Reuse stops at the local target/; sharing CI cache requires external sccache. No REAPI story (cf. bazel, buck2, nativelink).
  • mtime-based freshness is fragile and non-portable. Fingerprints depend on filesystem mtimes; clock skew, container layer copies, and checkout order cause spurious rebuilds or (rarely) missed ones. Content hashing is unstable-only (checksum-freshness).
  • No affected-package / --since selection. Cargo can't natively scope a build to "members changed since git ref" the way turborepo/nx do; you rebuild what fingerprints say is dirty, not what a diff says is touched.
  • No topological foreach or per-member task pipeline. Custom multi-step workflows live in .cargo/config.toml aliases or xtask binaries, not in a declarative task graph.
  • One global jobserver, scheduling is admittedly "rudimentary." The cost heuristic is a placeholder; no historical-timing prioritization yet.
  • Whole-workspace resolution is all-or-nothing. You cannot have two incompatible major versions of a build-time tool across members without [patch] gymnastics.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Workspace = one extra [workspace] tableNo new file format/tool; monorepo is an extension of the single crateDiscovery must walk up and down the tree; implicit path-dep membership
Root-package or virtual root, one mechanismSupports both "app + its libs" and "pure library collection" layoutsTwo modes to document; virtual roots have no buildable target of their own
Single shared Cargo.lock + target/One resolution (no drift); compile each local lib exactly onceWhole-workspace lock is all-or-nothing; can't hold two incompatible majors
[workspace.dependencies] + field.workspace = trueDRY versions/metadata; central upgrade point; Yarn-workspace:-likeOnly a fixed set of fields is inheritable; indirection when reading a member
Fingerprint + mtime freshness (not content-addressed)Fast, simple, "good enough" change detection without hashing every fileFragile to clock skew/container copies; not reproducible; no portable cache key
Local target/ cache only, no remote executionKeeps Cargo simple and platform-independentNo shared CI cache / REAPI; punts remote caching to sccache and wrappers
DAG + jobserver + .rmeta pipeliningSaturate cores; cooperate with make; overlap metadata/codegenCost model is a placeholder; one global jobserver; "rudimentary" scheduling
Four selection states (-p/--workspace/--exclude)Small, consistent command boundary on every subcommandNo --filter globs, no --since diff selection, no built-in foreach

Sources