Ramblings of an aging IT geek
← Ramblings of an aging IT geek
rust

i rewrote a tiny shell script in rust and i regret nothing

Rewriting a small but flaky shell utility as a Rust CLI with clap and anyhow, and an honest accounting of whether the extra effort actually paid off.

A code editor open on a small Rust project

I had a shell script. It had served faithfully for about two years, and it had started lying to me. Not dramatically: it would silently do the wrong thing when a path had a space in it, mangle output when a field was empty, and exit zero whether it had worked or not. Every time I fixed one edge case I introduced another, in the way that shell scripts have, where the quoting rules are a small dark art you re-learn from scratch each time you open the file.

So one wet weekend I rewrote it in Rust. The script was maybe forty lines. The question I want to answer honestly here is whether that was worth it, because "rewrite the working thing in Rust" is exactly the sort of decision that feels great on Saturday and looks daft on Monday.

what the script actually did

It walked a directory, read a small bit of metadata out of each file, and printed a table. That's it. Find files, parse a line or two, format columns, sort. The kind of job shell is allegedly perfect for, right up until the data has an awkward character in it and awk quietly disagrees with cut about where the fields are.

The failure mode that finally broke my patience was empty fields. A missing value shifted every column after it, so the table looked plausible and was wrong. Plausible-and-wrong is the worst outcome a tool can have. I would rather it crashed.

what rust gave me

The first thing, and the thing I underrate every single time until I'm using it again, is that Option and Result make the empty-field problem impossible to ignore. The compiler will not let me pretend a value is there when it might not be. The bug that took me three evenings to chase in shell simply could not compile in Rust without me deciding, explicitly, what an empty field means.

The argument parsing was the other pleasant surprise. In the script, options were a growing pile of case statements I was frightened of. With clap, it's a struct:

use clap::Parser;

#[derive(Parser)]
#[clap(about = "list files with their metadata, properly this time")]
struct Args {
    /// directory to scan
    #[clap(default_value = ".")]
    path: String,

    /// sort by size instead of name
    #[clap(long)]
    by_size: bool,
}

fn main() -> anyhow::Result<()> {
    let args = Args::parse();
    run(&args)
}

That gives me --help, --version, sane error messages on bad input, and a typo in a flag name caught at the boundary rather than three steps later. None of which the script had, because writing all that by hand in Bash is miserable enough that nobody does it.

Error handling with anyhow turned the script's worst habit, exiting zero on failure, into the default-correct behaviour. A ? on a fallible call propagates the error up, main returns Result, and a failure prints a real message and exits non-zero. I got the thing every script should have and almost none do: it tells the truth about whether it worked.

fn run(args: &Args) -> anyhow::Result<()> {
    let mut rows = Vec::new();
    for entry in std::fs::read_dir(&args.path)? {
        let entry = entry?;
        let meta = entry.metadata()?;
        rows.push((entry.file_name(), meta.len()));
    }
    if args.by_size {
        rows.sort_by_key(|(_, size)| *size);
    } else {
        rows.sort_by(|a, b| a.0.cmp(&b.0));
    }
    for (name, size) in rows {
        println!("{:>10}  {}", size, name.to_string_lossy());
    }
    Ok(())
}

the costs, honestly

It is not all free. The rewrite took an afternoon, against a script that already worked, which is the productivity hit nobody mentions when they evangelise. The binary is a couple of megabytes where the script was a few hundred bytes, and I now have a Cargo.toml and a target directory and a build step where before I had a file I could edit on the box. Distribution is genuinely harder: a shell script runs anywhere with a shell, whereas the Rust binary has to be built for each target I care about. For a personal tool on machines I control, that cost is near zero. For something I wanted to hand to a stranger, it would matter more.

Compile times for a forty-line CLI are fine, a second or two, but clap and anyhow pull in a respectable dependency tree and the first clean build is not instant. I notice it. It does not bother me at this size.

so, was it worth it

Yes, but not for the reason I expected. I expected to care about speed. I don't, for this: the script was already fast enough and nobody was waiting on it. What I actually bought was correctness I can trust without re-reading the code, and the confidence that the next edge case will be a compile error rather than a wrong table I find out about three weeks later from someone else.

The wider lesson I keep relearning is that the value of a small Rust rewrite isn't performance, it's that the language makes the failure modes loud. Shell lets you ignore the awkward cases right up until they bite. Rust makes you name them at the point you'd rather not. For a one-off, that ceremony is overkill. For a tool I'll lean on for the next two years, having the awkward cases shoved in my face at compile time is exactly the trade I want. The script is in a drawer now, and I do not miss it.