Kommandozeilenprogramm

Unser Programm wird greet heissen und die Funktionalität des bekannten Hello, world! Programms erweitern.

Projektstruktur

Erstelle zuerst ein neues Rust Projekt:

cargo new greet
cd greet

Als nächstes fügen wir die Bibliothek clap hinzu. Das ist kurz für "command line application parser" und ist die beliebteste Bibliothek, um CLIs zu bauen. Die Dokumentation von clap findest du hier: docs.rs/clap.

cargo add clap --features derive

Vergiss nicht --features derive! Das ist eine optionale Funktion, die wir aber unbedingt verwenden wollen.

"leerer" Kommandozeilenparser

Ab jetzt arbeiten wir in der Datei src/main.rs, um unser programm zu schreiben. Füge die erste Version des Programms ein:

use clap::Parser;

#[derive(clap::Parser)]
struct CliArgs {}

fn main() {
    let args = CliArgs::parse();
    println!("Hello, world!");
}

Ein paar Erklärungen:

  • struct CliArgs {} ist eine Typdefinition, momentan hat sie noch keine Felder. Hier werden wir spezifizieren, was für Kommandozeilenargumente wir erwarten.
  • #[derive(clap::Parser)] ist ein Makro, das unsere Typdefinition in ein Kommandozeilenparser verwandelt.

Wenn du dieses Programm mit cargo run ausführst, dann gibt es erstmal nur Hello, world! aus. (Plus eine Warnung, dass args nicht verwendet wird.)

Wenn du nun aber cargo run -- --help ausführst, dann siehst du schon den ersten Unterschied. clap generiert standardmässig eine --help flag, die dem User aufzeigt wie das Programm verwendet werden kann.

Übrigens: Die zwei Bindestriche in cargo run -- --help sind nötig, damit cargo weiss, dass die folgended argumente für das Programm selber sind, und nicht für cargo. Alternativ kannst du das Programm erst kompilieren mit cargo build und dann manuall ausführen mit ./target/debug/greet --help. Dann sind die zwei Bindestriche nicht mehr nötig.

Das erste Argument

Nun fügen wir das erste Argument hinzu. Anstatt die ganze Welt wollen wir bestimmte Personen grüssen. Dafür benötigen wir einen Namen. Füge ein neues Feld in user struct CliArgs ein:

struct CliArgs {
    name: String,
}

Dieses Feld ist nun in der Variable args verfügbar und wir können den Namen ausgeben:

println!("Hello, {}!", args.name);

Wenn wir dieses Programm ausführen mit cargo run, dann wird es einen Fehler ausgeben, weil das Argument name fehlt. Die Fehlerausgabe sagt uns auch schön, wie wir den Namen mitgeben müssen. Standardmässig sind die Argumente positionell, das heisst ein korrekter Aufruf ist: cargo run -- Jeremy.

Unterbefehle

Das ist toll soweit, aber unser Programm soll beides können. Der Benutze soll wählen können, ob die Welt oder eine Person gegrüsst werden soll. Dazu verwenden wir Unterbefehle (Subcommands) und müssen unsere Typdefinition umkrempeln:

#[derive(clap::Parser)]
struct CliArgs {
    #[command(subcommand)]
    command: Command,
}

#[derive(clap::Subcommand)]
enum Command {
    World,
    Person { name: String },
}

Hier kommt ein neuer Typ namens Command hinzu. Es ist ein enum, also eine Aufzählung aller möglichen Befehle. Beim Befehl World gibt es keine zusätzlichen Argumente, beim Befehl Person muss noch der Name mit. Ausserdem braucht es im Haupstruct CliArgs die Annotation #[command(subcommand)] über dem Feld command, damit das Makro von clap weiss, dass es sich nicht um ein Argument, sondern einen Unterbefehl handeln soll.

Die main Funktion müssen wir auch anpassen, weil args kein Feld name mehr hat:

fn main() {
    let args = CliArgs::parse();

    match args.command {
        Command::World => println!("Hello, world!"),
        Command::Person { name } => println!("Hello, {name}!"),
    };
}

Als erstes "matchen" wir auf args.command. Das bedeuted, wir führen unterschiedlichen Code aus, je nachdem welche "Struktur" der Befehl annimmt. Die match-Anweisung ist vergleichbar mit switch in anderen Sprachen, es ist aber wie gesagt auf die Struktur von Daten spezialisiert.

Handelt es sich beim Befehl um World, dann geben wir Hello, world! aus, ganz einfach. Wenn der Befehl Person ist, dann wird das enthaltene Feld name direkt an eine gleichnamige Variable gebunden, die wir danach ausgeben können.

Beobachte das folgende Verhalten des Programms:

  • cargo run schlägt fehl und zeigt die Liste verfügbarer Unterbefehle.
  • cargo run -- world ist erfolgreich.
  • cargo run -- person schlägt fehl und macht auf das fehlende argument "name" aufmerksam.
  • cargo run -- person Jeremy ist erfolgreich.

Fazit

Toll! Damit haben wir schon ein bisschen hands-on Erfahrung mit Makros und Enums gesammelt.

  • Makros nehmen Code als Eingabe (in Unserem fall die Typdeklarationen CliArgs und Command) und produzieren neuen Code daraus. Der produzierte Code kann ein ganzer Komgandozeilenparser sein.

  • Enums erlauben uns die Struktur unserer Daten präzise zu beschreiben. Die match-Anweisung lässt uns diese Struktur verarbeiten.