跳转到内容

用 Rust 和 clap 编写命令行工具

在命令行参数那一页,你用 std::env::args() 手动读取过参数。一两个参数这样做没问题。但真正的工具需要 --precision 3 这样的选项、-p 3 这样的简写、一个 --help 帮助页面、清楚的错误信息,还有像 git commit 和 git push 这样的子命令。这些全靠手写,代码又多又枯燥,还很容易出错。

clap(英文)(Command Line Argument Parser,命令行参数解析器)几乎是所有 Rust 命令行工具处理这件事时都会用的 crate。你只要把参数写成一个普通的 Rust 结构体,clap 就会帮你生成解析器、帮助文本和错误信息。在这个项目里,你会做一个叫 convert 的小小单位转换器,它有两个子命令:转换温度的 temp 和转换长度的 length。

下面是做好的工具在终端里的样子。本页所有的输出都来自真实运行的程序。

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

-- 后面的所有内容都会交给你的程序,而不是交给 Cargo;-q(quiet,安静模式)会隐藏 Cargo 自己打印的 “Compiling…” 这类信息。最后那条错误信息呢?不是我们写的,是 clap 写的。

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

关键在于 derive 这个 feature。它会启用 #[derive(Parser)],这是使用 clap 最友好的方式。现在你的 Cargo.toml 里有这样一段:

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

(你的版本号可能会更新一点。只要是 4 开头的版本,本页的内容都适用。如果你还没用过 cargo add,可以看看 Cargo 与 crate。)

先从一个只会把摄氏度转换成华氏度的工具开始。写一个结构体,每个参数对应一个字段,再在上面加上 #[derive(Parser)]。把 src/main.rs 替换成:

use clap::Parser;
/// 把摄氏温度转换成华氏温度
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// 摄氏温度
celsius: f64,
/// 小数点后显示几位
#[arg(short, long, default_value_t = 2)]
precision: usize,
/// 同时打印所用的公式
#[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!("(所用公式:°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
(所用公式:°F = °C × 9/5 + 32)

clap 会逐个读取字段,判断它是哪一种参数:

  • celsius 没有 #[arg(...)] 选项,所以它是一个位置参数(positional argument),也就是你输入的第一个普通值。因为它的类型是 f64,clap 还会顺手把文本 "21.5" 解析成数字。
  • #[arg(short, long)] 会生成一个选项,短名和长名都取自字段名:-p / --precision。省略这个选项时,就使用 default_value_t = 2 设定的默认值。
  • bool 字段会变成一个开关(flag):写了 --verbose(或 -v)就是 true,没写就是 false。
  • Option<T> 字段(这里没用到)表示一个可以完全不写的参数。

println! 里的 {:.*} 意思是“把下一个参数当作小数位数”。--precision 的值就是这样传到输出里的。

Cli::parse() 读取的是真实的命令行。Rust Playground 没有命令行,所以想在那里试用解析器,就要用 Cli::parse_from 加上一组假装输入的参数。第一项永远是程序名,这一点和 std::env::args() 一样:

use clap::Parser;
/// 把摄氏温度转换成华氏温度
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// 摄氏温度
celsius: f64,
/// 小数点后显示几位
#[arg(short, long, default_value_t = 2)]
precision: usize,
/// 同时打印所用的公式
#[arg(short, long)]
verbose: bool,
}
fn main() {
// 假装用户输入了: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);
}

输出:

Cli { celsius: 100.0, precision: 1, verbose: true }
100 °C = 212.0 °F
▶ 动手试一试

试着改一改这些假装的参数:去掉 "--verbose",或者把 "--precision" 换成 "-p"。

第 3 步:用文档注释生成帮助文本

Section titled “第 3 步:用文档注释生成帮助文本”

注意到结构体里那些 /// 开头的行了吗?它们是文档注释,clap 会把它们变成帮助页面。结构体上方的注释描述整个程序,每个字段上方的注释描述对应的参数。不用再多写任何代码,你就有了 --help(和 -h):

Terminal window
$ cargo run -q -- --help
把摄氏温度转换成华氏温度
Usage: convert [OPTIONS] <CELSIUS>
Arguments:
<CELSIUS> 摄氏温度
Options:
-p, --precision <PRECISION> 小数点后显示几位 [default: 2]
-v, --verbose 同时打印所用的公式
-h, --help Print help
-V, --version Print version
$ cargo run -q -- --version
convert 1.0

你自己写的说明会以中文显示,而 Usage:、Options:、Print help 这些是 clap 自带的文字,所以保持英文。

#[command(name = "convert", version = "1.0")] 这一行设置了这里显示的名称和版本号。如果只写 #[command(version)],clap 会改从你的 Cargo.toml 里读取版本号。

clap 还会帮你解释用法上的错误:

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

第一条的意思是“缺少必需的参数 <CELSIUS>”,第二条的意思是“'abc' 不是 <CELSIUS> 的有效值(无法解析成浮点数)”。

好的帮助文本是送给未来的自己的礼物。写文档注释时,就当作有人第一次看到你的工具——因为总有一天,那个人会是你自己。

现在来加上长度转换。git 和 cargo 这类工具用子命令(cargo build、cargo test)把不同的任务分开。在 clap 里,子命令就是一个带 #[derive(Subcommand)] 的枚举。每个变体是一个子命令,变体的字段就是这个子命令的参数:

use clap::{Parser, Subcommand};
/// 一个小巧的温度和长度单位转换器
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// 小数点后显示几位
#[arg(short, long, default_value_t = 2, global = true)]
precision: usize,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// 转换温度
Temp {
/// 要转换的温度
value: f64,
/// 原来的单位
#[arg(short, long)]
from: String,
/// 要转换成的单位
#[arg(short, long)]
to: String,
},
/// 转换长度
Length {
/// 要转换的长度
value: f64,
/// 原来的单位
from: String,
/// 要转换成的单位
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:?}");
// 用户用了哪个子命令,用普通的 match 就能知道。
match cli.command {
Command::Temp { value, from, to } => println!("temp: {value} {from} -> {to}"),
Command::Length { value, from, to } => println!("length: {value} {from} -> {to}"),
}
}

输出:

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
▶ 动手试一试

几个细节:

  • command 字段上的 #[command(subcommand)] 告诉 clap:“这个字段存放用户选中的子命令”。
  • 变体名 Temp 会变成子命令 temp(clap 会帮你转成小写)。
  • temp 用的是带名字的选项(--from c --to f),而 length 用的是位置参数(5 km mi)。这两种风格都很常见,这里两种都让你见识一下。
  • global = true 让 --precision 既可以写在子命令前面,也可以写在后面。没有它的话,-p 1 只能写在 length 前面。

现在帮助页面会列出所有子命令,而且每个子命令都有自己的帮助:

Terminal window
$ cargo run -q -- --help
一个小巧的温度和长度单位转换器
Usage: convert [OPTIONS] <COMMAND>
Commands:
temp 转换温度
length 转换长度
help Print this message or the help of the given subcommand(s)
Options:
-p, --precision <PRECISION> 小数点后显示几位 [default: 2]
-h, --help Print help
-V, --version Print version
$ cargo run -q -- temp --help
转换温度
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments:
<VALUE> 要转换的温度
Options:
-f, --from <FROM> 原来的单位
-p, --precision <PRECISION> 小数点后显示几位 [default: 2]
-t, --to <TO> 要转换成的单位
-h, --help Print help

不过这里有个问题。单位只是普通的 String,所以 clap 什么都收:

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

这样一来,之后每个单位都得我们自己手动检查。不如让 clap 来做。

第 5 步:用 ValueEnum 实现枚举和输入校验

Section titled “第 5 步:用 ValueEnum 实现枚举和输入校验”

当一个参数只能是少数几个词之一时,就把这些词写成一个枚举,并派生 ValueEnum。这样 clap 只接受这些词,会把它们列在帮助里,其他输入一律拒绝。下面这些枚举用来替换第 4 步里的 String 字段:

#[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,
}
  • 每个变体都会变成一个小写单词:Celsius → celsius,Km → km。
  • #[value(alias = "c")] 让 clap 也接受简写 c,但不会在帮助里显示它。
  • clap 要求实现 Clone。我们顺便加上了 Copy,因为这些小枚举复制起来很便宜,match 时也更省事。

在 Command 枚举里,把 from: String 改成 from: TempUnit(长度用 LengthUnit)。我们再加两条规则:

/// 小数点后显示几位
#[arg(short, long, default_value_t = 2, global = true,
value_parser = clap::value_parser!(u8).range(0..=6))]
precision: u8,
/// 要转换的温度
#[arg(allow_negative_numbers = true)]
value: f64,
  • value_parser!(u8).range(0..=6) 只接受 0 到 6 的精度。位数再多,打印出来的也只是噪音。
  • allow_negative_numbers = true 让你可以输入 -40。没有它的话,clap 会以为 -40 是一个叫 -4 的短选项(见常见误区)。温度可以是负数,所以我们允许;长度不会是负数,所以不允许。

现在,clap 会在你的代码运行之前就拦下错误的输入:

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

而且帮助页面现在会显示所有可选的值(possible values):

Terminal window
$ cargo run -q -- temp --help
转换温度
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments:
<VALUE> 要转换的温度
Options:
-f, --from <FROM> 原来的单位 [possible values: celsius, fahrenheit, kelvin]
-p, --precision <PRECISION> 小数点后显示几位 [default: 2]
-t, --to <TO> 要转换成的单位 [possible values: celsius, fahrenheit, kelvin]
-h, --help Print help

这就是 clap 的核心思想:只要解析成功,数据就已经是有效的。from 是一个真正的 TempUnit,而不是一个可能装着任何内容的字符串。程序的其余部分可以放心地信任它。

有些错误 clap 发现不了,因为它们取决于值的含义。-500 °C 作为数字完全没问题,但它比绝对零度(-273.15 °C)还低,所以不是一个真实存在的温度。这种检查应该放在我们自己的代码里。

一种整洁的写法是:把真正的工作放进一个返回 Result 的 run 函数,然后让 main 决定打印什么、返回哪个退出码。程序用一个数字告诉 shell 自己成功还是失败:0 表示成功,其他数字都表示失败。Rust 的 std::process::ExitCode 让 main 可以返回一个退出码:

fn main() -> ExitCode {
let cli = Cli::parse();
match run(&cli) {
Ok(line) => {
println!("{line}");
ExitCode::SUCCESS // 退出码 0
}
Err(message) => {
eprintln!("错误:{message}"); // 错误输出到 stderr
ExitCode::FAILURE // 退出码 1
}
}
}

clap 也遵守同样的规则。解析失败时,Cli::parse() 会把错误打印到 stderr,并以退出码 2 结束,这是表示“命令用法不对”的常用退出码。--help 和 --version 则以 0 退出。在终端里,echo $? 可以显示上一条命令的退出码:

Terminal window
$ cargo run -q -- temp -500 -f c -t k
错误:-500 °C 比绝对零度还低
$ 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

为什么这很重要?因为脚本和其他程序会检查退出码,来判断你的工具有没有正常完成工作。环境变量与进程那一页会更详细地介绍退出码和 stderr。在更大的工具里,run 的错误类型通常会用 anyhow 而不是 String,这样上下文信息和错误链都能轻松拿到。

下面是把所有部分合在一起的程序。把 src/main.rs 替换成它,然后试试本页开头的那些命令。

use clap::{Parser, Subcommand, ValueEnum};
use std::process::ExitCode;
/// 一个小巧的温度和长度单位转换器
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// 小数点后显示几位
#[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 {
/// 转换温度
Temp {
/// 要转换的温度
#[arg(allow_negative_numbers = true)]
value: f64,
/// 原来的单位
#[arg(short, long)]
from: TempUnit,
/// 要转换成的单位
#[arg(short, long)]
to: TempUnit,
},
/// 转换长度
Length {
/// 要转换的长度
value: f64,
/// 原来的单位
from: LengthUnit,
/// 要转换成的单位
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 {
// 1 个这种单位等于多少米。
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> {
// 第 1 步:不管收到什么单位,先换算成摄氏度。
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} {} 比绝对零度还低", from.symbol()));
}
// 第 2 步:再把摄氏度换算成想要的单位。
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!("错误:{message}");
ExitCode::FAILURE
}
}
}

run 里有两个小技巧:{result:.p$} 的意思是“用 p 位小数打印 result”;convert_temp(...) 后面的 ? 会在温度不可能存在时,带着错误提前返回。

第 8 步:用 try_parse_from 测试解析器

Section titled “第 8 步:用 try_parse_from 测试解析器”

出了问题时,Cli::parse() 会直接结束程序。对用户来说这正是我们想要的,但在测试里就不行了。Cli::try_parse_from(...) 会对你指定的参数列表做同样的解析,但它返回一个 Result,而不是退出程序。这让它非常适合用来检查解析器——而且它能在 Playground 里运行:

use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
/// 一个小巧的温度和长度单位转换器
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// 小数点后显示几位
#[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 {
/// 转换温度
Temp {
/// 要转换的温度
#[arg(allow_negative_numbers = true)]
value: f64,
/// 原来的单位
#[arg(short, long)]
from: TempUnit,
/// 要转换成的单位
#[arg(short, long)]
to: TempUnit,
},
/// 转换长度
Length {
/// 要转换的长度
value: f64,
/// 原来的单位
from: LengthUnit,
/// 要转换成的单位
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 把一行拆成参数列表,第一个是程序名。
match Cli::try_parse_from(line.split_whitespace()) {
Ok(cli) => println!(" ok: {cli:?}"),
Err(err) => {
// 只显示 clap 错误信息的第一行。
let first_line = err.to_string().lines().next().unwrap_or("").to_string();
println!(" {:?}: {first_line}", err.kind());
}
}
}
fn main() {
// 如果解析器本身配置错了(比如两个选项用了同一个短名),
// 这里会 panic。一份便宜的保险。
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");
}

输出:

$ 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
▶ 动手试一试

注意,--version 也会以 Err 的形式返回。对 clap 来说,“显示版本号然后停止”并不算一次成功的解析,所以它会以 DisplayVersion 这个类型报告(--help 则是 DisplayHelp)。Cli::parse() 会替你处理这些情况,并以退出码 0 结束。

在真正的项目里,这些检查要写进测试。把下面的代码加到 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

忘了 derive 这个 feature。 如果你只写了 cargo add clap,没加 --features derive,那些 derive 宏就不存在:

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

编译器在说:找不到名为 Parser 的 derive 宏;Parser 虽然导入了,但它只是一个 trait,没有附带 derive 宏。修复方法:运行 cargo add clap --features derive,或者手动在 Cargo.toml 里加上 features = ["derive"]。

负数看起来像选项。 用第 2 步的程序,试试一个寒冷的日子:

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 看到 -,就以为 -4 是一个短选项。修复方法:像第 5 步那样,在字段上加 #[arg(allow_negative_numbers = true)]。(用户也可以输入 -- -40:单独一个 -- 后面的所有内容都会被当作普通的值。)

在 Playground 里调用 Cli::parse()。 Playground 运行程序时不带任何参数,所以 parse() 会打印一条错误(对于有子命令的工具,则会打印帮助页面),然后在你的代码做任何事之前就以退出码 2 结束。在 Playground 和测试里,请用 Cli::parse_from([...]) 或 Cli::try_parse_from([...])。

用 println! 打印错误。 错误信息应该输出到 stderr(eprintln!),而不是 stdout。如果有人运行 convert ... > result.txt,错误依然会显示在屏幕上,而不会藏进文件里。

  • 更多单位。 给 LengthUnit 加上码(yd)和海里。编译器会指出每一个需要更新的 match。
  • 一个 weight 子命令,用来转换千克、磅和盎司。
  • 一个 --json 开关,用 serde_json 把结果打印成 JSON。
  • 没有给出值时从 stdin 读取,这样就能用管道把数字传进来:echo 100 | convert temp -f c -t f。用一个 Option<f64> 字段就能知道用户有没有输入值。
  • 用 anyhow 改进错误。 把 run 改成返回 anyhow::Result<String>,并使用 anyhow::bail!。参见 anyhow 与 thiserror。
  • Shell 自动补全。 clap_complete(英文)这个 crate 能根据你的 Cli 结构体,为 bash、zsh、fish 和 PowerShell 生成 Tab 补全脚本。
  • 用 cargo add clap --features derive 添加 clap,然后在结构体上加 #[derive(Parser)]。每个字段就是一个参数。
  • 普通字段是位置参数,#[arg(short, long)] 生成选项,bool 生成开关,default_value_t 设置默认值。/// 文档注释会变成 --help 的文字。
  • 在枚举上加 #[derive(Subcommand)] 就有了子命令;#[derive(ValueEnum)] 把参数限制在一组固定的词里。value_parser!(...).range(...) 用来检查数字范围。
  • 解析成功之后,数据就已经是有效的。剩下的检查放进一个返回 Result 的 run 函数里,并让 main 返回 ExitCode。
  • 用法错误时 clap 以退出码 2 结束,--help/--version 则是 0。你自己的失败也应该用非零退出码,并打印到 stderr。
  • Cli::try_parse_from 解析你指定的列表并返回 Result——非常适合测试和 Playground。

小测验

1. 哪条 cargo add 命令能启用 #[derive(Parser)]?

2. --help 里每个选项的说明,clap 是从哪里拿到的?

3. Cli::try_parse_from(["convert", "--version"]) 会返回什么?

4. 用户输入了无效的单位,Cli::parse() 解析失败。clap 会用哪个退出码?

5. 为什么在第 2 步的程序里 convert -40 会失败?

得分: 0 / 5