The job was small and unglamorous: tail a handful of log files, filter lines by a few patterns, colourise the matches, and print a tidy summary when I hit Ctrl-C. The honest tool for this is about fifteen lines of bash with grep and tail -f, and I have written that script perhaps a dozen times across as many machines. Every one of them is slightly different, none of them handle a log file being rotated out from under them, and all of them fall apart the moment I want to add one more clever bit of logic.
So I did the unreasonable thing and wrote it in Rust. This is an attempt to be honest with myself about whether that was worth it, because the easy answer ("Rust good") is not actually an answer.
the appeal
The thing that pulls me toward Rust for these one-offs is not speed. A log tailer is I/O bound; it spends its life waiting for the kernel to hand it bytes. The pull is that the language makes me describe the shape of the problem properly, and then it holds me to it.
Take the line buffer. In the shell version I never think about partial reads, because tail does that for me. The moment I'm reading from a file handle myself, I have to decide what happens when a read lands halfway through a line. Rust's BufReader and the lines() iterator make the common case trivial:
use std::io::{BufRead, BufReader};
use std::fs::File;
let file = File::open(&path)?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
if matches.iter().any(|m| line.contains(m)) {
println!("{}", colourise(&line));
}
}
That line? is doing real work. A line that isn't valid UTF-8 becomes an error I have to acknowledge rather than a silent corruption, and in log files from a dozen badly behaved daemons that case absolutely comes up.
the cost
Now the bill. The crate ecosystem is genuinely lovely, but the moment I wanted nice argument parsing I reached for clap, and clap pulls in a respectable tree of dependencies. A cold cargo build --release on my laptop took the better part of a minute, for a tool whose shell equivalent took zero seconds to "build" because there is nothing to build. For a quick script that I might iterate on twenty times in an afternoon, that feedback loop matters more than I'd like to admit.
There's also the matter of where the binary lives. The shell script is portable to anything with bash. The Rust binary is portable to exactly the platforms I remember to cross-compile for, and the first time I scp'd it to an old box and got a glibc version mismatch I felt every minute I'd spent. A static musl build fixes that, but now I'm maintaining a build matrix for a tool that prints coloured log lines.
And I'll be blunt about the file watching. Handling rotation properly (where the file you have open is renamed and a new one takes its place) meant pulling in notify and thinking about inotify semantics, inode changes, and the race between "file moved" and "new file created". The shell version simply doesn't handle this and nobody complains, because in practice you restart the tail and move on. I spent an hour getting it right in Rust. Was that hour repaid? Only if I run this thing for weeks at a time, which, in fairness, I do.
the honest verdict
Here is where I land. For a script I'll run once and throw away, the shell version wins every time and it isn't close. The compile cycle alone disqualifies Rust for genuinely throwaway work.
But this tool is not throwaway. It lives in ~/bin, I run it daily, and it now handles the rotation and encoding edge cases that the shell version quietly ignored for years. The cost was an afternoon and a build matrix; the return is a tool I trust enough to stop thinking about. That trust is the actual deliverable.
So: was it worth it? For this one, yes, narrowly, and mostly because it graduated from "quick script" to "thing I depend on" somewhere along the way. If I'd known that at the start I'd have reached for Rust sooner. The trap is that you rarely do know at the start, and writing the throwaway version first is not a mistake. It's how you find out the throwaway version isn't throwaway after all.
The compromise I've settled on: shell first, always, and rewrite in Rust only when a script earns it by surviving long enough to annoy me. This one earned it.