Mục lục
- Mục Tiêu Bài Học
- Đặc Tả: Clone wc Của Unix
- cargo new + Cargo.toml Với clap derive
- Define Args Struct Với derive Parser
- Đọc File Hoặc stdin Khi Không Có Arg
- Logic Đếm Line / Word / Char / Byte
- Format Output Giống wc + Unit Test
- cargo build --release + cargo install --path .
- Roadmap Mở Rộng: Multi-File + Glob
- Tổng Kết
- Bài Tập Củng Cố
- Bài Tiếp Theo
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
clapderive 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 pipecat foo.txt | wc. - Viết được hàm đếm line/word/char/byte trên
&str, hiểu khác biệt giữalen()(byte) vàchars().count()(Unicode scalar). - Format output căn lề đúng chuẩn
wcUnix (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ọimy-wcở bất kỳ thư mục nào.
Đặ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-cvớ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ốngwcgố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.
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
derivebậ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 = truebỏ debug symbol — binary cuối cùng dưới 1MB. - Pin major version
"4": clap đã ở v4 từ 2022, ổn định lâu dài.
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.
Đọc File Hoặc stdin Khi Không Có Arg
Logic: nếu user truyền file → fs::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.
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 -ldùng. File không có newline cuối → count thấp hơn 1 đơn vị, giốngwcgố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ácsplit(' ')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-cvà-m.
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 và -c, đây là test giá trị nhất vì nó "đóng đinh" hành vi đặc thù.
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ũ).
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:
- Multi-file: đổi
file: Option<PathBuf>thànhfiles: Vec<PathBuf>. Loop in từng file, in dòng cuốitotalaggregate giốngwc *.txt. - Glob expansion: shell Unix tự expand
*.txt, nhưng Windows cmd không. Thêm crateglob = "0.3"để expand thủ công, hỗ trợ cross-platform. - Binary-safe: đổi
read_to_stringsangread_to_end(&mut Vec<u8>), đếm byte trực tiếp; chỉ decode UTF-8 khi cần-whoặc-m. - JSON output: thêm flag
--json, dùngserde_jsonserializeCounts→ tích hợp với pipeline khác (jq, awk). - 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). - Parallel: với multi-file, dùng
rayonpar_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.
Tổng Kết
- Capstone đầu tiên: clone công cụ
wcUnix bằng Rust +clapderive — 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 pipecat foo | my-wc. - 4 metric đếm dùng 4 idiom khác nhau:
matches('\n').count(),split_whitespace().count(),chars().count(),len().-cvs-mphân biệt được vì String là UTF-8. - Tách logic thuần (
count,format_output) khỏi I/O để unit test dễ — testchars_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.
Bài Tập Củng Cố
Tự trả lời, đáp án ở cuối:
- Thêm flag
-L/--max-line-lengthin ra độ dài (số byte) của dòng dài nhất, giốngwc -Ltrên Linux. UpdateCountsvàcount()tương ứng. - Đổi positional
file: Option<PathBuf>thànhfiles: Vec<PathBuf>, in từng file một dòng, kèm dòng cuốitotalnếu >= 2 file. - Thêm flag
--jsonđể output dạng JSON thay vì plain text. Hint: thêmserde = { version = "1", features = ["derive"] }vàserde_json = "1", deriveSerializechoCounts. - Refactor để xử lý file binary (không phải UTF-8) không panic: đọc
Vec<u8>, đếm byte luôn, dùngString::from_utf8_lossykhi cần-w/-m. - Với file 1GB, hiện tại
read_to_stringngốn 1GB RAM. Refactor sangBufReader::lines()đếm streaming. Đo memory bằng/usr/bin/time -l my-wc bigfile(macOS) hoặc/usr/bin/time -v(Linux). - Thêm integration test trong
tests/cli.rsdùng crateassert_cmdđể spawn binary thực và verify stdout. Mục đích: cover code pathmainmà unit test không reach.
Đáp án
- Thêm field
max_line: usizevàoCountsvà flag#[arg(short = 'L', long)] max_line_length: boolvàoArgs. Trongcount():max_line: text.lines().map(|l| l.len()).max().unwrap_or(0). Trongformat_outputthêm nhánhif args.max_line_length { ... }. - Sửa thành
files: Vec<PathBuf>. Trongmain: nếufiles.is_empty()→ đọc stdin (giữ hành vi cũ); else loop, tích lũytotalCounts bằng cộng từng field. Sau loop, nếufiles.len() >= 2in dòngtotal. - Derive
SerializechoCounts(chỉ cần field bạn muốn expose). Trongformat_outputbranch theoargs.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. - Thay
read_to_stringbằngread_to_end(&mut Vec<u8>).bytes = data.len()tính trực tiếp trên Vec. Khi cần-whoặc-mhoặ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. - Dùng
BufReader::new(File::open(path)?).lines(), loop từngResult<String>, cập nhật từngCountsfield 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 bigfilerồi xem dòngmaximum resident set size. - Thêm
[dev-dependencies] assert_cmd = "2". Tạotests/cli.rs:use assert_cmd::Command; Command::cargo_bin("my-wc").unwrap().arg("Cargo.toml").assert().success().stdout(predicates::str::contains("Cargo.toml"));. Chạycargo testsẽ build binary release rồi spawn thực — cover từmain()trở đi mà unit test không reach.
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.
