What It Replaces
| Problem | Before | After |
|---|---|---|
| Build graph drift | cargo-hakari, workspace-hack crates |
cargo rail unify |
| Unused deps | cargo-udeps, cargo-machete, cargo-shear |
cargo rail unify |
| Dead features | cargo-features-manager, manual audit |
cargo rail unify |
| MSRV computation | cargo-msrv, compile-and-fail loops |
cargo rail unify |
| CI waste | paths-filter + shell scripts |
cargo rail affected |
| CI costs | Test everything, bill for everything | Test what changed |
| Crate extraction | git subtree, git-filter-repo, Google's Copybara |
cargo rail split |
| Release orchestration | release-plz, cargo-release, git-cliff |
cargo rail release |
11 dependencies. One config file.
Install
Optionally, install via the pre-built binaries or cargo binstall cargo-rail
MSRV policy
- MSRV source of truth:
Cargo.toml(rust-version, written asmajor.minor.patch) - Cargo requirement: Cargo shipped with that Rust release (newer Cargo is fine)
- CI: builds on MSRV to prevent accidental bumps
- Workspaces:
cargo rail unifywrites[workspace.package].rust-version; enable[unify].enforce_msrv_inheritance = trueto set[package].rust-version = { workspace = true }in member crates
Quick Start
Demo using ripgrep codebase.
cargo rail unify on ripgrep — 9 deps unified, 6 dead features pruned
Commands
affected / test
Graph-aware change detection. Only test what's affected:
CI Integration:
- uses: loadingalias/cargo-rail-action@v1
id: rail
- run: cargo nextest run ${{ steps.rail.outputs.cargo-args }}
if: steps.rail.outputs.should-test == 'true'
unify
Dependency unification based on Cargo's resolved output:
What it does:
- Unifies versions — writes to
[workspace.dependencies], converts members toworkspace = true - Prunes dead features — removes features never enabled in the resolved graph
- Fixes undeclared features — adds missing feature declarations to member manifests
- Detects unused deps — flags dependencies not used anywhere (auto-removes on apply)
- Computes MSRV — derives minimum Rust version from dependency graph
- Pins transitives — replaces
cargo-hakariwithout a workspace-hack crate
Multi-target aware: runs cargo metadata per target triple in parallel, computes feature intersections not unions.
split / sync
Extract crates with full git history. Bidirectional sync with 3-way conflict resolution:
Three modes:
single— one crate → one repo (most common)combined— multiple crates → one repo (shared utilities)workspace— multiple crates → workspace structure (mirrors monorepo)
Safety: refuses dirty worktree by default. --allow-dirty to override, --yes for CI.
release
Dependency-order publishing with changelog generation:
Safety: detects default branch, refuses detached HEAD, warns on non-default branch.
config
Manage configuration:
Configuration
Generated by cargo rail init at .config/rail.toml:
= ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin"]
[]
= false # enable for hakari replacement
= true
= true
= true
= "max" # deps | workspace | max
= false
[]
= "{crate}-{prefix}{version}"
= 5 # seconds between publishes
[]
= [".github/**", "scripts/**", "*.sh"]
Full reference: docs/config.md
Real-World Results
Tested on production workspaces:
| Repo | Crates | Deps Unified | Dead Features |
|---|---|---|---|
| tikv | 72 | 61 | 3 |
| meilisearch | 19 | 46 | 1 |
| helix | 12 | 16 | 1 |
| tokio | 10 | 10 | 0 |
| ripgrep | 10 | 9 | 6 |
| polars | 33 | 2 | 9 |
| ruff | 43 | 0 | 0 |
Demo recordings: examples/
Migrating from cargo-hakari
Create a branch. Run --check first. Review the diff. This touches your entire workspace.
# set pin_transitives = true in rail.toml
&&
Full guide: docs/migrate-hakari.md
Design Decisions
Resolution-based — Uses Cargo's actual resolver output, not syntax parsing. If Cargo resolves it, cargo-rail sees it.
Multi-target — Runs cargo metadata --filter-platform per target in parallel. Computes feature intersections, not unions, w/ guardrails where it counts.
System git — Uses your git binary directly. No libgit2, no gitoxide. Deterministic SHAs.
Lossless TOML — Preserves comments and formatting via toml_edit.
Minimal deps — 11 direct dependencies. Built the release workflow specifically to avoid 200+ dep toolchains.
FAQ
cargo-hakari creates a workspace-hack crate. cargo-rail writes unified versions directly to [workspace.dependencies] — no extra crate. Enable pin_transitives = true for equivalent behavior w/o the added CI check and lockfile steps.
Yes. Writes to [workspace.dependencies] and converts member manifests to { workspace = true }.
Supported. For pin_transitives, cargo-rail auto-selects a workspace member as the transitive host (or configure transitive_host explicitly).
Works via cargo metadata, which respects .cargo/config.toml.
For pure Rust workspaces, yes... it can. cargo-rail provides graph-aware testing, dependency unification, and crate extraction without learning a new build system. If you're using Bazel/Buck2 only for Rust (not polyglot builds), cargo-rail gives you the key benefits — affected analysis, hermetic builds via lockfiles, crate distribution — while staying in Cargo's ecosystem. I'm exploring the best way to build a proper cache feature (local will come first; remote will follow), as well.
Cargo workspaces are the foundation. cargo-rail adds what's missing: automatic version unification across the resolver's actual output, dead feature detection/pruning, MSRV computation from the dependency graph w/ options for how you use it, unused dep detection/removal, and graph-aware change detection. These require analysis Cargo doesn't do.
Depends on your workspace. In a 30-crate workspace where a PR touches 3 crates, you test 3 crates + their dependents instead of 50. I've seen 60-80% reductions in CI minutes for my own workspaces; teams with large workspaces and frequent, focused PRs will likely experience similar numbers.
Cargo unifies deps across your workspace. If crate-a depends on serde and crate-b depends on serde with features = ["derive"], Cargo builds serde once with derive enabled for both. Now crate-a can use #[derive(Serialize)] even though it never declared that feature — it's "borrowing" from crate-b.
This works fine until: (1) you test crate-a in isolation, (2) you publish crate-a, or (3) crate-b drops the feature. Then crate-a breaks with cryptic compile errors.
cargo rail unify detects these borrowed features and auto-fixes them by adding the missing declarations to each crate's Cargo.toml. Cleaner graphs, safer publishes, tests that actually test what you ship.
Documentation
Contributing
Issues, PRs, and feedback welcome.