A Second Vision for Cargo: My Thoughts on Ed Page’s Recent Article
loadingalias
· post · 19 min
A response to Ed Page: rebuild Cargo’s core first, and let the workflows, interfaces, and performance wins follow.
Ed Page recently published A Vision for Cargo, where he describes better dependency resolution, native async, caching, plumbing commands, safer build extensibility, and a more maintainable Cargo. I want most of what he wants. Where we disagree is how Cargo gets there.
I am focused here on the proposals that depend most directly on Cargo’s command model: plumbing, async execution, caching, resolution, build scripts, and avoiding unnecessary work. Those outcomes should fall out of a clean core; they’re not separate projects layered onto today’s architecture.
I hold an unpopular opinion in the Rust community: Cargo is currently Rust’s major DX bottleneck. I’m not saying that Cargo is at fault for every slow Rust build, but it does shape nearly every build that runs through it. It selects the resolved graph, feature sets, targets, profiles, build-script work, compiler invocations, scheduling, freshness, and reuse. rustc, the linker, build scripts, and the codebase determine much of the cost inside each unit. Cargo determines which units exist to begin with, when they run, and how often that cost is paid. This is really quite important. Cargo is the one build and package path the community broadly trusts, its architecture limits how much unnecessary work Rust devs and tools can avoid.
I didn’t wake up one morning and decide, ‘I know what’s best for Cargo.’ I genuinely respect the Cargo core team. I read what they publish, evaluate their work, and track issues and ideas across the Zulip channels. No, I’ve arrived at this view while building Cargo-Rail. I’ve built Cargo-Rail to fill the gaps I felt existed in Cargo, but the work continues to lead back to the same problem. What looked like ten missing plugins kept turning into the same job: every tool rebuilt its own version of Cargo’s context.
While working through issues exposed by Cargo-Rail’s integration into Iggy, I realized that the major blocker was Cargo itself. Cargo-Rail has to reconstruct Cargo from the outside. There is no other way. It loads the workspace, queries metadata, rebuilds an approximation of the dependency graph, infers what a command would do, then hopes the model still agrees with Cargo when execution starts.
After about a year hacking around Cargo, and many more using it, I no longer see these as separate Cargo-Rail gaps. They are symptoms of the same problem inside Cargo.
Cargo-Rail led me to one answer: a shared WorkspaceContext, established once for a command, from which graph and command views are derived and to which later observations are attached. I think Cargo should start here, then implement the idea properly at the layer that owns the facts. After all, Cargo can do what Cargo-Rail does better… much better.
I am not issuing a general stop-work order. I couldn’t even if I wanted to; I have no official connection to Cargo. Security, correctness, compat fixes, and small usability improvements should continue. Instead, I’m arguing for a freeze around new workflows, new features, and/or new functionality on a core we all know needs to be rewritten. To that end, the experiments that establish or test the new architecture should obviously continue.
I understand Ed’s general worry concerning another feature freeze. No one wants another indefinite freeze. Cargo’s previous freeze came from insufficient team capacity, and stopping feature work doesn’t create maintainers. My proposal only makes sense as a staffed, scheduled and time-boxed architecture experiment with a working cargo check and an external consumer as the exit criteria. We cannot wind up on some three year journey to change the core. The world looks different today. This work needs a six-month ‘validation’ schedule. It’s entirely possible to validate this in the next six-months.
I realize that nothing in Ed’s article ignores Cargo’s architecture issues. In the maintenance section, he calls it the largest constraint on the team’s ability to change Cargo. His plumbing proposal is explicitly a refactoring strategy: use clear external inputs and outputs to force the same decomposition internally. I also realize he’s not presenting the article as an ordered roadmap.
My disagreement is about the order I think those proposals require. The plumbing prototype is explicitly experimental, and it’s useful for that reason. I don’t think its workflow boundaries should define the architecture. Cargo should first establish one coherent internal path through resolution, planning, execution, and results. Plumbing and porcelain should then expose different entry and exit points on that same path.
Ed also mentioned wanting callers to change data between plumbing steps or replace a step entirely. That freedom makes the missing authority boundary more important. Cargo must distinguish state it produced from state a caller supplied, then know what must be revalidated before it acts on either.
In my opinion, the real risk begins when these experimental outputs start to become stable ecosystem contracts. If that contract is cut directly from today’s coupled architecture, Cargo will have to rebuild itself while preserving a public representation of the architecture it is replacing. The workflows Ed wants should emerge from the new core and then be used to pressure-test it. They should not decide its shape one prototype at a time.
Cargo-Rail already pays for the missing boundary. cargo rail plan needs an authoritative answer to a basic question: given this workspace, config, toolchain, and set of changes, what work will Cargo perform? Cargo doesn’t provide that answer. Cargo-Rail assembles an approximation from cargo metadata, manifest parsing, target ownership, dep edges, observed inputs, and its own work declarations, then translates the result back into exact arguments for Cargo and nextest. When something is missing, we schedule more work. When Cargo-Rail and Cargo interpret the workspace differently, Cargo must win when execution begins. Cargo needs to establish the command once, resolve from that state, produce a typed plan that can represent deferred work, and make both porcelain and future plumbing consume the same exact results.
We need to start with what Cargo already knows
I think Cargo needs to begin one step before Ed’s plumbing proposal. Before cargo test is split into smaller commands, Cargo needs one command-scoped view of the workspace: what Cargo loaded, what it resolved, which features and targets it selected, and what work it intends to run. This is the core the rest of the architecture hangs on.
Establish that command state once. Every stage takes an explicit input from it and produces an explicit result or observation. cargo test becomes the friendly front end to that pipe. Plumbing commands expose pieces of the same pipeline to other tools. No stage silently reloads the workspace and returns with a different interpretation. New information, including build-script output, re-enters the operation as a named state transition.
I’m running into this lesson every single day working on Cargo-Rail. Once every command shares the same workspace context, work that looked unrelated starts reusing the same state. Cargo-Rail has to construct that context from outside Cargo, so parts of it will always be an approximation. Cargo itself already owns the facts; it doesn’t need to force every tool to rebuild or rediscover them.
A shared command model is only the foundation for native async. I fully understand thatCargo would still need to give explicit owners to blocking filesystem work, networking, subprocesses, locks, cancellation, and progress. The planner names the units and deps; the async runtime drives the operations around them without every command rebuilding what Cargo is trying to do. Cargo already executes build units concurrently. The point is to make concurrency, I/O, and cancellation follow the same command state across the rest of Cargo.
Affected-work planning fits into the same path. Suppose crates/storage-engine/src/page.rs changes. Cargo can use the graph and recorded inputs it owns to determine what may have been affected. Project policy can then decide which checks may safely be skipped. Local dev, CI, and remote workers receive the same plan. If a manifest or another captured input changes before that plan runs, Cargo rejects it and computes another one. There is no second system trying to guess what cargo test would have done.
The resolver finally gets a clean edge here, too: a complete resolution request goes in, and a resolved graph with its decision evidence comes out. The algo behind that edge can change without dragging Cargo’s command implementation with it. Cargo already represents build scripts as units and records their outputs. The missing seam is a command model that accepts those outputs as named observations, ties them back to the work that produced them, and derives downstream work without a hidden side channel.
Nothing here is easy; the order isn’t going to make it any easier. It will keep each project from inventing another partial Cargo. Build the shared command model first. Put porcelain and plumbing on the same path. Move blocking work and execution onto native async with clear ownership. Add affected planning and caching using facts Cargo recorded itself. Replace the resolver behind a boundary designed for replacement - we must never weld any resolver to Cargo again.
Today, each project is being asked to invent its own partial model of Cargo. I want that cycle to stop and I want to prioritize it. Now.
Cargo has contexts; it doesn’t have captured authority
Cargo already has context objects. It creates a GlobalContext at startup and passes it into built-in commands. Commands that need a workspace usually construct a Workspace early. Compilation later creates a BuildContext containing the packages, profiles, target data, and unit graph for that command.
Cargo already carries a lot of context; it doesn’t carry authority in the sense I mean here.
GlobalContext holds process-wide config. Workspace knows about packages and members. The command-specific graph appears later in BuildContext. Freshness fingerprints and build-script output belong to the mutable BuildRunner.
Together, these types get a build done. They don’t give Cargo, or an outside consumer, a single chain connecting the facts a command observed to the decisions it made and the work those decisions allow.
I don’t think the separation between these types is a bug. Adding another large context object would likely not fix anything either. Some of the required bounds may already exist and only need to be extracted. However, make no mistake, others are going to require major restructuring. The test that matters is whether a choice can cross a phase boundary without losing its identity or quietly rebuilding its inputs.
Authority isn’t another word for evidence… evidence describes what Cargo has observed. Policy decides which conclusions are allowable. Capabilities then determine which effects an operation may perform. By captured authority, I mean the command-scoped binding across these three things:
- Capture the source, manifests, lockfile, effective config, toolchain, targets, and command inputs used by the operation.
- Derive resolution, planning, freshness, execution, and results from that evidence. If a later phase reads live state - that must be explicit.
- Keep each decision tied to the evidence and policy that produced it.
- Attach capabilities to effects instead of assuming that a valid decision authorizes every consumer to act on it.
- Before an effect, consume captured inputs, enforce an access boundary, or revalidate at an explicitly weaker guarantee. Missing evidence reduces permission: widen the selected work, bypass cache reuse, or refuse a mutation.
Captured doesn’t mean frozen, either. It can’t.
A build script may discover a native library, generate source, or emit a linker directive halfway through a build. Cargo should record that result inside the same operation and bind later decisions to the new state. Any claim Cargo makes about the result must remain limited to what it observed or enforced.
Revalidation has a hard limit too. A file can change after Cargo checks it and before the compiler reads it. A stronger assurance requires the compiler to consume captured content or run in an environment where the relevant inputs can’t change. Unrestricted build scripts and proc-macros can observe more than Cargo knows about. The model must therefore distinguish declared inputs, observed inputs, and enforced access bounds. Recording an output does not prove that Cargo knows everything that produced it.
CAPTURED INPUTS
workspace + lockfile + config + toolchain + command
|
v
authority S0
|
derive
v
decision
|
prove / revalidate
/ \
safe unknown
| |
effect widen / refuse
|
record
|
v
authority S1Every decision stays tied to the command state that produced it. Missing evidence widens selection; cache reuse and mutation stop when the evidence is too weak.
None of this requires a daemon or some giant structure behind one global lock. Different operations need different strengths of evidence. Shell completion can tolerate a “best effort” view; a cache restore or cargo publish simply cannot. Weak evidence must never acquire stronger authority merely because it crossed into another phase.
A working example: affected planning
I’m using affected planning here to turn the architecture into a practical question: what can this change actually affect, and what may safely be skipped?
The selectors belong to one captured operation. If the checkout changes, they expire. If the evidence is incomplete, the planner selects more work.
Cargo, Git, and CI each own part of that answer:
| Cargo should own | Git owns | Repository and CI policy own | |
|---|---|---|---|
| What it knows | Workspace, resolution, command units, targets, outputs, and dep-info | Source identities and changed paths | Required checks, external deps, and platform matrices |
| What it decides | Affected build scope, selectors, reasons, and supporting evidence | Which paths differ between two source states | Which comparison matters and which checks the change must pass |
| What happens on doubt | Widen the build scope or reject a stale plan | Report that the source no longer matches | Run broader work, apply stricter policy, or stop |
Cargo’s build graph isn’t a complete test-impact graph. A test can read a fixture at runtime, invoke another program, or depend on a service Cargo has never heard of. Repo policy has to declare those things or require broader work when they are unknown.
Narrowing can also change the build itself. Passing fewer -p arguments may change feature unification for shared deps. A planner must either preserve the intended unit config or resolve and validate the narrower command as an entirely different plan. Fewer packages doesn’t mean the same build with a handful of tests removed.
Cargo-Rail already demonstrates a narrower version of this ownership split. During dispatch, it creates one command-specific WorkspaceContext. Planning paths capture the source before invoking cargo metadata, which may create Cargo-managed state. Metadata is then loaded once, and the graph and command views are derived from that captured context.
The planner emits typed, named decisions. The companion GitHub Action validates the plan and checkout before lowering package and target selectors into arguments for Cargo, nextest, Just, or repo scripts. Those tools still own execution. Cargo-Rail only decides the scope.
That narrow system proves this boundary can work. Cargo-Rail isn’t a blueprint for Cargo. It still reconstructs facts from the outside that Cargo already owns, and that’s exactly what Cargo can remove.
Across three of my own workspaces, affected planning has cut enough local work and CI execution that I notice the difference… namely, my CI bills are cheaper. I don’t have any reproducible benches; I’m not going to invent a number or percentage to appease anyone. You’ll have to try it for yourself.
Affected planning tests only one part of the architecture. It doesn’t tell us whether the same authority can survive a resolver replacement, a cache decision, a build-script update, or an async subprocess. Those are the next tests. What it does give us today is more useful than another architecture diagram or blog post, though… it gives us a working target with failure modes we can inspect. It gives us a general proof of concept.
One Cargo interface, many tools
Today, Cargo makes external tools rediscover the same workspace in pieces. This happens for every single Cargo plugin; every single tool calling into Cargo.
cargo metadata does its job pretty well, but its output is broad by design. packages[].dependencies contains the deps declared in each manifest, while resolve.nodes[] describes the graph Cargo selected. Those structures overlap because they answer different questions; they are not accidental duplication. Inside each resolved node, however, the older dependencies field and the richer deps field do overlap. Both remain in metadata format v1 purely for compatibility.
The more expensive repetition happens around the command itself. Each cargo metadata invocation starts a Cargo process, asks it to load and resolve the workspace, receives a workspace-wide JSON document, and derives the narrower model the consumer needs. That document still doesn’t describe an exact cargo build -p crate or cargo test invocation. Cargo issue #7754 shows why this matters: workspace-wide feature resolution reported by metadata can differ from the features selected by a narrower package build.
Cargo’s unstable --unit-graph exposes command-specific units and their relationships. JSON messages expose artifacts, diagnostics, and build-script results during execution. Each interface publishes another piece of the build, leaving consumers to join the pieces themselves.
Cargo-Rail has hit this, too
Cargo-Rail has bumped into this directly. Its WorkspaceContext shares one canonical metadata result and graph across the command, but that result cannot answer every feature or target question. ResolutionViews invokes cargo metadata for each distinct non-default view, rebuilds a WorkspaceGraph, and caches the result. Multi-target work can therefore require one metadata load per target. The cache prevents the same view from loading twice during one operation; it cannot remove the repeated process startup, resolution, serialization, and graph reconstruction. Cargo-Rail still has to ask Cargo for JSON and rebuild a graph Cargo already held internally.
Cargo-Rail’s ‘unify’ workflow is an example of this win. Work normally divided among workspace-hack generation, feature maintenance, unused-dep scanners, and one-off manifest checks runs against the same workspace graph and feeds one mutation plan. The win is not only fewer Cargo processes; every diagnosis and fix refers to the same packages, targets, features, and resolution views.
Cargo-Rail isn’t special here. The rust-analyzer also refreshes project info with cargo metadata and separately runs build scripts through Cargo. nextest asks Cargo to build the test binaries, then independently discovers and schedules the tests. That specialized work should remain. What can disappear is the Cargo-specific joining around it, where tools correlate packages, command units, build-script results, and artifacts through identities Cargo should supply.
Language servers make the benefit easier to see. Rust Glancer’s account of why building a Rust LSP is hard describes a system that must answer from partial information: it discovers workspaces, tracks source state, and decides how much analysis each query needs. Cargo cannot remove the VFS, semantic indexing, type inference, cancellation, or prioritization. It can stop making the LSP reconstruct the build first. Packages, targets, features, editions, build-script results, generated source, and the identity of that state all belong to Cargo. Rust Glancer and rust-analyzer can still make completely different indexing and memory tradeoffs. They should not need different approximations of what Cargo thinks the project is. The proposed architeture upgrade allows the tools we all lean on daily to do significantly less work.
Cargo should provide one coherent family of interfaces to the facts it owns, with clear evidence for the decisions derived from them. Before choosing a daemon, stable Rust ABI, persisted snapshot, or process protocol, it needs internal values that are actually worth exposing: captured workspace and resolution identities, command-scoped units, decision reasons, build-script effects, artifacts, diagnostics, and terminal results. Then, and only then, should Cargo choose how another process receives them.
Those values need stable interactions across phases, not frozen internals. An blurred unit identifier can remain meaningful for one captured operation without making Cargo’s scheduler public API. A reason code can stay stable while its human explanation improves. Consumers can negotiate a version and reject contracts they don’t understand.
Cargo is a build and package manager… we all know this. It is not a CI system or general task runner. Some adjacent tools earn their place by adding policy or execution Cargo should not own. Tools that mainly reconstruct or join Cargo facts should shrink or disappear entirely. Specialized test scheduling, task languages, org-specific CI policy, historical reporting, and retries likely still belong outside Cargo. Cargo should own the structures and shared facts beneath them. The ecosystem can then disagree about policy without first disagreeing about what Cargo observed.
Make the work converge
I don’t want Cargo to treat plumbing, async execution, caching, resolver (presumably PubGrub) integration, and build scripts as five separate projects. This will only create five more places to decide what a package, unit, input, or result means. They should all meet in one new path through a command.
That path begins with captured inputs. Resolution consumes them and returns a named result. Planning turns that result into command units. Execution consumes those units and records what happened. Porcelain commands run the whole path. Plumbing commands expose useful boundaries within it. They are two interfaces to the same machinery, not two implementations of Cargo.
The accepted Cargo plumbing goal can do more than add commands and settle message formats. It can force Cargo to name the values that already pass between its phases, then make both plumbing and porcelain use them. If a plumbing command has to rebuild a second model of the workspace, the boundary is definitely in the wrong place and we can reevaluate before going any further.
With this path in place, the parts of Ed’s vision that depend on Cargo’s core stop looking like independent architecture projects. Native async can give blocking work, networking, subprocesses, locks, cancellation, and progress explicit owners around units Cargo has already planned. A cache reuses a unit only when the evidence and policy attached to it permit the reuse; storage providers can still own transport and retention. Affected planning selects a safe subset of the same units before execution. None of this becomes free, but each feature stops inventing its own version of the command.
The accepted work to extend PubGrub for Cargo makes the same case at the resolver barrier. The project is building a modular, separately testable resolver because the current one is too entangled to change safely. Cargo should be able to replace that resolver behind a clear input and result contract. If adopting PubGrub requires another excavation through the rest of Cargo, the architecture has failed its first replacement test. There is just no way we should be welding Cargo to another resolver.
The resolver is load-bearing, not healthy. Rust depends on its observable behavior remaining compatible, but the implementation beneath that behavior is brittle, under-tested, and a nightmare to change. That genuinely restricts better diagnostics, MSRV/security-aware resolution, and every tool that needs the exact graph Cargo selected. When that barrier is hard to change or consume, the cost spreads through the Rust DX for all of us. That is how a Cargo limitation becomes a Rust growth problem. That is why Rust feels bottlenecked by the work, and the pace of innovation at Cargo.
Build scripts are the clearly the harder test here. They can generate files and emit cfg values or linker directives after execution has started, so a plan cannot pretend to be a fixed list of compiler commands. A build-script result has to extend the same command state, and Cargo has to derive the affected downstream work from it. That doesn’t make an unrestricted build script hermetic or automatically cacheable. It gives Cargo one place to record what it learned, what remains unknown, and what the result is allowed to change. It also gives us a chance to decide what build scripts should do in the future. Should they really continue to be our dumping grounds?
Validate with cargo check
To be clear, I’m not proposing a “clean slate” Cargo 2 or a rewrite branch that drifts away for years. Too much of Rust depends on Cargo’s behavior. The new architecture has to grow inside the Cargo people already use. A refactor branch should stage the early work, but it cannot become a separate product. It must stay continuously comparable with Cargo and carry forward the behavior the ecosystem depends on. We have to work cleanly, but we have to remain integrated.
I say we start with one experimental path for cargo check. It is narrow enough to compare, but it still exercises resolution, planning, freshness, build scripts, compiler invocation, artifacts, and diagnostics. We keep the existing CLI. Capture the command inputs, call the current resolver through the new boundary, produce command-scoped units, execute them, and return typed results. We must obviously use the current resolver first; this has to be a deliberate choice. Changing the core architecture and the resolution alog at the same time would make every regression harder to explain. Once the old resolver works through the boundary, replace it with PubGrub, if PubGrub is still the right choice… and that’s another post for another time.
Run the old and new paths across Cargo’s fixtures and real workspaces. Compare the resolution, feature sets, unit graph, compiler invocations, artifacts, diagnostics, and exit status. Measure startup, no-op checks, and incremental checks as well. Some differences will be bugs in the new path; others will expose behavior Cargo must preserve or behavior worth changing. Either way, every difference needs a name and an explanation.
Then make one real external tool consume the same experimental command model. It should no longer need to reload metadata, reconstruct Cargo’s graph, or join artifacts back to guessed units. The proof is whether Cargo and a consumer can both delete the code the new abstraction replaced.
I would allot six-months and a specific owner. The goal is not to finish the Cargo migration end to end. It is to prove the boundary in running code. During that period, feature work that strengthens the new path belongs on it. Work that would stabilize the old coupling as a new public contract must wait. At the end, the review should require a working cargo check, explained compat results, credible perf numbers, and an external consumer with less reconstruction code.
If this experiment can’t produce the evidence above, end the freeze and reevaluate. I don’t see that being a real issue, though. I think this will not only work, but it will drive Cargo towards the future.
If it works, I get to delete the reconstruction code Cargo should have owned all along.