I've written about rewriting a shell script in Rust before. This is the next size up: a tool with several subcommands, a config file, and enough surface area that the language choice actually had consequences rather than just bragging rights. The job was unglamorous. We had three or four ad-hoc scripts for poking at our deployment artifacts (listing them, diffing two of them, promoting one to a new environment) and they'd grown the usual barnacles. Different argument conventions, different ideas about exit codes, one of them written in Python and one in Bash for no reason anyone could remember. I wanted one binary with one mental model.
So: was it worth it? Yes, but not for all the reasons I expected, and a couple of the costs landed harder than last time.
the shape of the thing
The structure that fell out was a single binary with subcommands, the way git or cargo work. clap does this nicely. You describe the commands and their arguments declaratively and you get parsing, --help per subcommand, and sane error messages without writing any of it yourself.
let matches = App::new("artifactl")
.version("0.3")
.subcommand(SubCommand::with_name("list")
.about("list artifacts in a bucket")
.arg(Arg::with_name("bucket").required(true)))
.subcommand(SubCommand::with_name("promote")
.about("promote an artifact to an environment")
.arg(Arg::with_name("id").required(true))
.arg(Arg::with_name("env").required(true)))
.get_matches();
That's the boring part, and boring is exactly what I wanted from it. The first time someone on the team ran artifactl promote --help and got a clear answer without asking me, I felt the hour spent setting it up pay itself back.
the bit that was genuinely better
The thing Rust bought me, and the thing I keep coming back to, is that the unhappy paths are named. Every fallible operation returns a Result, and the compiler will not let you forget one. When you're talking to S3 and parsing JSON and reading a config file, there are a lot of ways to fail, and in the old scripts most of those ways ended in a stack trace or, worse, a silent wrong answer.
I leaned on a small error enum and the ? operator, which by early 2017 has become the comfortable way to do this. You define your error type, you implement From for the underlying errors so they convert on the way up, and then your functions read almost like the happy path while still handling everything:
fn load_config(path: &Path) -> Result<Config, AppError> {
let mut s = String::new();
File::open(path)?.read_to_string(&mut s)?;
let cfg = toml::from_str(&s)?;
Ok(cfg)
}
Each ? there is a different failure (file missing, not readable, malformed TOML) and each one gets reported with a message a human can act on. That's the whole pitch, really. The compiler nags you into handling the world as it actually is rather than as you hoped.
where it fought me
Two things slowed me down, and I'd rather be honest about them than pretend the experience was frictionless.
The first was error handling ergonomics, which is funny given I just praised it. Defining and threading a custom error type is more ceremony than I'd like. I tried a couple of the helper crates floating around for reducing the boilerplate, and they worked, but the ecosystem hasn't settled on one obvious answer yet, so you're making a small bet every time. For a personal tool that's fine. For something the team depends on, I picked the most boring option and moved on.
The second was build and iteration time. A clean build of this, with its handful of dependencies, is not fast, and the edit-compile-run loop for a CLI you're testing by hand gets tedious. I got used to keeping cargo build running in a watch loop and only doing full runs when something actually compiled. It's livable, but if you're coming from a scripting language the first afternoon feels slow.
the accounting
The rough numbers, because I find them more useful than vibes. The old scripts were maybe 200 lines across three files in two languages. The Rust version is around 600 lines in one place, plus a Cargo.toml. So three times the code, which sounds bad until you remember the old code did almost no error handling and had no shared argument conventions, so a fair chunk of that growth is me finally doing the work the scripts were skipping.
What I actually got for the extra lines: one binary to deploy with no runtime, a consistent interface my colleagues can discover without reading source, and failures that tell you what went wrong instead of dumping you at a prompt. What I paid: a slower inner loop and an afternoon wrestling error types into a shape I liked.
Would I reach for Rust for a ten-line glue script? No. Bash still wins that, comfortably. But for a tool with more than one job, that other people will run, and that I'll have to maintain past the point where I remember how it works, the trade keeps coming out in Rust's favour. The static binary alone has saved me more deployment grief than the rewrite cost. I'll take it.