Перейти к содержимому

Проект: CLI-утилита на Rust с clap

В уроке Аргументы командной строки ты читал аргументы вручную через std::env::args(). Для одного-двух аргументов это работает. Но настоящим утилитам нужны опции вроде --precision 3, короткие формы вроде -p 3, страница --help, понятные сообщения об ошибках и подкоманды, как git commit и git push. Писать всё это вручную — много скучного кода, в котором легко ошибиться.

clap (на английском; Command Line Argument Parser — «разборщик аргументов командной строки») — крейт, которым для этого пользуется почти каждая консольная программа на Rust. Ты описываешь аргументы обычной структурой 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…». А последнее сообщение об ошибке («недопустимое значение ‘rankine’ для ‘–from’», дальше — список допустимых)? Его писали не мы. Это сделал clap. Сообщения самого clap — на английском.

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

Самое важное здесь — возможность (feature) derive. Она включает #[derive(Parser)] — удобный способ работать с clap. Теперь в Cargo.toml есть:

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

(Номер версии у тебя может быть чуть новее. Для этой страницы подойдёт любая версия, начинающаяся с 4. Если cargo add для тебя в новинку, загляни в урок Cargo и крейты.)

Начнём с утилиты, которая умеет только переводить градусы Цельсия в градусы Фаренгейта. Пишешь структуру, в которой одно поле на каждый аргумент, и ставишь на неё #[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)

Строки /// — это doc-комментарии, о них чуть ниже. Мы перевели их на русский, потому что из них clap строит текст справки.

clap читает каждое поле и решает, какой это аргумент:

  • У celsius нет настроек #[arg(...)], поэтому это позиционный аргумент: первое простое значение, которое ты вводишь. Его тип — f64, так что clap заодно превращает текст "21.5" в число.
  • #[arg(short, long)] создаёт опцию с коротким и длинным именем, взятыми из названия поля: -p / --precision. Если её не указать, используется default_value_t = 2.
  • Поле типа bool становится флагом: оно равно true, если указан --verbose (или -v), и 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".

Заметил строки /// в структуре? Это doc-комментарии (комментарии документации), и 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

Строка #[command(name = "convert", version = "1.0")] задаёт имя и версию, которые здесь показаны. Если написать просто #[command(version)], clap возьмёт версию из твоего Cargo.toml. Заголовки разделов (Usage, Options) и строки вроде Print help clap печатает на английском.

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

В первом случае clap сообщает, что не указан обязательный аргумент <CELSIUS>, во втором — что abc не похоже на дробное число.

Хорошая справка — подарок самому себе в будущем. Пиши doc-комментарии так, будто человек видит твою утилиту впервые: однажды этим человеком окажешься ты сам.

Теперь добавим длину. Утилиты вроде 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!("температура: {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(subcommand)] на поле command говорит 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.

Если аргумент может быть только одним из нескольких слов, опиши эти слова перечислением и добавь derive(ValueEnum). Тогда clap принимает только эти слова, перечисляет их в справке и отклоняет всё остальное. Эти перечисления заменяют поля String из шага 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,
}
  • Каждый вариант становится словом в нижнем регистре: Celsius → celsius, Km → km.
  • #[value(alias = "c")] дополнительно принимает короткую форму 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), так что такой температуры не бывает. Эта проверка — дело нашего собственного кода.

Аккуратный приём — вынести настоящую работу в функцию run, которая возвращает Result, а main пусть решает, что напечатать и какой код завершения (exit code) вернуть. Программы сообщают оболочке об успехе или неудаче числом: 0 значит успех, всё остальное — неудачу. std::process::ExitCode в Rust позволяет 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 {
// Сколько метров в одной такой единице.
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$} значит «напечатать result с p знаками после запятой», а ? после convert_temp(...) досрочно возвращает ошибку, если такой температуры не бывает.

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() {
// Паникует, если сам парсер настроен неправильно (например, у двух
// опций одинаковое короткое имя). Дешёвая страховка.
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 «показать версию и остановиться» — это не успешный разбор, поэтому он сообщает об этом с видом (kind) 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!("ожидалась 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. Если выполнить 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

Компилятор не находит derive-макрос Parser: импортирован только трейт. Исправление: выполни cargo add clap --features derive или вручную добавь features = ["derive"] в Cargo.toml.

Отрицательные числа похожи на опции. Запусти программу из шага 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 — короткая опция («найден неожиданный аргумент ‘-4’»). Исправление: добавь полю #[arg(allow_negative_numbers = true)], как мы сделали на шаге 5. (Пользователь может ещё ввести -- -40: всё, что стоит после отдельного --, считается обычным значением.)

Вызывать Cli::parse() в Playground. Playground запускает программу без аргументов, поэтому parse() печатает ошибку (а для утилиты с подкомандами — страницу справки) и завершается с кодом 2 ещё до того, как твой код что-то сделает. Там и в тестах используй Cli::parse_from([...]) или Cli::try_parse_from([...]).

Печатать ошибки через println!. Сообщениям об ошибках место в stderr (eprintln!), а не в stdout. Если кто-то запустит convert ... > result.txt, ошибки всё равно появятся на экране, а не спрячутся в файле.

  • Больше единиц. Добавь в LengthUnit ярды (yd) и морские мили. Компилятор укажет на каждый match, который нужно обновить.
  • Подкоманда weight для килограммов, фунтов и унций.
  • Флаг --json, который печатает результат в JSON с помощью serde_json.
  • Чтение значений из stdin, если значение не указано, чтобы можно было передавать числа через конвейер: echo 100 | convert temp -f c -t f. Поле Option<f64> подскажет, ввели ли значение.
  • Ошибки получше с anyhow. Пусть run возвращает anyhow::Result<String>, а для ошибок используй anyhow::bail!. См. урок anyhow и thiserror.
  • Автодополнение в оболочке. Крейт clap_complete (на английском) генерирует по твоей структуре Cli скрипты автодополнения по Tab для bash, zsh, fish и PowerShell.
  • Добавь clap через cargo add clap --features derive, а потом поставь #[derive(Parser)] на структуру. Каждое поле — один аргумент.
  • Простые поля — позиционные аргументы, #[arg(short, long)] создаёт опции, bool — флаги, а default_value_t задаёт значения по умолчанию. Doc-комментарии /// становятся текстом --help.
  • #[derive(Subcommand)] на перечислении даёт подкоманды; #[derive(ValueEnum)] ограничивает аргумент фиксированным набором слов. value_parser!(...).range(...) проверяет диапазон чисел.
  • Если разбор прошёл, данные уже корректны. Остальные проверки помести в функцию run, которая возвращает Result, а из main возвращай ExitCode.
  • clap завершается с кодом 2 при ошибках использования и с кодом 0 для --help/--version. Твои собственные неудачи тоже должны давать ненулевой код и печататься в stderr.
  • Cli::try_parse_from разбирает выбранный тобой список и возвращает Result — идеально для тестов и Playground.

Проверь себя

1. Какая команда cargo add включает #[derive(Parser)]?

2. Откуда clap берёт описание каждой опции в --help?

3. Что вернёт Cli::try_parse_from(["convert", "--version"])?

4. Пользователь ввёл несуществующую единицу, и Cli::parse() не смог разобрать команду. С каким кодом завершится clap?

5. Почему convert -40 не работает в программе из шага 2?

Результат: 0 / 5

  • clap на docs.rs (на английском) — полная документация API с примерами для каждого атрибута.
  • Учебник по clap derive (на английском) — официальное пошаговое руководство: аргументы, опции, подкоманды и проверка значений.
  • Справочник по clap derive (на английском) — объяснение каждой настройки #[command(...)], #[arg(...)] и #[value(...)].
  • Command Line Applications in Rust (на английском) — бесплатная книга о создании настоящих консольных утилит: вывод, ошибки, тестирование и упаковка.
  • Exit codes — Command Line Applications in Rust (на английском) — что значат коды завершения и как правильно ими пользоваться.