Danh sách bài viết

Bài 312: Capstone 1: CLI Tool Với clap — wc Clone

Bài 312 của series Rust Cơ Bản — bài đầu tiên của Group 39 (Capstone Projects). Sau 311 bài lý thuyết và bài tập nhỏ, đây là lúc gộp kiến thức thành một CLI hoàn chỉnh: clone công cụ wc kinh điển của Unix bằng Rust + clap derive. Bạn sẽ dựng project từ cargo new, define args struct với 4 flag -l/-w/-c/-m, đọc input từ file hoặc stdin khi không có argument, viết logic đếm line/word/char/byte, format output giống wc gốc, thêm unit test, build release, và cargo install --path . để chạy như binary toàn cục. Cuối bài có roadmap mở rộng multi-file + glob để bạn tiếp tục tự rèn.

11/06/2026
12 phút đọc
1 lượt xem
1

Mục Tiêu Bài Học

Sau bài học, bạn sẽ:

  • Dựng được một CLI tool Rust hoàn chỉnh từ cargo new đến install binary toàn cục.
  • Define được argument parser với clap derive macro, kèm 4 boolean flag và 1 positional optional.
  • Đọc dữ liệu từ std::io::stdin() khi user không truyền file, mô phỏng đúng hành vi pipe cat foo.txt | wc.
  • Viết được hàm đếm line/word/char/byte trên &str, hiểu khác biệt giữa len() (byte) và chars().count() (Unicode scalar).
  • Format output căn lề đúng chuẩn wc Unix (7 ký tự, right-aligned).
  • Viết unit test cho hàm thuần (không I/O) bằng #[test].
  • Build release tối ưu và install qua cargo install --path . để gọi my-wc ở bất kỳ thư mục nào.
2

Đặc Tả: Clone wc Của Unix

wc (word count) là công cụ Unix xuất hiện từ Version 1 AT&T UNIX (1971). Bản POSIX hiện đại nhận file hoặc stdin, in ra số dòng / từ / byte. Đặc tả chúng ta clone lại trong Rust:

  • Flag:
    • -l / --lines: đếm số dòng (số ký tự \n).
    • -w / --words: đếm số từ (phân tách bằng whitespace bất kỳ).
    • -c / --bytes: đếm số byte.
    • -m / --chars: đếm số ký tự (Unicode scalar — khác -c với input UTF-8 đa ngôn ngữ).
  • Argument positional (optional): đường dẫn file. Nếu không có → đọc từ stdin (chuẩn pipe Unix).
  • Mặc định (khi không truyền flag nào): bật -l -w -c đồng thời, giống wc gốc.
  • Output: các con số căn phải 7 ký tự, phân cách space, đuôi là tên file (hoặc rỗng nếu đọc stdin). Ví dụ 3 8 42 foo.txt.

So với wc thật, ta bỏ qua: multi-file aggregate, flag -L (longest line), locale. Đủ để demo capstone CLI đúng tinh thần Unix.

3

cargo new + Cargo.toml Với clap derive

Tạo project binary với edition mới nhất:

cargo new my-wc --edition 2024
cd my-wc
cargo add clap --features derive

cargo add (từ Cargo 1.62+) tự thêm dependency vào Cargo.toml. Cargo.toml kết quả:

[package]
name = "my-wc"
version = "0.1.0"
edition = "2024"
description = "wc clone viết bằng Rust + clap"
license = "MIT"

[dependencies]
clap = { version = "4", features = ["derive"] }

[profile.release]
opt-level = 3
lto = "thin"
strip = true

Vài lưu ý:

  • Feature derive bật macro #[derive(Parser)]. Nếu không bật, clap chỉ có builder API verbose hơn.
  • Profile release: lto = "thin" giảm binary size 15–30%, strip = true bỏ debug symbol — binary cuối cùng dưới 1MB.
  • Pin major version "4": clap đã ở v4 từ 2022, ổn định lâu dài.
4

Define Args Struct Với derive Parser

clap derive cho phép khai báo CLI bằng struct + attribute. Mỗi field thành một flag/argument, doc comment trở thành help text. Mở src/main.rs:

use clap::Parser;
use std::path::PathBuf;

/// wc clone — đếm dòng, từ, ký tự, byte.
#[derive(Parser, Debug)]
#[command(name = "my-wc", version, about, long_about = None)]
struct Args {
    /// Đường dẫn file (nếu không có, đọc từ stdin)
    file: Option<PathBuf>,

    /// Đếm số dòng
    #[arg(short = 'l', long = "lines")]
    lines: bool,

    /// Đếm số từ
    #[arg(short = 'w', long = "words")]
    words: bool,

    /// Đếm số byte
    #[arg(short = 'c', long = "bytes")]
    bytes: bool,

    /// Đếm số ký tự Unicode (khác -c với input UTF-8)
    #[arg(short = 'm', long = "chars")]
    chars: bool,
}

impl Args {
    /// Khi không truyền flag nào, bật -l -w -c (mặc định wc Unix)
    fn resolve_defaults(mut self) -> Self {
        if !self.lines && !self.words && !self.bytes && !self.chars {
            self.lines = true;
            self.words = true;
            self.bytes = true;
        }
        self
    }
}

Giải thích nhanh: #[command(version)] tự gắn flag --version đọc từ Cargo.toml. Option<PathBuf> = positional optional. bool = flag không nhận value (có/không). resolve_defaults mô phỏng đúng hành vi wc: gọi không flag → in 3 cột mặc định.

5

Đọc File Hoặc stdin Khi Không Có Arg

Logic: nếu user truyền filefs::read_to_string; nếu không → đọc toàn bộ stdin tới EOF. Cả 2 trả về String để pass cho hàm đếm.

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

fn read_input(file: Option<&Path>) -> io::Result<String> {
    match file {
        Some(path) => fs::read_to_string(path),
        None => {
            let mut buf = String::new();
            io::stdin().read_to_string(&mut buf)?;
            Ok(buf)
        }
    }
}

Pattern này là idiom Rust cho CLI Unix-style: trả io::Result<String> để ? propagate lên main. io::stdin().read_to_string block tới khi user nhấn Ctrl-D (EOF) hoặc pipe đóng.

Edge case: file nhị phân không phải UTF-8 sẽ fail read_to_string với InvalidData. wc thật xử lý byte thuần (read_to_end vào Vec<u8>). Đây là trade-off đơn giản hoá cho capstone — bài tập cuối có yêu cầu fix.

6

Logic Đếm Line / Word / Char / Byte

Tách logic thành các hàm thuần (không I/O) để dễ test:

#[derive(Debug, PartialEq)]
struct Counts {
    lines: usize,
    words: usize,
    chars: usize,
    bytes: usize,
}

fn count(text: &str) -> Counts {
    Counts {
        lines: text.matches('\n').count(),
        words: text.split_whitespace().count(),
        chars: text.chars().count(),
        bytes: text.len(), // String là Vec<u8>, len() = số byte UTF-8
    }
}

Bốn metric, bốn idiom Rust khác nhau:

  • matches('\n').count() — đếm xuất hiện của ký tự, đúng nghĩa "số \n" mà wc -l dùng. File không có newline cuối → count thấp hơn 1 đơn vị, giống wc gốc.
  • split_whitespace() — phân tách bằng mọi whitespace (space, tab, newline, Unicode whitespace), tự bỏ token rỗng đầu/cuối. Khác split(' ') sẽ tạo token rỗng khi gặp multi-space.
  • chars().count() — số Unicode scalar value, O(n) vì phải decode UTF-8 từng char.
  • len() — số byte trong String (đã UTF-8 encode). "héllo" có 4 char nhưng 5 byte vì é chiếm 2 byte. Đây chính là khác biệt giữa -c-m.
7

Format Output Giống wc + Unit Test

wc dùng width 7, right-align. Format trong Rust dùng {:>7}:

fn format_output(args: &Args, c: &Counts, name: &str) -> String {
    let mut out = String::new();
    if args.lines { out.push_str(&format!("{:>7}", c.lines)); }
    if args.words { out.push_str(&format!("{:>7}", c.words)); }
    if args.chars { out.push_str(&format!("{:>7}", c.chars)); }
    if args.bytes { out.push_str(&format!("{:>7}", c.bytes)); }
    if !name.is_empty() {
        out.push(' ');
        out.push_str(name);
    }
    out
}

fn main() -> io::Result<()> {
    let args = Args::parse().resolve_defaults();
    let text = read_input(args.file.as_deref())?;
    let counts = count(&text);
    let name = args.file.as_ref()
        .and_then(|p| p.to_str())
        .unwrap_or("");
    println!("{}", format_output(&args, &counts, name));
    Ok(())
}

Thêm unit test ở cuối main.rs (capstone bao gồm test):

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn count_empty() {
        let c = count("");
        assert_eq!(c, Counts { lines: 0, words: 0, chars: 0, bytes: 0 });
    }

    #[test]
    fn count_simple() {
        let c = count("hello world\nrust\n");
        assert_eq!(c.lines, 2);
        assert_eq!(c.words, 3);
        assert_eq!(c.chars, 17);
        assert_eq!(c.bytes, 17);
    }

    #[test]
    fn chars_vs_bytes_unicode() {
        let c = count("héllo"); // 5 char, 6 byte
        assert_eq!(c.chars, 5);
        assert_eq!(c.bytes, 6);
    }
}

Chạy: cargo test — cả 3 test pass. Lưu ý test chars_vs_bytes_unicode chứng minh đúng sự khác biệt giữa -m-c, đây là test giá trị nhất vì nó "đóng đinh" hành vi đặc thù.

8

cargo build --release + cargo install --path .

Build production binary:

cargo build --release
ls -lh target/release/my-wc
# -rwxr-xr-x  1 user  staff   780K  ...  target/release/my-wc

Install local crate thành binary toàn cục — copy vào ~/.cargo/bin/ (đã có trong $PATH sau khi cài rustup):

cargo install --path .
#   Installing my-wc v0.1.0 (/Users/you/projects/my-wc)
#    Compiling my-wc v0.1.0 (...)
#     Finished `release` profile [optimized] target(s) in 4.21s
#   Installing /Users/you/.cargo/bin/my-wc
#    Installed package `my-wc v0.1.0` (executable `my-wc`)

Demo chạy thực tế:

echo "hello rust world" | my-wc
#       1       3      17

my-wc Cargo.toml
#      12      24     245 Cargo.toml

my-wc -l -w src/main.rs
#      85     220 src/main.rs

cat README.md | my-wc -m -c
#    1543    1612

Để gỡ: cargo uninstall my-wc. Để cập nhật sau khi sửa code: chạy lại cargo install --path . --force (cờ --force ghi đè bản cũ).

9

Roadmap Mở Rộng: Multi-File + Glob

Phiên bản hiện tại đã đủ chạy. Nếu muốn rèn thêm, đây là roadmap nâng cấp tự nhiên:

  1. Multi-file: đổi file: Option<PathBuf> thành files: Vec<PathBuf>. Loop in từng file, in dòng cuối total aggregate giống wc *.txt.
  2. Glob expansion: shell Unix tự expand *.txt, nhưng Windows cmd không. Thêm crate glob = "0.3" để expand thủ công, hỗ trợ cross-platform.
  3. Binary-safe: đổi read_to_string sang read_to_end(&mut Vec<u8>), đếm byte trực tiếp; chỉ decode UTF-8 khi cần -w hoặc -m.
  4. JSON output: thêm flag --json, dùng serde_json serialize Counts → tích hợp với pipeline khác (jq, awk).
  5. Streaming: với file lớn (GB), dùng BufReader::new(file).lines() đọc từng dòng thay vì load hết vào RAM. Memory O(1) thay vì O(n).
  6. Parallel: với multi-file, dùng rayon par_iter đếm song song. Capstone Bài 313 sẽ đi sâu hướng này.

Mỗi gạch đầu dòng là một PR riêng nếu bạn host project trên GitHub. Đây cũng là cách build portfolio thực tế: bắt đầu nhỏ, iterate có chủ đích.

10

Tổng Kết

  • Capstone đầu tiên: clone công cụ wc Unix bằng Rust + clap derive — gói trọn pipeline CLI từ parse arg, đọc I/O, logic thuần, format, test, build release, install.
  • clap = { version = "4", features = ["derive"] } + #[derive(Parser)] sinh argument parser, help, version tự động chỉ từ struct + doc comment.
  • Idiom Unix: file: Option<PathBuf>, nếu None thì đọc từ io::stdin().read_to_string() — cho phép pipe cat foo | my-wc.
  • 4 metric đếm dùng 4 idiom khác nhau: matches('\n').count(), split_whitespace().count(), chars().count(), len(). -c vs -m phân biệt được vì String là UTF-8.
  • Tách logic thuần (count, format_output) khỏi I/O để unit test dễ — test chars_vs_bytes_unicode đóng đinh hành vi đặc thù.
  • cargo install --path . copy binary vào ~/.cargo/bin/ để chạy như command toàn cục; --force để overwrite khi cập nhật.
  • Roadmap mở rộng tự nhiên: multi-file, glob, binary-safe, JSON output, streaming, parallel — mỗi cái là một PR portfolio.
11

Bài Tập Củng Cố

Tự trả lời, đáp án ở cuối:

  1. Thêm flag -L / --max-line-length in ra độ dài (số byte) của dòng dài nhất, giống wc -L trên Linux. Update Countscount() tương ứng.
  2. Đổi positional file: Option<PathBuf> thành files: Vec<PathBuf>, in từng file một dòng, kèm dòng cuối total nếu >= 2 file.
  3. Thêm flag --json để output dạng JSON thay vì plain text. Hint: thêm serde = { version = "1", features = ["derive"] }serde_json = "1", derive Serialize cho Counts.
  4. Refactor để xử lý file binary (không phải UTF-8) không panic: đọc Vec<u8>, đếm byte luôn, dùng String::from_utf8_lossy khi cần -w/-m.
  5. Với file 1GB, hiện tại read_to_string ngốn 1GB RAM. Refactor sang BufReader::lines() đếm streaming. Đo memory bằng /usr/bin/time -l my-wc bigfile (macOS) hoặc /usr/bin/time -v (Linux).
  6. Thêm integration test trong tests/cli.rs dùng crate assert_cmd để spawn binary thực và verify stdout. Mục đích: cover code path main mà unit test không reach.
Đáp án
  1. Thêm field max_line: usize vào Counts và flag #[arg(short = 'L', long)] max_line_length: bool vào Args. Trong count(): max_line: text.lines().map(|l| l.len()).max().unwrap_or(0). Trong format_output thêm nhánh if args.max_line_length { ... }.
  2. Sửa thành files: Vec<PathBuf>. Trong main: nếu files.is_empty() → đọc stdin (giữ hành vi cũ); else loop, tích lũy total Counts bằng cộng từng field. Sau loop, nếu files.len() >= 2 in dòng total.
  3. Derive Serialize cho Counts (chỉ cần field bạn muốn expose). Trong format_output branch theo args.json: serde_json::to_string(&counts).unwrap() thay vì format manual. Cẩn thận: JSON nên include cả 4 metric kể cả khi flag không bật, để consumer không phải đoán field nào tồn tại.
  4. Thay read_to_string bằng read_to_end(&mut Vec<u8>). bytes = data.len() tính trực tiếp trên Vec. Khi cần -w hoặc -m hoặc -l: let text = String::from_utf8_lossy(&data); — invalid byte thay bằng U+FFFD nhưng không panic. Trade-off: lossy không đếm chính xác char gốc nhưng an toàn cho mọi input.
  5. Dùng BufReader::new(File::open(path)?).lines(), loop từng Result<String>, cập nhật từng Counts field incremental. Với stdin: BufReader::new(io::stdin().lock()).lines(). Memory giữ ở mức buffer size (8KB default), không phụ thuộc file size. Đo: macOS /usr/bin/time -l my-wc bigfile rồi xem dòng maximum resident set size.
  6. Thêm [dev-dependencies] assert_cmd = "2". Tạo tests/cli.rs: use assert_cmd::Command; Command::cargo_bin("my-wc").unwrap().arg("Cargo.toml").assert().success().stdout(predicates::str::contains("Cargo.toml"));. Chạy cargo test sẽ build binary release rồi spawn thực — cover từ main() trở đi mà unit test không reach.
12

Bài Tiếp Theo

Bài 313: Capstone 2: File Processor — Đếm Word/Line Từ Folder Bằng walkdir — nâng cấp my-wc thành tool xử lý toàn bộ folder: dùng walkdir traverse đệ quy, rayon parallel iterator đếm song song nhiều file, indicatif progress bar, cuối cùng aggregate kết quả vào HashMap.