コンテンツにスキップ

Rustとclapで作るコマンドラインツール

コマンドライン引数のページでは、std::env::args() を使って引数を自分の手で読み取りました。引数が1つか2つなら、それで十分です。でも本物のツールには、--precision 3 のようなオプション、-p 3 のような短い書き方、--help のページ、わかりやすいエラーメッセージ、そして git commit や git push のようなサブコマンドが必要です。これを全部手で書くと、退屈で間違えやすいコードが山ほどできてしまいます。

clap(英語)(Command Line Argument Parser)は、ほとんどすべてのRust製CLIがこのために使っているクレートです。引数をふつうのRustの構造体として書くだけで、パーサー、ヘルプの文章、エラーメッセージをclapが作ってくれます。このプロジェクトでは、convert という小さな単位変換ツールを作ります。サブコマンドは2つで、温度を変換する 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が書いてくれたのです。「--from に ‘rankine’ は使えません。使える値は celsius、fahrenheit、kelvin です」という意味です。clapのメッセージは英語で表示されます。

ステップ1: プロジェクトを作ってclapを追加する

Section titled “ステップ1: プロジェクトを作ってclapを追加する”
Terminal window
cargo new convert
cd convert
cargo add clap --features derive

大事なのは derive フィーチャーです。これで、clapを手軽に使う方法である #[derive(Parser)] が使えるようになります。Cargo.toml には次の行が入ります。

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

(バージョン番号は少し新しいかもしれません。4 で始まるものなら、このページの内容はそのまま使えます。cargo add が初めての人はCargoとクレートを見てください。)

まずは、摂氏を華氏に変換するだけのツールから始めましょう。引数1つにつきフィールドを1つ持つ構造体を書いて、#[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 のフィールドはフラグになります。--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'.

1つ目は「必須の引数 <CELSIUS> が指定されていない」、2つ目は「'abc' は <CELSIUS> の値として正しくない(小数としてパースできない)」という意味です。

わかりやすいヘルプは、未来の自分への贈り物です。初めてそのツールを見る人に向けて、ドキュメンテーションコメントを書きましょう。いつか、その「初めて見る人」はあなた自身になるのですから。

次は、長さにも対応させましょう。git や cargo のようなツールは、サブコマンド(cargo build、cargo test)を使って、いろいろな仕事をまとめています。clapでは、サブコマンドは #[derive(Subcommand)] を付けた列挙型です。バリアント1つがサブコマンド1つになり、そのフィールドがサブコマンドの引数になります。

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!("温度: {value} {from} -> {to}"),
Command::Length { value, from, to } => println!("長さ: {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" } }
長さ: 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" } }

これでは、あとで単位を1つずつ手でチェックしなければなりません。clapにやってもらいましょう。

ステップ5: ValueEnumで列挙型と入力チェック

Section titled “ステップ5: ValueEnumで列挙型と入力チェック”

引数がいくつかの決まった単語のどれかでしかありえないときは、その単語を列挙型として書き、ValueEnum をderiveします。すると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")] を付けると、短い形の c も受け付けます。ヘルプには表示されません。
  • clapには Clone が必要です。Copy も付けているのは、こうした小さな列挙型はコピーのコストが小さく、match が書きやすくなるからです。

Command 列挙型の from: String を from: TempUnit に変えます(長さのほうは LengthUnit にします)。さらに、ルールを2つ追加します。

/// 小数点以下に表示する桁数
#[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'.

最後のエラーは「9 は 0..=6 の範囲に入っていない」という意味です。

ヘルプのページにも、使える値(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 です。プログラムの残りの部分は、それを信頼できます。

ステップ6: エラーと終了コード

Section titled “ステップ6: エラーと終了コード”

clapには見つけられない間違いもあります。値の意味によって決まる間違いです。-500 °C は数値としては何の問題もありませんが、絶対零度(-273.15 °C)より低いので、現実にはありえない温度です。このチェックは自分のコードで行います。

すっきりした書き方は、本当の処理を Result を返す run 関数に入れ、何を表示してどの終了コードを返すかは main に決めさせる方法です。プログラムは、成功したか失敗したかを数値でシェルに伝えます。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 のエラー型には String ではなく anyhow を使うことが多いでしょう。そうすれば、エラーの文脈やエラーの連鎖が手軽に手に入ります。

ステップ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 には小さな工夫が2つあります。{result:.p$} は「result を小数点以下 p 桁で表示する」という意味です。そして 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 のメッセージの1行目だけを表示する
let first_line = err.to_string().lines().next().unwrap_or("").to_string();
println!(" {:?}: {first_line}", err.kind());
}
}
}
fn main() {
// パーサーの設定自体が間違っていたら(たとえば、2つのオプションに
// 同じ短い名前を付けたら)パニックする。手軽な保険です。
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 という種類(kind)のエラーとして報告されます(--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!("temp を期待しましたが、{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 フィーチャーを忘れる:--features derive を付けずに cargo add clap とすると、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マクロが見つからない。インポートされているのはトレイトだけで、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! で表示する:エラーメッセージは stdout ではなく stderr(eprintln!)に出すものです。誰かが convert ... > result.txt のように実行しても、エラーはファイルの中に隠れずに画面に表示されます。

  • 単位を増やす:LengthUnit にヤード(yd)や海里を追加しましょう。更新が必要な match を、コンパイラがすべて教えてくれます。
  • weight サブコマンド:キログラム、ポンド、オンスを変換します。
  • --json フラグ:serde_json を使って、結果をJSONで表示します。
  • 値がないときは標準入力から読む:echo 100 | convert temp -f c -t f のように、数値をパイプで渡せるようにします。Option<f64> のフィールドを使えば、値が打たれたかどうかがわかります。
  • anyhowでエラーをわかりやすくする:run が anyhow::Result<String> を返すようにして、anyhow::bail! を使います。anyhowとthiserrorを参照してください。
  • シェルの補完:clap_complete(英語)クレートを使うと、Cli 構造体から bash、zsh、fish、PowerShell 用のタブ補完スクリプトを生成できます。
  • cargo add clap --features derive でclapを追加し、構造体に #[derive(Parser)] を付けます。フィールド1つが引数1つです。
  • ふつうのフィールドは位置引数、#[arg(short, long)] はオプション、bool はフラグになり、default_value_t でデフォルト値を決めます。/// のドキュメンテーションコメントは --help の文章になります。
  • 列挙型に #[derive(Subcommand)] を付けるとサブコマンドに、#[derive(ValueEnum)] を付けると決まった単語だけを受け付ける引数になります。value_parser!(...).range(...) で数値の範囲をチェックできます。
  • パースが成功したら、データはもう正しい状態です。残りのチェックは Result を返す run 関数に入れ、main からは ExitCode を返します。
  • clapは、使い方の間違いには終了コード2で、--help / --version には0で終了します。自分のコードで失敗したときも0以外のコードを使い、stderr に表示しましょう。
  • Cli::try_parse_from は、自分で選んだリストをパースして Result を返します。テストやPlaygroundにぴったりです。

理解度チェック

1. #[derive(Parser)] を使えるようにする cargo add のコマンドはどれですか?

2. clapは --help に表示する各オプションの説明をどこから取ってきますか?

3. Cli::try_parse_from(["convert", "--version"]) は何を返しますか?

4. ユーザーが正しくない単位を打ち、Cli::parse() が失敗しました。clapはどの終了コードを使いますか?

5. ステップ2のプログラムで convert -40 が失敗するのはなぜですか?

スコア: 0 / 5

  • docs.rsのclap(英語):APIのドキュメント全体です。すべての属性に例が付いています。
  • The clap derive tutorial(英語):引数、オプション、サブコマンド、入力チェックを順を追って学べる公式チュートリアルです。
  • The clap derive reference(英語):#[command(...)]、#[arg(...)]、#[value(...)] のすべてのオプションの説明です。
  • Command Line Applications in Rust(英語):本物のCLIツールの作り方を学べる無料の本です。出力、エラー、テスト、パッケージ化まで扱っています。
  • Exit codes — Command Line Applications in Rust(英語):終了コードの意味と、上手な使い方。