RFC 1525: cargo-workspace

tools (workspaces)

Summary

Improve Cargo's story around multi-crate single-repo project management by introducing the concept of workspaces. All packages in a workspace will share Cargo.lock and an output directory for artifacts.

Motivation

A common method to organize a multi-crate project is to have one repository which contains all of the crates. Each crate has a corresponding subdirectory along with a Cargo.toml describing how to build it. There are a number of downsides to this approach, however:

Solving these two problems should help ease the development of large Rust projects by ensuring that all dependencies remain in sync and builds by default use already-built artifacts if available.

Detailed design

Cargo will grow the concept of a workspace for managing repositories of multiple crates. Workspaces will then have the properties:

With workspaces, Cargo can now solve the problems set forth in the motivation section. Next, however, workspaces need to be defined. In the spirit of much of the rest of Cargo's configuration today this will largely be automatic for conventional project layouts but will have explicit controls for configuration.

New manifest keys

First, let's look at the new manifest keys which will be added to Cargo.toml:

[workspace]
members = ["relative/path/to/child1", "../child2"]

# or ...

[package]
workspace = "../foo"

The root Cargo.toml of a workspace, indicated by the presence of [workspace], is responsible for defining the entire workspace (listing all members). This example here means that two extra crates will be members of the workspace (which also includes the root).

The package.workspace key is used to point at a workspace's root crate. For example this Cargo.toml indicates that the Cargo.toml in ../foo is the root Cargo.toml of root crate, that this package is a member of.

These keys are mutually exclusive when applied in Cargo.toml. A crate may either specify package.workspace or specify [workspace]. That is, a crate cannot both be a root crate in a workspace (contain [workspace]) and also be a member crate of another workspace (contain package.workspace).

"Virtual" Cargo.toml

A good number of projects do not necessarily have a "root Cargo.toml" which is an appropriate root for a workspace. To accommodate these projects and allow for the output of a workspace to be configured regardless of where crates are located, Cargo will now allow for "virtual manifest" files. These manifests will currently only contains the [workspace] table and will notably be lacking a [project] or [package] top level key.

Cargo will for the time being disallow many commands against a virtual manifest, for example cargo build will be rejected. Arguments that take a package, however, such as cargo test -p foo will be allowed. Workspaces can eventually get extended with --all flags so in a workspace root you could execute cargo build --all to compile all crates.

Validating a workspace

A workspace is valid if these two properties hold:

  1. A workspace has only one root crate (that with [workspace] in Cargo.toml).
  2. All workspace crates defined in workspace.members point back to the workspace root with package.workspace.

While the restriction of one-root-per workspace may make sense, the restriction of crates pointing back to the root may not. If, however, this restriction were not in place then the set of crates in a workspace may differ depending on which crate it was viewed from. For example if workspace root A includes B then it will think B is in A's workspace. If, however, B does not point back to A, then B would not think that A was in its workspace. This would in turn cause the set of crates in each workspace to be different, further causing Cargo.lock to get out of sync if it were allowed. By ensuring that all crates have edges to each other in a workspace Cargo can prevent this situation and guarantee robust builds no matter where they're executed in the workspace.

To alleviate misconfiguration Cargo will emit an error if the two properties above do not hold for any crate attempting to be part of a workspace. For example, if the package.workspace key is specified, but the crate is not a workspace root or doesn't point back to the original crate an error is emitted.

Implicit relations

The combination of the package.workspace key and [workspace] table is enough to specify any workspace in Cargo. Having to annotate all crates with a package.workspace parent or a workspace.members list can get quite tedious, however! To alleviate this configuration burden Cargo will allow these keys to be implicitly defined in some situations.

The package.workspace can be omitted if it would only contain ../ (or some repetition of it). That is, if the root of a workspace is hierarchically the first Cargo.toml with [workspace] above a crate in the filesystem, then that crate can omit the package.workspace key.

Next, a crate which specifies [workspace] without a members key will transitively crawl path dependencies to fill in this key. This way all path dependencies (and recursively their own path dependencies) will inherently become the default value for workspace.members.

Note that these implicit relations will be subject to the same validations mentioned above for all of the explicit configuration as well.

Workspaces in practice

Many Rust projects today already have Cargo.toml at the root of a repository, and with the small addition of [workspace] in the root Cargo.toml, a workspace will be ready for all crates in that repository. For example:

Some examples of layouts that will require extra configuration, along with the configuration necessary, are:

Projects like the compiler will likely need exhaustively explicit configuration. The rust repo conceptually has two workspaces, the standard library and the compiler, and these would need to be manually configured with workspace.members and package.workspace keys amongst all crates.

Lockfile and override interactions

One of the main features of a workspace is that only one Cargo.lock is generated for the entire workspace. This lock file can be affected, however, with both [replace] overrides as well as paths overrides.

Primarily, the Cargo.lock generate will not simply be the concatenation of the lock files from each project. Instead the entire workspace will be resolved together all at once, minimizing versions of crates used and sharing dependencies as much as possible. For example one path dependency will always have the same set of dependencies no matter which crate is being compiled.

When interacting with overrides, workspaces will be modified to only allow [replace] to exist in the workspace root. This Cargo.toml will affect lock file generation, but no other workspace members will be allowed to have a [replace] directive (with an informative error message being produced).

Finally, the paths overrides will be applied as usual, and they'll continue to be applied relative to whatever crate is being compiled (not the workspace root). These are intended for much more local testing, so no restriction of "must be in the root" should be necessary.

Note that this change to the lockfile format is technically incompatible with older versions of Cargo.lock, but the entire workspaces feature is also incompatible with older versions of Cargo. This will require projects that wish to work with workspaces and multiple versions of Cargo to check in multiple Cargo.lock files, but if projects avoid workspaces then Cargo will remain forwards and backwards compatible.

Future Extensions

Once Cargo understands a workspace of crates, we could easily extend various subcommands with a --all flag to perform tasks such as:

Furthermore, workspaces could start to deduplicate metadata among crates like version numbers, URL information, authorship, etc.

This support isn't proposed to be added in this RFC specifically, but simply to show that workspaces can be used to solve other existing issues in Cargo.

Drawbacks

Alternatives

Unresolved questions