Back to Tutorials

Writing Your First Rust CLI

15 min readPractical

Let's build a simple command-line application in both Python and Rust to compare the development experience and end results.

Project Setup

First, let's set up our projects. We'll create a simple CLI tool that counts lines, words, and characters in a file (like wc).

CLI Application Setup

Python
# Python: Using Click for CLI
# File: wc.py
import click
import sys
from pathlib import Path

@click.command()
@click.argument('file', type=click.Path(exists=True))
@click.option('--lines/--no-lines', '-l', default=True, help='Count lines')
@click.option('--words/--no-words', '-w', default=True, help='Count words')
@click.option('--chars/--no-chars', '-c', default=False, help='Count characters')
def wc(file, lines, words, chars):
    """A simple word count program."""
    try:
        content = Path(file).read_text()
        counts = {}
        
        if lines:
            counts['lines'] = len(content.splitlines())
        if words:
            counts['words'] = len(content.split())
        if chars:
            counts['chars'] = len(content)
            
        click.echo(f"{file}")
        for name, count in counts.items():
            click.echo(f"  {name}: {count}")
            
    except Exception as e:
        click.echo(f"Error: {e}", err=True)
        sys.exit(1)

if __name__ == '__main__':
    wc()
Rust
// Rust: Using clap for CLI
// File: src/main.rs
use clap::Parser;
use std::fs;
use std::path::PathBuf;

/// A simple word count program
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
struct Args {
    /// Input file
    file: PathBuf,

    /// Count lines
    #[arg(short, long, default_value_t = true)]
    lines: bool,

    /// Count words
    #[arg(short, long, default_value_t = true)]
    words: bool,

    /// Count characters
    #[arg(short, long, default_value_t = false)]
    chars: bool,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Args::parse();
    
    let content = fs::read_to_string(&args.file)?;
    let mut counts = std::collections::HashMap::new();
    
    if args.lines {
        counts.insert("lines", content.lines().count());
    }
    if args.words {
        counts.insert("words", content.split_whitespace().count());
    }
    if args.chars {
        counts.insert("chars", content.chars().count());
    }
    
    println!("{}", args.file.display());
    for (name, count) in counts {
        println!("  {}: {}", name, count);
    }
    
    Ok(())
}

Dependencies

Both languages use package/dependency management, but they work differently:

Dependency Management

Python
# Python: requirements.txt or pyproject.toml
# Using pyproject.toml (modern approach)
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"

[project]
name = "py-wc"
version = "0.1.0"
description = "A word count program in Python"
requires-python = ">=3.8"
dependencies = [
    "click>=8.0.0",
]

[project.scripts]
py-wc = "wc:wc"
Rust
// Rust: Cargo.toml
[package]
name = "rust-wc"
version = "0.1.0"
edition = "2021"
description = "A word count program in Rust"

[dependencies]
clap = { version = "4.0", features = ["derive"] }

Building and Running

Let's see how to build and run our applications:

Building and Running

Python
# Install in development mode
$ pip install -e .

# Run the script
$ py-wc --help
Usage: py-wc [OPTIONS] FILE

  A simple word count program.

Arguments:
  FILE  [required]

Options:
  -l, --lines / --no-lines  Count lines  [default: True]
  -w, --words / --no-words  Count words  [default: True]
  -c, --chars / --no-chars  Count characters  [default: False]
  --help                    Show this message and exit.
Rust
# Build in debug mode (fast compile, slower runtime)
$ cargo build

# Build in release mode (slower compile, faster runtime)
$ cargo build --release

# Run directly with Cargo
$ cargo run -- --help
A simple word count program

Usage: rust-wc [OPTIONS] <FILE>

Arguments:
  <FILE>  Input file

Options:
  -l, --lines  Count lines [default: true]
  -w, --words  Count words [default: true]
  -c, --chars  Count characters [default: false]
  -h, --help   Print help
  -V, --version    Print version

Error Handling

Both languages handle errors, but Rust's approach is more explicit:

Error Handling

Python
# Python: Exceptions
try:
    with open("nonexistent.txt") as f:
        content = f.read()
except FileNotFoundError as e:
    print(f"Error: {e}", file=sys.stderr)
    sys.exit(1)
except Exception as e:
    print(f"Unexpected error: {e}", file=sys.stderr)
    sys.exit(1)
Rust
// Rust: Result type
use std::fs::File;
use std::io::Read;

fn read_file(path: &str) -> Result<String, Box<dyn std::error::Error>> {
    let mut file = File::open(path)?;
    let mut content = String::new();
    file.read_to_string(&mut content)?;
    Ok(content)
}

// Usage:
match read_file("nonexistent.txt") {
    Ok(content) => println!("File content: {}", content),
    Err(e) => eprintln!("Error reading file: {}", e),
}

Testing

Both languages have built-in testing support:

Testing

Python
# test_wc.py
import pytest
from wc import wc
from click.testing import CliRunner

def test_wc_lines():
    runner = CliRunner()
    result = runner.invoke(wc, ["--no-words", "--no-chars", "test.txt"])
    assert "lines: 10" in result.output
    assert result.exit_code == 0
Rust
// tests/wc_test.rs
#[test]
fn test_count_lines() {
    let content = "Hello
world
";
    let args = Args {
        file: "test.txt".into(),
        lines: true,
        words: false,
        chars: false,
    };
    
    // Test logic here
    assert_eq!(count_lines(&content), 2);
}

// In Cargo.toml:
// [[test]]
// name = "wc_test"
// path = "tests/wc_test.rs"

Packaging and Distribution

Packaging for distribution works differently in both ecosystems:

Packaging and Distribution

Python
# Python: Using setuptools and PyPI
# Build distribution
$ python -m build

# Upload to PyPI
$ twine upload dist/*

# Install from PyPI
$ pip install py-wc
Rust
// Rust: Using Cargo and crates.io
# Build for release
$ cargo build --release

# The binary is in target/release/rust-wc

# Publish to crates.io
$ cargo publish

# Install globally
$ cargo install rust-wc

Performance Comparison

Performance Note

Rust's compiled nature gives it a significant performance advantage:

  • Startup time: Rust is typically 10-100x faster than Python
  • Memory usage: Rust uses about half the memory of Python
  • CPU usage: Rust is often 2-10x faster for CPU-bound tasks
  • Binary size: Rust produces standalone binaries (larger but self-contained)

Key Takeaways

  • Development speed: Python is often quicker for prototyping
  • Performance: Rust provides better performance and lower resource usage
  • Error handling: Rust's explicit error handling catches more issues at compile time
  • Distribution: Rust produces standalone binaries, Python requires an interpreter
  • Learning curve: Rust has a steeper learning curve but offers more control

When to Choose Which?

Choose Python When:

  • Rapid prototyping is needed
  • Performance is not critical
  • You need extensive data science libraries
  • Team is more familiar with Python

Choose Rust When:

  • Performance is critical
  • Memory safety is important
  • You want to avoid runtime errors
  • Building system-level tools