cargo-shape-check 0.1.0

Skip unnecessary downstream crate rebuilds by hashing only the public API surface
Documentation

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 fn signatures including return types, parameters, generics, and bounds. Bodies are excluded unless downstream crates can see them (see below).

  • Data types pub struct, pub enum definitions including all fields, variants, and repr attributes.

  • Traits pub trait definitions including default method bodies, which downstream crates use directly.

  • Other items pub type aliases, pub const, pub static, pub use re-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/Sync becoming !Send/!Sync due to a private field type change) are not detected from source-level parsing.

  • pub fn() -> impl Trait where 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

  1. Parses each crate's source tree with syn
  2. Extracts publicly-visible items using a syn::visit::Visit traversal
  3. Canonicalizes the output (sorted, formatted via quote)
  4. Hashes with SHA-256
  5. Compares against a saved baseline to detect changes

No compilation required. No nightly toolchain. Hashing runs in ~25ms per crate.

License