用 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。
你将做出什么
Section titled “你将做出什么”下面是做好的工具在终端里的样子。本页所有的输出都来自真实运行的程序。
$ cargo run -q -- temp 100 --from celsius --to fahrenheit100 °C = 212.00 °F$ cargo run -q -- temp -40 -f c -t f-40 °C = -40.00 °F$ cargo run -q -- length 5 km mi5 km = 3.11 mi$ cargo run -q -- -p 0 length 1 mi ft1 mi = 5280 ft$ cargo run -q -- temp 20 --from rankine --to cerror: invalid value 'rankine' for '--from <FROM>' [possible values: celsius, fahrenheit, kelvin]
For more information, try '--help'.-- 后面的所有内容都会交给你的程序,而不是交给 Cargo;-q(quiet,安静模式)会隐藏 Cargo 自己打印的 “Compiling…” 这类信息。最后那条错误信息呢?不是我们写的,是 clap 写的。
第 1 步:创建项目并添加 clap
Section titled “第 1 步:创建项目并添加 clap”cargo new convertcd convertcargo add clap --features derive关键在于 derive 这个 feature。它会启用 #[derive(Parser)],这是使用 clap 最友好的方式。现在你的 Cargo.toml 里有这样一段:
[dependencies]clap = { version = "4.6.7", features = ["derive"] }(你的版本号可能会更新一点。只要是 4 开头的版本,本页的内容都适用。如果你还没用过 cargo add,可以看看 Cargo 与 crate。)
第 2 步:第一个解析器
Section titled “第 2 步:第一个解析器”先从一个只会把摄氏度转换成华氏度的工具开始。写一个结构体,每个参数对应一个字段,再在上面加上 #[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)"); }}$ cargo run -q -- 21.521.5 °C = 70.70 °F$ cargo run -q -- 100 --precision 1 --verbose100 °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):
$ 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 -- --versionconvert 1.0你自己写的说明会以中文显示,而 Usage:、Options:、Print help 这些是 clap 自带的文字,所以保持英文。
#[command(name = "convert", version = "1.0")] 这一行设置了这里显示的名称和版本号。如果只写 #[command(version)],clap 会改从你的 Cargo.toml 里读取版本号。
clap 还会帮你解释用法上的错误:
$ cargo run -q --error: the following required arguments were not provided: <CELSIUS>
Usage: convert <CELSIUS>
For more information, try '--help'.$ cargo run -q -- abcerror: invalid value 'abc' for '<CELSIUS>': invalid float literal
For more information, try '--help'.第一条的意思是“缺少必需的参数 <CELSIUS>”,第二条的意思是“'abc' 不是 <CELSIUS> 的有效值(无法解析成浮点数)”。
好的帮助文本是送给未来的自己的礼物。写文档注释时,就当作有人第一次看到你的工具——因为总有一天,那个人会是你自己。
第 4 步:子命令
Section titled “第 4 步:子命令”现在来加上长度转换。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前面。
现在帮助页面会列出所有子命令,而且每个子命令都有自己的帮助:
$ 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 什么都收:
$ cargo run -q -- temp 20 --from rankine --to cCli { 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 会在你的代码运行之前就拦下错误的输入:
$ cargo run -q -- temp 20 --from rankine --to cerror: invalid value 'rankine' for '--from <FROM>' [possible values: celsius, fahrenheit, kelvin]
For more information, try '--help'.$ cargo run -q -- length 3 yd merror: 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 cmerror: invalid value '9' for '--precision <PRECISION>': 9 is not in 0..=6
For more information, try '--help'.而且帮助页面现在会显示所有可选的值(possible values):
$ 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,而不是一个可能装着任何内容的字符串。程序的其余部分可以放心地信任它。
第 6 步:错误和退出码
Section titled “第 6 步:错误和退出码”有些错误 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 $? 可以显示上一条命令的退出码:
$ cargo run -q -- temp -500 -f c -t k错误:-500 °C 比绝对零度还低$ echo $?1$ cargo run -q -- length 3 yd merror: 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,这样上下文信息和错误链都能轻松拿到。
第 7 步:完整的程序
Section titled “第 7 步:完整的程序”下面是把所有部分合在一起的程序。把 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); }}$ 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 步的程序,试试一个寒冷的日子:
$ cargo run -q -- -40error: 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
- docs.rs 上的 clap(英文)——完整的 API 文档,每个属性都有示例。
- clap derive 教程(英文)——官方的分步教程,涵盖参数、选项、子命令和输入校验。
- clap derive 参考(英文)——逐一解释每个
#[command(...)]、#[arg(...)]和#[value(...)]选项。 - Command Line Applications in Rust(英文)——一本免费的书,讲解如何构建真正的命令行工具:输出、错误、测试和打包发布。
- 退出码 — Command Line Applications in Rust(英文)——退出码的含义,以及如何用好它们。