I have been using Rust for small internal tools for about a year. Not systems programming, not game engines — just the kind of CLI utilities you write for yourself: log parsers, file converters, data transformers.

Here is what I have learned about where Rust helps and where it genuinely hurts.

The basic setup

A minimal Rust CLI tool with argument parsing looks like this. I use clap for arguments because the derive macro makes it readable.

[dependencies]
clap = { version = "4", features = ["derive"] }
anyhow = "1"
use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "logfilter", about = "Filter log files by level and pattern")]
struct Args {
    /// Path to the log file
    #[arg(short, long)]
    file: String,

    /// Minimum log level to include (debug, info, warn, error)
    #[arg(short, long, default_value = "info")]
    level: String,

    /// Optional pattern to match against log messages
    #[arg(short, long)]
    pattern: Option<String>,
}

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

The derive macro generates all the argument parsing, --help output, and error messages. This is genuinely ergonomic. You add a field and it just works.

Error handling with anyhow

For internal tools, I use anyhow everywhere. It lets you use ? freely without defining your own error types.

use std::fs::File;
use std::io::{BufRead, BufReader};
use anyhow::{Context, Result};

fn run(args: &Args) -> Result<()> {
    let file = File::open(&args.file)
        .with_context(|| format!("Failed to open log file: {}", args.file))?;

    let reader = BufReader::new(file);
    let min_level = parse_level(&args.level)?;

    for (line_num, line) in reader.lines().enumerate() {
        let line = line.with_context(|| format!("Failed to read line {}", line_num + 1))?;
        let entry = parse_log_entry(&line);

        if entry.level >= min_level {
            if let Some(pattern) = &args.pattern {
                if line.contains(pattern.as_str()) {
                    println!("{}", line);
                }
            } else {
                println!("{}", line);
            }
        }
    }

    Ok(())
}

The .with_context() call attaches a human-readable message to the error. When something goes wrong, you get useful output instead of a cryptic error code.

Structuring the data model

Even for small tools, I define a struct for log entries. It makes the code self-documenting and eliminates string-parsing bugs.

#[derive(Debug, PartialEq, PartialOrd)]
enum LogLevel {
    Debug,
    Info,
    Warn,
    Error,
}

#[derive(Debug)]
struct LogEntry<'a> {
    level: LogLevel,
    message: &'a str,
    timestamp: &'a str,
}

fn parse_log_entry(line: &str) -> LogEntry<'_> {
    // Simple parsing for logs like: "2026-03-20T10:00:00Z [INFO] message"
    let parts: Vec<&str> = line.splitn(3, ' ').collect();
    let timestamp = parts.first().copied().unwrap_or("");
    let level_str = parts.get(1).copied().unwrap_or("[INFO]");
    let message = parts.get(2).copied().unwrap_or(line);

    let level = match level_str {
        "[DEBUG]" => LogLevel::Debug,
        "[WARN]"  => LogLevel::Warn,
        "[ERROR]" => LogLevel::Error,
        _         => LogLevel::Info,
    };

    LogEntry { level, message, timestamp }
}

fn parse_level(s: &str) -> anyhow::Result<LogLevel> {
    match s.to_lowercase().as_str() {
        "debug" => Ok(LogLevel::Debug),
        "info"  => Ok(LogLevel::Info),
        "warn"  => Ok(LogLevel::Warn),
        "error" => Ok(LogLevel::Error),
        other   => anyhow::bail!("Unknown log level: {}", other),
    }
}

The PartialOrd derive on LogLevel makes level comparison (entry.level >= min_level) just work, because the enum variants are ordered by declaration.

Where Rust genuinely hurts for this use case

The compile times are the main friction. A project with clap and anyhow takes 20–30 seconds for the first cargo build --release. Incremental builds are fast, but the initial build and CI times are real costs.

For a script you write once and run forever, this is fine. For something you are iterating on quickly, it is annoying.

The other friction: lifetimes. The LogEntry<'a> lifetime annotation above is necessary because I hold references into the original line string. For most tools, avoiding lifetimes by just using String (allocating) is the right call. The performance difference is irrelevant at this scale.

When I reach for Rust vs Python

I reach for Rust when:

  • The tool processes large files where memory usage matters
  • I want a single binary I can scp to a server without worrying about Python versions
  • I want the compiler to prevent a category of runtime bugs before I ship it

I reach for Python when:

  • I need the output in 20 minutes
  • The logic involves a lot of string manipulation or regex
  • I am going to run it twice and throw it away

Both are good answers. The honest rule: use Rust for the tools that will outlive the week you wrote them.