Проект: 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 для длины.
Что у нас получится
Заголовок раздела «Что у нас получится»Вот готовая утилита в терминале. Весь вывод на этой странице получен настоящим запуском программы.
$ 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…». А последнее сообщение об ошибке («недопустимое значение ‘rankine’ для ‘–from’», дальше — список допустимых)? Его писали не мы. Это сделал clap. Сообщения самого clap — на английском.
Шаг 1: создаём проект и добавляем clap
Заголовок раздела «Шаг 1: создаём проект и добавляем clap»cargo new convertcd convertcargo add clap --features deriveСамое важное здесь — возможность (feature) derive. Она включает #[derive(Parser)] — удобный способ работать с clap. Теперь в Cargo.toml есть:
[dependencies]clap = { version = "4.6.7", features = ["derive"] }(Номер версии у тебя может быть чуть новее. Для этой страницы подойдёт любая версия, начинающаяся с 4. Если cargo add для тебя в новинку, загляни в урок Cargo и крейты.)
Шаг 2: первый парсер
Заголовок раздела «Шаг 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)Строки /// — это 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".
Шаг 3: справка из doc-комментариев
Заголовок раздела «Шаг 3: справка из doc-комментариев»Заметил строки /// в структуре? Это doc-комментарии (комментарии документации), и 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Строка #[command(name = "convert", version = "1.0")] задаёт имя и версию, которые здесь показаны. Если написать просто #[command(version)], clap возьмёт версию из твоего Cargo.toml. Заголовки разделов (Usage, Options) и строки вроде Print help clap печатает на английском.
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'.В первом случае clap сообщает, что не указан обязательный аргумент <CELSIUS>, во втором — что abc не похоже на дробное число.
Хорошая справка — подарок самому себе в будущем. Пиши doc-комментарии так, будто человек видит твою утилиту впервые: однажды этим человеком окажешься ты сам.
Шаг 4: подкоманды
Заголовок раздела «Шаг 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!("температура: {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.
Теперь на странице справки перечислены подкоманды, а у каждой подкоманды есть своя справка:
$ 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
Заголовок раздела «Шаг 5: перечисления и проверка через ValueEnum»Если аргумент может быть только одним из нескольких слов, опиши эти слова перечислением и добавь 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 ловит неверный ввод ещё до того, как запустится твой код:
$ 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: ошибки и коды завершения
Заголовок раздела «Шаг 6: ошибки и коды завершения»Некоторые ошибки 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 $? показывает последний код завершения:
$ 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: программа целиком
Заголовок раздела «Шаг 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 { // Сколько метров в одной такой единице. 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(...) досрочно возвращает ошибку, если такой температуры не бывает.
Шаг 8: тестируем парсер через try_parse_from
Заголовок раздела «Шаг 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() { // Паникует, если сам парсер настроен неправильно (например, у двух // опций одинаковое короткое имя). Дешёвая страховка. 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); }}$ 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 в холодный день:
$ 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 — короткая опция («найден неожиданный аргумент ‘-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 (на английском) — что значат коды завершения и как правильно ими пользоваться.