ข้าม​ไป​ยัง​เนื้อหา

สร้าง CLI จริง — clap, serde, std::fs, cargo test และ modules

หกบท​ที่​ผ่าน​มา​เรา​สะสม​ชิ้น​ส่วน​ไว้​ครบ — struct Task, enum Command, error type ด้วย thiserror, Vec/iterator สำหรับ​ข้อมูล และ trait/generics สำหรับ​พฤติกรรม บท​นี้​คือ บท​จ่าย​ผล: เรา​จะ​ประกอบ​ทุก​อย่าง​เป็น taskcli ตัว​จริง​ที่​รับ argument จาก​บรรทัด​คำ​สั่ง อ่าน/เขียน file JSON และ​มี test ครอบ ถ้า​คุณ​มา​จาก .NET นี่​คือ​จุด​ที่ Rust map กับ​ของ​ที่​คุณ​คุ้น — System.CommandLine สำหรับ parse args, System.Text.Json สำหรับ (de)serialize, และ xUnit สำหรับ test — ยกเว้น ทุก​ตัว​ทำงาน​ตอน compile time ด้วย codegen ไม่ใช่ reflection ตอน runtime

📦 kaen-taskcli

code ลงมือ​ของ​บท​นี้​อยู่​ใน repo kaen-taskcli (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท​นี้​คือ​จุด​ที่ code กระจัดกระจาย​จาก​บท3–6 มา​รวม​เป็น project เดียว​ที่ cargo build แล้ว​รัน​ได้​จริง มี --help/--version ให้​อัตโนมัติ เก็บ​สถานะ​เป็น JSON และ​มี cargo test ผ่าน​ครบ ทั้ง clap/serde เป็น derive macro ที่​ทำงาน​ตอน compile — คุณ​จะ​เห็น​ว่า struct คือ spec ไม่ใช่ code parse ที่​เขียน​มือ

ก่อน​จะ​หยิบ clap มา​ดู​ว่า​ทำไม​เรา​ถึง​อยาก​ได้​มัน ❌ นี่​คือ version ดิบ ที่ parse std::env::args() เอง​ล้วนๆ:

use std::env;
use std::process;
fn main() {
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("usage: taskcli <add|list|done> [args]");
process::exit(2); // usage error -> exit 2
}
match args[1].as_str() {
"add" => {
let Some(title) = args.get(2) else {
eprintln!("error: 'add' ต้องมีชื่อ task");
process::exit(2);
};
println!("เพิ่ม task: {title}");
}
"list" => println!("(ยังไม่ได้ทำการแสดงผลจริง)"),
"done" => {
let Some(raw) = args.get(2) else {
eprintln!("error: 'done' ต้องมี id");
process::exit(2);
};
match raw.parse::<u32>() {
Ok(id) => println!("ทำ #{id} เสร็จแล้ว"),
Err(_) => {
eprintln!("error: id ไม่ใช่ตัวเลข: {raw}");
process::exit(2);
}
}
}
other => {
eprintln!("error: ไม่รู้จักคำสั่ง {other}");
process::exit(2);
}
}
}

มัน compile และ​ทำงาน​ได้ แต่​เจ็บ​ตรง​ไหน​สังเกต​ได้​ทันที: เรา​ไล่ index เอง (args[1], args.get(2)), เขียน error message กับ exit code เอง​ทุก​จุด, ไม่มี --help ไม่มี --version, และ​ถ้า​เพิ่ม flag --file เข้า​มา code parse จะ​บวม​เป็น​สอง​เท่า ทั้งหมด​นี้​คือ งาน​ซ้ำซาก ที่​ทุก CLI ต้อง​ทำ — และ​เป็น​เหตุผล​ที่​เรา​จะ​ยก​มัน​ให้ clapclapcrate parse argument CLI แบบ derive (v4.x, ต้อง​เปิด feature `derive`) จัดการ

std::env::args() panic กับ argument ที่​ไม่ใช่ UTF-8

std::env::args() จะ panic ถ้า​มี argument ที่​ไม่ใช่ UTF-8 (เช่น​ชื่อ file ที่​เข้า​รหัส​แปลกๆ บน​บาง​ระบบ) ถ้า​ต้อง​รองรับ​กรณี​นั้น​ให้​ใช้ std::env::args_os() ที่​คืน OsString แทน — แต่​ใน​ทาง​ปฏิบัติ clap จัดการ​เรื่อง​นี้​ให้​เรา​อยู่​แล้ว จึง​ไม่​ต้อง​กังวล​เมื่อ​ย้าย​ไป​ใช้ derive

clap version 4 แบบ derive พลิก​วิธี​คิด: แทนที่​จะ​เขียน code parse คุณ ประกาศ รูปร่าง​ของ argument เป็น struct แล้ว macro สร้าง​ตัว parse ให้ — พร้อม --help/--version และ error message ที่​ขึ้น exit code 2 เอง​ครบ

use std::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use taskcli::Store;
/// ตัวจัดการ task เล็ก ๆ บนบรรทัดคำสั่ง
#[derive(Debug, Parser)]
#[command(name = "taskcli", version, about)]
struct Cli {
/// file เก็บข้อมูล
#[arg(long, default_value = "tasks.json")]
file: PathBuf,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// เพิ่ม task ใหม่
Add { title: String },
/// แสดง task ทั้งหมด
List,
/// ทำเครื่องหมายว่าเสร็จแล้ว
Done { id: u32 },
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => { eprintln!("error: {e}"); ExitCode::FAILURE }
}
}
fn run(cli: Cli) -> Result<(), taskcli::TaskError> {
let mut store = Store::load(&cli.file)?;
match cli.command {
Command::Add { title } => {
let t = store.add(title);
println!("เพิ่ม #{}: {}", t.id, t.title);
store.save()?;
}
Command::List => for t in store.tasks() {
println!("[{}] #{} {}", if t.done {"x"} else {" "}, t.id, t.title);
},
Command::Done { id } => { store.mark_done(id)?; store.save()?; println!("#{id} เสร็จแล้ว"); }
}
Ok(())
}

เทียบ​กับ version ดิบ ข้าง​บน: ไม่มี args[1] ไม่มี .parse::<u32>() เขียน​มือ — #[derive(Parser)] อ่าน​จาก​ชนิด​ของ field เอง (id: u32 แปล​ว่า clap จะ parse เป็น u32 และ​ปฏิเสธ​ค่าที่​ไม่ใช่​ตัวเลข​ให้​พร้อม error) #[derive(Subcommand)] บน enum Command ทำให้ add/list/done กลาย​เป็น subcommand แบบ git-style และ Cli::parse() อ่าน std::env::args() ให้​อัตโนมัติ พร้อม​สร้าง --help/--version จาก doc comment (///) ที่​เรา​เขียน

feature 'derive' ปิด​เป็น​ค่า​ปริยาย — และ 'env' ต้อง​เปิด​แยก

clap ปิด derive macro ไว้​เป็น​ค่า​ปริยาย ต้อง​เปิด features = ["derive"] ใน Cargo.toml ไม่​งั้น #[derive(Parser)] จะ​ไม่มี​ให้​ใช้ และ​ถ้า​คุณ​อยาก​อ่าน​ค่า​จาก environment variable ด้วย #[arg(env = "TASKCLI_FILE")] ต้อง​เปิด features = ["derive", "env"] เพิ่ม — ไม่​งั้น​ชน error[E0599] เพราะ attribute env ไม่​ถูก compile เข้า​มา นี่​ต่าง​จาก​ธรรมเนียม .NET ที่​ทุก​อย่าง​มา​ใน package เดียว: crate ของ Rust แบ่ง​เป็น feature เพื่อ​ไม่​ให้​แบก dependency ที่​ไม่​ได้​ใช้

taskcli เก็บ​สถานะ​เป็น file JSON serdeserdeframework (de)serialization แบบ derive (คู่​กับ serde_json สำหรับ JSON) คือ framework (de)serialization ที่​ทำงาน​ด้วย derive — คุณ​แปะ #[derive(Serialize, Deserialize)] บน struct แล้ว​ได้ code แปลง​ไป/กลับ JSON ฟรี ส่วน serde_json คือ backend ที่​รู้จัก​รูปแบบ JSON จริงๆ

use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Task {
pub id: u32,
pub title: String,
#[serde(default)]
pub done: bool,
}
// A: อ่าน/เขียนทั้ง file ทีเดียว
let text = std::fs::read_to_string(&path)?;
let tasks: Vec<Task> = serde_json::from_str(&text)?;
std::fs::write(&path, serde_json::to_string_pretty(&tasks)?)?;
// B: แบบ streaming ผ่าน buffered reader/writer
use std::io::{BufReader, BufWriter};
let tasks: Vec<Task> = serde_json::from_reader(BufReader::new(std::fs::File::open(&path)?))?;
serde_json::to_writer_pretty(BufWriter::new(std::fs::File::create(&path)?), &tasks)?;

#[serde(default)] บน done คือ​ประกัน​ความ​เข้า​กัน​ได้​ย้อนหลัง: ถ้า file JSON เก่า​ไม่มี key done เลย serde จะ​เติม​ค่า Default (คือ false) ให้​แทนที่​จะ error — สำนวน​นี้​ทำให้​คุณ​เพิ่ม field ใหม่​ใน​อนาคต​ได้​โดย​ไม่​ทำ file เดิม​พัง

จุด​ที่​คน​ไทย​ควร​สบายใจ: serde ทำงาน​ระดับ byte และ JSON เป็น UTF-8 — ข้อความ "เขียนโค้ด" เขียน​ลง file แล้ว​อ่าน​กลับ​มา​ได้ String เท่า​กัน​เป๊ะ​ทุก byte ไม่มี​การ​แปลง encoding ซ่อน​อยู่ (ต่าง​จาก​บาง library ที่ escape เป็น \uXXXX) เทียบ​กับ System.Text.Json ฝั่ง .NET แนวคิด​ใกล้​กัน​มาก — ยกเว้น serde สร้าง code (de)serialize ตอน compile ไม่ใช่ reflection ตอน runtime จึง​ไม่มี​ต้นทุน warm-up ต่อ​ชนิด และ​ปิด derive เป็น​ค่า​ปริยาย​เช่น​เดียว​กับ clap (ต้อง features = ["derive"])

เรา​เอา​กล้าม​เนื้อ thiserror จาก​บท4 มา​ใช้​ซ้ำ สร้าง error type เดียว​ที่​ครอบ​ทุก​ทาง​ล้มเหลว​ของ taskcli:

use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TaskError {
#[error("อ่าน/เขียน file {path} ไม่ได้")]
Io { path: PathBuf, #[source] source: std::io::Error },
#[error("file ข้อมูลไม่ใช่ JSON ที่ถูกต้อง")]
Parse(#[from] serde_json::Error),
#[error("ไม่พบ task id {0}")]
NotFound(u32),
}

สังเกต​สอง​สำนวน: #[from] serde_json::Error สร้าง From ให้​อัตโนมัติ เพื่อ​ให้ ? แปลง error ของ serde_json เป็น TaskError::Parse ให้​เอง (นี่​คือ​กลไก From ที่​เรา​แกะ​ใน​บท6) ส่วน Io ใช้ #[source] แทน #[from] เพราะ​เรา​อยาก​แนบ path เข้าไป​ใน​ข้อความ​ด้วย — จึง​ต้อง​ประกอบ variant เอง ไม่ใช่​ให้ ? แปลงตรงๆ

Rust มี modulemoduleต้นไม้​เนมสเปซ​ตอน compile ภายใน crate; ไม่มี​อะไร public จนกว่า​จะ​ใส่ `pub` เป็นต้นไม้ namespace ตอน compile ภายใน crate เดียว — และ​กฎ​สำคัญ​ที่​ทำให้​วิศวกร C# สะดุด: ไม่มี​อะไร public จนกว่า​จะ​ใส่ pub (ต่าง​จาก C# ที่ public ผูก​กับ namespace หลวมๆ) สำนวน​มาตรฐาน​ของ CLI คือ​แยก​เป็น library crate (lib.rs) ที่​เก็บ​ตรรกะ​ทั้งหมด กับ binary crate (main.rs) ที่​บาง​เฉียบ ทำ​หน้าที่​แค่ parse args แล้ว​เรียก library

// src/lib.rs — ราก ของ library crate
pub mod error;
pub mod store;
pub mod task;
pub use error::TaskError;
pub use store::Store;
pub use task::Task;
// src/store.rs เข้าถึง module พี่น้องผ่าน `crate::`
use crate::{error::TaskError, task::Task};
// src/main.rs พึ่ง library ด้วยชื่อ crate
use taskcli::Store;

ทำไม​ต้อง​แยก? ไม่ใช่​แค่​ความ​สวยงาม — มัน​คือ​สิ่ง​ที่​ทำให้​ตรรกะ test ได้​จาก​ภายนอก เพราะ integration test ใน tests/ จะ​เห็น​เฉพาะ public API ของ library เท่านั้น ถ้า​เรา​อัด​ทุก​อย่าง​ไว้​ใน main.rs (binary crate ล้วน) integration test จะ​เข้าถึง​มัน​ไม่​ได้​เลย เทียบ​กับ .NET: crate (lib หรือ bin) ≈ assembly/project หนึ่ง​ตัว ไม่ใช่ namespace — และ module คือ namespace ภายใน crate นั้น

flowchart TB
  MAIN["src/main.rs<br/>binary crate (เปลือกบาง)<br/>parse args → เรียก library"]
  LIB["src/lib.rs<br/>library crate (ราก)<br/>pub use TaskError, Store, Task"]
  TASK["task<br/>struct Task + serde"]
  STORE["store<br/>struct Store: load/add/save"]
  ERR["error<br/>enum TaskError (thiserror)"]
  IT["tests/integration.rs<br/>เห็นเฉพาะ public API"]

  MAIN -->|"use taskcli::Store"| LIB
  LIB --> TASK
  LIB --> STORE
  LIB --> ERR
  IT -.->|"public API เท่านั้น"| LIB

  classDef bin fill:#fde68a,stroke:#92400e,color:#451a03;
  classDef lib fill:#fed7aa,stroke:#7c2d12,color:#431407;
  classDef mod fill:#fef3c7,stroke:#a16207,color:#422006;
  classDef test fill:#e0e7ff,stroke:#3730a3,color:#1e1b4b;
  class MAIN bin;
  class LIB lib;
  class TASK,STORE,ERR mod;
  class IT test;

คำ​บรรยาย​ภาพ: โครงสร้าง crate ของ taskclimain.rs เป็น binary crate เปลือก​บาง​ที่ use taskcli::Store จาก library crate (lib.rs) ซึ่ง​รวม module task/store/error ไว้; tests/integration.rs เข้าถึง​ได้​เฉพาะ public API ของ library จึง​ต้อง​แยก lib ออก​จาก bin ไม่​งั้น test จาก​ภายนอก​แตะ​ตรรกะ​ไม่​ได้

Rust มี test ใน​ตัว ไม่​ต้อง​ลง framework เสริม แบ่ง​เป็น​สอง​ที่​ตาม​ระดับ​การ​เข้าถึง: unit test อยู่​ใน file เดียว​กับ code ใน #[cfg(test)] mod tests (เห็น private item ได้) และ integration test อยู่​ใน folder tests/ ระดับ​บน​สุด (เห็น​แค่ public API — เหมือน​ผู้​ใช้ library จริง)

// unit test ใน file เดียวกับ code — เห็น private item
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_task_is_not_done() {
let t = Task::new(1, "เขียน code".into());
assert_eq!(t.id, 1);
assert!(!t.done);
}
}
// tests/integration.rs — public API เท่านั้น
use taskcli::Store;
#[test]
fn add_then_reload_persists_tasks() {
let file = std::env::temp_dir().join("taskcli-it.json");
{ let mut s = Store::load(&file).unwrap(); s.add("ซื้อของ"); s.save().unwrap(); }
assert_eq!(Store::load(&file).unwrap().tasks().len(), 1);
std::fs::remove_file(&file).ok();
}
// ตรวจ spec clap เอง
#[cfg(test)]
mod cli_tests {
use clap::{CommandFactory, Parser};
use super::Cli;
#[test] fn cli_definition_is_valid() { Cli::command().debug_assert(); }
#[test] fn parses_add() {
let cli = Cli::parse_from(["taskcli", "add", "ทดสอบ"]);
assert!(matches!(cli.command, super::Command::Add { .. }));
}
}

สอง​สำนวน​ที่​ต้อง​จำ: Cli::command().debug_assert() เป็น test ที่​จับ spec clap ที่​เขียน​ผิด (เช่น subcommand ชื่อ​ซ้ำ, short flag ชน​กัน) ตั้งแต่​ตอน test ไม่ใช่​ตอน​ผู้​ใช้​รัน​จริง; และ Cli::parse_from([...]) ให้​เรา test ตัว parser โดย​ป้อน argument ปลอม​เข้าไป ไม่​ต้อง​พึ่ง argv จริง เทียบ​กับ xUnit ฝั่ง .NET ที่ test project แยก​ออก​ไป​ต่างหาก — Rust วาง unit test ไว้ ใน file เดียวกัน และ​แตะ private member ได้ ส่วน integration test แยก​ออก​ไป​เหมือน xUnit

CLI ที่​ดี​ต้อง​คืน exit code ให้ shell/CI อ่าน​ได้ taskcli แยก​สาม​ค่า​อย่าง​จงใจ:

  • 0 (ExitCode::SUCCESS) — สำเร็จ
  • 1 (ExitCode::FAILURE) — error ใน domain ของ​เรา (file อ่าน​ไม่​ได้, JSON เสีย, ไม่​พบ task id) เรา​จับ​ใน match run(cli) แล้ว eprintln!("error: {e}") เป็น​รูปแบบ Display (ไม่ใช่ Debug) ให้​ผู้​ใช้​อ่าน​รู้เรื่อง
  • 2 — usage error (คำ​สั่ง​ผิด, argument ขาด) ซึ่ง clap คืนให้​เอง​อัตโนมัติ เมื่อ parse ไม่​ผ่าน
อย่า​เหมา​ว่า 'ล้มเหลว = exit 1' เสมอ

มี​กับดัก​สอง​ข้อ: หนึ่ง — ถ้า​คุณ​ให้ main คืน Result แทน ExitCode แล้ว​ปล่อย error หลุด​ออก​ไป Rust จะ​พิมพ์ error เป็น​รูปแบบ Debug (มี { ... } รกตา) แล้ว exit 1 เสมอ เรา​จึง​เลือก​คืน ExitCode เอง​เพื่อ​คุม Display ให้​สวย; สอง — clap คืน exit 2 สำหรับ usage error ไม่ใช่ 1 ดังนั้น​อย่า​เขียน test ที่ assert ว่า “ทุก​ความ​ล้มเหลว == 1” เพราะ argument ผิด​จะ​ได้ 2 ต่างหาก

API เก่า​ของ clap ที่​ห้าม​ใช้

ถ้า​ไป​เจอ​บทความ​เก่า​ที่​สอน clap แบบ builder — App::new, Arg::with_name, .takes_value(true), .get_matches() — หรือ crate structopt (ถูก​ยุบ​รวม​เข้า clap ตั้งแต่ version 3 แล้ว) นั่น​คือ​ของ​ยุค clap 2/3 ที่​ล้าสมัย คอร์ส​นี้​ใช้ clap 4 แบบ derive (#[command]/#[arg]) ล้วน อย่า​ปน​สำนวน​เก่า​เข้า​มา

นี่​คือ Cargo.toml เต็ม​ของ taskcli และ​เป็น file ที่ ไว​ต่อ version ที่สุด ใน​คอร์ส — patch เลื่อน​ได้​ทุก​สัปดาห์ ตัวเลข exact ด้าน​ล่าง​จึง​ลง​วัน​ที่​กำกับ (as-of 2026-07-21) ใน​ทาง​ปฏิบัติ​ให้ pin เป็น caret req ("4", "1", "2") แล้ว​ปล่อย Cargo.lock ตรึง patch ให้​เอง:

[package]
name = "taskcli"
version = "0.1.0"
edition = "2024"
[dependencies]
clap = { version = "4.6.4", features = ["derive"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
thiserror = "2.0.19"
anyhow = "1.0.104" # optional: error ระดับ application/main

สอง​จุด​ที่​พลาด​บ่อย: thiserror ต้อง pin "2" (major ปัจจุบัน​คือ 2.x — code หน้าตา​เหมือน 1.x ทุก​อย่าง ต่าง​แค่​บรรทัด version) และ​ทั้ง clap/serde ต้อง​เปิด features = ["derive"] ไม่​งั้น macro ไม่​ถูก compile เข้า​มา ส่วน anyhow เป็น optional สำหรับ error ระดับ application เท่านั้น (อย่า​ใช้​ใน public API ของ library เพราะ​มัน​ลบ​ชนิด error ทิ้ง) เลข patch เป๊ะ​ข้าง​บน (clap 4.6.4, serde 1.0.229, serde_json 1.0.151, thiserror 2.0.19, anyhow 1.0.104) คือ​ชุด​ที่ resolve ได้ ณ 2026-07 — patch ขยับ​ได้​ตลอด ใน​ทาง​ปฏิบัติ​เขียน​แค่ caret req "4"/"1"/"2" ก็พอ แล้ว​ปล่อย​ให้ Cargo.lock ตรึง patch จริง​ให้ (เช็ก version ล่าสุด​ได้ที่ lib.rs หรือ docs.rs)

บท​นี้​ประกอบ taskcli ให้​เสร็จ​เป็น​ตัว​จริง: เริ่ม​จาก version ดิบ​ที่ parse std::env::args() เอง​ด้วย index แล้ว​ยก​ระดับ​เป็น clap derive ที่ struct คือ arg spec (#[derive(Parser)] + #[derive(Subcommand)], ต้อง​เปิด features = ["derive"]); ใช้ serde/serde_json round-trip ข้อมูล​ภาษา​ไทย​แบบ byte เท่า​กัน​เป๊ะ (พร้อม #[serde(default)] กัน file เก่า​พัง); นำ thiserror จาก​บท4 มาสร้าง TaskError เดียว; แยก lib.rs (ตรรกะ) กับ main.rs (เปลือก​บาง) เพื่อ​ให้ integration test เข้าถึง public API ได้ — โดย​จำ​ว่า​ไม่มี​อะไร public จนกว่า​จะ​ใส่ pub; เขียน cargo test ทั้ง unit (เห็น private), integration (tests/, public เท่านั้น) และ Cli::command().debug_assert() ตรวจ spec clap; และ​ให้ main คืน ExitCode เพื่อ​คุม exit code 0/1/2 พร้อม​พิมพ์ error เป็น Display

บท​หน้า​เป็น​บท​ปิด​คอร์ส​และ capstone: fearless concurrency — เรา​จะ​เติม​การ​ประมวล​ผล​แบบ​ขนาน​ให้ taskcli ด้วย std::thread, Arc<Mutex<T>>, mpsc channel และ scoped threads แล้ว​เห็น​ว่า​ทำไม data race ถึง​กลาย​เป็น compile error ใน​โลก​ของ Rust — ปิด​วงจร “compiler เป็น​ครู” ที่​เรา​เปิด​ไว้​ตั้งแต่​บท1


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 7

ข้อ 1 / 3

ทำไม `taskcli` ถึงแยก code เป็น `src/lib.rs` (library crate) กับ `src/main.rs` (binary crate เปลือกบาง) แทนที่จะอัดทุกอย่างไว้ใน main.rs?