[Rust Series 20] Capstone: Building a Safe CLI Tool

한국어 버전

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 list prints every note.
  • Missing files yield an empty list instead of panicking.
  • Invalid arguments produce clear error messages.
  • cargo test passes.

Safety Checklist

  • Every Result travels 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, the Command structure still holds.

Practice

  1. Add a search <keyword> command that prints only lines containing the keyword.
  2. Implement a memo list --json option to render notes as a JSON array.
  3. Port the storage module to async with tokio and ensure existing tests still pass.

Wrap-Up

That's the finale of our 20-part Rust journey. After covering smart pointers, testing, concurrency, async, and this CLI capstone, designing small Rust tools should feel far less daunting. The next step is to pick a real project in your domain of interest—web, CLI, or systems—and extend it from this capstone foundation.

💬 댓글

이 글에 대한 의견을 남겨주세요