You've learned the pieces. Now let's build something real.

We're going to build GrepLite — a simplified version of the grep command-line tool. It will:

This project uses: ownership, borrowing, structs, enums, error handling, traits, and the standard library.

Step 1: Project Setup

$ cargo new grep_lite
$ cd grep_lite

Your Cargo.toml should look like:

[package]
name = "grep_lite"
version = "0.1.0"
edition = "2021"

[dependencies]

Step 2: The Main Structure

Let's start with a clean structure:

use std::env;
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();
    
    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {}", err);
        process::exit(1);
    });
    
    // We'll add more here
}

We need a Config struct to hold our parsed arguments:

struct Config {
    query: String,
    filename: String,
}

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("Not enough arguments");
        }
        
        let query = args[1].clone();
        let filename = args[2].clone();
        
        Ok(Config { query, filename })
    }
}

Step 3: Reading the File

Now let's add the file-reading logic:

use std::fs;
use std::io::{self, Read};

fn run(config: Config) -> Result<(), Box<dyn io::Error>> {
    let contents = fs::read_to_string(&config.filename)?;
    
    for line in contents.lines() {
        if line.contains(&config.query) {
            println!("{}", line);
        }
    }
    
    Ok(())
}

Update main to call it:

fn main() {
    let args: Vec<String> = env::args().collect();
    
    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("Problem parsing arguments: {}", err);
        process::exit(1);
    });
    
    if let Err(e) = run(config) {
        eprintln!("Application error: {}", e);
        process::exit(1);
    }
}

Try it:

$ cargo run -- "fn" src/main.rs

You should see lines containing "fn".

Step 4: Adding Line Numbers

Let's make it more useful:

fn run(config: Config) -> Result<(), Box<dyn io::Error>> {
    let contents = fs::read_to_string(&config.filename)?;
    
    for (line_number, line) in contents.lines().enumerate() {
        if line.contains(&config.query) {
            println!("{}: {}", line_number + 1, line);
        }
    }
    
    Ok(())
}

Step 5: Better Error Handling

Let's make our errors more specific using an enum:

#[derive(Debug)]
enum CliError {
    NotEnoughArgs,
    FileNotFound(String),
    PermissionDenied(String),
    IoError(io::Error),
}

impl From<io::Error> for CliError {
    fn from(error: io::Error) -> Self {
        CliError::IoError(error)
    }
}

impl std::fmt::Display for CliError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            CliError::NotEnoughArgs => write!(f, "Usage: grep_lite <query> <filename>"),
            CliError::FileNotFound(name) => write!(f, "File not found: {}", name),
            CliError::PermissionDenied(name) => write!(f, "Permission denied: {}", name),
            CliError::IoError(e) => write!(f, "IO error: {}", e),
        }
    }
}

Update Config::new to use this:

impl Config {
    fn new(args: &[String]) -> Result<Config, CliError> {
        if args.len() < 3 {
            return Err(CliError::NotEnoughArgs);
        }
        
        let query = args[1].clone();
        let filename = args[2].clone();
        
        // Check if file exists before we try to read
        if !std::path::Path::new(&filename).exists() {
            return Err(CliError::FileNotFound(filename));
        }
        
        Ok(Config { query, filename })
    }
}

Step 6: Adding a Case-Insensitive Option

Let's add a flag for case-insensitive search:

struct Config {
    query: String,
    filename: String,
    case_sensitive: bool,
}

impl Config {
    fn new(args: &[String]) -> Result<Config, CliError> {
        if args.len() < 3 {
            return Err(CliError::NotEnoughArgs);
        }
        
        let query = args[1].clone();
        let filename = args[2].clone();
        
        // Check for -i flag
        let case_sensitive = !args.contains(&"-i".to_string());
        
        if !std::path::Path::new(&filename).exists() {
            return Err(CliError::FileNotFound(filename));
        }
        
        Ok(Config { query, filename, case_sensitive })
    }
}

Update the search logic:

fn run(config: Config) -> Result<(), CliError> {
    let contents = fs::read_to_string(&config.filename)?;
    
    let query = if config.case_sensitive {
        config.query.clone()
    } else {
        config.query.to_lowercase()
    };
    
    for (line_number, line) in contents.lines().enumerate() {
        let line_to_check = if config.case_sensitive {
            line.to_string()
        } else {
            line.to_lowercase()
        };
        
        if line_to_check.contains(&query) {
            println!("{}: {}", line_number + 1, line);
        }
    }
    
    Ok(())
}

Now try:

$ cargo run -- "function" src/main.rs
$ cargo run -- -i "Function" src/main.rs

Step 7: Refactoring with Traits

Let's clean this up with better organization:

// src/lib.rs

use std::fs;
use std::io;
use std::io::Read;

pub struct Config {
    pub query: String,
    pub filename: String,
    pub case_sensitive: bool,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("Usage: grep_lite <query> <filename> [-i]");
        }
        
        Ok(Config {
            query: args[1].clone(),
            filename: args[2].clone(),
            case_sensitive: !args.contains(&"-i".to_string()),
        })
    }
}

pub fn run(config: &Config) -> Result<(), Box<dyn io::Error>> {
    let mut file = fs::File::open(&config.filename)?;
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    
    for (line_number, line) in contents.lines().enumerate() {
        let matches = if config.case_sensitive {
            line.contains(&config.query)
        } else {
            line.to_lowercase().contains(&config.query.to_lowercase())
        };
        
        if matches {
            println!("{}: {}", line_number + 1, line);
        }
    }
    
    Ok(())
}

And simplify main.rs:

// src/main.rs

use std::env;
use std::process;

use grep_lite::{Config, run};

fn main() {
    let args: Vec<String> = env::args().collect();
    
    let config = Config::new(&args).unwrap_or_else(|err| {
        eprintln!("{}", err);
        process::exit(1);
    });
    
    if let Err(e) = run(&config) {
        eprintln!("Error: {}", e);
        process::exit(1);
    }
}

Step 8: Testing

Add a test:

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn case_sensitive() {
        let query = "duct";
        let contents = "\
Rust:
safe, fast, productive.
Pick three.
Duct tape.";
        
        assert_eq!(vec!["safe, fast, productive."], search(query, contents));
    }
    
    fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
        contents
            .lines()
            .filter(|line| line.contains(query))
            .collect()
    }
}

Run tests:

$ cargo test

The Complete Picture

What we built:

This is what "real Rust" looks like. Not one concept in isolation — everything working together.

Where to Go Next

You now have the fundamentals. What to learn next:

The Rust book (free online) covers all of this. So does "Programming Rust" by Blandy & Orendorff.

You've started something. Keep going.