Pular para o conteúdo

Projeto: CLI com clap em Rust

Na página de Argumentos de linha de comando, você leu os argumentos à mão com std::env::args(). Isso funciona para um ou dois argumentos. Mas ferramentas de verdade precisam de opções como --precision 3, formas curtas como -p 3, uma página de --help, boas mensagens de erro e subcomandos como git commit e git push. Escrever tudo isso à mão dá muito código chato e fácil de errar.

O clap (sigla em inglês para Command Line Argument Parser) é o crate que quase toda CLI (ferramenta de linha de comando) em Rust usa para isso. Você descreve os seus argumentos como uma struct normal do Rust, e o clap escreve para você o parser, o texto de ajuda e as mensagens de erro. Neste projeto, você vai construir o convert, um pequeno conversor de unidades com dois subcomandos: temp, para temperaturas, e length, para comprimentos.

Aqui está a ferramenta pronta, rodando num terminal. Todas as saídas desta página vêm de rodar o programa de verdade.

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

Tudo o que vem depois de -- vai para o seu programa em vez de ir para o Cargo, e o -q (de quiet, silencioso) esconde as linhas “Compiling…” do próprio Cargo. E aquela última mensagem de erro? Não fomos nós que escrevemos. Foi o clap. Ela diz que rankine é um valor inválido (invalid value) para --from e lista os valores possíveis (possible values). As mensagens prontas do clap são sempre em inglês.

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

A feature derive é a parte importante. Ela ativa o #[derive(Parser)], que é o jeito mais amigável de usar o clap. Agora o seu Cargo.toml contém:

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

(O seu número de versão pode ser um pouco mais novo. Qualquer versão que comece com 4 funciona para esta página. Veja Cargo e crates se o cargo add for novidade para você.)

Vamos começar com uma ferramenta que só converte Celsius para Fahrenheit. Você escreve uma struct com um campo para cada argumento e coloca #[derive(Parser)] nela. Substitua o src/main.rs por:

use clap::Parser;
/// Converte uma temperatura de Celsius para Fahrenheit
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// A temperatura em Celsius
celsius: f64,
/// Quantas casas decimais mostrar
#[arg(short, long, default_value_t = 2)]
precision: usize,
/// Também mostra a fórmula que foi usada
#[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órmula usada: °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órmula usada: °F = °C × 9/5 + 32)

O clap lê cada campo e decide que tipo de argumento ele é:

  • celsius não tem nenhuma opção #[arg(...)], então é um argumento posicional: o primeiro valor simples que você digita. Como o tipo dele é f64, o clap também converte o texto "21.5" em número para você.
  • #[arg(short, long)] cria uma opção com um nome curto e um nome longo, tirados do nome do campo: -p / --precision. O default_value_t = 2 é usado quando você não passa a opção.
  • Um campo bool vira uma flag: ele é true se --verbose (ou -v) estiver presente, e false se não estiver.
  • Um campo Option<T> (que não usamos aqui) seria um argumento que você pode deixar de fora por completo.

O {:.*} no println! quer dizer “use o próximo argumento como o número de casas decimais”. É assim que o --precision chega até a saída.

O Cli::parse() lê a linha de comando de verdade. O Rust Playground não tem linha de comando, então, para testar o parser lá, use Cli::parse_from com uma lista de argumentos de faz de conta. O primeiro item é sempre o nome do programa, igualzinho ao std::env::args():

use clap::Parser;
/// Converte uma temperatura de Celsius para Fahrenheit
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// A temperatura em Celsius
celsius: f64,
/// Quantas casas decimais mostrar
#[arg(short, long, default_value_t = 2)]
precision: usize,
/// Também mostra a fórmula que foi usada
#[arg(short, long)]
verbose: bool,
}
fn main() {
// Faz de conta que o usuário digitou: 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);
}

Saída:

Cli { celsius: 100.0, precision: 1, verbose: true }
100 °C = 212.0 °F
▶ Experimente você mesmo

Experimente mudar os argumentos de faz de conta: tire o "--verbose", ou troque "--precision" por "-p".

Passo 3: Texto de ajuda a partir dos comentários de documentação

Seção intitulada “Passo 3: Texto de ajuda a partir dos comentários de documentação”

Reparou nas linhas /// da struct? Elas são comentários de documentação, e o clap transforma esses comentários na página de ajuda. O comentário acima da struct descreve o programa inteiro; o comentário acima de cada campo descreve aquele argumento. Você ganha o --help (e o -h) sem escrever mais nada:

Terminal window
$ cargo run -q -- --help
Converte uma temperatura de Celsius para Fahrenheit
Usage: convert [OPTIONS] <CELSIUS>
Arguments:
<CELSIUS> A temperatura em Celsius
Options:
-p, --precision <PRECISION> Quantas casas decimais mostrar [default: 2]
-v, --verbose Também mostra a fórmula que foi usada
-h, --help Print help
-V, --version Print version
$ cargo run -q -- --version
convert 1.0

Os textos que vieram dos seus comentários aparecem em português. Já os títulos e textos fixos do clap ficam em inglês: Usage (uso), Arguments (argumentos), Options (opções), [default: 2] (padrão: 2), Print help (mostrar a ajuda) e Print version (mostrar a versão).

A linha #[command(name = "convert", version = "1.0")] define o nome e a versão que aparecem aqui. Se você escrever só #[command(version)], o clap pega a versão do seu Cargo.toml.

O clap também explica os erros para você:

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

O primeiro erro diz que um argumento obrigatório não foi informado (required arguments were not provided). O segundo diz que abc não é um número decimal válido (invalid float literal). Nos dois casos, o clap sugere rodar --help para ver mais informações.

Um bom texto de ajuda é um presente para o seu eu do futuro. Escreva os comentários de documentação como se alguém estivesse vendo a sua ferramenta pela primeira vez, porque um dia essa pessoa vai ser você.

Agora vamos aceitar comprimentos também. Ferramentas como o git e o cargo usam subcomandos (cargo build, cargo test) para agrupar tarefas diferentes. No clap, um subcomando é um enum com #[derive(Subcommand)]. Cada variante é um subcomando, e os campos dela são os argumentos daquele subcomando:

use clap::{Parser, Subcommand};
/// Um pequeno conversor de unidades para temperaturas e comprimentos
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Quantas casas decimais mostrar
#[arg(short, long, default_value_t = 2, global = true)]
precision: usize,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Converte uma temperatura
Temp {
/// A temperatura a converter
value: f64,
/// A unidade de origem
#[arg(short, long)]
from: String,
/// A unidade de destino
#[arg(short, long)]
to: String,
},
/// Converte um comprimento
Length {
/// O comprimento a converter
value: f64,
/// A unidade de origem
from: String,
/// A unidade de destino
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:?}");
// Você descobre qual subcomando foi usado com um match normal.
match cli.command {
Command::Temp { value, from, to } => println!("temperatura: {value} {from} -> {to}"),
Command::Length { value, from, to } => println!("comprimento: {value} {from} -> {to}"),
}
}

Saída:

Cli { precision: 2, command: Temp { value: 100.0, from: "c", to: "f" } }
Cli { precision: 1, command: Length { value: 5.0, from: "km", to: "mi" } }
comprimento: 5 km -> mi
▶ Experimente você mesmo

Alguns detalhes:

  • #[command(subcommand)] no campo command diz ao clap: “este campo guarda o subcomando escolhido”.
  • O nome da variante Temp vira o subcomando temp (o clap passa para minúsculas por você).
  • O temp usa opções com nome (--from c --to f), enquanto o length usa argumentos posicionais (5 km mi). Os dois estilos são comuns, e assim você vê cada um deles.
  • global = true deixa o --precision aparecer antes ou depois do subcomando. Sem isso, o -p 1 só seria aceito antes do length.

A página de ajuda agora lista os subcomandos (Commands), e cada subcomando tem a sua própria ajuda:

Terminal window
$ cargo run -q -- --help
Um pequeno conversor de unidades para temperaturas e comprimentos
Usage: convert [OPTIONS] <COMMAND>
Commands:
temp Converte uma temperatura
length Converte um comprimento
help Print this message or the help of the given subcommand(s)
Options:
-p, --precision <PRECISION> Quantas casas decimais mostrar [default: 2]
-h, --help Print help
-V, --version Print version
$ cargo run -q -- temp --help
Converte uma temperatura
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments:
<VALUE> A temperatura a converter
Options:
-f, --from <FROM> A unidade de origem
-p, --precision <PRECISION> Quantas casas decimais mostrar [default: 2]
-t, --to <TO> A unidade de destino
-h, --help Print help

Mas tem um problema. As unidades são Strings simples, então o clap aceita qualquer coisa:

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

Teríamos que conferir cada unidade à mão depois. Vamos deixar o clap fazer isso.

Quando um argumento só pode ser uma entre poucas palavras, descreva essas palavras como um enum e derive ValueEnum. Aí o clap aceita só essas palavras, lista todas elas na ajuda e recusa todo o resto. Estes enums substituem os campos String do Passo 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,
}
  • Cada variante vira uma palavra em minúsculas: Celsius → celsius, Km → km.
  • #[value(alias = "c")] também aceita a forma curta c, sem mostrá-la na ajuda.
  • O clap precisa de Clone. Também acrescentamos Copy, porque esses enums pequenos são baratos de copiar, e isso facilita o match.

No enum Command, troque from: String por from: TempUnit (e por LengthUnit nos comprimentos). Também acrescentamos mais duas regras:

/// Quantas casas decimais mostrar
#[arg(short, long, default_value_t = 2, global = true,
value_parser = clap::value_parser!(u8).range(0..=6))]
precision: u8,
/// A temperatura a converter
#[arg(allow_negative_numbers = true)]
value: f64,
  • value_parser!(u8).range(0..=6) só aceita uma precisão de 0 a 6. Mais do que isso só mostraria ruído.
  • allow_negative_numbers = true deixa você digitar -40. Sem isso, o clap acha que -40 é uma opção curta chamada -4 (veja Erros comuns). Temperaturas podem ser negativas, então permitimos; comprimentos não podem, então não permitimos.

Agora o clap pega as entradas ruins antes mesmo de o seu código rodar:

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

O último erro diz que 9 não está no intervalo 0..=6 (9 is not in 0..=6). E a página de ajuda agora mostra os valores possíveis:

Terminal window
$ cargo run -q -- temp --help
Converte uma temperatura
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments:
<VALUE> A temperatura a converter
Options:
-f, --from <FROM> A unidade de origem [possible values: celsius, fahrenheit, kelvin]
-p, --precision <PRECISION> Quantas casas decimais mostrar [default: 2]
-t, --to <TO> A unidade de destino [possible values: celsius, fahrenheit, kelvin]
-h, --help Print help

Esta é a grande ideia do clap: depois que o parsing dá certo, os seus dados já são válidos. O from é um TempUnit de verdade, e não uma string que pode ser qualquer coisa. O resto do seu programa pode confiar nele.

Alguns erros o clap não consegue pegar, porque dependem do significado dos valores. -500 °C é um número perfeitamente válido, mas é mais frio que o zero absoluto (-273.15 °C), então não é uma temperatura real. Essa verificação fica no nosso próprio código.

Um padrão organizado é colocar o trabalho de verdade numa função run que devolve um Result, e deixar o main decidir o que mostrar e qual código de saída (exit code) devolver. Os programas informam sucesso ou falha ao shell com um número: 0 quer dizer sucesso, e qualquer outro valor quer dizer falha. O std::process::ExitCode do Rust deixa o main devolver um desses números:

fn main() -> ExitCode {
let cli = Cli::parse();
match run(&cli) {
Ok(line) => {
println!("{line}");
ExitCode::SUCCESS // código de saída 0
}
Err(message) => {
eprintln!("erro: {message}"); // erros vão para o stderr
ExitCode::FAILURE // código de saída 1
}
}
}

O clap segue as mesmas regras. Quando o parsing falha, o Cli::parse() mostra o erro no stderr e sai com o código 2, o código de costume para “você usou o comando do jeito errado”. --help e --version saem com 0. Num terminal, echo $? mostra o último código de saída:

Terminal window
$ cargo run -q -- temp -500 -f c -t k
erro: -500 °C é mais frio que o zero absoluto
$ 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

Repare que a primeira mensagem começa com erro:, porque fomos nós que escrevemos, e a segunda começa com error:, porque veio do clap.

Por que isso importa? Scripts e outros programas conferem o código de saída para saber se a sua ferramenta funcionou. A página Ambiente e processos explica códigos de saída e o stderr com mais detalhes. Em ferramentas maiores, você provavelmente usaria o anyhow como tipo de erro da run em vez de String, e ganharia contexto e cadeias de erros de graça.

Aqui está tudo junto. Substitua o src/main.rs por isto e experimente os comandos do começo da página.

use clap::{Parser, Subcommand, ValueEnum};
use std::process::ExitCode;
/// Um pequeno conversor de unidades para temperaturas e comprimentos
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Quantas casas decimais mostrar
#[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 {
/// Converte uma temperatura
Temp {
/// A temperatura a converter
#[arg(allow_negative_numbers = true)]
value: f64,
/// A unidade de origem
#[arg(short, long)]
from: TempUnit,
/// A unidade de destino
#[arg(short, long)]
to: TempUnit,
},
/// Converte um comprimento
Length {
/// O comprimento a converter
value: f64,
/// A unidade de origem
from: LengthUnit,
/// A unidade de destino
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 {
// Quantos metros vale uma unidade desta.
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> {
// Passo 1: transformar o que recebemos em Celsius.
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} {} é mais frio que o zero absoluto", from.symbol()));
}
// Passo 2: transformar Celsius na unidade que queremos.
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!("erro: {message}");
ExitCode::FAILURE
}
}
}

Dois truques pequenos na run: {result:.p$} quer dizer “mostre o result com p casas decimais”, e o ? depois de convert_temp(...) sai mais cedo com o erro se a temperatura for impossível.

O Cli::parse() encerra o programa quando algo está errado. É isso que você quer para os usuários, mas não num teste. O Cli::try_parse_from(...) faz o mesmo parsing numa lista de argumentos que você escolhe e devolve um Result em vez de encerrar o programa. Isso faz dele perfeito para conferir o seu parser, e ele roda no Playground:

use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
/// Um pequeno conversor de unidades para temperaturas e comprimentos
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Quantas casas decimais mostrar
#[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 {
/// Converte uma temperatura
Temp {
/// A temperatura a converter
#[arg(allow_negative_numbers = true)]
value: f64,
/// A unidade de origem
#[arg(short, long)]
from: TempUnit,
/// A unidade de destino
#[arg(short, long)]
to: TempUnit,
},
/// Converte um comprimento
Length {
/// O comprimento a converter
value: f64,
/// A unidade de origem
from: LengthUnit,
/// A unidade de destino
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 nos dá os argumentos, com o nome do programa primeiro.
match Cli::try_parse_from(line.split_whitespace()) {
Ok(cli) => println!(" ok: {cli:?}"),
Err(err) => {
// Mostra só a primeira linha da mensagem do clap.
let first_line = err.to_string().lines().next().unwrap_or("").to_string();
println!(" {:?}: {first_line}", err.kind());
}
}
}
fn main() {
// Entra em panic se o próprio parser estiver mal configurado (por exemplo,
// duas opções com o mesmo nome curto). Um seguro barato.
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");
}

Saída:

$ 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
▶ Experimente você mesmo

Repare que o --version também volta como um Err. Para o clap, “mostrar a versão e parar” não é um parsing bem-sucedido, então ele é informado com o tipo DisplayVersion (e o --help com DisplayHelp). O Cli::parse() cuida desses casos para você e sai com o código 0.

No seu projeto de verdade, as mesmas verificações vão para os testes. Acrescente isto no fim do 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!("esperava temp, mas veio {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

Cada ponto (.) é um teste que passou, e o resumo confirma: 4 aprovados (passed), 0 com falha (failed).

Esquecer a feature derive. Se você escrever cargo add clap sem --features derive, as macros de derive não existem:

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

O compilador diz que não encontrou a macro de derive Parser e explica que, sem a feature, Parser é só um trait. Solução: rode cargo add clap --features derive, ou acrescente features = ["derive"] no Cargo.toml à mão.

Números negativos parecem opções. Com o programa do Passo 2, experimente um dia frio:

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

O clap vê o - e acha que -4 é uma opção curta, por isso reclama de um argumento inesperado (unexpected argument). Solução: acrescente #[arg(allow_negative_numbers = true)] ao campo, como fizemos no Passo 5. (Os usuários também podem digitar -- -40; tudo o que vem depois de um -- sozinho é tratado como valor simples, como a dica tip sugere.)

Chamar Cli::parse() no Playground. O Playground roda o seu programa sem nenhum argumento, então o parse() mostra um erro (ou, numa ferramenta com subcomandos, a página de ajuda) e sai com o código 2 antes de o seu código fazer qualquer coisa. Lá, e nos testes, use Cli::parse_from([...]) ou Cli::try_parse_from([...]).

Mostrar erros com println!. Mensagens de erro vão no stderr (eprintln!), não no stdout. Se alguém rodar convert ... > resultado.txt, os erros continuam aparecendo na tela, em vez de ficarem escondidos dentro do arquivo.

  • Mais unidades. Acrescente jardas (yd) e milhas náuticas ao LengthUnit. O compilador vai apontar cada match que você precisa atualizar.
  • Um subcomando weight para quilos, libras e onças.
  • Uma flag --json que mostra o resultado em JSON usando o serde_json.
  • Ler valores do stdin quando nenhum valor for informado, para você poder mandar números por um pipe: echo 100 | convert temp -f c -t f. Um campo Option<f64> diz se um valor foi digitado.
  • Erros melhores com o anyhow. Mude a run para devolver anyhow::Result<String> e use anyhow::bail!. Veja anyhow e thiserror.
  • Autocompletar no shell. O crate clap_complete (em inglês) gera, a partir da sua struct Cli, scripts de autocompletar com Tab para bash, zsh, fish e PowerShell.
  • Adicione o clap com cargo add clap --features derive e depois coloque #[derive(Parser)] numa struct. Cada campo é um argumento.
  • Campos simples são posicionais, #[arg(short, long)] cria opções, bool cria flags e default_value_t define valores padrão. Os comentários de documentação /// viram o texto do --help.
  • #[derive(Subcommand)] num enum dá subcomandos; #[derive(ValueEnum)] limita um argumento a um conjunto fixo de palavras. value_parser!(...).range(...) confere intervalos de números.
  • Depois que o parsing dá certo, os dados já são válidos. Coloque as verificações que sobram numa função run que devolve Result, e devolva um ExitCode no main.
  • O clap sai com o código 2 em erros de uso e com 0 em --help/--version. As suas próprias falhas também devem usar um código diferente de zero e mostrar a mensagem no stderr.
  • Cli::try_parse_from faz o parsing de uma lista que você escolhe e devolve um Result: perfeito para testes e para o Playground.

Teste o que aprendeu

1. Qual comando cargo add ativa o #[derive(Parser)]?

2. De onde o clap tira a descrição de cada opção no --help?

3. O que Cli::try_parse_from(["convert", "--version"]) devolve?

4. Um usuário digita uma unidade inválida e o Cli::parse() falha. Qual código de saída o clap usa?

5. Por que convert -40 falha no programa do Passo 2?

Pontuação: 0 / 5

Todos os links abaixo estão em inglês.