The verdict first, because I know how these posts usually end up hedging: yes, it was worth it, but not for the reasons I expected going in. I rewrote a small internal CLI tool from a tangle of shell into Rust, and the thing I gained most wasn't speed, it was the ability to stop thinking about the edge cases because the compiler was thinking about them for me.
The tool itself is dull, which is the point. It reads a config file, talks to an internal HTTP endpoint, parses the JSON it gets back, and prints a tidy summary that other scripts consume. It started life as a shell script with curl and jq and a couple of awk incantations I no longer fully understood. It worked, mostly. It also fell over in interesting ways whenever the endpoint returned something slightly off, and the error messages were the kind that send you straight to set -x.
why not just leave it as shell
A fair question, and for a long time the answer was "because it works". What pushed me over was that it had quietly become load-bearing. Three other things depended on its output. Every time the upstream JSON shape wobbled, the shell version would either print nonsense or exit 0 with empty output, which is worse, because everything downstream trusted it. Shell has no real notion of "this field might be missing", so every access was an unspoken assumption, and unspoken assumptions in a thing four other scripts depend on are how you get a bad afternoon.
I'd been writing Rust on and off for a while, Rust 2015 having settled down nicely and the 2018 edition just about to land, and this felt like the right size of problem to commit to it. Small enough to finish, real enough to matter.
There's also a personal angle I'll own up to. I learn a language properly by giving it a job that I genuinely care about getting right, not by working through exercises. A toy program lets you skip all the parts that actually teach you something: error handling, the bits where the data is malformed, the question of how you package the thing and ship it to a server. A real tool forces you through all of that, and a small real tool forces you through it without taking a week. This was the smallest real job I had lying around, so it got volunteered.
the borrow checker tax, paid honestly
I will not pretend the first evening was fun. I spent a genuinely irritating hour fighting the borrow checker over a string I was trying to hold a reference to whilst also mutating the thing it came from. The compiler was right and I was wrong, which is the usual outcome, but it doesn't feel like that at the time. It feels like the language is being pedantic about a thing you "know" is fine.
The thing is, every one of those fights was a bug I would otherwise have shipped. The string I wanted to borrow-and-mutate was exactly the sort of aliasing that goes wrong under you in C and goes silently weird in shell. Rust just refuses to let you express it. Annoying in the moment, correct in the aggregate.
The real turning point was the JSON. With serde, the whole "might be missing" problem became a type:
#[derive(Deserialize)]
struct Status {
name: String,
healthy: bool,
#[serde(default)]
detail: Option<String>,
}
A missing detail is now an Option I have to handle, a missing name is a hard parse error with a message that actually says what was wrong. The shell version's "exit 0 with empty output" failure mode simply cannot happen, because a malformed response stops at the parse boundary with a clear error, rather than dribbling through every downstream consumer.
Error handling was the other quiet win. Instead of checking $? after every line and hoping, I used ? propagation and a single match at the top:
fn main() {
if let Err(e) = run() {
eprintln!("error: {}", e);
std::process::exit(1);
}
}
Every fallible thing inside run() bubbles up to that one place. The error path is no longer something I have to remember to write at each call site; it's the default, and forgetting it is a compile error. Compare that to shell, where the error path is whatever you remember to check after each command, and a forgotten check is a silent success that isn't one. In Rust I cannot accidentally ignore a Result; the compiler nags me until I either handle it or explicitly say I don't care. That nagging is the whole value proposition.
Argument parsing was the one place I reached for a dependency rather than rolling my own, and clap made the boring parts pleasant. A few lines of declaration and I had --help, --version, a usage message that's actually correct, and validation of the flags before any of my code runs. The shell version's idea of argument handling was a case statement that I'd extended three times and no longer trusted. Replacing it with something that generates its own help text, and keeps that help text honest as I add flags, removed a small ongoing maintenance tax I'd stopped noticing.
what it actually bought
Three things, concretely.
- A single static binary. I build it, drop it on the box, and there are no
jqversion differences, no "is this GNU awk or BSD awk", no dependency on what happens to be installed.scpand done. - It either works or it tells you exactly why it didn't. No more silent empty output. The downstream scripts now trust it because it earned the trust by refusing to lie.
- It's faster, but honestly that came free and I'd not have bothered for speed alone. Shelling out to
curlandjqwas never the bottleneck. The startup overhead of the binary is lower, the runtime is negligible either way, and nobody was waiting on it.
the honest cost
It took an evening and a bit, against the half hour it would have taken to patch the shell script for the immediate problem. If this were a throwaway, that maths is terrible and I'd have stayed in shell without a second thought. Rust is the wrong tool for a five-line glue script you'll delete next week, and I'd push back on anyone reaching for it there.
But this wasn't throwaway. It was a small thing that had grown teeth, and the rewrite turned a pile of assumptions into a pile of types the compiler checks every build. That's the trade: more time up front, in exchange for the class of bug I was actually hitting becoming impossible to write. For a tool four other things depend on, I'll take that trade every time. Would I do it again? I already have, twice since.