Learning Rust by Building Something Real
Theory only gets you so far in Rust. The ownership system, lifetimes, and error handling patterns click into place when you're solving a real problem.
This guide builds a file processing CLI tool progressively, introducing Rust patterns as they become necessary. By the end, you'll have working code that demonstrates idiomatic Rust.
Project Setup
# Create a new binary project
cargo new file-processor
cd file-processor
# Add dependencies to Cargo.toml
cargo add clap --features derive
cargo add anyhow
cargo add thiserror
cargo add serde --features derive
cargo add serde_json
cargo add tokio --features full
cargo add rayon # Parallel iterators
# Cargo.toml
[package]
name = "file-processor"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
anyhow = "1"
thiserror = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
rayon = "1"
Command-Line Interface with Clap
// src/main.rs
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "file-processor")]
#[command(about = "A tool for processing text files", long_about = None)]
#[command(version = "1.0")]
struct Cli {
/// Verbose output
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Count lines, words, and characters in files
Count {
/// Files to process
#[arg(required = true)]
files: Vec<PathBuf>,
/// Show only line counts
#[arg(short, long)]
lines_only: bool,
},
/// Search for pattern in files
Search {
/// Pattern to search for
pattern: String,
/// Files to search
#[arg(required = true)]
files: Vec<PathBuf>,
/// Case-insensitive search
#[arg(short, long)]
ignore_case: bool,
},
/// Convert files (CSV to JSON, etc.)
Convert {
input: PathBuf,
output: PathBuf,
#[arg(long, default_value = "auto")]
format: String,
},
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Count { files, lines_only } => {
count_command(files, lines_only, cli.verbose)?;
}
Commands::Search { pattern, files, ignore_case } => {
search_command(pattern, files, ignore_case)?;
}
Commands::Convert { input, output, format } => {
convert_command(input, output, format)?;
}
}
Ok(())
}
Error Handling: thiserror and anyhow
// src/errors.rs
use thiserror::Error;
use std::path::PathBuf;
// Define your domain errors with thiserror
#[derive(Error, Debug)]
pub enum ProcessingError {
#[error("File not found: {path}")]
FileNotFound { path: PathBuf },
#[error("Permission denied: {path}")]
PermissionDenied { path: PathBuf },
#[error("Invalid format in {file}: {reason}")]
InvalidFormat { file: PathBuf, reason: String },
#[error("IO error: {0}")]
Io(#[from] std::io::Error), // Automatic From implementation
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
// In library code: use specific error types
pub fn parse_file(path: &PathBuf) -> Result<Vec<Record>, ProcessingError> {
let content = std::fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ProcessingError::FileNotFound { path: path.clone() }
} else if e.kind() == std::io::ErrorKind::PermissionDenied {
ProcessingError::PermissionDenied { path: path.clone() }
} else {
ProcessingError::Io(e)
}
})?;
// Parse CSV
let records: Vec<Record> = content
.lines()
.skip(1) // Skip header
.map(|line| parse_csv_line(line))
.collect::<Result<Vec<_>, _>>()?;
Ok(records)
}
// In application code: use anyhow for easy error propagation
fn main() -> anyhow::Result<()> {
let records = parse_file(&PathBuf::from("data.csv"))
.with_context(|| "Failed to parse data.csv")?;
println!("Parsed {} records", records.len());
Ok(())
}
File I/O Patterns
use std::fs::{self, File};
use std::io::{self, BufRead, BufReader, BufWriter, Write};
use std::path::{Path, PathBuf};
// Reading files efficiently
fn count_lines(path: &Path) -> io::Result<usize> {
let file = File::open(path)?;
let reader = BufReader::new(file); // Buffered reading — essential for large files
Ok(reader.lines().count())
}
// Processing line by line (memory-efficient)
fn process_large_file(path: &Path) -> anyhow::Result<()> {
let file = File::open(path)?;
let reader = BufReader::new(file);
for (line_num, line) in reader.lines().enumerate() {
let line = line?; // Propagate IO errors
if line_num % 100_000 == 0 {
println!("Processing line {}", line_num);
}
// Process line...
process_line(&line)?;
}
Ok(())
}
// Writing files
fn write_results(path: &Path, data: &[Record]) -> io::Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file); // Buffered writing
writeln!(writer, "id,name,value")?; // Header
for record in data {
writeln!(writer, "{},{},{}", record.id, record.name, record.value)?;
}
writer.flush()?; // Ensure all buffered data is written
Ok(())
}
// Directory walking
fn find_files(dir: &Path, extension: &str) -> Vec<PathBuf> {
let mut results = Vec::new();
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
results.extend(find_files(&path, extension)); // Recursive
} else if path.extension().and_then(|e| e.to_str()) == Some(extension) {
results.push(path);
}
}
}
results
}
Parallel Processing with Rayon
use rayon::prelude::*;
use std::time::Instant;
fn process_files_parallel(files: &[PathBuf]) -> Vec<Result<FileStats, ProcessingError>> {
files
.par_iter() // Switch from iter() to par_iter() for parallelism
.map(|path| analyze_file(path))
.collect()
}
fn analyze_file(path: &Path) -> Result<FileStats, ProcessingError> {
let content = fs::read_to_string(path)?;
Ok(FileStats {
path: path.to_path_buf(),
lines: content.lines().count(),
words: content.split_whitespace().count(),
chars: content.chars().count(),
bytes: content.len(),
})
}
fn main() {
let files: Vec<PathBuf> = find_files(Path::new("."), "txt");
println!("Processing {} files...", files.len());
let start = Instant::now();
let results = process_files_parallel(&files);
println!("Completed in {:?}", start.elapsed());
let (successes, errors): (Vec<_>, Vec<_>) = results.into_iter().partition(|r| r.is_ok());
let total_lines: usize = successes
.iter()
.filter_map(|r| r.as_ref().ok())
.map(|s| s.lines)
.sum();
println!("Total lines: {}", total_lines);
println!("Errors: {}", errors.len());
}
Async with Tokio
use tokio::fs;
use tokio::io::{AsyncBufReadExt, BufReader};
// Async file reading
async fn read_file_async(path: &str) -> anyhow::Result<String> {
let content = fs::read_to_string(path).await?;
Ok(content)
}
// Async HTTP requests + file writing
async fn download_and_save(url: &str, output_path: &str) -> anyhow::Result<()> {
let client = reqwest::Client::new();
let response = client.get(url).send().await?;
if !response.status().is_success() {
anyhow::bail!("HTTP error: {}", response.status());
}
let bytes = response.bytes().await?;
fs::write(output_path, &bytes).await?;
println!("Downloaded {} bytes to {}", bytes.len(), output_path);
Ok(())
}
// Process multiple files concurrently
async fn process_files_concurrent(paths: &[&str]) -> Vec<anyhow::Result<String>> {
let futures: Vec<_> = paths
.iter()
.map(|&path| read_file_async(path))
.collect();
// Run all futures concurrently, collect results
futures::future::join_all(futures).await
}
// Tokio main
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let paths = ["file1.txt", "file2.txt", "file3.txt"];
let results = process_files_concurrent(&paths).await;
for (path, result) in paths.iter().zip(results) {
match result {
Ok(content) => println!("{}: {} chars", path, content.len()),
Err(e) => eprintln!("{}: Error — {}", path, e),
}
}
Ok(())
}
Testing in Rust
// Unit tests in the same file
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_count_lines() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "line 1").unwrap();
writeln!(file, "line 2").unwrap();
writeln!(file, "line 3").unwrap();
let count = count_lines(file.path()).unwrap();
assert_eq!(count, 3);
}
#[test]
fn test_parse_csv_record() {
let line = "1,Alice,100.5";
let record = parse_csv_line(line).unwrap();
assert_eq!(record.id, 1);
assert_eq!(record.name, "Alice");
assert!((record.value - 100.5).abs() < f64::EPSILON);
}
#[test]
fn test_file_not_found_error() {
let result = parse_file(&PathBuf::from("/nonexistent/file.csv"));
assert!(matches!(result, Err(ProcessingError::FileNotFound { .. })));
}
// Async test
#[tokio::test]
async fn test_read_file_async() {
let result = read_file_async("/etc/hostname").await;
assert!(result.is_ok());
}
}
Traits: Go Interfaces for Rustaceans
// Trait definition
trait FileProcessor {
fn process(&self, path: &Path) -> anyhow::Result<ProcessResult>;
fn supports(&self, extension: &str) -> bool;
// Default implementation
fn description(&self) -> String {
format!("Processor for {:?}", self.supported_extensions())
}
fn supported_extensions(&self) -> Vec<&str>;
}
// Implementations
struct CsvProcessor;
struct JsonProcessor;
impl FileProcessor for CsvProcessor {
fn process(&self, path: &Path) -> anyhow::Result<ProcessResult> {
// CSV-specific processing
Ok(ProcessResult { count: 0 })
}
fn supports(&self, ext: &str) -> bool { ext == "csv" }
fn supported_extensions(&self) -> Vec<&str> { vec!["csv"] }
}
impl FileProcessor for JsonProcessor {
fn process(&self, path: &Path) -> anyhow::Result<ProcessResult> {
// JSON-specific processing
Ok(ProcessResult { count: 0 })
}
fn supports(&self, ext: &str) -> bool { ext == "json" }
fn supported_extensions(&self) -> Vec<&str> { vec!["json"] }
}
// Dynamic dispatch with Box<dyn Trait>
fn get_processor(path: &Path) -> Option<Box<dyn FileProcessor>> {
let ext = path.extension()?.to_str()?;
let processors: Vec<Box<dyn FileProcessor>> = vec![
Box::new(CsvProcessor),
Box::new(JsonProcessor),
];
processors.into_iter().find(|p| p.supports(ext))
}
Building real programs in Rust reveals why it's worth the learning curve: the compile-time guarantees mean your program either doesn't compile or it works correctly. The debugging-in-production experience is dramatically better than in memory-unsafe languages.
→ Convert text to binary representation with the Text to Binary converter.