Interface en ligne de commande et entrées / sorties

Concepts

Les concepts abordés dans cet exemple sont:

Discussion

L'écosystème de Rust contient énormément de librairies ergonomiques et efficaces. Dans ce chapitre, nous parlerons de clap, une librairie pour construire des interfaces en ligne de commande. Nous en profiterons pour discuter également les entrées / sorties et en particulier comment écrire dans des fichiers. Finalement, nous verrons également avoir une gestion d'erreur un peu plus ergonomique à l'aide de l'opérateur ? et de la fonction map_err().

Vous pouvez trouver plus d'informations aux liens suivants:

L'interface à la ligne de commande et l'utilisation de librairies externes

Dans cette section nous allons voir une façon différente de lire la ligne de commande (par rapport à ce que nous avons fait dans la partie 07).

Cette façon de faire est trop complexe pour construire une vraie application et rajouterait beaucoup d'efforts à chaque fois qu'on veut en reconstruire une: elle demanderait un parsing long et fastidieux de la ligne de commande manuel. La librairie clap, la librairie de CLI la plus populaire pour Rust, nous permet de de construire une interface pour un programme avec arguments nommés, optionnel, et un menu d'aide de façon élégante et une bonne gestion des erreurs.

Afin d'utiliser une librairie externe, il faut l'ajouter comme dépendance dans le fichier Cargo.toml de notre projet. Pour ce faire, il y a deux méthodes, et nous allons voir comment cela fonctionne pour clap:

  1. Ajouter la ligne
clap = { version = "4.4.0", features = ["derive"] }

sous l'entrée [dependecies].

  1. Utiliser l'outil cargo qui le fait pour nous
cargo add clap --features derive

Il y a énormément de fonctionnalités dans l'outil cargo.

Ces deux façons sont équivalentes. Lors de l'ajout manuel, on doit choisir la version manuellement qu'on veut mettre dans le fichier Cargo.toml (cela permet de figer une version) ou on peut remplacer 4.4.0 par * pour avoir toujours la dernière version de la crate à utiliser. Cependant cette façon de faire n'est pas recommandée, car cela peut "casser" la compilation lors d'une mise à jour majeure (ou avoir des effets de sécurité indésirables).

On note également, qu'on a un champs features qui est optionnel, mais qui ici est mis à derive. Le langage Rust permet d'omettre une partie des fonctionnalités d'une librairies qui sont ajoutées à l'aide d'annotations lorsque la feature est activée. Nous n'entrerons pas dans les détails de ces annotations, mais avons besoin de la feature derive pour compiler notre code.

Nous pouvons à présent commencer à écrire nos fonctions pour lire la ligne de commande à l'aide de la librairie clap. Nous allons voir deux façons différentes de créer une telle interface avec la librairie: les pattern builder et derive (c'est pour ce dernier que nous avons besoin de la feature derive).

Le but de cette interface à la ligne de commande est pour l'utilisateur·trice de pouvoir choisir les options suivantes pour notre petit programme de calcul de minimum dans une liste.

  1. Entrer à la main une liste de nombres.
  2. Créer une liste de count nombres aléatoires qui seront lus depuis /dev/urandom.
  3. Écrire la liste de nombres et le minimum de la liste dans un fichier de sortie (en en fournissant le nom) ou sur l'écran.

Il faut noter que l'option 1 et 2 son mutuellement exclusives. L'option 3 écrira dans un fichier uniquement si un nom de fichier est fourni par l'utilisateur·trice.

Il est fondamental que si les entrées sont mal formatées (on ne donne pas des nombres p.ex.) ou si on essaie d'utiliser les options 1 et 2 en même temps, on ait un bon traitement de l'erreur et un message d'erreur lisible.

Des tentatives d'exécution typiques seraient

$ cargo run -- --numbers 1 2 3 4 5 6

où on donne une liste de nombres après l'option --numbers ou encore

$ cargo run -- --output fichier.txt --numbers 1 2 3 4 5 6

--output permet de spécifier un nom de fichier. En revanche, on doit avoir une erreur si on essaie de faire

$ cargo run -- --count 10 --numbers 1 2 3 4 5 6

car on ne veut pas pouvoir générer deux listes de nombres, mais une seule.

Ainsi on a trois arguments possibles et tous sont optionnels, mais deux sont exclusifs.

Le builder pattern

Nous allons voir à présent comment construire une interface en ligne de commande à proprement parler avec clap. Pour ce faire et comprendre le fonctionnement interne de la librairie nous allons d'abord étudier le builder pattern, qui consiste à construire l'interface à l'aide de fonctions qui vont construire petit à petit notre application.

La fonction qui va faire tourner notre application se trouve dans src/io.rs et a la signature suivante

pub fn read_command_line_builder() -> Result<(), String>

On remarque qu'elle ne prend aucun argument en paramètre et qu'elle retourne un Result<(), String>. En d'autres termes, si tout s'est bien passé, nous ne retournons "rien". Dans l'éventualité où quelque chose ne s'est pas passé comme prévu, nous retournons une chaîne de caractères qui contiendra un message d'erreur.

Le création de la "commande" se trouve dans le code

    let matches = 
        Command::new(COMMAND)
            .author(AUTHOR)
            .version(VERSION)
            .arg(
                Arg::new("numbers") // id
                    .short('n')         // version courte -n
                    .long("numbers")    // ou longue --numbers
                    .help("A list of i32 numbers") // l'aide
                    .num_args(1..) // combien il y a d'entrées
                    .allow_negative_numbers(true) // on peut avoir des négatifs
                    .value_parser(value_parser!(i32)) // on veut s'assurer que ça soit des nombres
                    .required(false), // optionnel
            )
            .arg(
                Arg::new("count")
                    .short('c')
                    .long("count")
                    .help("How many random numbers we want?")
                    .value_parser(value_parser!(usize))
                    .conflicts_with("numbers") // impossible d'avoir -c et -n
                    .required(false),
            )
            .arg(
                Arg::new("output")
                    .short('o')
                    .long("output")
                    .help("Should we write output in a file?")
                    .required(false),
            )
            .get_matches();

Ici nous effectuons diverses opérations. Nous commençons par créer une nouvelle commande dont le nom est cli1

const COMMAND: &str = "cli";
const AUTHOR: &str = "Orestis Malaspinas";
const VERSION: &str = "0.1.0";
        Command::new(COMMAND)
            .author(AUTHOR)
            .version(VERSION)

avec différents composants optionnels, comme le nom de l'auteur de l'application, sa version, etc. Cela permet maintenant d'ajouter les arguments sur cette application.

Comme discuté plus haut nous voulons trois arguments (numbers, output, et count) qui sont ajouté dans un ordre qui n'a aucune importance.

        Command::new(COMMAND)
            .author(AUTHOR)
            .version(VERSION)

L'appel à la méthode .arg() nous permet d'ajouter un nouvel argument, créé avec l'appel à

Arg::new(id)

id est une chaîne de caractères qui permet d'identifier de façon unique l'argument. Puis viennent toutes les propriétés de notre argument:

  • short('n'): l'option peut être nommée -n,
  • long("numbers"): l'option peut être nommée --numbers, permettant d'appeler le programme avec
$ cargo run -- --numbers 1 2 3 4 5 6 
$ cargo run -- -n 1 2 3 4 5 6 
  • help("A list of i32 numbers"): le message d'aide si nous appelons
⋊> ~/g/p/r/c/r/cli on 25-cli-i-o ⨯ cargo run -- --help    
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/cli --help`
Usage: cli [OPTIONS]

Options:
  -n, --numbers <numbers>...  A list of i32 numbers
  -c, --count <count>         How many random numbers we want?
  -o, --output <output>       Should we write output in a file?
  -h, --help                  Print help
  -V, --version               Print version
  • num_args(1..): qui permet d'avoir plusieurs valeurs dans l'argument et savons qu'il doit y en avoir plus d'un. Sans cet argument, l'appel
$ cargo run -- --numbers 1 2 3 4 5 6

considérerait le 2 comme la valeur d'un autre argument et ne ferait pas partie de numbers. Puis viennent encore

  • allow_negative_numbers(true): pour autoriser les nombres négatifs (sinon - est parsé comme nouvel argument)
  • value_parser(value_parser!(i32)): on ne veut que des i32 (les nombres à virgules, les lettres, etc sont automatiquement rejetées et un message d'erreur est affiché)
  • required(false): est-ce que l'argument est obligatoire (ici ce n'est pas le cas)? En d'autres termes est-ce l'exécution suivante est valide?
$ cargo run -- # sans option numbers

Si l'argument est required(true) alors il est nécessaire de spécifier l'option sinon on aura un message d'erreur.

Dans la suite du code on crée encore deux arguments count et output. Nous avons déjà couvert les différentes fonctions appelée, à l'exception d'une:

  • conflicts_with("numbers"): ici nous spécifions que l'argument count ne peut pas être présent en même temps que l'argument numbers (peu importe l'ordre d'appel). Ainsi si nous essayons d'exécuter
$ cargo run -- --numbers 1 2 3 4 5 6 -c 6

nous aurons le message d'erreur

error: the argument '--numbers <numbers>...' cannot be used with '--count <count>'

Usage: cli --numbers <numbers>...

For more information, try '--help'

Après avoir construit les arguments, nous devons appeler la fonction get_matches() qui termine la construction de la commande et vérifie s'il n'y a pas d'arguments qui sont contradictoires (un message d'erreur sera produit à l'exécution si cela est le cas).

Lorsque ce code est exécuté, notre programme peut maintenant parser la ligne de commande lorsqu'il est exécuté. A nous maintenant d'utiliser correctement les différents arguments. Ici, nous devons traiter deux "groupes" d'arguments:

  • numbers et count qui sont deux options exclusives,
  • output qui est optionnel également.

Le traitement de

    let numbers = 
        if let Some(count) = 
            matches.get_one::<usize>("count") 
        {
            read_from_urandom(*count)?
        } else if let Some(numbers) = 
            matches.get_many::<i32>("numbers") 
        {
            numbers.copied().collect()
        } else {
            Vec::new()
        };

crée la liste de nombre que nous voulons avoir pour calculer le minimum.

Comme les deux arguments sont optionnels, nous voyons que pour les déstructurer, il faut passer par une construction if let Some() = .... Dans le cas de l'argument count nous savons que nous voulons un usize dont l'identifiant est "count". Si l'argument est présent,

            matches.get_one::<usize>("count") 

retourne Some(&usize) (nous obtenons une référence vers l'argument) et nous appellerons la fonction read_from_urandom() (que nous discuterons plus bas). Sinon, nous devons vérifier si l'argument numbers est présents et quelles valeurs lui sont assignées. Ainsi, si l'argument est présent

            matches.get_many::<i32>("numbers") 

retournera un nombre arbitraire de références d'entiers, qui seront ensuite transformés en Vec<i32> à l'aide de la ligne

numbers.copied().collect()

qui commence par faire une copie des valeurs de la liste de références pour pouvoir en devenir les propriétaires, puis les met dans un Vec<i32> (il faut noter que le type de numbers est inféré grâce au reste du code). A la fin de ce if let Some() = ... nous retournons un Vec<i32> (qui peut être vide) qui sera utilisé dans la suite de la fonction.

Il nous reste à décider si nous allons écrire les sorties de notre programme (la liste de nombre et son minimum) dans un fichier ou dans la sortie standard à l'aide du code

    if let Some(output) = 
        matches.get_one::<String>("output") 
    {
        write_to_file(output, &numbers)?;
    } else {
        println!("Among the Somethings in the list:");
        print_tab(&numbers);
        println!("{}", find_min(&numbers).to_string());
    }

Rien de très nouveau ici, si "output" est présent

        matches.get_one::<String>("output") 

nous pouvons déstructurer le retour de la fonction et obtenir le nom du fichier dans lequel nous allons écrire dans la fonction write_to_file() ou le cas échant écrire dans la sortie standard .

Gestion d'erreur un peu simplifiée

Aussi bien les appels à read_from_urandom() que write_to_file() sont suivis d'un ?. Ces fonctions doivent manipuler des fichiers et peuvent donc échouer à n'importe quel moment (si le fichier n'existe pas, s'il ne peut être créé, etc). Elles retournent donc des Result. L'opérateur ? en Rust est très pratique. Il est utilisé pour répercuter les erreurs de façon courte dans les fonctions. En gros il répond à la question: "Est-ce que la fonction a retourné Ok() ou Err()?" Si la réponse est Ok() il retourne ce qui est contenu dans le Ok() qui peut être assigné à une variable (ou retourné de la fonction). En revanche si la réponse est Err(), on retourne l'erreur de la fonction courante. Cet opérateur permet d'éviter d'alourdi le code avec du pattern matching à chaque appel qui peut échouer et est très utilisé dans le code Rust.

Le derive pattern

Nous avons vu en grand détail comment construire une commande avec un builder design. Nous allons voir à présent de façon très succincte comment faire la même chose avec le pattern derive. Ici, tout le code écrit plus haut sera généré pour nous à l'aide de macro Rust, et nous avons uniquement besoin de spécifier ce qui doit être généré. Afin de créer une interface en ligne de commande nous devons uniquement créer une struct annotée

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct CliMin {
    #[arg(short, long, help = "A list of i32 numbers", num_args=1.., allow_negative_numbers=true, value_parser = clap::value_parser!(i32))]
    numbers: Option<Vec<i32>>,
    #[arg(short, long, help = "How many random numbers we want?", value_parser = clap::value_parser!(usize), conflicts_with = "numbers")]
    count: Option<usize>,
    #[arg(short, long, help = "Filename for writing the numbers.")]
    output: Option<String>,
}

qui contient différent champs qui sont annotés (ou pas).

La ligne

#[derive(Parser)]

va dire au compilateur de Rust de générer automatiquement tout le parsing d'arguments en fonction de ce qu'il va trouver dans la structure en dessous, ici CliMin. Puis vient la commande préprocesseur

#[command(author, version, about, long_about = None)]

qui va créer la nouvelle commande à proprement parler. Elle correspond à

        Command::new(COMMAND)
            .author(AUTHOR)
            .version(VERSION)

A la différence de cette ligne où on spécifie explicitement l'auteur, etc., ici, le contenu du champs author, version, etc. est directement récupéré du Cargo.toml. Ensuite la structure CliMin possède trois membres: numbers, count, et output qui sont annotés avec un #[arg()].

    #[arg(short, long, help = "A list of i32 numbers", num_args=1.., allow_negative_numbers=true, value_parser = clap::value_parser!(i32))]
    numbers: Option<Vec<i32>>,

Cette annotation va dire au compilateur de générer automatiquement le code pour toutes les paires clés valeurs se trouvant entre les parenthèses. Si une valeur est absente alors un comportement par défaut est appliqué. Ainsi, long va générer automatiquement que l'option en version longue pour la variable numbers est --numbers. De façon similaire, par défaut la version short de numbers sera -n (la première lettre de numbers). Cette façon de faire par défaut permet de réduire la quantité de code. Par contre, elle est également dangereuse, car si deux champs commencent par le même nom, seul le premier aura le short qui lui correspondra.

Le reste des arguments correspondent à toutes les méthodes de la version builder vues dans le chapitre précédent. Il y a deux grandes différences:

  1. On ne trouve pas d'équivalent à required(false). En fait, l'obligation ou non de spécifier un argument est directement inféré par le type de l'argument: si c'est une Option alors l'argument est... optionnel.
  2. numbers est directement parsé en Vec<i32>, pas besoin de faire des conversions.

Le reste du code est relativement trivial. Pour utiliser notre interface en ligne de commande

pub fn read_command_line_derive() -> Result<(), String> {
    let cli = CliMin::parse();
    let numbers = if let Some(count) = cli.count {
        read_from_urandom(count)?
    } else if let Some(numbers) = cli.numbers {
        numbers
    } else {
        Vec::new()
    };
    if let Some(output) = cli.output {
        write_to_file(&output, &numbers)?;
    } else {
        println!("Among the Somethings in the list:");
        print_tab(&numbers);
        println!("{}", find_min(&numbers).to_string());
    }
    Ok(())
}

Il faut appeler la fonction parse()

    let cli = CliMin::parse();

et les champs cli.cout, cli.numbers, et cli.output seront automatiquement assignés aux valeurs dans la cli si elles sont compatibles avec les formats spécifiés dans les arguments correspondants. Sinon des erreurs seront affichées.

Les entrées / sorties

Il y a deux fonctions qui gèrent la lecture / écriture de fichiers dans notre programme.

  1. La fonction read_from_urandom() permet de lire le fichier /dev/urandom qui contient dans les systèmes unix des "vrais" nombres aléatoires générés par le bruit du système.
  2. La fonction write_to_file() qui permet d'écrire la liste de nombre, ainsi que le minimum dans un fichier. Nous allons brièvement discuter ces fonctions, afin de comprendre un peu mieux comment faire des entrées / sorties depuis le disque en Rust.

Lecture de fichier

Pour la lecture de fichier, nous nous concentrons sur la fonction

fn read_from_urandom(count: usize) -> Result<Vec<i32>, String> {
    let file = File::open("/dev/urandom").map_err(|_| "Could not open /dev/urandom")?;
    let mut buf_reader = BufReader::new(file);
    let mut numbers = vec![0; count * 4];
    buf_reader
        .read_exact(&mut numbers)
        .map_err(|_| "Could not read numbers")?;
    Ok(numbers
        .chunks(4)
        .map(|i| i32::from_be_bytes(i.try_into().unwrap()))
        .collect::<Vec<_>>())
}

L'ouverture d'un fichier en lecture se fait avec la fonction File::open() qui peut échouer si le fichier n'existe pas par exemple. Dans le cas d'une erreur, nous nous empressons de convertir l'erreur dans un message d'erreur avec la fonction map_err() qui prend en argument une fonction anonyme qui a pour argument ce qui est encapsulé dans le type Err() et qui retourne une nouvelle valeur qui sera encapsulée dans une nouvelle Err(). Cette façon de faire n'est pas très idiomatique pour Rust, mais elle nous satisfait pour le moment, afin d'avoir des types de retour homogènes et de pouvoir utiliser l'opérateur ? (voir plus haut).

Comme nous lisons dans le fichier /dev/urandom qui est un flux continu d'octets, nous définissons une mémoire tampon sur le fichier et allons lire exactement 4 * count fois octets, soit exactement l'équivalent de count entiers 32-bits soit i32

    let mut buf_reader = BufReader::new(file);
    let mut numbers = vec![0; count * 4];
    buf_reader
        .read_exact(&mut numbers)
        .map_err(|_| "Could not read numbers")?;

Finalement, notre mémoire tableau numbers (qui est rien d'autre qu'une suite d'octets) est convertie en Vec<i32> grâce à la puissance des itérateurs.

    Ok(numbers
        .chunks(4)
        .map(|i| i32::from_be_bytes(i.try_into().unwrap()))
        .collect::<Vec<_>>())

Dans un premier temps le tableau est découpé en tranches de 4 éléments grâce à la méthode .chunks(4) (l'itérateur est maintenant une suite d'itérateurs de 4 éléments). Puis chacun des éléments de l'itérateur (soit 4 octets) est transformé en i32, grâce à la méthode map(|i| i32::from_be_bytes(i.try_into().unwrap())). Il faut noter qu'ici nous utilisons la fonction try_into() qui peut échouer si nous n'avons pas 4 octets à disposition quand nous faisons la conversion. Ici, par construction cela ne peut pas se produire et pouvons unwrap() le résultat. Finalement, à l'aide de collect(), nous créons un Vec<i32> à partir de l'itérateur obtenu et l'encapsulons dans un Ok(), car le résultat de notre fonction est un succès, si tout s'est bien passé.

Écriture dans un fichier

Pour l'écriture dans un fichier, nous nous concentrons sur la fonction

fn write_to_file(output: &str, numbers: &[i32]) -> Result<(), String> {
    let mut file = File::create(output).map_err(|_| format!("Failed to create {output}"))?;
    writeln!(file, "Among the Somethings in the list:")
        .map_err(|_| "Failed to write header into file.")?;
    for n in numbers {
        write!(file, "{n} ").map_err(|_| format!("Failed to write {n} into file."))?;
    }
    writeln!(file,).map_err(|_| "Failed to write carriage return into file.")?;
    writeln!(file, "{}", find_min(numbers).to_string())
        .map_err(|_| "Failed to write minimum value into file.")?;
    Ok(())
}

Nous commençons par créer un fichier à l'aide de la fonction File::create() qui prend en argument le chemin où créer le fichier.

    let mut file = File::create(output).map_err(|_| format!("Failed to create {output}"))?;

Si un fichier existe déjà il est écrasé par défaut. Si la création est impossible, une erreur est retournée. De plus le fichier est ouvert en mode écriture. Il faut noter que nous allons modifier le fichier, et il est donc mutable. Ainsi, nous pouvons écrire dans le fichier à l'aide des macros write! et writeln! qui s'utilisent comme print! et println! à l'exception qu'elles prennent des fichier en argument et retournent une erreur en cas d'échec.

    writeln!(file, "Among the Somethings in the list:")
        .map_err(|_| "Failed to write header into file.")?;
    for n in numbers {
        write!(file, "{n} ").map_err(|_| format!("Failed to write {n} into file."))?;
    }
    writeln!(file,).map_err(|_| "Failed to write carriage return into file.")?;
    writeln!(file, "{}", find_min(numbers).to_string())
        .map_err(|_| "Failed to write minimum value into file.")?;

A nouveau, toutes les erreurs sont transformées en messages (des chaînes de caractères) pour simplifier les concepts abordés dans ce code.

1

Cet identifiant permet d'identifier de façon unique la commande dans le cas où nous en créerions plusieurs dans la même application ce qui n'est pas le cas ici.