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:
- Take a search term and a file path as arguments
- Read the file and find lines containing the term
- Print matching lines with line numbers
- Handle errors gracefully (file not found, no permission, etc.)
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:
- Structs: Config to hold parsed data
- Enums: Error types for different failure modes
- Traits: Used
From<io::Error>for conversion - Generics:
Box<dyn io::Error>— any error type - Ownership: Args moved into Config, references in search
- Borrowing:
&config.filename,&contents - Error handling:
?operator, Result types, process::exit - Standard library: env, fs, io, process, vec
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:
- Cargo & Crates.io — sharing code with the world
- Lifetimes in depth — the 'a annotation for references
- Iterators — the most powerful tool in Rust
- ** lifetimes** — advanced borrowing patterns
- Unsafe Rust — when you need to step outside the safety net
- Async & Tokio — async I/O for network servers
The Rust book (free online) covers all of this. So does "Programming Rust" by Blandy & Orendorff.
You've started something. Keep going.