Rust's compiler can yell at the agent now
On a Saturday evening in February 2018 I wrote the same tiny program three times. Read a number from a file, loop that many times, print an approximation of π. The Python version was sixteen lines. PHP about the same. Rust needed twenty-four, and I remember the compiler asking for every one of the extra ones.
That was my first contact with Rust. I did not enjoy it, and I did not touch the language again for years. When I finally came back, it was for a reason that had nothing to do with Rust getting easier. Somebody else was typing.
The evening in question
The program is from my speed comparison, a repository that runs one calculation, the Leibniz formula for π, in as many languages as I can install. I started it to recreate a comparison from someone else’s blog post and add the languages he had left out. The result people look at is a chart. What I actually produced that evening was three programs, a Dockerfile, and a shell script.
Rust felt like it took much longer than the other two. I had to look up how to open a file, why reading it needs a mutable string, what parse::<i32>() is, and why my integer would not multiply with my float. Each compiler message was correct. Each one also sent me to the documentation.
Here are the Python and Rust files, exactly as committed.
Sixteen lines of Python. Twenty-four lines of Rust.
leibniz.pyPython 3.6
with open("rounds.txt") as file:
rounds = int(file.read())
x = 1
pi = 1
for i in range(2, rounds + 2):
x *= -1
pi += x / (2 * i - 1)
pi *= 4
print("π = " + str(pi)) leibniz.rsRust 1.22
use std::fs::File;
use std::io::prelude::*;
fn main() {
let mut file = File::open("./rounds.txt").expect("file not found");
let mut contents = String::new();
file.read_to_string(&mut contents)
.expect("something went wrong reading the file");
let rounds = &contents.trim().parse::<i32>().unwrap();
// We need to use f64 instead of f32 because otherwise the precision would be off
let mut x: f64 = 1.0;
let mut pi: f64 = 1.0;
for i in 2..(rounds + 2) {
x *= -1.0;
pi = pi + (x / (2.0 * i as f64 - 1.0));
}
pi = pi * 4.0;
println!("π = {}", pi);
} -
.expect("file not found")Opening a file can fail. Rust makes that possibility part of the value, so the program has to say what happens when it does.
-
.parse::<i32>().unwrap()Text becomes a number only once you name which number type you want, and only if you also handle the case where the text is not one.
-
let mut x: f64 = 1.0;Precision is a decision. The comment above it was added seven minutes after the first commit, once the result came out wrong.
-
i as f64An integer does not quietly become a floating-point number. The cast has to be written down.
Nothing in the Rust version is unreasonable. A file can be missing. A string is not a number until you say which kind of number. Precision is a decision. Rust insists that all of this is written down, and on a Saturday evening with a chart to fill, I did not want to write it down.
Then the results came in.
The first three results, as published that evening.
- PHP 7
- 0.06s
- Fastest of the three that evening.
- Rust
- 0.12s
- a debug build, twice PHP
- Compiled with rustc leibniz.rs and nothing else.
- Python 3
- 0.34s
- CPython, the reference point.
Rust lost to PHP. The comparison script explains why: it ran rustc leibniz.rs with no optimization flag, so the benchmark measured a debug build. I did not notice. Three years later a contributor did, and fixed it with the commit message “rust speed compare should use release profile.”
So the first Rust result I ever published was a debug build that lost to PHP 7, from February 2018 until May 2021. The compiler had checked everything about my program except the flag that mattered.
What the commit log shows
Before writing this I went back to the commit history to check my memory of that evening. It was less helpful than I hoped.
The first commit is from 16:32. The Docker setup, the script, and the Python, PHP and Rust programs all landed between 20:03 and 20:05, seventeen seconds apart. Three and a half hours passed in between, and the log does not say how much of that was Rust. The only hint is a commit seven minutes later that adds a comment to the Rust file explaining why the numbers have to be f64. You do not write that comment unless you got it wrong first.
The log is better at the bigger picture.
17 entries in fourteen days. Rust was the third.
Sat 17 Feb: Python, PHP, Rust, JavaScript, Go. Sun 18 Feb: Ruby, Crystal, C, Lua, Julia, Nim. Wed 21 Feb: C++. Mon 26 Feb: PyPy, Java. Tue 27 Feb: Swift. Fri 2 Mar: R, PHP 5.6
Rust was the third language in, ahead of JavaScript and Go. Twelve entries in two days, seventeen in two weeks. Crystal is in there because it looks like Ruby and compiles to a binary, which I found interesting at the time. Rust got the same treatment as everything else: write the loop, commit, look at the chart, move on. And the debug build sat in the script for three years.
So, honestly: Rust did not cost me a week. It cost me an evening. But that evening left a bad taste, and the bad taste lasted a lot longer.
Ten pull requests in eleven minutes
The comparison grew for a while, went quiet, and grew again when other people pushed it. The commit counts show this better than I can describe it.
Commits per year in speed-comparison.
Shared scale: 0–290 commits
- 2018
- 72 commits
- 2019
- 0 commits
- 2020
- 0 commits
- 2021
- 7 commits
- 2022
- 290 commits
- 2023
- 21 commits
- 2024
- 8 commits
- 2025
- 272 commits
- 2026 so far
- 88 commits
My last real commit before the gap was in November 2023. In all of 2024 I merged two pull requests. People kept sending things anyway: a SIMD version for Swift, OCaml, a GraalVM build, a Zig implementation that could keep up with C. They opened pull requests and waited. Some waited three years.
It was not that I had lost interest. Every language in the project had its own container recipe, its own base image, its own flags and quirks, and I had to relearn all of that before I could touch anything. Updating one language was an afternoon. Updating all of them was something I kept putting off, and the pull requests sat there while I did. I wrote about that plumbing in The slowest part of my speed comparison, so I will not repeat it here.
On 4 December 2025 I came back, this time with a coding agent.
What was waiting, and what one evening did with it.
- Pull requests waiting
- 16
- The oldest opened on 18 October 2022.
- Issues waiting
- 20
- Merged in eleven minutes
- 10PRs
- Swift SIMD, OCaml, GraalVM, Zig, Java, Rust, .NET, dependencies.
- Commits in December 2025
- 270
- after eight in all of 2024
Ten pull requests merged in eleven minutes. The first agent-written commits half an hour later. An instruction file for the agent the next day. December ended with 270 commits; the period from January 2023 to November 2025 had 31. The agent did not make the project simple. What it did was let me work through the bespoke parts one at a time without having to hold every recipe in my head, and that was enough to turn a project I felt guilty about back into a project I worked on.
That December also sent me down a rabbit hole of small command-line tools. That is where Rust comes back into the story.
A todo list with a runtime inside
The first of those tools was tdx, a terminal todo manager that keeps its tasks in a normal Markdown file. My first thought was to write it in Python, the language I get things done in fastest. Then I discovered Bun, the JavaScript runtime that comes back into this story later, and Ink, which is React for terminals. Bun promised a single executable from TypeScript, so the first tdx, committed on 21 November 2025, was Bun and Ink. It worked, and writing a terminal UI in JSX felt surprisingly natural.
The executable was 59 MB. Or about 90, depending on which of my own two numbers from that week you believe. Either way, that is what a bundled interpreter costs, and Python would have shipped exactly the same way: a small script wrapped around a whole runtime. The language was comfortable. The file it turned into was not. A tool I run fifty times a day should not carry a runtime the size of a small operating system.
The next day’s commit is “refactor: migrate from TypeScript/Bun to Go”. I had liked Go since I first tried it around 2014, and the rewrite got the binary down to 4 MB. Building tdx has the details. What matters for this story is smaller: a language that compiles to one file is worth a lot to me, and I had not realised how much until I watched an entire interpreter get bundled into a todo list.
A Python runtime with a Zig host
The second tool started with calculators. Some programmable calculators run Python, on hardware with almost no memory. That works because of MicroPython, a small implementation of the language built for microcontrollers. If a calculator can carry a Python runtime, so can a CLI: bundle the interpreter and the script into one executable, and you get Python-style code with a Go-sized binary.
That became Kipferl, called μcharm at the time. In eight months it swapped its Python runtime once and its host language once.
Two Python runtimes, two hosts, eight months.
- 16 Dec 2025
MicroPython, hosted by Zig
The proof of concept: MicroPython embedded in a Zig command-line tool. It replaced a Python wrapper script within half an hour.
- 20 Dec 2025
PocketPy, hosted by Zig
A cleaner C API and fuller Python 3 syntax. Version 0.5.0 followed the next day, the last Zig release.
- 4 Aug 2026
PocketPy, hosted by Rust
About thirty pull requests in one day, each gated on the same 1,669 compatibility checks.
After tdx, the plan was to write the host in Go. That plan lasted about a morning. The whole job is embedding a C runtime and calling into it, and Go can do that, but I did not want cgo at the center of the project. Zig calls C directly, so Zig got the job. The first README still has the table I wrote to justify it: Go with Charm at about 10 MB, Rust with Ratatui at about 5 MB and marked “Hard”, MicroPython at 690 KB.
Four days later I swapped MicroPython for PocketPy, another small Python implementation. It was easier to wire up, its C API was built for embedding rather than for hardware, and it understood more of Python 3. Version 0.5.0 shipped on 21 December 2025, pinned to Zig 0.15.2. Then I did not touch the project for seven and a half months.
The ground moved while I was away
Two things happened in that gap.
Zig 0.16.0 came out in April 2026. Zig is a pre-1.0 language and does not pretend otherwise. The release notes list a new std.Io interface, moved filesystem APIs, a deprecated @cImport, and a replaced @Type. Sensible changes for a language that is still settling. They are also the kind of changes that mean the project you left in December does not compile in August, and Kipferl had 28,000 lines of Zig by then.
Then, in July, Jarred Sumner published Rewriting Bun in Rust. Bun is a JavaScript runtime and was the best-known large Zig codebase. He had it ported to Rust in eleven days by dozens of Claude agents running in parallel, with separate agents reviewing their work: half a million lines, 6,502 commits, 128 bugs fixed in the release that followed, 19 regressions introduced and later fixed. The reason he gave was ordinary. Use-after-free, double-free, a forgotten free in an error path. “In safe Rust, these are compiler errors.”
The next day Andrew Kelley, who created Zig, replied. His argument was that Bun’s problems came from how Bun was built rather than from the language, and that the rewrite had shipped, in his words, a million lines of unreviewed code on the strength of a test suite. He did not put it gently. The post calls Bun a net liability for Zig, calls the ported code “unreviewed slop”, and has personal things to say about its author. He ends by admitting the resentment in it and apologising to Zig users caught in the middle.
I read both posts in one sitting. The technical argument was interesting, but it is not what settled things for me. Watching the creator of a language write like that about its most visible project did. I did not want my small project sitting on a foundation that was still moving, maintained by people that angry at their biggest user. I know that is a feeling rather than an analysis. It was still the deciding one.
The Linux kernel was in the background too. Rust had been an experiment there for years, with loud opponents. In November 2025 Linus Torvalds said it had “reached a stage where Rust is truly becoming part of the kernel, and it’s no longer just an experimental thing”, and in December the experimental label came off. If the kernel could put up with the compiler, I figured a Python packager could.
One day, thirty pull requests
The first pull request of the migration went in on 4 August 2026. Its description gives the reason in one sentence: over 28,000 lines coupled to pre-1.0 Zig APIs, and the pinned 0.15.2 code already failing against stable 0.16.0. Rust offered a stable language and could keep PocketPy, the Python API, and the binary format exactly as they were.
The port happened in waves over one day. The agent typed. Every wave had the same gate in front of it: all 1,669 compatibility checks pass, all four release targets build. First the runtime core and the loader, then the CLI commands, then the modules one at a time: terminal handling, argument parsing, textwrap, heapq, itertools, filesystem, archives. Version 0.6.0 shipped the next day with the new name. The cleanup commit deleted 85,310 lines of Zig.
I would prefer to say the Rust version came out smaller and faster. My own migration retrospective says it did not, at least not at first.
The honest numbers from the port.
- Startup, Zig 0.5.0
- 4.33ms
- Median.
- Startup, Rust port
- 7.04ms
- slower than Zig
- Median; 7.68 ms for the tree-shaken core profile.
- Binary, Zig 0.5.0
- 2.31MB
- Binary, Rust full runtime
- 4.82MB
- larger than Zig
- 4,817,925 bytes.
- Binary, Rust tree-shaken
- 1.45MB
- 69.9% below the full runtime
- 1,450,837 bytes for a minimal app.
- Compatibility checks
- 1,669 / 1,669
- Required before each module wave could land.
The port started slower and larger. What got the binary small again was tree shaking, added in the same release: look at the imports, pick the smaller prebuilt runtime when that is safe, and a minimal app comes out at 1.45 MB, under the old Zig build. Startup never caught up. We are talking about three milliseconds, and I took that trade without much thought, but I want it on the record.
What actually changed my mind about Rust came a month later, and it was not about speed.
What the lints found
In September I asked for a review pass with the compiler turned up as far as it goes. Clippy’s pedantic and nursery groups, plus a list of restriction lints: no unwrap, no expect, no indexing into slices, no unchecked arithmetic, no as casts, no panic, no unreachable. If I had to work through that configuration by hand on a hobby project, I would give up in an hour. With an agent going through the diagnostics one by one, it turned into a bug hunt.
What the strict lints turned up
-
string_sliceA 64-byte download checksum containing non-ASCII bytes could slice a UTF-8 string at an invalid boundary and panic.
FixDecode validated ASCII hexadecimal bytes; a regression test uses a Unicode token of the formerly accepted length.
-
panic inside extern "C"HTTPConnection(..., timeout=1e300) aborted the whole process: a duration conversion panicked inside a C callback.
FixFallible duration conversion and checked deadlines; the test verifies a catchable Python error and no abort.
-
unreachableCommand dispatch on strings carried an impossible unreachable! branch.
FixAn exhaustive Run/Dev/Build enum. Invalid states cannot reach the function.
-
arithmetic_side_effectsProgress-bar multiplication overflowed at large values; a median of two f64::MAX values overflowed too.
FixWider arithmetic and f64::midpoint, with boundary tests for both.
-
indexing_slicingRegex replacement scanning could step into a Unicode continuation byte after a backslash.
FixAdvance by whole characters; test Unicode replacement input.
-
as_conversionsLogarithm-based frexp and ldexp returned zero or infinity for representable subnormal values.
FixUse the platform libm with checked exponent conversions; bit-preserving round-trip tests include signed zero.
These are real bugs. A download checksum with unexpected bytes in it would have crashed the builder. A Python script setting a silly HTTP timeout would have killed the whole process instead of raising an error, because the panic happened inside a C callback with nowhere to go. The floating-point helpers were wrong for small numbers. Each one turned up the same way: a lint complained about a line, the agent had to explain why the line was fine, and it was not.
This is what I could not see in 2018. The compiler is saying exactly the same things it said back then. The difference is who has to listen. For me, every message was an interruption: stop, look it up, learn the rule, come back to what I was doing. For an agent, a compiler message is about the best instruction it can get. It points at one line, it says what is wrong, and it shows up before a test fails or a user complains. The thing that drove me away from Rust is the thing that makes it good to hand to a machine.
So the work splits like this now. I decide what to build, pick the libraries, read the architecture, and write the tests I care about. The agent types. The compiler does the first review, and it is a stricter reviewer than I am at eleven at night.
Where Rust went, and where it did not
After Kipferl, new projects just started in Rust.
Where Rust landed, and where it did not
- Kipferl moved
Python-style CLIs as small binaries
Zig to Rust
4 Aug 2026
- vrdx moved
Decision records in Markdown
Python to Rust
12 Sep 2026
- Hub tooling moved
Private repository audit script
Python to Rust
19 Sep 2026
- Quirl started
A shell with two pipelines
new to Rust
15 Aug 2026
- Latchrun started
Credential-scoped command sessions
new to Rust
19 Sep 2026
- Kindred started
A local family-history graph
new to Rust
19 Sep 2026
- tdx stayed
Todos in a Markdown file
Go to Go
Measured; rewrite declined
- Sceno stayed
Architecture diagrams from KDL
Go to Go
Quirl, the shell from I wanted a shell that could keep my old habits, was Rust from its first commit and got the strict lint set ten days later. vrdx, a small tool for decision records in Markdown, had been Python with a terminal UI since October 2025; I ported it in September. The audit script in my private hub repository followed a week later. Two new tools started in Rust on the same day.
tdx did not move, and I think that is worth showing. In September I ran a proper comparison of Go and Rust on its Markdown parsing, same fixtures and same edits on both sides. Rust was about ten times faster on the matched operations and its test binary was a sixth of the size. It also compiled twice as slowly, and the existing Go editor already handles a thousand tasks in about three milliseconds. A rewrite would have to reproduce the terminal UI, undo, the version history, and the careful save behaviour, all for a speedup nobody would notice. The recommendation in the repository is to keep tdx in Go, and it is still Go. So is Sceno, my diagram tool.
“Wherever I can” means: wherever the compiler’s help is worth more than the compile time. That covers most new things and not every old one.
Let the compiler do the first review
If you want to try the same setup, here is the prompt I give agents on every Rust project now. It is one piece of text: the instruction, then the two configuration files it refers to. The button copies all of it.
The prompt, with both files.
Use stable Rust. Enable the Clippy configuration below in Cargo.toml and clippy.toml. Treat every diagnostic as a finding to understand, not a warning to silence: fix the cause, or add a narrowly scoped #[expect(lint, reason = "...")] with a real reason. Do not weaken production lints to make the build pass. Tests may use unwrap, expect, panic and indexing through clippy.toml. Run cargo fmt, cargo clippy --all-targets --all-features -- -D warnings, and cargo test before reporting work as done.
Cargo.tomltoml
[lints.clippy]
pedantic = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
unwrap_used = "deny"
expect_used = "deny"
indexing_slicing = "deny"
arithmetic_side_effects = "deny"
unreachable = "deny"
unimplemented = "deny"
unchecked_time_subtraction = "deny"
todo = "deny"
string_slice = "deny"
panic_in_result_fn = "deny"
panic = "deny"
exit = "deny"
as_conversions = "deny" clippy.tomltoml
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true Turn on only what fits your project. On an existing codebase, add the groups one at a time instead of in one huge rewrite, and keep the exceptions narrow and explained. And none of this proves anything about your logic. Kipferl’s review notes say so in plain words, and a clean Clippy run has never once told me my program does the right thing.
It is also not an answer to Kelley’s objection. Agents produce code faster than anyone reads it, and that is a real problem. A strict compiler is my partial answer: the first reader never gets tired, never lets anything slide, and knows every rule I would have had to look up. It is only partial because it only reads for the things it understands.
I still have the file from that Saturday evening. Twenty-four lines, a comment about f64, a debug build that lost to PHP for three years. The compiler was right about every one of those lines. I just had to be the one in the room when it said so.
It can yell at somebody else now.