[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 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.

Live Practice

Rust Practice Sandbox

CodeSandbox

Run the starter project in CodeSandbox, compare it with the lesson code, and keep experimenting.

Rust startercargoterminal
  1. Fork the starter and open src/main.rs
  2. Paste the lesson code and run cargo check plus cargo run in order
  3. Change types, values, or borrowing flow and compare the compiler feedback with the output

Rust practice here is mainly terminal-driven rather than browser-preview driven. Lessons that need multiple files or extra crates may require a bit more setup inside the starter.

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.

💬 댓글

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