Développer les fonctionnalités de la bibliothèque avec le TDD

Maintenant que nous avons extrait la logique dans src/lib.rs et que nous avons laissé la récupération des arguments et la gestion des erreurs dans src/main.rs, il est bien plus facile d'écrire les tests pour les fonctionnalités de base de notre code. Nous pouvons appeler les fonctions directement avec différents arguments et vérifier les valeurs de retour sans avoir à appeler notre binaire dans la ligne de commande.

Dans cette section, nous allons ajouter la logique de recherche au programme minigrep en utilisant le processus de développement orienté par les tests (c'est le TDD : Test-Driven Development). Cette technique de développement de logiciels suit ces trois étapes :

  1. Ecrire un test qui échoue et lancez-le pour vous assurer qu'il va échouer pour la raison que vous attendiez.
  2. Ecrire ou modifier juste assez de code pour faire réussir ce nouveau test.
  3. Remanier le code que vous venez d'ajouter ou de changer pour vous assurer que les tests continuent à réussir.
  4. Recommencer à l'étape 1 !

Ce processus n'est qu'une des différentes manières d'écrire des programmes, mais le TDD peut aussi aider à piloter sa conception. Ecrire les tests avant d'écrire le code qui fait réussir les tests aide à maintenir une haute couverture de tests tout le long du processus.

Nous allons expérimenter cela avec l'implémentation de la fonctionnalité qui va rechercher la chaîne de caractères demandée dans le contenu du fichier et générer une liste de lignes qui correspond à cette recherche. Nous ajouterons cette fonctionnalité dans une fonction rechercher.

Ecrire un test qui échoue

Comme nous n'en avons plus besoin, enlevons les instructions println! de src/lib.rs et src/main.rs que nous avions utilisé pour vérifier le bon comportement du programme. Ensuite, dans src/lib.rs, nous allons ajouter un module tests avec une fonction de test, comme nous l'avions fait dans le chapitre 11. La fonction de test définit le comportement que nous voulons qu'ait la fonction rechercher : elle va prendre en arguments une recherche et le texte dans lequel rechercher, et elle va retourner seulement les lignes du texte qui correspondent à la recherche. L'encart 12-15 montre ce test, qui ne se compile pas encore.

Fichier : src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_resultat() {
        let recherche = "duct";
        let contenu = "\
Rust:
sécurité, rapidité, productivité.
Obtenez les trois en même temps.";

        assert_eq!(
            vec!["sécurité, rapidité, productivité."],
            rechercher(recherche, contenu)
        );
    }
}

Encart 12-15 : Création d'un test qui échoue pour la fonction rechercher que nous souhaitons concevoir

Ce test recherche la chaîne de caractères "duct". Le texte dans lequel nous recherchons fait trois lignes, et seulement une d'entre elles contient "duct" (remarquez que l'antislash après la double-guillet ouvrante indique à Rust de ne pas insérer un caractère de nouvelle ligne au début du contenu de ce litéral de chaîne de caractère). Nous vérifions que la valeur retournée par la fonction rechercher contient seulement la ligne que nous avions prévu.

Nous ne pouvons pas encore exécuter ce test et vérifier s'il échoue car même le test ne peut pas se compiler : la fonction rechercher n'existe pas encore ! Donc pour le moment nous allons ajouter juste assez de code pour que le test puisse compiler et s'exécuter en ajoutant une définition de la fonction rechercher qui retourne un vecteur vide, comme dans l'encart 12-16. Ensuite le test va compiler et échouer car un vecteur vide ne correspond pas au vecteur qui contient la ligne "sécurité, rapidité, productivité."

Fichier : src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    Ok(())
}

pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    vec![]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_resultat() {
        let recherche = "duct";
        let contenu = "\
Rust:
sécurité, rapidité, productivité.
Obtenez les trois en même temps.";

        assert_eq!(vec!["sécurité, rapidité, productivité."], rechercher(recherche, contenu));
    }
}

Encart 12-16 : Définition du strict minimum de la fonction rechercher pour que notre test puisse compiler

Remarquez que nous avons besoin de préciser explicitement une durée de vie 'a définie dans la signature de rechercher et l'utiliser sur l'argument contenu et la valeur de retour. Rappelez-vous que dans le chapitre 10 nous avions vu que le paramètre de durée de vie indique quelle durée de vie d'argument est connectée à la durée de vie de la valeur de retour. Dans notre cas, nous indiquons que le vecteur retourné devrait contenir des slices de chaînes de caractères qui proviennent des slices de l'argument contenu (et pas de l'argument recherche).

Autrement dit, nous disons à Rust que les données retournées par la fonction rechercher vont vivre aussi longtemps que la donnée dans l'argument contenu de la fonction rechercher. C'est très important ! Les données sur lesquelles pointent les slices doivent toujours être en vigueur pour que la référence reste valide ; si le compilateur croit que nous créons des slices de recherche plutôt que de contenu, ses vérifications de sécurité seront incorrectes.

Si nous oublions les annotations de durée de vie et que nous essayons de compiler cette fonction, nous allons obtenir cette erreur :

$ cargo build
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
error[E0106]: missing lifetime specifier
  --> src/lib.rs:28:51
   |
28 | pub fn rechercher(recherche: &str, contenu: &str) -> Vec<&str> {
   |                              ----           ----         ^ expected named lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `recherche` or `contenu`
help: consider introducing a named lifetime parameter
   |
28 | pub fn rechercher<'a>(recherche: &'a str, contenu: &'a str) -> Vec<&'a str> {
   |                  ++++             ++            ++              ++

error: aborting due to previous error

For more information about this error, try `rustc --explain E0106`.
error: could not compile `minigrep` due to previous error

Rust ne peut pas deviner lequel des deux arguments nous allons utiliser, donc nous devons lui dire. Comme contenu est l'argument qui contient tout notre texte et que nous voulons retourner des extraits de ce texte qui correspondent à la recherche, nous savons que contenu est l'argument qui doit être connecté à la valeur de retour, en utilisant la syntaxe de durée de vie.

Les autres langages de programmation n'ont pas besoin que vous connectiez les arguments aux valeurs de retour dans la signature. Bien que cela puisse paraître étrange, cela devient plus facile au fil du temps. Vous devriez peut-être comparer cet exemple à la section 3 du chapitre 10.

Maintenant, exécutons le test :

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished test [unoptimized + debuginfo] target(s) in 0.97s
     Running unittests (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::un_resultat ... FAILED

failures:

---- tests::un_resultat stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
  left: `["sécurité, rapidité, productivité."]`,
 right: `[]`', src/lib.rs:44:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    tests::un_resultat

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

error: test failed, to rerun pass '--lib'

Très bien, le test a échoué, comme nous nous y attendions. Faisons maintenant en sorte qu'il réussisse !

Ecrire du code pour réussir au test

Pour le moment, notre test échoue car nous retournons toujours un vecteur vide. Pour corriger cela et implémenter rechercher, notre programme doit suivre les étapes suivantes :

  • Itérer sur chacune des lignes de contenu.
  • Vérifier si la ligne contient la chaîne de caractères recherchée.
  • Si c'est le cas, l'ajouter à la liste des valeurs que nous retournerons.
  • Si ce n'est pas le cas, ne rien faire.
  • Retourner la liste des résultats qui ont été trouvés.

Travaillons sur chacune de ces étapes, en commençant par l'itération sur les lignes.

Itérer sur chacune des lignes avec la méthode lines

Rust a une méthode très pratique pour gérer l'itération ligne-par-ligne des chaînes de caractères, judicieusement appelée lines, qui fonctionne comme dans l'encart 12-17. Notez que cela ne se compile pas encore.

Fichier : src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    Ok(())
}

pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    for ligne in contenu.lines() {
        // faire quelquechose avec ligne ici
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_resultat() {
        let recherche = "duct";
        let contenu = "\
Rust:
sécurité, rapidité, productivité.
Obtenez les trois en même temps.";

        assert_eq!(vec!["sécurité, rapidité, productivité."], rechercher(recherche, contenu));
    }
}

Encart 12-17 : Itération sur chacune des lignes de contenu

La méthode lines retourne un itérateur. Nous verrons plus tard les itérateurs dans le chapitre 13, mais souvenez-vous que vous avez vu cette façon d'utiliser un itérateur dans l'encart 3-5, dans lequel nous avions utilisé une boucle for sur un itérateur pour exécuter du code sur chaque élément d'une collection.

Trouver chaque ligne correspondante à la recherche

Ensuite, nous allons vérifier que la ligne courante contient la chaîne de caractères que nous recherchons. Heureusement, les chaînes de caractères ont une méthode contains assez pratique qui fait cela pour nous ! Ajoutez l'appel à la méthode contains dans la fonction rechercher, comme dans l'encart 12-18. Notez qu'ici non plus nous ne pouvons pas encore compiler.

Fichier : src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    Ok(())
}

pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    for ligne in contenu.lines() {
        if ligne.contains(recherche) {
            // faire quelquechose avec la ligne ici
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_resultat() {
        let recherche = "duct";
        let contenu = "\
Rust:
sécurité, rapidité, productivité.
Obtenez les trois en même temps.";

        assert_eq!(vec!["sécurité, rapidité, productivité."], rechercher(recherche, contenu));
    }
}

Encart 12-18 : Ajout d'une fonctionnalité pour trouver quelle ligne contient la chaîne de caractères recherche

Stocker les lignes trouvées

Nous avons aussi besoin d'un moyen de stocker les lignes qui contiennent la chaîne de caractères que nous recherchons. Pour cela, nous pouvons créer un vecteur mutable avant la boucle for et appeler la méthode push pour enregistrer la ligne dans le vecteur. Après la boucle for, nous retournons le vecteur, comme dans l'encart 12-19 :

Fichier : src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    Ok(())
}

pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    let mut resultats = Vec::new();

    for ligne in contenu.lines() {
        if ligne.contains(recherche) {
            resultats.push(ligne);
        }
    }

    resultats
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_resultat() {
        let recherche = "duct";
        let contenu = "\
Rust:
sécurité, rapidité, productivité.
Obtenez les trois en même temps.";

        assert_eq!(vec!["sécurité, rapidité, productivité."], rechercher(recherche, contenu));
    }
}

Encart 12-19 : Enregistrement des lignes qui sont trouvées afin que nous puissions les retourner

Maintenant, notre fonction rechercher retourne uniquement les lignes qui contiennent recherche, et notre test devrait réussir. Exécutons le test :

$ cargo test
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished test [unoptimized + debuginfo] target(s) in 1.22s
     Running unittests (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 1 test
test tests::un_resultat ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running unittests (target/debug/deps/minigrep-9cd200e5fac0fc94)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

   Doc-tests minigrep

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Notre test a réussi, donc nous savons que cela fonctionne !

Arrivé à ce stade, nous pourrions envisager des pistes de remaniement pour l'implémentation de la fonction de recherche tout en faisant en sorte que les tests réussissent toujours afin de conserver les mêmes fonctionnalités. Le code de la fonction de recherche n'est pas mauvais, mais il ne profite pas de quelques fonctionnalités utiles des itérateurs. Nous retrouverons cet exemple dans le chapitre 13, dans lequel nous explorerons les itérateurs en détail, et ainsi découvrir comment nous pourrions l'améliorer.

Utiliser la fonction rechercher dans la fonction run

Maintenant que la fonction rechercher fonctionne et est testée, nous devons appeler rechercher dans notre fonction run. Nous devons passer à rechercher la valeur de config.recherche et le contenu que run obtient en lisant le fichier. Ensuite, run devra afficher chaque ligne retournée par rechercher :

Fichier : src/lib.rs

use std::error::Error;
use std::fs;

pub struct Config {
    pub recherche: String,
    pub nom_fichier: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("il n'y a pas assez d'arguments");
        }

        let recherche = args[1].clone();
        let nom_fichier = args[2].clone();

        Ok(Config { recherche, nom_fichier })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let contenu = fs::read_to_string(config.nom_fichier)?;

    for ligne in rechercher(&config.recherche, &contenu) {
        println!("{}", ligne);
    }

    Ok(())
}

pub fn rechercher<'a>(recherche: &str, contenu: &'a str) -> Vec<&'a str> {
    let mut resultats = Vec::new();

    for ligne in contenu.lines() {
        if ligne.contains(recherche) {
            resultats.push(ligne);
        }
    }

    resultats
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn un_resultat() {
        let recherche = "duct";
        let contenu = "\
Rust:
sécurité, rapidité, productivité.
Obtenez les trois en même temps.";

        assert_eq!(vec!["sécurité, rapidité, productivité."], rechercher(recherche, contenu));
    }
}

Nous utilisons ici aussi une boucle for pour récupérer chaque ligne provenant de rechercher et l'afficher.

Maintenant, l'intégralité du programme devrait fonctionner ! Essayons-le, pour commencer avec un mot qui devrait retourner exactement une seule ligne du poème d'Emily Dickinson, “frog” :

$ cargo run frog poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.38s
     Running `target/debug/minigrep frog poem.txt`
How public, like a frog

Super ! Maintenant, essayons un mot qui devrait retourner plusieurs lignes, comme “body” :

$ cargo run body poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!

Et enfin, assurons-nous que nous n'obtenons aucune ligne lorsque nous cherchons un mot qui n'est nulle part dans le poème, comme “monomorphization” :

$ cargo run monomorphization poem.txt
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.0s
     Running `target/debug/minigrep monomorphization poem.txt`

Très bien ! Nous avons construit notre propre mini-version d'un outil classique et nous avons beaucoup appris sur la façon de structurer nos applications. Nous en avons aussi appris un peu sur les entrées et sorties des fichiers, les durées de vie, les tests et l'interprétation de la ligne de commande.

Pour clôturer ce projet, nous allons brièvement voir comment travailler avec les variables d'environnement et comment écrire sur la sortie standard des erreurs, ce qui peut s'avérer utile lorsque vous écrivez des programmes en ligne de commande.