What You'll Learn
We'll close the series by weaving previous concepts into a safe CLI tool. The goal is simple: build a program that appends and searches notes in a text file while keeping modules separated, handling errors safely, and shipping tests.
Scenario and Project Layout
Users run commands such as memo add "Rust ownership" to store a note and memo list to view saved entries. We'll structure the project like this:
src/
main.rs // parses args and dispatches commands
commands.rs // add and list logic
storage.rs // file read/write abstraction
Breaking out modules keeps each unit testable and reusable.
Before diving in, revisit these earlier entries for context:
- Part 11:
Result,?, propagating errors - Part 12: modules and file layout
- Part 17: writing tests
storage Module: File I/O and Errors
// src/storage.rs
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::Path;
pub fn append_line<P: AsRef<Path>>(path: P, line: &str) -> io::Result<()> {
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
writeln!(file, "{}", line)?;
Ok(())
}
pub fn read_all<P: AsRef<Path>>(path: P) -> io::Result<Vec<String>> {
if !path.as_ref().exists() {
return Ok(vec![]);
}
let content = fs::read_to_string(path)?;
Ok(content.lines().map(|s| s.to_string()).collect())
}
io::Result plus ? passes errors up the call stack. When the file doesn't exist, we return an empty vector so first-time users avoid a panic.
commands Module: Domain Logic
// src/commands.rs
use crate::storage;
use std::io;
pub enum Command {
Add { text: String },
List,
}
impl Command {
pub fn run(self, path: &str) -> io::Result<()> {
match self {
Command::Add { text } => {
storage::append_line(path, &text)?;
println!("Added: {text}");
}
Command::List => {
let items = storage::read_all(path)?;
if items.is_empty() {
println!("No saved notes yet.");
} else {
for (idx, item) in items.iter().enumerate() {
println!("{idx}: {item}");
}
}
}
}
Ok(())
}
}
Abstracting commands lets you add subcommands like search later without rewriting main.
main.rs: Arg Parsing and Error Reporting
mod commands;
mod storage;
use commands::Command;
fn main() {
if let Err(err) = run() {
eprintln!("Error: {err}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let mut args = std::env::args().skip(1);
let cmd = match args.next().as_deref() {
Some("add") => {
let text = args.next().ok_or("Provide text to add")?;
Command::Add { text }
}
Some("list") => Command::List,
_ => {
println!("Usage: memo <add|list> [text]");
return Ok(());
}
};
cmd.run("memo.txt")?;
Ok(())
}
run returns Box<dyn Error> so you can bubble up different error types. main prints a friendly message to stderr and exits with a non-zero status.
Tests: Verifying storage
Because storage touches the filesystem, use crates like tempfile to create scratch files for tests.
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn append_and_read_back() {
let file = NamedTempFile::new().unwrap();
let path = file.path();
append_line(path, "hello").unwrap();
append_line(path, "rust").unwrap();
let lines = read_all(path).unwrap();
assert_eq!(lines, vec!["hello", "rust"]);
}
}
If the file API works, the higher layers are far more likely to behave. Test the CLI layer with integration tests or manual runs.
Post-Build Checklist
memo add "hello"appends exactly one line.memo listprints every note.- Missing files yield an empty list instead of panicking.
- Invalid arguments produce clear error messages.
cargo testpasses.
Safety Checklist
- Every
Resulttravels upward with meaningful messages. - Modules keep each file's responsibility small.
- Tests cover the core I/O logic.
- If you later switch to async with
tokio::fs, theCommandstructure still holds.
Practice in CodeSandbox
The sandbox below uses CodeSandbox's Rust starter. Move the main code into src/main.rs, then compare cargo check and cargo run so you can read the compiler feedback beside the final output.
💬 댓글
이 글에 대한 의견을 남겨주세요