Overview
The JavaScript runtime Bun has a suspiciously named branch on its GitHub repo oven-sh/bun called claude/phase-a-port. Inside it lives docs/PORTING.md, a 30KB+ guide that translates Bun’s Zig codebase into Rust one file at a time — a complete type map, idiom map, and crate map. The claude/ prefix is the giveaway: this is almost certainly being driven by Anthropic’s Claude Code.
What Was Found
- oven-sh/bun (89K+ stars, “Incredibly fast JavaScript runtime, bundler, test runner, and package manager – all in one”) has a live
claude/phase-a-portbranch. - It contains
docs/PORTING.md, a 1:1 Zig-to-Rust translation guide. Tens of thousands of lines, with complete type maps, idiom maps, and crate maps. - Phase A’s goal is precise: “a draft
.rslands next to each.zig. Compilation is NOT required. Logic must be faithful.” - Phase B is where everything is forced through the compiler, crate by crate.
Why It Matters
Bun is the largest infrastructure project ever built in Zig: runtime, bundler, package manager all in one binary, with a single domain at bun.com. Zig still ships frequent 0.x breakage and is generally seen as not-yet-stable on ABI and language semantics. The biggest codebase on top of it deciding to port to Rust is itself an industry signal. Zig-to-Rust is not the usual direction.
And the branch name is the tell. No human team names a working branch claude/phase-a-port. That’s the shape of “hand phase A to the Claude Code agent and watch.”
Inside the Guide
Ground rules
- Each
.rslives in the same directory and has the same basename as its.zig. - Cross-area types are referenced as
bun_<area>::Type(Cargo.toml wireup happens in Phase B). - Forbidden: tokio, rayon, hyper, async-trait, futures,
std::fs/net/process. Bun has its own event loop and goes straight to syscalls. - Forbidden:
async fn. Everything is a callback plus state machine. unsafeis OK wherever Zig was unsafe. Everyunsafeblock needs// SAFETY: <why>.- If unsure, leave a
// TODO(port): <reason>. A flag beats a guess. - Zig perf idioms (
appendAssumeCapacity, arena bulk-free, comptime monomorphization) become plain Rust with a// PERF(port): ...comment, then Phase B greps and benches them.
Crate map (excerpt)
| Zig namespace | Rust crate |
|---|---|
bun.String, bun.strings, ZigString | bun_str |
bun.sys, bun.FD, Maybe(T) | bun_sys |
bun.jsc, JSValue, JSGlobalObject | bun_jsc |
bun.uws, us_socket_t, Loop | bun_uws_sys / bun_uws |
bun.allocators, MimallocArena | bun_alloc |
bun.shell | bun_shell |
bun.bake | bun_bake |
bun.install | bun_install |
bun.bundle_v2, Transpiler | bun_bundler |
MimallocArena is an arena allocator built on top of mimalloc; bun.uws is Bun’s own event loop binding (uSockets). Critically, neither uses an async runtime like tokio — and the porting guide forbids one explicitly.
Type map (excerpt)
| Zig | Rust |
|---|---|
[]const u8 (struct field) | Box<[u8]> / Vec<u8> / &'static [u8] / arena raw ptr — decide by reading deinit |
[:0]const u8 | &ZStr (length-carrying NUL-terminated) |
?T | Option<T> |
anyerror!T | Result<T, bun_core::Error> (always, in Phase A) |
comptime T: type | <T> (generic + trait bound) |
comptime n: uN | <const N: uN> |
inline for over tuple | const [T; N] + for |
for (slice, 0..) |x, i| | for (i, x) in slice.iter().enumerate() |
defer x.deinit() | delete — handled implicitly by impl Drop |
errdefer alloc.free(x) (just-built local) | delete — ? drops it for you |
errdefer { side effects } | scopeguard::guard(...) and disarm on the success path |
Notable micro-rules
bun_core::Erroris#[repr(transparent)] NonZeroU16— a heap-free Copy-able error newtype with a link-time-registered name table.anyhow::ErrorandBox<dyn Error>are banned because of heap allocation, lack ofCopy, and broken@errorNamesnapshot compatibility.bun.Wyhash11is kept distinct fromstd.hash.Wyhash(seed 0) for on-disk compatibility. Lockfiles, npm manifest cache, and integrity all depend on it — the Rust port keeps the separate implementation.defer pool.put(x)becomes a Drop-guard pattern in Rust. Manual defer is forbidden.- The
scopeguard::guard((), \|_\| ...)“unit state” pattern is forbidden — it usually means a missing RAII type. @errorName(e)becomes anIntoStaticStrderive. NeverDisplayorformat!("{e:?}")— JSerror.code, snapshot tests, and crash-handler traces depend on the exact string.for (a, b) \|x, y\|becomesfor (x, y) in a.iter().zip(b)plus adebug_assert_eq!(a.len(), b.len()). Zig asserts; Rust’szipsilently truncates.- TLS code stays on BoringSSL via FFI — not rewritten as pure-Rust RustTLS.
Phase A vs Phase B
- Phase A = one
.zig→ one.rs. Doesn’t have to compile. Logic faithful, idiomatic Rust shape. - Phase B = crate-by-crate compile pass. Sweep
// TODO(port)and// PERF(port)markers in batch.
This split is the load-bearing piece. Try to do everything at once and the LLM’s context collapses; carve it into one-zig-to-one-rs units and a single session can finish one. Compilation correctness is deferred entirely to Phase B.
What This Means — agent-skills, In Production
The PORTING.md document itself is the interesting artifact.
- A guide written by humans for an LLM to follow. Producing a 30KB+ map up front isn’t “Claude, port this for me” — it’s “Claude, here’s exactly what to translate to what.” It’s the agent-skills idea applied in production.
- Type-by-type decisions are nailed down in advance. Whether
[]const u8(as a struct field) becomesBox<[u8]>or&'static [u8]is not left to the LLM’s judgment — there’s a meta-rule (“look atdeinit”) that forces the decision. - A
docs/LIFETIMES.tsvfile is referenced explicitly: per-field OWNED / SHARED / BORROW_PARAM / STATIC / JSC_BORROW / BACKREF / INTRUSIVE / FFI / ARENA / UNKNOWN classes pre-classified by hand. The LLM is told to copy that column verbatim. The cross-file analysis is precomputed and handed to the model. - Three markers (
PORT NOTE,TODO(port),PERF(port)) are the phase handoff. Whoever (or whichever future LLM session) picks up Phase B cangreponce and have a queue of work.
Insights
This is the first publicly visible attempt to migrate a major codebase between systems languages using LLM automation, and the interesting takeaway is that the leverage is in the guide, not the model. PORTING.md pre-decides type maps and idiom maps, LIFETIMES.tsv pre-decides ownership per field, and TODO/PERF/PORT NOTE markers pre-design the phase-to-phase handoff. The LLM is intentionally left no room to be creative — it just executes “this line becomes that line.” Banning tokio, rayon, async-trait, and the rest of the canonical Rust async stack reflects the same instinct: Bun has its own event loop and FFI assets like BoringSSL that an LLM “Rust-ifying” would silently break. PORTING.md may end up the textbook example of an LLM-driven port. If massive codebase migrations become economically tractable as LLM spend, the deciding cost factor isn’t going to be GPUs or model choice — it’s going to be how much guide you wrote before you pressed Run.
References
Bun and the porting branch
Languages and ecosystems
Tooling / crates referenced
- scopeguard crate — RAII guard standing in for
errdefer - mimalloc — backing allocator for
MimallocArena - BoringSSL — TLS dependency kept on FFI
- tokio — async runtime explicitly forbidden in Phase A
