正在加载,请稍候…

Rust for Practical Systems Programming: CLI Tools, File I/O, and Error Handling

Build real programs in Rust: command-line tools with clap, file I/O, error handling with thiserror/anyhow, async with Tokio, and patterns for writing idiomatic production Rust code.

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.