Skip to main content
Back to posts

🦀 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.

February 2018 / the same calculation twice

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);
}
  1. .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.

  2. .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.

  3. 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.

  4. i as f64

    An integer does not quietly become a floating-point number. The cast has to be written down.

Both files as committed on 17 February 2018 (Python 20:05:16, Rust 20:05:33), shebang and encoding lines omitted from the Python file. The marked lines are the ones the Rust compiler would not let the program leave out.

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.

In numbers

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.
Wall-clock times from the README committed on 17 February 2018, measured with the shell's time command inside an Alpine 3.7 container: Python 3.6.3, PHP 7.1.14, rustc 1.22.1. The Rust file was compiled with rustc and no optimization flag, so this is a debug build. It stayed that way until a contributor added the release profile in May 2021.

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.

Speed-comparison / the first two weeks

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

Arrival dates come from the commit log of niklas-heer/speed-comparison (author dates, Berlin time). PyPy is an interpreter variant, PHP 5.6 a version variant; both count as entries in the comparison. The log records when each file was committed, not how long it took to write.

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.

Comparison

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
All authors, from the repository's git log on 20 September 2026. 2019 and 2020 had no commits at all. 270 of the 272 commits in 2025 landed in December; 2026 counts through 20 September.

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.

In numbers

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
Pull requests and issues counted with the GitHub CLI: waiting on 1 January 2025 means opened before that date and closed after it or still open. The ten merges happened on 4 December 2025 between 22:13 and 22:24 Berlin time. The December commit count comes from the git log and includes bot commits.

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.

How it works

Two Python runtimes, two hosts, eight months.

  1. 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.

  2. 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.

  3. 4 Aug 2026

    PocketPy, hosted by Rust

    About thirty pull requests in one day, each gated on the same 1,669 compatibility checks.

Dates are the first commits of each change in niklas-heer/kipferl. The Python-facing API and the single-file packaging stayed the same throughout.

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.

In numbers

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.
From the Kipferl migration retrospective and the 0.6.0 release notes, measured on Apple Silicon. The port itself started slower and produced a larger file; profile-based tree shaking in the same release brought a minimal app below the old Zig size. Startup stayed slower than the Zig build.

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.

Kipferl / Rust review, 5 September 2026

What the strict lints turned up

  1. string_slice

    A 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.

  2. 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.

  3. unreachable

    Command dispatch on strings carried an impossible unreachable! branch.

    FixAn exhaustive Run/Dev/Build enum. Invalid states cannot reach the function.

  4. arithmetic_side_effects

    Progress-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.

  5. indexing_slicing

    Regex replacement scanning could step into a Unicode continuation byte after a backslash.

    FixAdvance by whole characters; test Unicode replacement input.

  6. as_conversions

    Logarithm-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.

Six of the findings recorded in Kipferl's docs/rust-review.md after enabling Clippy's pedantic and nursery groups plus the restriction lints listed later in this article. The lint column names the rule family that flagged the code; the actual fix came from reading why the compiler objected. Every entry has a regression test.

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.

August and September 2026

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

Dates are the first commit of each migration or project from the repositories' histories. Moved: an existing implementation was ported. Started: the project began in Rust. Stayed: Go remained after a measured evaluation (tdx) or without one (Sceno).

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.

Copy and paste / for your next Rust project

The prompt, with both files.

One prompt: the instruction and both files below.

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

The configuration from Kipferl's September review pass. Cargo.toml sets the lint groups and restriction lints to deny; clippy.toml relaxes them for tests.

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.