Port to Rust
A port is a behaviour-preserving move, and nothing else
The one rule this skill exists to enforce: during a port, behaviour changes and code improvements are separate commits, separate reviews, and preferably separate weeks. A port that also fixes bugs cannot be validated — every difference is ambiguous between the fix and the regression, and the differential harness reports both identically. Improvements are written down as a follow-up list and shipped after parity is proven.
Start from the parity contract, not from the code
Before any Rust is written, the contract answers four questions: what the
unit of parity is (a CLI invocation, a library function, an HTTP response, a
file artifact); which differences are acceptable (log wording, timing, map
iteration order, float formatting); which are not (any observable output a
caller can branch on); and how parity is measured — the full form in
PARITY-CONTRACT.md. A port without a written contract does not have a
definition of done, so it finishes when someone gets tired.
Five phases, in order
| Phase | Ends when |
|---|---|
| 1. Inventory and seam | The boundary the Rust will live behind is named, and the call sites crossing it are counted |
| 2. Characterize | The existing behaviour is captured as executable tests — against the source implementation, not the intended one |
| 3. Port leaf-first | The lowest-dependency module is in Rust, called through the seam, with both implementations still present |
| 4. Run both | Every input reaching the seam goes through both implementations and the outputs are compared |
| 5. Cut over and delete | Traffic goes to Rust only, and the source implementation is deleted rather than left as a dead fallback |
Phase 2 characterizes the behaviour that exists, bugs included — a test that
asserts the intended behaviour is a bug report disguised as a test. Phase 5
is not optional: a source implementation left in place stops being maintained
and starts being a lie about what runs; the depth on each phase is in
PHASES.md.
Decide the end state before the seam
Three end states; the choice governs everything downstream — the seam, the contract, the verification, whether bindings are deliverable or scaffold:
| End state | What ships | Bindings |
|---|---|---|
| A. Replacement | A standalone Rust binary or crate; the source implementation is deleted | None, ever. The seam is a process, CLI, or network boundary |
| B. Rust core with a language binding as the product | A Rust engine plus a binding layer that stays — the package the existing consumers import is the deliverable | Permanent. The binding surface is public API: it gets semver, error-type mapping, packaging per platform, and its own docs |
| C. Bindings as scaffold | A standalone Rust binary or crate, reached through a binding layer that is deleted at cut-over | Temporary. Built to be thrown away, and phase 5 is not done until it is gone |
B and C are not the same work, and conflating them is the most expensive mistake here. Scaffold bindings may be crude because they die; product bindings are a public surface that outlives the port. Ask which one is wanted — a user porting a CLI to Rust usually wants A, and handing them a native extension module answers a question nobody asked. The answer goes in the parity contract, because it changes what the unit of parity is: for A the unit is an invocation or a request; for B it is the binding signature the existing callers already call.
Pick the incremental strategy from the seam, not from taste
| Strategy | Reach for it when |
|---|---|
| In-place FFI — Rust compiled into the existing build and called across a language boundary | The source language has a cheap, stable FFI story and the seam is a function call |
| Strangler at a process boundary — the Rust binary takes traffic for one route, endpoint, or subcommand at a time | The seam is already a process, socket, or CLI boundary; the languages do not interoperate cheaply |
| Whole-unit replacement | The unit is small enough to port and validate in one sitting — a single CLI tool, one library with a narrow surface |
The FFI mechanics — which crate, which build integration, which header generator — sit in the per-language skills.
Parity is proven, not asserted
The evidence is both implementations running over the same inputs and
agreeing. Building that harness is /rust-testing; this skill decides what
it has to cover — every input class the contract names, including the error
paths, which are the ones a port silently narrows — and the harness runs in
CI from phase 3 onward, not once at the end, because a difference found six
modules later cannot be attributed.
What is deliberately not ported
Dead code, workarounds for limitations the source language had and Rust does not, and abstraction layers that existed to work around the source type system — each a judgment call written down in the contract, not made silently mid-file. The inverse error is worse and more common: porting the abstraction rather than the behaviour, so a five-layer class hierarchy becomes five Rust traits nobody needs.
Where the target-side judgment goes
This skill never decides what the Rust should look like. Expression shape is
/idiomatic-rust; a clone added to satisfy the borrow checker mid-port is
/ownership-not-clone; what a fallible path returns is /rust-errors;
whether a state should be constructible at all is /type-driven-design; the
public surface of the new crate is /rust-api-design; async and cancellation
are /async-rust; any unsafe in an FFI shim is /unsafe-rust; the review
pass over a ported diff is /rust-code-review. The deferral matters most
here: mid-port is exactly when an agent reaches for clone and unwrap to
make a translation compile, and those two are the signature of a port that
will read like its source forever. Separate the domain aspects from the
language aspects before writing the Rust; a construct that exists only for
a source-language limitation is not ported; structural similarity to the
source is a signal to re-examine — the entry is in ANTIPATTERNS.md.
Anti-patterns
The top three: transliteration, big-bang rewrite, and improving while
porting; the full list, with the tell for each, is in ANTIPATTERNS.md.
Verification
cargo test --all-features # includes the differential and characterization tests
cargo clippy --all-targets --all-features # add -- -D warnings only if the target repo has no lint config
cargo fmt --check
Plus the port-specific check the compiler cannot make: run the differential harness over the recorded corpus and report the count of inputs compared and the count of differences, not a pass/fail. Zero differences over nine inputs is not parity, and a report that hides the denominator hides that.