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

I Rewrote a Shell Script in Rust, and I'd Do It Again

Rebuilding a fiddly shell script as a small Rust CLI, what it cost me, and where it actually paid off.

A terminal showing code

I had a shell script. It started life as a three-line thing to tidy up some log directories, and over about two years it grew into 200 lines of Bash that nobody, including me, fully understood. It parsed dates, it filtered by size, it shelled out to find and xargs in ways that broke the moment a filename had a space in it, and it had a --dry-run flag that was honoured in roughly half the code paths.

The breaking point was a colleague asking, reasonably, whether it was safe to run on the production archive box. I read it again and realised I couldn't answer with confidence. That's the moment a script has outgrown being a script.

So I rewrote it as a small Rust CLI. This is a note about whether that was worth it, because "rewrite it in Rust" is the sort of thing that's easy to say and easy to regret.

Why Rust and not, say, Python

The honest answer is partly that I wanted the practice, and I'll own that. But there were real reasons too.

The script ran on machines where I couldn't rely on a particular Python being present, or on the right set of packages being installed. A single static binary I could scp across and run was genuinely simpler. No virtualenv, no "which python is this", no surprise when the box turned out to have 3.6 and a 2.7 fighting over /usr/bin/python.

The other reason was the filename handling. Bash and spaces in paths are old enemies, and I was tired of IFS games. Rust's PathBuf and OsString handle the awkward cases without me having to think about them, which is exactly the kind of correctness I wanted for something pointed at a production archive.

The shape of it

The whole thing is clap for argument parsing, walkdir for the traversal, and the standard library for the rest. Nothing exotic. The argument struct is the part I like most, because it documents itself:

use clap::Parser;
use std::path::PathBuf;

#[derive(Parser)]
#[command(name = "logtidy", about = "Prune old log directories safely")]
struct Args {
    /// Root directory to scan
    root: PathBuf,

    /// Delete anything older than this many days
    #[arg(long, default_value_t = 30)]
    older_than: u64,

    /// Show what would happen without touching anything
    #[arg(long)]
    dry_run: bool,
}

The --dry-run flag is now a single boolean threaded through one function, not a thing I hope I remembered to check in six places. Either we're deleting or we're not, decided once.

A close-up of source code on screen

The traversal is the boring kind of code that's a pleasure to write because the type system keeps the lights on:

for entry in WalkDir::new(&args.root).min_depth(1) {
    let entry = entry?;
    if entry.file_type().is_dir() && is_stale(&entry, args.older_than)? {
        if args.dry_run {
            println!("would remove {}", entry.path().display());
        } else {
            fs::remove_dir_all(entry.path())?;
        }
    }
}

The ? operator deserves a mention. In the Bash version, error handling was a polite fiction. A find that failed mid-run would carry on regardless, and you'd find out later when something was half-deleted. Here, an I/O error propagates up and the program stops with a sensible message. That alone made me trust it on the production box in a way I never trusted the script.

What it cost

A developer's screen with a build running

Let's not pretend it was free.

The first build pulled in a respectable pile of crates and took the better part of a minute on a cold cargo cache. For a tool that replaces 200 lines of Bash, that feels absurd, and on some level it is. Incremental builds after that were a couple of seconds, which is fine, but the up-front weight is real.

It's also more code. The Bash version was 200 lines of dense, frightening Bash. The Rust version is maybe 150 lines spread across a couple of files, plus a Cargo.toml, plus a target directory I have to remember to .gitignore. It is not obviously "less" in the way a rewrite is supposed to feel.

And there's a tax on the next person. A shell script anyone on the team can read and patch in place. A Rust binary needs a toolchain to change, and a build step to ship. For a tool that lives on one box and changes twice a year, that's a fair question to ask.

Was it worth it

For this tool, yes, but for specific reasons rather than because Rust is good.

It was worth it because the thing pointed a loaded gun at production data, and I wanted the compiler standing between me and a mistake. It was worth it because static binary deployment removed an entire category of "it works on my machine". And it was worth it because the --dry-run flag now means what it says everywhere, which is the single most important property a destructive tool can have.

It would not have been worth it for a script that glues two commands together on my laptop. That's the bit people skip when they evangelise rewrites. The right tool for a ten-line job is ten lines of shell, and reaching for cargo new there is just showing off.

The measure I keep coming back to is the question that started all this. Can I tell a colleague, plainly, that it's safe to run? With the script, no. With the binary, yes. That was the whole point, and on that narrow test it earned its keep.