Aller au contenu

Projet : outil CLI avec clap en Rust

Sur la page Arguments de ligne de commande, vous avez lu les arguments à la main avec std::env::args(). Cela fonctionne pour un ou deux arguments. Mais les vrais outils ont besoin d’options comme --precision 3, de formes courtes comme -p 3, d’une page --help, de bons messages d’erreur, et de sous-commandes comme git commit et git push. Écrire tout cela à la main, c’est beaucoup de code ennuyeux et propice aux erreurs.

clap (Command Line Argument Parser, « analyseur d’arguments de ligne de commande ») est la crate que presque tous les outils CLI en Rust utilisent pour cela. Vous décrivez vos arguments sous la forme d’une struct Rust ordinaire, et clap écrit pour vous l’analyseur (le parser), le texte d’aide et les messages d’erreur. Dans ce projet, vous allez construire convert, un petit convertisseur d’unités avec deux sous-commandes : temp pour les températures et length pour les longueurs.

Voici l’outil terminé, dans un terminal. Toutes les sorties de cette page viennent d’une exécution réelle du programme.

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

Tout ce qui suit -- va à votre programme plutôt qu’à Cargo, et -q (quiet, silencieux) masque les lignes « Compiling… » de Cargo lui-même. Et ce dernier message d’erreur ? Ce n’est pas nous qui l’avons écrit, c’est clap. Il est en anglais, comme tous les messages générés par clap : il dit que rankine n’est pas une valeur valide pour --from, et liste les valeurs possibles.

Les nombres s’affichent avec un point décimal (212.00), car c’est ainsi que Rust formate les nombres à virgule.

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

La feature derive est l’élément important. Elle active #[derive(Parser)], la façon la plus agréable d’utiliser clap. Votre Cargo.toml contient maintenant :

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

(Votre numéro de version peut être un peu plus récent. Tout ce qui commence par 4 fonctionne pour cette page. Voyez Cargo et les crates si cargo add est nouveau pour vous.)

Commençons par un outil qui convertit seulement des degrés Celsius en Fahrenheit. Vous écrivez une struct avec un champ par argument, et vous mettez #[derive(Parser)] dessus. Remplacez src/main.rs par :

use clap::Parser;
/// Convertit une température de Celsius en Fahrenheit
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// La température en degrés Celsius
celsius: f64,
/// Nombre de chiffres à afficher après la virgule
#[arg(short, long, default_value_t = 2)]
precision: usize,
/// Affiche aussi la formule utilisée
#[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!("(formule : °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
(formule : °F = °C × 9/5 + 32)

clap lit chaque champ et décide de quel genre d’argument il s’agit :

  • celsius n’a pas d’options #[arg(...)], c’est donc un argument positionnel : la première valeur simple que vous tapez. Comme son type est f64, clap convertit aussi pour vous le texte "21.5" en nombre.
  • #[arg(short, long)] crée une option avec un nom court et un nom long tirés du champ : -p / --precision. default_value_t = 2 est utilisé quand vous ne la donnez pas.
  • Un champ bool devient un flag (un drapeau) : il vaut true si --verbose (ou -v) est présent, false sinon.
  • Un champ Option<T> (non utilisé ici) serait un argument que l’on peut complètement omettre.

{:.*} dans println! veut dire « utilise l’argument suivant comme nombre de décimales ». C’est ainsi que --precision arrive jusqu’à l’affichage.

Cli::parse() lit la vraie ligne de commande. Le Rust Playground n’a pas de ligne de commande : pour y essayer le parser, utilisez Cli::parse_from avec une liste d’arguments fictifs. Le premier élément est toujours le nom du programme, exactement comme avec std::env::args() :

use clap::Parser;
/// Convertit une température de Celsius en Fahrenheit
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// La température en degrés Celsius
celsius: f64,
/// Nombre de chiffres à afficher après la virgule
#[arg(short, long, default_value_t = 2)]
precision: usize,
/// Affiche aussi la formule utilisée
#[arg(short, long)]
verbose: bool,
}
fn main() {
// On fait comme si l'utilisateur avait tapé : 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);
}

Sortie :

Cli { celsius: 100.0, precision: 1, verbose: true }
100 °C = 212.0 °F
▶ Essayez par vous-même

Essayez de modifier les arguments fictifs : retirez "--verbose", ou remplacez "--precision" par "-p".

Étape 3 : le texte d’aide à partir des commentaires de documentation

Section intitulée « Étape 3 : le texte d’aide à partir des commentaires de documentation »

Avez-vous remarqué les lignes /// dans la struct ? Ce sont des commentaires de documentation, et clap les transforme en page d’aide. Le commentaire au-dessus de la struct décrit tout le programme ; le commentaire au-dessus de chaque champ décrit cet argument. Vous obtenez --help (et -h) sans rien écrire d’autre :

Terminal window
$ cargo run -q -- --help
Convertit une température de Celsius en Fahrenheit
Usage: convert [OPTIONS] <CELSIUS>
Arguments:
<CELSIUS> La température en degrés Celsius
Options:
-p, --precision <PRECISION> Nombre de chiffres à afficher après la virgule [default: 2]
-v, --verbose Affiche aussi la formule utilisée
-h, --help Print help
-V, --version Print version
$ cargo run -q -- --version
convert 1.0

Vos descriptions apparaissent en français, mais les titres (Usage:, Options:) et les textes que clap ajoute lui-même (Print help, [default: 2]) restent en anglais : ils viennent de clap.

La ligne #[command(name = "convert", version = "1.0")] fixe le nom et la version affichés ici. Si vous écrivez simplement #[command(version)], clap prend la version dans votre Cargo.toml.

clap vous explique aussi les erreurs :

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

Le premier message dit qu’un argument obligatoire (<CELSIUS>) n’a pas été fourni ; le second, que abc n’est pas un nombre à virgule valide.

Un bon texte d’aide est un cadeau pour votre futur vous. Écrivez les commentaires de documentation comme si quelqu’un découvrait votre outil pour la première fois, parce qu’un jour, ce quelqu’un, ce sera vous.

Gérons maintenant aussi les longueurs. Des outils comme git et cargo utilisent des sous-commandes (cargo build, cargo test) pour regrouper différentes tâches. Avec clap, une sous-commande est un enum avec #[derive(Subcommand)]. Chaque variante est une sous-commande, et ses champs sont les arguments de cette sous-commande :

use clap::{Parser, Subcommand};
/// Un petit convertisseur d'unités pour les températures et les longueurs
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Nombre de chiffres à afficher après la virgule
#[arg(short, long, default_value_t = 2, global = true)]
precision: usize,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Convertit une température
Temp {
/// La température à convertir
value: f64,
/// L'unité de départ
#[arg(short, long)]
from: String,
/// L'unité d'arrivée
#[arg(short, long)]
to: String,
},
/// Convertit une longueur
Length {
/// La longueur à convertir
value: f64,
/// L'unité de départ
from: String,
/// L'unité d'arrivée
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:?}");
// On découvre quelle sous-commande a été utilisée avec un simple match.
match cli.command {
Command::Temp { value, from, to } => println!("temp : {value} {from} -> {to}"),
Command::Length { value, from, to } => println!("length : {value} {from} -> {to}"),
}
}

Sortie :

Cli { precision: 2, command: Temp { value: 100.0, from: "c", to: "f" } }
Cli { precision: 1, command: Length { value: 5.0, from: "km", to: "mi" } }
length : 5 km -> mi
▶ Essayez par vous-même

Quelques détails :

  • #[command(subcommand)] sur le champ command dit à clap « ce champ contient la sous-commande choisie ».
  • Le nom de variante Temp devient la sous-commande temp (clap le met en minuscules pour vous).
  • temp utilise des options nommées (--from c --to f) alors que length utilise des arguments positionnels (5 km mi). Les deux styles sont courants ; vous voyez ainsi chacun d’eux.
  • global = true permet à --precision d’apparaître avant ou après la sous-commande. Sans cela, -p 1 ne serait accepté qu’avant length.

La page d’aide liste maintenant les sous-commandes, et chaque sous-commande a sa propre aide :

Terminal window
$ cargo run -q -- --help
Un petit convertisseur d'unités pour les températures et les longueurs
Usage: convert [OPTIONS] <COMMAND>
Commands:
temp Convertit une température
length Convertit une longueur
help Print this message or the help of the given subcommand(s)
Options:
-p, --precision <PRECISION> Nombre de chiffres à afficher après la virgule [default: 2]
-h, --help Print help
-V, --version Print version
$ cargo run -q -- temp --help
Convertit une température
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments:
<VALUE> La température à convertir
Options:
-f, --from <FROM> L'unité de départ
-p, --precision <PRECISION> Nombre de chiffres à afficher après la virgule [default: 2]
-t, --to <TO> L'unité d'arrivée
-h, --help Print help

Mais il y a un problème. Les unités sont de simples String, donc clap accepte n’importe quoi :

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

Il faudrait vérifier chaque unité à la main plus tard. Laissons clap s’en charger.

Quand un argument ne peut prendre qu’un petit nombre de mots, décrivez ces mots sous forme d’enum et dérivez ValueEnum. clap n’accepte alors que ces mots, les liste dans l’aide et rejette tout le reste. Ces enums remplacent les champs String de l’étape 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,
}
  • Chaque variante devient un mot en minuscules : Celsius → celsius, Km → km.
  • #[value(alias = "c")] accepte aussi la forme courte c, sans l’afficher dans l’aide.
  • clap a besoin de Clone. Nous ajoutons aussi Copy, parce que ces petits enums ne coûtent rien à copier et que cela simplifie les match.

Les unités de longueur gardent leurs abréviations anglaises : in (inch, pouce), ft (foot, pied) et mi (mile).

Dans l’enum Command, remplacez from: String par from: TempUnit (et LengthUnit pour les longueurs). Nous ajoutons aussi deux règles :

/// Nombre de chiffres à afficher après la virgule
#[arg(short, long, default_value_t = 2, global = true,
value_parser = clap::value_parser!(u8).range(0..=6))]
precision: u8,
/// La température à convertir
#[arg(allow_negative_numbers = true)]
value: f64,
  • value_parser!(u8).range(0..=6) n’accepte qu’une précision de 0 à 6. Au-delà, on n’afficherait que du bruit.
  • allow_negative_numbers = true vous permet de taper -40. Sans cela, clap croit que -40 est une option courte appelée -4 (voir Erreurs courantes). Les températures peuvent être négatives, donc on l’autorise ; les longueurs non, donc on ne le fait pas.

Désormais, clap attrape les mauvaises saisies avant même que votre code ne s’exécute :

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

Le dernier message dit que 9 n’est pas dans l’intervalle 0..=6. Et la page d’aide affiche maintenant les valeurs possibles :

Terminal window
$ cargo run -q -- temp --help
Convertit une température
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments:
<VALUE> La température à convertir
Options:
-f, --from <FROM> L'unité de départ [possible values: celsius, fahrenheit, kelvin]
-p, --precision <PRECISION> Nombre de chiffres à afficher après la virgule [default: 2]
-t, --to <TO> L'unité d'arrivée [possible values: celsius, fahrenheit, kelvin]
-h, --help Print help

C’est la grande idée de clap : une fois l’analyse réussie, vos données sont déjà valides. from est un vrai TempUnit, pas une chaîne qui pourrait contenir n’importe quoi. Le reste de votre programme peut lui faire confiance.

Certaines erreurs échappent à clap, parce qu’elles dépendent du sens des valeurs. -500 °C est un nombre tout à fait correct, mais c’est plus froid que le zéro absolu (-273.15 °C) : ce n’est donc pas une vraie température. Cette vérification revient à notre propre code.

Une organisation propre consiste à mettre le vrai travail dans une fonction run qui renvoie un Result, et à laisser main décider quoi afficher et quel code de sortie renvoyer. Les programmes signalent leur réussite ou leur échec au shell avec un nombre : 0 veut dire succès, tout le reste veut dire échec. Le type std::process::ExitCode de Rust permet à main d’en renvoyer un :

fn main() -> ExitCode {
let cli = Cli::parse();
match run(&cli) {
Ok(line) => {
println!("{line}");
ExitCode::SUCCESS // code de sortie 0
}
Err(message) => {
eprintln!("erreur : {message}"); // les erreurs vont sur stderr
ExitCode::FAILURE // code de sortie 1
}
}
}

clap suit les mêmes règles. Quand l’analyse échoue, Cli::parse() affiche l’erreur sur stderr et quitte avec le code 2, le code habituel pour « vous avez mal utilisé la commande ». --help et --version quittent avec 0. Dans un terminal, echo $? affiche le dernier code de sortie :

Terminal window
$ cargo run -q -- temp -500 -f c -t k
erreur : -500 °C est plus froid que le zéro absolu
$ 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

Remarquez la différence : « erreur : » vient de notre programme, que nous avons traduit, alors que « error: » vient de clap.

Pourquoi est-ce important ? Les scripts et les autres programmes vérifient les codes de sortie pour savoir si votre outil a fonctionné. La page Variables d’environnement et processus détaille les codes de sortie et stderr. Dans des outils plus gros, vous utiliseriez sans doute anyhow comme type d’erreur de run au lieu de String, pour obtenir gratuitement du contexte et des chaînes d’erreurs.

Voici tout le code réuni. Remplacez src/main.rs par ceci et essayez les commandes du début de la page.

use clap::{Parser, Subcommand, ValueEnum};
use std::process::ExitCode;
/// Un petit convertisseur d'unités pour les températures et les longueurs
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Nombre de chiffres à afficher après la virgule
#[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 {
/// Convertit une température
Temp {
/// La température à convertir
#[arg(allow_negative_numbers = true)]
value: f64,
/// L'unité de départ
#[arg(short, long)]
from: TempUnit,
/// L'unité d'arrivée
#[arg(short, long)]
to: TempUnit,
},
/// Convertit une longueur
Length {
/// La longueur à convertir
value: f64,
/// L'unité de départ
from: LengthUnit,
/// L'unité d'arrivée
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 {
// Combien de mètres vaut une unité de ce type.
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> {
// Étape 1 : convertir ce qu'on a reçu en degrés 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} {} est plus froid que le zéro absolu", from.symbol()));
}
// Étape 2 : convertir les degrés Celsius dans l'unité voulue.
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!("erreur : {message}");
ExitCode::FAILURE
}
}
}

Deux petites astuces dans run : {result:.p$} veut dire « affiche result avec p décimales », et le ? après convert_temp(...) sort de la fonction plus tôt avec l’erreur si la température est impossible.

Étape 8 : tester votre parser avec try_parse_from

Section intitulée « Étape 8 : tester votre parser avec try_parse_from »

Cli::parse() quitte le programme quand quelque chose ne va pas. C’est ce qu’on veut pour les utilisateurs, mais pas dans un test. Cli::try_parse_from(...) fait la même analyse sur une liste d’arguments de votre choix, et renvoie un Result au lieu de quitter. C’est parfait pour vérifier votre parser, et cela tourne dans le Playground :

use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
/// Un petit convertisseur d'unités pour les températures et les longueurs
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Nombre de chiffres à afficher après la virgule
#[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 {
/// Convertit une température
Temp {
/// La température à convertir
#[arg(allow_negative_numbers = true)]
value: f64,
/// L'unité de départ
#[arg(short, long)]
from: TempUnit,
/// L'unité d'arrivée
#[arg(short, long)]
to: TempUnit,
},
/// Convertit une longueur
Length {
/// La longueur à convertir
value: f64,
/// L'unité de départ
from: LengthUnit,
/// L'unité d'arrivée
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 nous donne les arguments, le nom du programme en premier.
match Cli::try_parse_from(line.split_whitespace()) {
Ok(cli) => println!(" ok : {cli:?}"),
Err(err) => {
// On n'affiche que la première ligne du message de clap.
let first_line = err.to_string().lines().next().unwrap_or("").to_string();
println!(" {:?}: {first_line}", err.kind());
}
}
}
fn main() {
// Panique si le parser lui-même est mal configuré (par exemple, deux
// options avec le même nom court). Une assurance qui ne coûte rien.
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");
}

Sortie :

$ 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
▶ Essayez par vous-même

Remarquez que --version revient lui aussi sous forme d’Err. Pour clap, « afficher la version et s’arrêter » n’est pas une analyse réussie, donc c’est signalé avec le type DisplayVersion (et --help avec DisplayHelp). Cli::parse() gère ces cas pour vous et quitte avec le code 0.

Dans votre vrai projet, ces mêmes vérifications vont dans des tests. Ajoutez ceci à la fin de 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 attendu, mais obtenu {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

Oublier la feature derive. Si vous écrivez cargo add clap sans --features derive, les macros derive n’existent pas :

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

Le compilateur ne trouve ni la macro derive Parser ni l’attribut command, et précise que Parser est importé seulement comme trait. La correction : lancez cargo add clap --features derive, ou ajoutez features = ["derive"] à la main dans Cargo.toml.

Les nombres négatifs ressemblent à des options. Avec le programme de l’étape 2, essayez un jour de grand froid :

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 voit - et croit que -4 est une option courte (« argument inattendu »). La correction : ajoutez #[arg(allow_negative_numbers = true)] sur le champ, comme nous l’avons fait à l’étape 5. (Les utilisateurs peuvent aussi taper -- -40 : tout ce qui suit un -- isolé est traité comme une simple valeur.)

Appeler Cli::parse() dans le Playground. Le Playground lance votre programme sans arguments, donc parse() affiche une erreur (ou, pour un outil avec des sous-commandes, la page d’aide) et quitte avec le code 2 avant que votre code ne fasse quoi que ce soit. Utilisez-y Cli::parse_from([...]) ou Cli::try_parse_from([...]), ainsi que dans les tests.

Afficher les erreurs avec println!. Les messages d’erreur vont sur stderr (eprintln!), pas sur stdout. Si quelqu’un lance convert ... > resultat.txt, les erreurs s’affichent quand même à l’écran au lieu de se cacher dans le fichier.

  • Plus d’unités. Ajoutez les yards (yd) et les milles marins à LengthUnit. Le compilateur vous montrera chaque match à mettre à jour.
  • Une sous-commande weight pour les kilogrammes, les livres et les onces.
  • Un flag --json qui affiche le résultat en JSON avec serde_json.
  • Lire les valeurs depuis stdin quand aucune valeur n’est donnée, pour pouvoir envoyer des nombres par un pipe : echo 100 | convert temp -f c -t f. Un champ Option<f64> vous indique si une valeur a été tapée.
  • De meilleures erreurs avec anyhow. Faites renvoyer anyhow::Result<String> à run et utilisez anyhow::bail!. Voir anyhow et thiserror.
  • L’autocomplétion dans le shell. La crate clap_complete (en anglais) génère, à partir de votre struct Cli, des scripts de complétion par tabulation pour bash, zsh, fish et PowerShell.
  • Ajoutez clap avec cargo add clap --features derive, puis mettez #[derive(Parser)] sur une struct. Chaque champ est un argument.
  • Les champs simples sont positionnels, #[arg(short, long)] crée des options, bool crée des flags, et default_value_t fixe des valeurs par défaut. Les commentaires de documentation /// deviennent le texte de --help.
  • #[derive(Subcommand)] sur un enum vous donne des sous-commandes ; #[derive(ValueEnum)] limite un argument à un ensemble fixe de mots. value_parser!(...).range(...) vérifie des intervalles de nombres.
  • Une fois l’analyse réussie, les données sont déjà valides. Mettez les vérifications restantes dans une fonction run qui renvoie un Result, et renvoyez un ExitCode depuis main.
  • clap quitte avec le code 2 pour les erreurs d’utilisation et 0 pour --help/--version. Vos propres échecs doivent aussi utiliser un code différent de zéro, et s’afficher sur stderr.
  • Cli::try_parse_from analyse une liste de votre choix et renvoie un Result : parfait pour les tests et le Playground.

Petit quiz

1. Quelle commande cargo add active #[derive(Parser)] ?

2. Où clap trouve-t-il la description de chaque option dans --help ?

3. Que renvoie Cli::try_parse_from(["convert", "--version"]) ?

4. Un utilisateur tape une unité invalide et Cli::parse() échoue. Quel code de sortie clap utilise-t-il ?

5. Pourquoi convert -40 échoue-t-il avec le programme de l'étape 2 ?

Score : 0 / 5