Zum Inhalt springen

Projekt: CLI-Tool mit clap in Rust

Auf der Seite Kommandozeilenargumente hast du Argumente von Hand mit std::env::args() gelesen. Für ein oder zwei Argumente klappt das gut. Echte Werkzeuge brauchen aber mehr: Optionen wie --precision 3, Kurzformen wie -p 3, eine --help-Seite, gute Fehlermeldungen und Unterbefehle wie bei git commit und git push. Das alles selbst zu schreiben, ist eine Menge langweiliger und fehleranfälliger Code.

clap (Command Line Argument Parser) ist das Crate, das fast jedes Rust-CLI-Tool dafür benutzt. Du beschreibst deine Argumente als ganz normales Rust-Struct, und clap schreibt den Parser, den Hilfetext und die Fehlermeldungen für dich. In diesem Projekt baust du convert, einen kleinen Einheitenumrechner mit zwei Unterbefehlen: temp für Temperaturen und length für Längen.

So sieht das fertige Werkzeug im Terminal aus. Jede Ausgabe auf dieser Seite stammt aus einem echten Programmlauf.

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

Alles nach -- geht an dein Programm statt an Cargo, und -q (quiet, „leise“) blendet Cargos eigene „Compiling…“-Zeilen aus. Und die Fehlermeldung ganz unten? Die haben nicht wir geschrieben, sondern clap.

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

Der wichtige Teil ist das Feature derive. Es schaltet #[derive(Parser)] frei – die bequeme Art, clap zu benutzen. Deine Cargo.toml enthält jetzt:

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

(Deine Versionsnummer kann etwas neuer sein. Alles, was mit 4 beginnt, funktioniert für diese Seite. Falls cargo add neu für dich ist, schau bei Cargo und Crates vorbei.)

Wir fangen mit einem Werkzeug an, das nur Celsius in Fahrenheit umrechnet. Du schreibst ein Struct mit einem Feld pro Argument und setzt #[derive(Parser)] darauf. Ersetze src/main.rs durch:

use clap::Parser;
/// Rechnet eine Temperatur von Celsius in Fahrenheit um
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Die Temperatur in Celsius
celsius: f64,
/// Wie viele Nachkommastellen angezeigt werden
#[arg(short, long, default_value_t = 2)]
precision: usize,
/// Zeigt zusätzlich die verwendete Formel an
#[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!("(Formel: °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
(Formel: °F = °C × 9/5 + 32)

clap schaut sich jedes Feld an und entscheidet, was für ein Argument es ist:

  • celsius hat keine #[arg(...)]-Optionen, also ist es ein Positionsargument: der erste einfache Wert, den du tippst. Weil sein Typ f64 ist, wandelt clap den Text "21.5" auch gleich in eine Zahl um.
  • #[arg(short, long)] macht daraus eine Option mit Kurz- und Langnamen, die aus dem Feldnamen abgeleitet werden: -p / --precision. default_value_t = 2 ist der Standardwert, wenn du die Option weglässt.
  • Ein bool-Feld wird zu einem Flag: Es ist true, wenn --verbose (oder -v) dasteht, sonst false.
  • Ein Option<T>-Feld (hier nicht benutzt) wäre ein Argument, das man auch ganz weglassen darf.

{:.*} in println! bedeutet: „Nimm das nächste Argument als Anzahl der Nachkommastellen.“ So landet --precision in der Ausgabe.

Die ///-Zeilen sind Doc-Kommentare – dazu gleich mehr. Wir haben sie ins Deutsche übersetzt, weil clap daraus den Hilfetext baut.

Cli::parse() liest die echte Kommandozeile. Der Rust Playground hat aber keine Kommandozeile. Um den Parser dort auszuprobieren, nimmst du Cli::parse_from mit einer Liste ausgedachter Argumente. Das erste Element ist immer der Programmname, genau wie bei std::env::args():

use clap::Parser;
/// Rechnet eine Temperatur von Celsius in Fahrenheit um
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Die Temperatur in Celsius
celsius: f64,
/// Wie viele Nachkommastellen angezeigt werden
#[arg(short, long, default_value_t = 2)]
precision: usize,
/// Zeigt zusätzlich die verwendete Formel an
#[arg(short, long)]
verbose: bool,
}
fn main() {
// So tun, als hätte jemand das getippt: 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);
}

Ausgabe:

Cli { celsius: 100.0, precision: 1, verbose: true }
100 °C = 212.0 °F
▶ Selbst ausprobieren

Spiel mit den ausgedachten Argumenten: Lass "--verbose" weg oder ersetze "--precision" durch "-p".

Sind dir die ///-Zeilen im Struct aufgefallen? Das sind Doc-Kommentare, und clap macht daraus die Hilfeseite. Der Kommentar über dem Struct beschreibt das ganze Programm, der Kommentar über jedem Feld beschreibt dieses Argument. --help (und -h) bekommst du, ohne noch etwas zu schreiben:

Terminal window
$ cargo run -q -- --help
Rechnet eine Temperatur von Celsius in Fahrenheit um
Usage: convert [OPTIONS] <CELSIUS>
Arguments:
<CELSIUS> Die Temperatur in Celsius
Options:
-p, --precision <PRECISION> Wie viele Nachkommastellen angezeigt werden [default: 2]
-v, --verbose Zeigt zusätzlich die verwendete Formel an
-h, --help Print help
-V, --version Print version
$ cargo run -q -- --version
convert 1.0

Deine Beschreibungen erscheinen auf Deutsch. Die Überschriften („Usage“, „Options“) und Zeilen wie „Print help“ stammen von clap selbst und bleiben Englisch.

Die Zeile #[command(name = "convert", version = "1.0")] legt den Namen und die Version fest, die hier angezeigt werden. Wenn du nur #[command(version)] schreibst, nimmt clap die Version stattdessen aus deiner Cargo.toml.

clap erklärt auch Fehler für dich:

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

Die Meldungen sind Englisch, weil clap sie so ausgibt. Die erste sagt „das Pflichtargument <CELSIUS> fehlt“, die zweite „abc ist keine gültige Kommazahl“.

Guter Hilfetext ist ein Geschenk an dein zukünftiges Ich. Schreib die Doc-Kommentare so, als würde jemand dein Werkzeug zum ersten Mal sehen – denn eines Tages bist du dieser Jemand.

Jetzt sollen auch Längen gehen. Werkzeuge wie git und cargo gruppieren verschiedene Aufgaben in Unterbefehle (Subcommands), etwa cargo build und cargo test. In clap ist ein Unterbefehl ein Enum mit #[derive(Subcommand)]. Jede Variante ist ein Unterbefehl, und ihre Felder sind die Argumente dieses Unterbefehls:

use clap::{Parser, Subcommand};
/// Ein kleiner Einheitenumrechner für Temperaturen und Längen
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Wie viele Nachkommastellen angezeigt werden
#[arg(short, long, default_value_t = 2, global = true)]
precision: usize,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand, Debug)]
enum Command {
/// Rechnet eine Temperatur um
Temp {
/// Die Temperatur, die umgerechnet wird
value: f64,
/// Die Einheit, aus der umgerechnet wird
#[arg(short, long)]
from: String,
/// Die Einheit, in die umgerechnet wird
#[arg(short, long)]
to: String,
},
/// Rechnet eine Länge um
Length {
/// Die Länge, die umgerechnet wird
value: f64,
/// Die Einheit, aus der umgerechnet wird
from: String,
/// Die Einheit, in die umgerechnet wird
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:?}");
// Welcher Unterbefehl benutzt wurde, findest du mit einem normalen match heraus.
match cli.command {
Command::Temp { value, from, to } => println!("Temperatur: {value} {from} -> {to}"),
Command::Length { value, from, to } => println!("Länge: {value} {from} -> {to}"),
}
}

Ausgabe:

Cli { precision: 2, command: Temp { value: 100.0, from: "c", to: "f" } }
Cli { precision: 1, command: Length { value: 5.0, from: "km", to: "mi" } }
Länge: 5 km -> mi
▶ Selbst ausprobieren

Ein paar Details:

  • #[command(subcommand)] auf dem Feld command sagt clap: „In diesem Feld steht der gewählte Unterbefehl.“
  • Aus dem Variantennamen Temp wird der Unterbefehl temp (clap schreibt ihn für dich klein).
  • temp benutzt benannte Optionen (--from c --to f), length dagegen Positionsargumente (5 km mi). Beide Stile sind üblich, so siehst du jeden einmal.
  • Mit global = true darf --precision vor oder nach dem Unterbefehl stehen. Ohne das würde -p 1 nur vor length akzeptiert.

Die Hilfeseite listet jetzt die Unterbefehle auf, und jeder Unterbefehl hat seine eigene Hilfe:

Terminal window
$ cargo run -q -- --help
Ein kleiner Einheitenumrechner für Temperaturen und Längen
Usage: convert [OPTIONS] <COMMAND>
Commands:
temp Rechnet eine Temperatur um
length Rechnet eine Länge um
help Print this message or the help of the given subcommand(s)
Options:
-p, --precision <PRECISION> Wie viele Nachkommastellen angezeigt werden [default: 2]
-h, --help Print help
-V, --version Print version
$ cargo run -q -- temp --help
Rechnet eine Temperatur um
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments:
<VALUE> Die Temperatur, die umgerechnet wird
Options:
-f, --from <FROM> Die Einheit, aus der umgerechnet wird
-p, --precision <PRECISION> Wie viele Nachkommastellen angezeigt werden [default: 2]
-t, --to <TO> Die Einheit, in die umgerechnet wird
-h, --help Print help

Aber es gibt ein Problem. Die Einheiten sind einfache Strings, also nimmt clap alles an:

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

Wir müssten später jede Einheit von Hand prüfen. Lassen wir das lieber clap erledigen.

Wenn ein Argument nur eines von wenigen Wörtern sein darf, beschreibst du diese Wörter als Enum und leitest ValueEnum ab. Dann akzeptiert clap nur genau diese Wörter, zeigt sie in der Hilfe an und lehnt alles andere ab. Diese Enums ersetzen die String-Felder aus Schritt 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,
}
  • Jede Variante wird zu einem kleingeschriebenen Wort: Celsius → celsius, Km → km.
  • #[value(alias = "c")] akzeptiert zusätzlich die Kurzform c, ohne sie in der Hilfe anzuzeigen.
  • clap braucht Clone. Wir nehmen auch Copy dazu, weil diese kleinen Enums billig zu kopieren sind und das match einfacher macht.

Ändere im Enum Command das from: String zu from: TempUnit (und bei Längen zu LengthUnit). Außerdem kommen zwei weitere Regeln dazu:

/// Wie viele Nachkommastellen angezeigt werden
#[arg(short, long, default_value_t = 2, global = true,
value_parser = clap::value_parser!(u8).range(0..=6))]
precision: u8,
/// Die Temperatur, die umgerechnet wird
#[arg(allow_negative_numbers = true)]
value: f64,
  • value_parser!(u8).range(0..=6) akzeptiert nur eine Genauigkeit von 0 bis 6. Mehr Stellen wären nur Rauschen.
  • allow_negative_numbers = true erlaubt dir, -40 zu tippen. Ohne das hält clap -40 für eine Kurzoption namens -4 (siehe Häufige Fehler). Temperaturen können negativ sein, also erlauben wir es. Längen nicht, also dort nicht.

Jetzt fängt clap ungültige Eingaben ab, bevor dein Code überhaupt läuft:

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

rankine und yd stehen nicht in der Liste der möglichen Werte („possible values“), und 9 liegt nicht im Bereich 0..=6.

Und die Hilfeseite zeigt jetzt die möglichen Werte an:

Terminal window
$ cargo run -q -- temp --help
Rechnet eine Temperatur um
Usage: convert temp [OPTIONS] --from <FROM> --to <TO> <VALUE>
Arguments:
<VALUE> Die Temperatur, die umgerechnet wird
Options:
-f, --from <FROM> Die Einheit, aus der umgerechnet wird [possible values: celsius, fahrenheit, kelvin]
-p, --precision <PRECISION> Wie viele Nachkommastellen angezeigt werden [default: 2]
-t, --to <TO> Die Einheit, in die umgerechnet wird [possible values: celsius, fahrenheit, kelvin]
-h, --help Print help

Das ist die große Idee hinter clap: Sobald das Parsen klappt, sind deine Daten schon gültig. from ist ein echtes TempUnit, kein String, in dem irgendetwas stehen könnte. Der Rest deines Programms kann sich darauf verlassen.

Manche Fehler kann clap nicht erkennen, weil sie von der Bedeutung der Werte abhängen. -500 °C ist eine völlig normale Zahl, aber kälter als der absolute Nullpunkt (-273.15 °C) – also keine echte Temperatur. Diese Prüfung gehört in unseren eigenen Code.

Ein aufgeräumtes Muster: Steck die eigentliche Arbeit in eine Funktion run, die ein Result zurückgibt, und lass main entscheiden, was ausgegeben wird und welcher Exit-Code (Rückgabecode) zurückgeht. Programme melden der Shell mit einer Zahl, ob sie erfolgreich waren: 0 heißt Erfolg, alles andere heißt Fehlschlag. Mit std::process::ExitCode aus Rusts Standardbibliothek kann main einen solchen Code zurückgeben:

fn main() -> ExitCode {
let cli = Cli::parse();
match run(&cli) {
Ok(line) => {
println!("{line}");
ExitCode::SUCCESS // Exit-Code 0
}
Err(message) => {
eprintln!("Fehler: {message}"); // Fehler gehören nach stderr
ExitCode::FAILURE // Exit-Code 1
}
}
}

clap hält sich an dieselben Regeln. Wenn das Parsen fehlschlägt, schreibt Cli::parse() den Fehler nach stderr und beendet das Programm mit Code 2, dem üblichen Code für „du hast den Befehl falsch benutzt“. --help und --version enden mit 0. Im Terminal zeigt echo $? den letzten Exit-Code an:

Terminal window
$ cargo run -q -- temp -500 -f c -t k
Fehler: -500 °C ist kälter als der absolute Nullpunkt
$ 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

Unsere eigene Meldung beginnt mit „Fehler:“, die von clap mit „error:“. Wenn dir das zu gemischt ist, kannst du natürlich auch bei error: bleiben.

Warum ist das wichtig? Skripte und andere Programme prüfen Exit-Codes, um zu wissen, ob dein Werkzeug funktioniert hat. Die Seite Umgebung und Prozesse erklärt Exit-Codes und stderr genauer. In größeren Werkzeugen würdest du für den Fehlertyp von run wahrscheinlich anyhow statt String nehmen. Dann bekommst du Kontext und Fehlerketten geschenkt.

Hier ist alles zusammen. Ersetze src/main.rs durch diesen Code und probier die Befehle vom Anfang der Seite aus.

use clap::{Parser, Subcommand, ValueEnum};
use std::process::ExitCode;
/// Ein kleiner Einheitenumrechner für Temperaturen und Längen
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Wie viele Nachkommastellen angezeigt werden
#[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 {
/// Rechnet eine Temperatur um
Temp {
/// Die Temperatur, die umgerechnet wird
#[arg(allow_negative_numbers = true)]
value: f64,
/// Die Einheit, aus der umgerechnet wird
#[arg(short, long)]
from: TempUnit,
/// Die Einheit, in die umgerechnet wird
#[arg(short, long)]
to: TempUnit,
},
/// Rechnet eine Länge um
Length {
/// Die Länge, die umgerechnet wird
value: f64,
/// Die Einheit, aus der umgerechnet wird
from: LengthUnit,
/// Die Einheit, in die umgerechnet wird
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 {
// Wie viele Meter eine Einheit dieser Art ist.
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> {
// Schritt 1: Den Wert erst einmal in Celsius umrechnen.
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} {} ist kälter als der absolute Nullpunkt", from.symbol()));
}
// Schritt 2: Celsius in die gewünschte Einheit umrechnen.
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!("Fehler: {message}");
ExitCode::FAILURE
}
}
}

Zwei kleine Tricks in run: {result:.p$} bedeutet „gib result mit p Nachkommastellen aus“, und das ? nach convert_temp(...) kehrt mit dem Fehler vorzeitig zurück, wenn die Temperatur unmöglich ist.

Cli::parse() beendet das Programm, wenn etwas nicht stimmt. Für Benutzer ist das genau richtig, aber nicht in einem Test. Cli::try_parse_from(...) parst genauso, aber eine Liste von Argumenten, die du selbst wählst, und gibt ein Result zurück, statt das Programm zu beenden. Damit kannst du deinen Parser wunderbar prüfen – und es läuft im Playground:

use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
/// Ein kleiner Einheitenumrechner für Temperaturen und Längen
#[derive(Parser, Debug)]
#[command(name = "convert", version = "1.0")]
struct Cli {
/// Wie viele Nachkommastellen angezeigt werden
#[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 {
/// Rechnet eine Temperatur um
Temp {
/// Die Temperatur, die umgerechnet wird
#[arg(allow_negative_numbers = true)]
value: f64,
/// Die Einheit, aus der umgerechnet wird
#[arg(short, long)]
from: TempUnit,
/// Die Einheit, in die umgerechnet wird
#[arg(short, long)]
to: TempUnit,
},
/// Rechnet eine Länge um
Length {
/// Die Länge, die umgerechnet wird
value: f64,
/// Die Einheit, aus der umgerechnet wird
from: LengthUnit,
/// Die Einheit, in die umgerechnet wird
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 liefert uns die Argumente, den Programmnamen zuerst.
match Cli::try_parse_from(line.split_whitespace()) {
Ok(cli) => println!(" ok: {cli:?}"),
Err(err) => {
// Nur die erste Zeile der Meldung von clap anzeigen.
let first_line = err.to_string().lines().next().unwrap_or("").to_string();
println!(" {:?}: {first_line}", err.kind());
}
}
}
fn main() {
// Löst eine Panic aus, wenn der Parser selbst falsch eingerichtet ist
// (zum Beispiel zwei Optionen mit demselben Kurznamen). Billige Versicherung.
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");
}

Ausgabe:

$ 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
▶ Selbst ausprobieren

Fällt dir auf, dass auch --version als Err zurückkommt? Für clap ist „Version anzeigen und aufhören“ kein erfolgreiches Parsen. Deshalb wird es mit der Art (kind) DisplayVersion gemeldet (und --help mit DisplayHelp). Cli::parse() kümmert sich selbst darum und beendet das Programm mit Code 0.

In deinem echten Projekt gehören dieselben Prüfungen in Tests. Füge das ans Ende von src/main.rs an:

#[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 erwartet, aber {other:?} bekommen"),
}
}
#[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

Das Feature derive vergessen. Wenn du cargo add clap ohne --features derive schreibst, gibt es die Derive-Makros nicht:

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

Der Compiler findet das Derive-Makro Parser nicht – importiert ist nur der Trait – und auch das Attribut command nicht. Lösung: Führ cargo add clap --features derive aus oder trag features = ["derive"] von Hand in die Cargo.toml ein.

Negative Zahlen sehen aus wie Optionen. Probier mit dem Programm aus Schritt 2 einen kalten Tag aus:

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 sieht das - und hält -4 für eine Kurzoption („unexpected argument“ heißt „unerwartetes Argument“). Lösung: Schreib #[arg(allow_negative_numbers = true)] an das Feld, wie in Schritt 5. (Benutzer können auch -- -40 tippen: Alles nach einem einzelnen -- gilt als einfacher Wert.)

Cli::parse() im Playground aufrufen. Der Playground startet dein Programm ohne Argumente. Deshalb gibt parse() einen Fehler aus (oder, bei einem Werkzeug mit Unterbefehlen, die Hilfeseite) und beendet das Programm mit Code 2, bevor dein Code irgendetwas tut. Nimm dort und in Tests Cli::parse_from([...]) oder Cli::try_parse_from([...]).

Fehler mit println! ausgeben. Fehlermeldungen gehören nach stderr (eprintln!), nicht nach stdout. Wenn jemand convert ... > result.txt ausführt, erscheinen die Fehler dann trotzdem auf dem Bildschirm, statt in der Datei zu verschwinden.

  • Mehr Einheiten. Füge Yards (yd) und Seemeilen zu LengthUnit hinzu. Der Compiler zeigt dir jedes match, das du anpassen musst.
  • Ein Unterbefehl weight für Kilogramm, Pfund und Unzen.
  • Ein Flag --json, das das Ergebnis mit serde_json als JSON ausgibt.
  • Werte von stdin lesen, wenn kein Wert angegeben ist. Dann kannst du Zahlen hineinleiten: echo 100 | convert temp -f c -t f. Ein Option<f64>-Feld verrät dir, ob ein Wert getippt wurde.
  • Bessere Fehler mit anyhow. Lass run ein anyhow::Result<String> zurückgeben und benutze anyhow::bail!. Siehe anyhow und thiserror.
  • Shell-Vervollständigung. Das Crate clap_complete (auf Englisch) erzeugt aus deinem Cli-Struct Skripte für die Tab-Vervollständigung in bash, zsh, fish und PowerShell.
  • Füge clap mit cargo add clap --features derive hinzu und setze #[derive(Parser)] auf ein Struct. Jedes Feld ist ein Argument.
  • Einfache Felder sind Positionsargumente, #[arg(short, long)] macht Optionen, bool macht Flags, und default_value_t setzt Standardwerte. Aus ///-Doc-Kommentaren wird der --help-Text.
  • #[derive(Subcommand)] auf einem Enum liefert Unterbefehle. #[derive(ValueEnum)] beschränkt ein Argument auf eine feste Menge von Wörtern. value_parser!(...).range(...) prüft Zahlenbereiche.
  • Wenn das Parsen klappt, sind die Daten schon gültig. Die restlichen Prüfungen kommen in eine Funktion run, die ein Result zurückgibt, und main gibt einen ExitCode zurück.
  • clap beendet das Programm bei Bedienfehlern mit Code 2 und bei --help/--version mit 0. Deine eigenen Fehlschläge sollten auch einen Code ungleich 0 nutzen und nach stderr schreiben.
  • Cli::try_parse_from parst eine Liste, die du selbst wählst, und gibt ein Result zurück – perfekt für Tests und den Playground.

Kurzer Check

1. Welcher cargo add-Befehl schaltet #[derive(Parser)] frei?

2. Woher nimmt clap die Beschreibung jeder Option in --help?

3. Was gibt Cli::try_parse_from(["convert", "--version"]) zurück?

4. Jemand tippt eine ungültige Einheit, und Cli::parse() schlägt fehl. Welchen Exit-Code benutzt clap?

5. Warum schlägt convert -40 im Programm aus Schritt 2 fehl?

Punkte: 0 / 5