Bỏ qua để đến nội dung

Dự án: công cụ dòng lệnh (CLI) với clap trong Rust

Ở trang Đối số dòng lệnh, bạn đã tự tay đọc đối số bằng std::env::args(). Cách đó ổn với một hai đối số. Nhưng công cụ thật cần các tùy chọn như --precision 3, dạng viết tắt như -p 3, một trang --help, thông báo lỗi tốt, và các lệnh con (subcommand) như git commit và git push. Tự viết tất cả những thứ đó là rất nhiều code nhàm chán và dễ sai.

clap (tiếng Anh, Command Line Argument Parser) là crate mà gần như mọi công cụ CLI viết bằng Rust dùng cho việc này. Bạn mô tả các đối số của mình bằng một struct Rust bình thường, và clap viết giúp bạn bộ phân tích (parser), phần trợ giúp và các thông báo lỗi. Trong dự án này, bạn sẽ xây convert, một công cụ đổi đơn vị nhỏ với hai lệnh con: temp cho nhiệt độ và length cho chiều dài.

Code và kết quả trên trang này giữ nguyên tiếng Anh, vì các chú thích /// trở thành chính trang --help mà bạn sẽ thấy bên dưới. Chỉ các chú thích // thường được dịch.

Đây là công cụ hoàn chỉnh trong terminal. Mọi kết quả trên trang này đều đến từ việc chạy chương trình thật.

Terminal window
$ cargo run -q -- temp 100 --from celsius --to fahrenheit
100 °C = 212.00 °F
$ cargo run -q -- temp -40 -f c -t f
-40 °C = -40.00 °F
$ cargo run -q -- length 5 km mi
5 km = 3.11 mi
$ cargo run -q -- -p 0 length 1 mi ft
1 mi = 5280 ft
$ cargo run -q -- temp 20 --from rankine --to c
error: invalid value 'rankine' for '--from <FROM>'
[possible values: celsius, fahrenheit, kelvin]
For more information, try '--help'.

Mọi thứ sau -- được gửi cho chương trình của bạn thay vì cho Cargo, còn -q (quiet, im lặng) ẩn các dòng “Compiling…” của chính Cargo. Còn thông báo lỗi cuối cùng (giá trị rankine không hợp lệ cho --from, kèm danh sách giá trị được phép)? Ta không hề viết nó. clap viết đấy.

Terminal window
cargo new convert
cd convert
cargo add clap --features derive

Feature derive là phần quan trọng. Nó bật #[derive(Parser)], cách dùng clap thân thiện nhất. Giờ Cargo.toml của bạn chứa:

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

(Số phiên bản của bạn có thể mới hơn một chút. Bất kỳ phiên bản nào bắt đầu bằng 4 đều dùng được cho trang này. Xem Cargo và crate nếu cargo add còn mới với bạn.)

Hãy bắt đầu với một công cụ chỉ đổi độ C sang độ F. Bạn viết một struct với mỗi field là một đối số và đặt #[derive(Parser)] lên nó. Thay src/main.rs bằng:

use clap::Parser;
/// Convert a temperature from Celsius to Fahrenheit
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// The temperature in Celsius
celsius: f64,
/// How many digits to show after the decimal point
#[arg(short, long, default_value_t = 2)]
precision: usize,
/// Also print the formula that was used
#[arg(short, long)]
verbose: bool,
}
fn main() {
let cli = Cli::parse();
let fahrenheit = cli.celsius * 9.0 / 5.0 + 32.0;
println!("{} °C = {:.*} °F", cli.celsius, cli.precision, fahrenheit);
if cli.verbose {
println!("(used °F = °C × 9/5 + 32)");
}
}
Terminal window
$ cargo run -q -- 21.5
21.5 °C = 70.70 °F
$ cargo run -q -- 100 --precision 1 --verbose
100 °C = 212.0 °F
(used °F = °C × 9/5 + 32)

clap đọc từng field và quyết định đó là loại đối số gì:

  • celsius không có tùy chọn #[arg(...)] nào, nên nó là một đối số vị trí (positional argument): giá trị trơn đầu tiên bạn gõ. Vì kiểu của nó là f64, clap còn parse chữ "21.5" thành số giúp bạn.
  • #[arg(short, long)] tạo một tùy chọn (option) với tên ngắn và tên dài lấy từ field: -p / --precision. default_value_t = 2 được dùng khi bạn bỏ trống nó.
  • Một field bool trở thành một cờ (flag): nó là true nếu có --verbose (hoặc -v), false nếu không.
  • Một field Option<T> (không dùng ở đây) sẽ là một đối số bạn có thể bỏ hẳn.

{:.*} trong println! nghĩa là “dùng đối số tiếp theo làm số chữ số thập phân”. Đó là cách --precision đến được kết quả in ra.

Cli::parse() đọc dòng lệnh thật. Rust Playground không có dòng lệnh, nên để thử parser ở đó, hãy dùng Cli::parse_from với một danh sách đối số giả vờ. Phần tử đầu tiên luôn là tên chương trình, giống hệt std::env::args():

use clap::Parser;
/// Convert a temperature from Celsius to Fahrenheit
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// The temperature in Celsius
celsius: f64,
/// How many digits to show after the decimal point
#[arg(short, long, default_value_t = 2)]
precision: usize,
/// Also print the formula that was used
#[arg(short, long)]
verbose: bool,
}
fn main() {
// Giả vờ người dùng đã gõ: convert 100 --precision 1 --verbose
let cli = Cli::parse_from(["convert", "100", "--precision", "1", "--verbose"]);
println!("{cli:?}");
let fahrenheit = cli.celsius * 9.0 / 5.0 + 32.0;
println!("{} °C = {:.*} °F", cli.celsius, cli.precision, fahrenheit);
}

Kết quả:

Cli { celsius: 100.0, precision: 1, verbose: true }
100 °C = 212.0 °F
▶ Tự chạy thử

Hãy thử đổi các đối số giả vờ: bỏ "--verbose", hoặc đổi "--precision" thành "-p".

Bạn có để ý các dòng /// trong struct không? Đó là các doc comment (chú thích tài liệu), và clap biến chúng thành trang trợ giúp. Chú thích phía trên struct mô tả cả chương trình; chú thích phía trên mỗi field mô tả đối số đó. Bạn có --help (và -h) mà không phải viết thêm gì:

Terminal window
$ cargo run -q -- --help
Convert a temperature from Celsius to Fahrenheit
Usage: convert [OPTIONS] <CELSIUS>
Arguments:
<CELSIUS> The temperature in Celsius
Options:
-p, --precision <PRECISION> How many digits to show after the decimal point [default: 2]
-v, --verbose Also print the formula that was used
-h, --help Print help
-V, --version Print version
$ cargo run -q -- --version
convert 1.0

Dòng #[command(name = "convert", version = "1.0")] đặt tên và phiên bản hiển thị ở đây. Nếu bạn chỉ viết #[command(version)], clap sẽ lấy phiên bản từ Cargo.toml của bạn.

Các dòng /// của bạn có thể viết bằng tiếng Việt; khi đó trang --help cũng hiện tiếng Việt. Ở đây chúng được giữ tiếng Anh để khớp với kết quả thật ở trên. clap còn giải thích các lỗi giúp bạn:

Terminal window
$ cargo run -q --
error: the following required arguments were not provided:
<CELSIUS>
Usage: convert <CELSIUS>
For more information, try '--help'.
$ cargo run -q -- abc
error: invalid value 'abc' for '<CELSIUS>': invalid float literal
For more information, try '--help'.

Lỗi đầu tiên báo thiếu đối số bắt buộc <CELSIUS>; lỗi thứ hai báo abc không phải là một số thực hợp lệ. Trang trợ giúp tốt là món quà cho chính bạn trong tương lai. Hãy viết doc comment như thể có ai đó đang thấy công cụ của bạn lần đầu, vì một ngày nào đó, người đó sẽ là bạn.

Giờ hãy hỗ trợ cả chiều dài. Những công cụ như git và cargo dùng lệnh con (cargo build, cargo test) để nhóm các công việc khác nhau. Trong clap, lệnh con là một enum có #[derive(Subcommand)]. Mỗi biến thể là một lệnh con, và các field của nó là đối số của lệnh con đó:

use clap::{Parser, Subcommand};
/// A small unit converter for temperatures and lengths
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// How many digits to show after the decimal point
#[arg(short, long, default_value_t = 2, global = true)]
precision: usize,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Convert a temperature
Temp {
/// The temperature to convert
value: f64,
/// The unit to convert from
#[arg(short, long)]
from: String,
/// The unit to convert to
#[arg(short, long)]
to: String,
},
/// Convert a length
Length {
/// The length to convert
value: f64,
/// The unit to convert from
from: String,
/// The unit to convert to
to: String,
},
}
fn main() {
let cli = Cli::parse_from(["convert", "temp", "100", "--from", "c", "--to", "f"]);
println!("{cli:?}");
let cli = Cli::parse_from(["convert", "length", "5", "km", "mi", "-p", "1"]);
println!("{cli:?}");
// Bạn biết lệnh con nào được dùng bằng một match bình thường.
match cli.command {
Command::Temp { value, from, to } => println!("temp: {value} {from} -> {to}"),
Command::Length { value, from, to } => println!("length: {value} {from} -> {to}"),
}
}

Kết quả:

Cli { precision: 2, command: Temp { value: 100.0, from: "c", to: "f" } }
Cli { precision: 1, command: Length { value: 5.0, from: "km", to: "mi" } }
length: 5 km -> mi
▶ Tự chạy thử

Vài chi tiết:

  • #[command(subcommand)] trên field command nói với clap rằng “field này chứa lệnh con được chọn”.
  • Tên biến thể Temp trở thành lệnh con temp (clap tự đổi thành chữ thường giúp bạn).
  • temp dùng các tùy chọn có tên (--from c --to f) còn length dùng đối số vị trí (5 km mi). Cả hai kiểu đều phổ biến; bạn được thấy mỗi kiểu một lần.
  • global = true cho phép --precision xuất hiện trước hoặc sau lệnh con. Thiếu nó, -p 1 chỉ được chấp nhận khi đứng trước length.

Trang trợ giúp giờ liệt kê các lệnh con, và mỗi lệnh con có trang trợ giúp riêng:

Terminal window
$ cargo run -q -- --help
A small unit converter for temperatures and lengths
Usage: convert [OPTIONS] <COMMAND>
Commands:
temp Convert a temperature
length Convert a length
help Print this message or the help of the given subcommand(s)
Options:
-p, --precision <PRECISION> How many digits to show after the decimal point [default: 2]
-h, --help Print help
-V, --version Print version
$ cargo run -q -- temp --help
Convert a temperature
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments:
<VALUE> The temperature to convert
Options:
-f, --from <FROM> The unit to convert from
-p, --precision <PRECISION> How many digits to show after the decimal point [default: 2]
-t, --to <TO> The unit to convert to
-h, --help Print help

Nhưng có một vấn đề. Các đơn vị chỉ là String trơn, nên clap chấp nhận bất cứ thứ gì:

Terminal window
$ cargo run -q -- temp 20 --from rankine --to c
Cli { precision: 2, command: Temp { value: 20.0, from: "rankine", to: "c" } }

Ta sẽ phải tự kiểm tra từng đơn vị về sau. Hãy để clap làm việc đó.

Bước 5: Enum và kiểm tra dữ liệu với ValueEnum

Phần tiêu đề “Bước 5: Enum và kiểm tra dữ liệu với ValueEnum”

Khi một đối số chỉ có thể là một trong vài từ, hãy mô tả các từ đó bằng một enum và derive ValueEnum. Khi đó clap chỉ chấp nhận những từ đó, liệt kê chúng trong trang trợ giúp, và từ chối mọi thứ khác. Các enum này thay cho các field String ở Bước 4:

#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
enum TempUnit {
#[value(alias = "c")]
Celsius,
#[value(alias = "f")]
Fahrenheit,
#[value(alias = "k")]
Kelvin,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
enum LengthUnit {
Mm,
Cm,
M,
Km,
In,
Ft,
Mi,
}
  • Mỗi biến thể trở thành một từ viết thường: Celsius → celsius, Km → km.
  • #[value(alias = "c")] còn chấp nhận dạng ngắn c, mà không hiện nó trong trang trợ giúp.
  • clap cần Clone. Ta thêm cả Copy, vì các enum nhỏ này sao chép rất rẻ và điều đó giúp match dễ hơn.

Trong enum Command, đổi from: String thành from: TempUnit (và LengthUnit cho chiều dài). Ta cũng thêm hai quy tắc nữa:

/// How many digits to show after the decimal point
#[arg(short, long, default_value_t = 2, global = true,
value_parser = clap::value_parser!(u8).range(0..=6))]
precision: u8,
/// The temperature to convert
#[arg(allow_negative_numbers = true)]
value: f64,
  • value_parser!(u8).range(0..=6) chỉ chấp nhận độ chính xác từ 0 đến 6. Nhiều hơn thế chỉ in ra những chữ số vô nghĩa.
  • allow_negative_numbers = true cho phép bạn gõ -40. Thiếu nó, clap nghĩ -40 là một tùy chọn ngắn tên -4 (xem Lỗi thường gặp). Nhiệt độ có thể âm, nên ta cho phép; chiều dài thì không, nên ta không cho.

Giờ clap bắt được dữ liệu nhập sai trước khi code của bạn kịp chạy:

Terminal window
$ cargo run -q -- temp 20 --from rankine --to c
error: invalid value 'rankine' for '--from <FROM>'
[possible values: celsius, fahrenheit, kelvin]
For more information, try '--help'.
$ cargo run -q -- length 3 yd m
error: invalid value 'yd' for '<FROM>'
[possible values: mm, cm, m, km, in, ft, mi]
For more information, try '--help'.
$ cargo run -q -- -p 9 length 1 m cm
error: invalid value '9' for '--precision <PRECISION>': 9 is not in 0..=6
For more information, try '--help'.

Và trang trợ giúp giờ hiện các giá trị được phép:

Terminal window
$ cargo run -q -- temp --help
Convert a temperature
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments:
<VALUE> The temperature to convert
Options:
-f, --from <FROM> The unit to convert from [possible values: celsius, fahrenheit, kelvin]
-p, --precision <PRECISION> How many digits to show after the decimal point [default: 2]
-t, --to <TO> The unit to convert to [possible values: celsius, fahrenheit, kelvin]
-h, --help Print help

Đây là ý tưởng lớn của clap: khi parse thành công, dữ liệu của bạn đã hợp lệ sẵn. from là một TempUnit thật, không phải một chuỗi có thể là bất cứ thứ gì. Phần còn lại của chương trình có thể tin tưởng nó.

Có những lỗi clap không bắt được, vì chúng phụ thuộc vào ý nghĩa của giá trị. -500 °C là một con số hoàn toàn hợp lệ, nhưng nó lạnh hơn độ không tuyệt đối (-273.15 °C), nên không phải là một nhiệt độ có thật. Việc kiểm tra đó thuộc về code của chính ta.

Một mẫu gọn gàng là đặt công việc thật vào một hàm run trả về Result, và để main quyết định in gì và trả về mã thoát (exit code) nào. Chương trình báo thành công hay thất bại cho shell bằng một con số: 0 nghĩa là thành công, số khác nghĩa là thất bại. std::process::ExitCode của Rust cho phép main trả về một mã như vậy:

fn main() -> ExitCode {
let cli = Cli::parse();
match run(&cli) {
Ok(line) => {
println!("{line}");
ExitCode::SUCCESS // mã thoát 0
}
Err(message) => {
eprintln!("error: {message}"); // lỗi được ghi ra stderr
ExitCode::FAILURE // mã thoát 1
}
}
}

clap theo đúng những quy tắc đó. Khi parse thất bại, Cli::parse() in lỗi ra stderr và thoát với mã 2, mã thông thường cho “bạn dùng lệnh sai cách”. --help và --version thoát với mã 0. Trong terminal, echo $? hiện mã thoát gần nhất:

Terminal window
$ cargo run -q -- temp -500 -f c -t k
error: -500 °C is colder than absolute zero
$ echo $?
1
$ cargo run -q -- length 3 yd m
error: invalid value 'yd' for '<FROM>'
[possible values: mm, cm, m, km, in, ft, mi]
For more information, try '--help'.
$ echo $?
2

Vì sao điều này quan trọng? Script và các chương trình khác kiểm tra mã thoát để biết công cụ của bạn có chạy thành công không. Trang Biến môi trường và tiến trình nói chi tiết hơn về mã thoát và stderr. Với công cụ lớn hơn, có lẽ bạn sẽ dùng anyhow làm kiểu lỗi cho run thay vì String, để có ngữ cảnh và chuỗi lỗi miễn phí.

Đây là mọi thứ ghép lại. Thay src/main.rs bằng đoạn này và thử các lệnh ở đầu trang.

use clap::{Parser, Subcommand, ValueEnum};
use std::process::ExitCode;
/// A small unit converter for temperatures and lengths
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// How many digits to show after the decimal point
#[arg(short, long, default_value_t = 2, global = true,
value_parser = clap::value_parser!(u8).range(0..=6))]
precision: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Convert a temperature
Temp {
/// The temperature to convert
#[arg(allow_negative_numbers = true)]
value: f64,
/// The unit to convert from
#[arg(short, long)]
from: TempUnit,
/// The unit to convert to
#[arg(short, long)]
to: TempUnit,
},
/// Convert a length
Length {
/// The length to convert
value: f64,
/// The unit to convert from
from: LengthUnit,
/// The unit to convert to
to: LengthUnit,
},
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
enum TempUnit {
#[value(alias = "c")]
Celsius,
#[value(alias = "f")]
Fahrenheit,
#[value(alias = "k")]
Kelvin,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
enum LengthUnit {
Mm,
Cm,
M,
Km,
In,
Ft,
Mi,
}
impl TempUnit {
fn symbol(self) -> &'static str {
match self {
TempUnit::Celsius => "°C",
TempUnit::Fahrenheit => "°F",
TempUnit::Kelvin => "K",
}
}
}
impl LengthUnit {
// Một đơn vị này bằng bao nhiêu mét.
fn meters(self) -> f64 {
match self {
LengthUnit::Mm => 0.001,
LengthUnit::Cm => 0.01,
LengthUnit::M => 1.0,
LengthUnit::Km => 1000.0,
LengthUnit::In => 0.0254,
LengthUnit::Ft => 0.3048,
LengthUnit::Mi => 1609.344,
}
}
fn name(self) -> &'static str {
match self {
LengthUnit::Mm => "mm",
LengthUnit::Cm => "cm",
LengthUnit::M => "m",
LengthUnit::Km => "km",
LengthUnit::In => "in",
LengthUnit::Ft => "ft",
LengthUnit::Mi => "mi",
}
}
}
fn convert_temp(value: f64, from: TempUnit, to: TempUnit) -> Result<f64, String> {
// Bước 1: đổi giá trị nhận được, dù là gì, sang độ C.
let celsius = match from {
TempUnit::Celsius => value,
TempUnit::Fahrenheit => (value - 32.0) * 5.0 / 9.0,
TempUnit::Kelvin => value - 273.15,
};
if celsius < -273.15 {
return Err(format!("{value} {} is colder than absolute zero", from.symbol()));
}
// Bước 2: đổi độ C sang đơn vị ta muốn.
Ok(match to {
TempUnit::Celsius => celsius,
TempUnit::Fahrenheit => celsius * 9.0 / 5.0 + 32.0,
TempUnit::Kelvin => celsius + 273.15,
})
}
fn convert_length(value: f64, from: LengthUnit, to: LengthUnit) -> f64 {
value * from.meters() / to.meters()
}
fn run(cli: &Cli) -> Result<String, String> {
let p = cli.precision as usize;
match cli.command {
Command::Temp { value, from, to } => {
let result = convert_temp(value, from, to)?;
Ok(format!("{value} {} = {result:.p$} {}", from.symbol(), to.symbol()))
}
Command::Length { value, from, to } => {
let result = convert_length(value, from, to);
Ok(format!("{value} {} = {result:.p$} {}", from.name(), to.name()))
}
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(&cli) {
Ok(line) => {
println!("{line}");
ExitCode::SUCCESS
}
Err(message) => {
eprintln!("error: {message}");
ExitCode::FAILURE
}
}
}

Hai mẹo nhỏ trong run: {result:.p$} nghĩa là “in result với p chữ số thập phân”, còn dấu ? sau convert_temp(...) trả về sớm kèm lỗi nếu nhiệt độ không thể có thật.

Cli::parse() thoát chương trình khi có gì sai. Đó là điều bạn muốn cho người dùng, nhưng không phải trong một bài test. Cli::try_parse_from(...) làm cùng việc parse trên một danh sách đối số do bạn chọn, và trả về một Result thay vì thoát. Điều đó khiến nó hoàn hảo để kiểm tra parser, và nó chạy được trên Playground:

use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
/// A small unit converter for temperatures and lengths
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// How many digits to show after the decimal point
#[arg(short, long, default_value_t = 2, global = true,
value_parser = clap::value_parser!(u8).range(0..=6))]
precision: u8,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Convert a temperature
Temp {
/// The temperature to convert
#[arg(allow_negative_numbers = true)]
value: f64,
/// The unit to convert from
#[arg(short, long)]
from: TempUnit,
/// The unit to convert to
#[arg(short, long)]
to: TempUnit,
},
/// Convert a length
Length {
/// The length to convert
value: f64,
/// The unit to convert from
from: LengthUnit,
/// The unit to convert to
to: LengthUnit,
},
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
enum TempUnit {
#[value(alias = "c")]
Celsius,
#[value(alias = "f")]
Fahrenheit,
#[value(alias = "k")]
Kelvin,
}
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq)]
enum LengthUnit {
Mm,
Cm,
M,
Km,
In,
Ft,
Mi,
}
fn check(line: &str) {
println!("$ {line}");
// split_whitespace cho ta các đối số, tên chương trình đứng đầu.
match Cli::try_parse_from(line.split_whitespace()) {
Ok(cli) => println!(" ok: {cli:?}"),
Err(err) => {
// Chỉ hiện dòng đầu tiên trong thông báo của clap.
let first_line = err.to_string().lines().next().unwrap_or("").to_string();
println!(" {:?}: {first_line}", err.kind());
}
}
}
fn main() {
// Panic nếu chính parser được thiết lập sai (ví dụ, hai
// tùy chọn trùng tên ngắn). Một khoản bảo hiểm rẻ.
Cli::command().debug_assert();
check("convert temp -40 -f c -t fahrenheit");
check("convert length 5 km mi -p 1");
check("convert temp 20 --from rankine --to c");
check("convert -p 9 length 1 m cm");
check("convert temp 10 --to c");
check("convert --version");
}

Kết quả:

$ convert temp -40 -f c -t fahrenheit
ok: Cli { precision: 2, command: Temp { value: -40.0, from: Celsius, to: Fahrenheit } }
$ convert length 5 km mi -p 1
ok: Cli { precision: 1, command: Length { value: 5.0, from: Km, to: Mi } }
$ convert temp 20 --from rankine --to c
InvalidValue: error: invalid value 'rankine' for '--from <FROM>'
$ convert -p 9 length 1 m cm
ValueValidation: error: invalid value '9' for '--precision <PRECISION>': 9 is not in 0..=6
$ convert temp 10 --to c
MissingRequiredArgument: error: the following required arguments were not provided:
$ convert --version
DisplayVersion: convert 1.0
▶ Tự chạy thử

Để ý rằng --version cũng trả về một Err. Với clap, “hiện phiên bản rồi dừng” không phải là một lần parse thành công, nên nó được báo với loại DisplayVersion (và --help với DisplayHelp). Cli::parse() lo những trường hợp đó giúp bạn và thoát với mã 0.

Trong project thật, các phép kiểm tra tương tự được đưa vào bài test. Thêm đoạn này vào cuối src/main.rs:

#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_is_set_up_correctly() {
Cli::command().debug_assert();
}
#[test]
fn parses_a_temperature() {
let cli = Cli::try_parse_from(["convert", "temp", "-40", "-f", "c", "-t", "f"]).unwrap();
assert_eq!(cli.precision, 2);
match cli.command {
Command::Temp { value, from, to } => {
assert_eq!(value, -40.0);
assert_eq!(from, TempUnit::Celsius);
assert_eq!(to, TempUnit::Fahrenheit);
}
other => panic!("expected temp, got {other:?}"),
}
}
#[test]
fn rejects_an_unknown_unit() {
let result = Cli::try_parse_from(["convert", "length", "3", "yd", "m"]);
let err = result.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
}
#[test]
fn minus_forty_is_the_same_in_both() {
let f = convert_temp(-40.0, TempUnit::Celsius, TempUnit::Fahrenheit).unwrap();
assert_eq!(f, -40.0);
}
}
Terminal window
$ cargo test -q
running 4 tests
....
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Quên feature derive. Nếu bạn viết cargo add clap mà không có --features derive, các derive macro không tồn tại:

error: cannot find derive macro `Parser` in this scope
--> src/main.rs:4:10
|
4 | #[derive(Parser, Debug)]
| ^^^^^^
|
note: `Parser` is imported here, but it is only a trait, without a derive macro
--> src/main.rs:1:5
|
1 | use clap::Parser;
| ^^^^^^^^^^^^
error: cannot find attribute `command` in this scope

Trình biên dịch báo rằng nó không tìm thấy derive macro Parser: Parser đã được import, nhưng chỉ là một trait, không có derive macro đi kèm. Cách sửa: chạy cargo add clap --features derive, hoặc tự thêm features = ["derive"] vào Cargo.toml.

Số âm trông giống tùy chọn. Với chương trình ở Bước 2, hãy thử một ngày lạnh giá:

Terminal window
$ cargo run -q -- -40
error: unexpected argument '-4' found
tip: to pass '-4' as a value, use '-- -4'
Usage: convert [OPTIONS] <CELSIUS>
For more information, try '--help'.

clap thấy dấu - và nghĩ -4 là một tùy chọn ngắn (“unexpected argument”, đối số không mong đợi). Cách sửa: thêm #[arg(allow_negative_numbers = true)] vào field, như ta đã làm ở Bước 5. (Người dùng cũng có thể gõ -- -40; mọi thứ sau một dấu -- đứng riêng được coi là giá trị trơn.)

Gọi Cli::parse() trên Playground. Playground chạy chương trình của bạn mà không có đối số nào, nên parse() in ra một lỗi (hoặc, với công cụ có lệnh con, trang trợ giúp) và thoát với mã 2 trước khi code của bạn kịp làm gì. Ở đó, và trong test, hãy dùng Cli::parse_from([...]) hoặc Cli::try_parse_from([...]).

In lỗi bằng println!. Thông báo lỗi thuộc về stderr (eprintln!), không phải stdout. Nếu ai đó chạy convert ... > result.txt, lỗi vẫn hiện trên màn hình thay vì trốn trong file.

  • Thêm đơn vị. Thêm yard (yd) và hải lý vào LengthUnit. Trình biên dịch sẽ chỉ ra mọi match bạn cần cập nhật.
  • Một lệnh con weight cho kilogam, pound và ounce.
  • Một cờ --json in kết quả dưới dạng JSON bằng serde_json.
  • Đọc giá trị từ stdin khi không có giá trị nào được đưa vào, để bạn có thể đưa số vào qua pipe: echo 100 | convert temp -f c -t f. Một field Option<f64> cho bạn biết có giá trị nào được gõ hay không.
  • Lỗi tốt hơn với anyhow. Đổi run để trả về anyhow::Result<String> và dùng anyhow::bail!. Xem anyhow và thiserror.
  • Tự hoàn thành trong shell. Crate clap_complete (tiếng Anh) sinh script tự hoàn thành bằng phím Tab cho bash, zsh, fish và PowerShell từ struct Cli của bạn.
  • Thêm clap bằng cargo add clap --features derive, rồi đặt #[derive(Parser)] lên một struct. Mỗi field là một đối số.
  • Field trơn là đối số vị trí, #[arg(short, long)] tạo tùy chọn, bool tạo cờ, và default_value_t đặt giá trị mặc định. Doc comment /// trở thành nội dung --help.
  • #[derive(Subcommand)] trên một enum cho bạn các lệnh con; #[derive(ValueEnum)] giới hạn một đối số trong một tập từ cố định. value_parser!(...).range(...) kiểm tra khoảng giá trị số.
  • Sau khi parse thành công, dữ liệu đã hợp lệ sẵn. Đặt các phép kiểm tra còn lại vào một hàm run trả về Result, và trả về một ExitCode từ main.
  • clap thoát với mã 2 khi dùng sai lệnh và mã 0 với --help/--version. Lỗi của chính bạn cũng nên dùng mã khác 0, và in ra stderr.
  • Cli::try_parse_from parse một danh sách do bạn chọn và trả về một Result, hoàn hảo cho test và cho Playground.

Kiểm tra nhanh

1. Lệnh cargo add nào bật #[derive(Parser)]?

2. clap lấy mô tả của mỗi tùy chọn trong --help từ đâu?

3. Cli::try_parse_from(["convert", "--version"]) trả về gì?

4. Người dùng gõ một đơn vị không hợp lệ và Cli::parse() thất bại. clap dùng mã thoát nào?

5. Vì sao convert -40 thất bại trong chương trình ở Bước 2?

Điểm: 0 / 5