Public API shape hashing for Cargo workspaces
Cargo rebuilds every downstream dependent when any source file in an upstream
crate changes, even if the change is purely internal. cargo-shape-check
hashes only the public API surface of each crate in a workspace and reports
which crates actually changed their public interface.
In controlled experiments on rust-analyzer, skipping unnecessary downstream rebuilds delivers a 35x speedup on private edits (17s to 0.5s) with zero false-skips across 25 test cases.
Install
cargo install cargo-shape-check
Usage
Use build as a drop-in replacement for cargo build. On its first run it
performs a full build and saves a baseline of every crate's public API hash. On
subsequent runs it uses git to find which crates have source changes, hashes
only those, and skips downstream dependents when no public API changed.
$ cargo shape-check build
shape-check: no baseline found, running full build and saving baseline
Compiling stdx v0.0.0
...
Finished `dev` profile in 1m 37s
shape-check: baseline saved (44 crates)
$ echo "// private comment" >> crates/stdx/src/lib.rs
$ cargo shape-check build
Compiling stdx v0.0.0
Finished `dev` profile in 3.02s
shape-check: private changes only in [stdx], 43 downstream crates skipped
Extra arguments are forwarded to cargo. For example cargo shape-check build --release passes --release through to the underlying cargo build.
The diagnostic commands are useful for scripting and CI:
$ cargo shape-check save
Saved 44 crate hashes to .shape-check.json
$ cargo shape-check status
All 44 crates have unchanged public APIs. Downstream rebuilds can be skipped.
$ cargo shape-check check --quiet
Public API changed (1):
~ stdx 544756717d56c90c -> 0fd2bae739a50167
$ cargo shape-check check --json
{
"unchanged": ["hir", "hir-def", ...],
"changed": ["stdx"],
"added": [],
"removed": []
}
The status command exits 0 when all public APIs are unchanged and 1 when any
crate has a public surface change. check shows a full diff against the saved
baseline. hash prints the hash of a single crate.
Motivation
Cargo uses file mtimes (or optionally content checksums) to decide when to rebuild. When a source file in a leaf crate changes, all transitive dependents are rebuilt, regardless of whether the public API actually changed.
In large Rust workspaces this creates significant wasted work. We measured four major open source projects by walking 200 recent commits each, hashing the public API at each commit, and counting how many downstream rebuilds were triggered by changes that did not alter the public surface.
| Project | Crates | Private-only changes | Wasted downstream rebuilds |
|---|---|---|---|
| rust-analyzer | 44 | 75% | 66% |
| Bevy | 78 | 73% | 70% |
| Nushell | 38 | 95% | 93% |
| Deno | 73 | 78% | 81% |
73 to 95% of crate-level source changes across these projects do not touch the public API. Cargo rebuilds downstream anyway. See rust-lang/cargo#14604 for upstream discussion of this problem.
What gets hashed
The public surface of a crate consists of all items visible to downstream dependents. Specifically:
-
Functions
pub fnsignatures including return types, parameters, generics, and bounds. Bodies are excluded unless downstream crates can see them (see below). -
Data types
pub struct,pub enumdefinitions including all fields, variants, and repr attributes. -
Traits
pub traitdefinitions including default method bodies, which downstream crates use directly. -
Other items
pub typealiases,pub const,pub static,pub usere-exports, and#[macro_export]macros. -
Impl blocks Public methods and trait implementations, including the impl header and associated types/consts.
Function bodies are included in the hash when downstream crates can observe them:
#[inline]and#[inline(always)]functions, whose bodies are inlined into call sites- Generic functions with type or const parameters, which downstream crates monomorphize
const fn, whose bodies downstream crates may const-evaluate
All other pub fn bodies are excluded from the hash. A comment change, a local
variable rename, or a refactor inside a non-inline non-generic function body
will not change the hash.
Known limitations
-
Macro-generated public surfaces are not detected. The tool parses source text, not macro-expanded output.
-
Auto-trait inference changes (
Send/Syncbecoming!Send/!Syncdue to a private field type change) are not detected from source-level parsing. -
pub fn() -> impl Traitwhere the inferred concrete type changes without the source signature changing.
These are inherent to a source-level approach. A rustc-internal implementation operating on post-expansion, type-resolved data would close these gaps.
No false-skips were observed across 25 adversarial test cases covering comments, local variable renames, private function additions, doc-only changes, generic function body edits, inline function body edits, new public items, visibility changes, and trait method additions.
How it works
- Parses each crate's source tree with syn
- Extracts publicly-visible items using a
syn::visit::Visittraversal - Canonicalizes the output (sorted, formatted via quote)
- Hashes with SHA-256
- Compares against a saved baseline to detect changes
No compilation required. No nightly toolchain. Hashing runs in ~25ms per crate.