Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 856 minIntermédiaire

Projet : un outil en ligne de commande, modules et cargo test

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • découper un projet en modules et séparer bibliothèque et exécutable ;
  • ajouter une crate avec cargo add et vérifier ses dépendances ;
  • lire les arguments de la ligne de commande avec clap ;
  • écrire des tests unitaires et d'intégration, puis les lancer avec cargo test.

4 exercices corrigés1 schéma

Ce chapitre assemble tout le cours dans un vrai programme : parc, l'outil en ligne de commande (CLI) qui gère l'inventaire informatique de Sorbier Logistique. Tu y apprends trois choses que demande tout projet sérieux : découper le code en modules, utiliser des crates (les bibliothèques de Rust) et écrire des tests lancés par cargo test. Le code complet est donné, fichier par fichier : il compile, ses seize tests passent et clippy n'a rien à redire.

Le cahier des charges

Le service informatique tient l'inventaire dans un fichier parc.csv, une ligne par poste :

text
numero;type;marque;prix_ht;annee;statut
PC-0001;portable;Dell;89900;2023;affecte:Julie Lefèvre
PC-0002;fixe;HP;64900;2020;affecte:Karim Morel
PC-0003;portable;Lenovo;104900;2024;affecte:Sofiane Benali
PC-0004;fixe;HP;61900;2019;reforme
PC-0005;serveur;Dell;349000;2021;affecte:Salle serveur
PC-0006;portable;Lenovo;99900;2021;reparation
PC-0007;fixe;Dell;72900;2025;stock

Les prix sont en centimes, hors taxes. L'outil doit proposer quatre commandes :

CommandeEffet
parc lister [--type portable]Lister les postes, éventuellement d'un seul type
parc statsCompter les postes par type, calculer la valeur du parc (hors postes réformés) et lister ceux à renouveler (5 ans ou plus)
parc ajouter PC-0008 portable Lenovo 799,90 2026Ajouter un poste neuf, en stock ; le prix se saisit en euros
parc affecter PC-0007 "Camille Roux"Affecter un poste à un utilisateur, sauf s'il est réformé

Chaque erreur (fichier illisible, ligne mal formée, numéro inconnu ou déjà pris) doit afficher un message clair et terminer le programme avec un code de sortie non nul, pour qu'un script puisse la détecter.

Organiser le code en modules

Un module regroupe du code qui va ensemble, comme un espace de noms en C# ou un fichier importé en JavaScript. On le déclare avec mod nom;, et Rust cherche son code dans le fichier src/nom.rs. Tout ce qu'un module contient est privé par défaut ; le mot-clé pub le rend visible à l'extérieur.

Le projet sépare une bibliothèque (src/lib.rs, le code métier, testable) et un exécutable (src/main.rs, qui ne fait que lire la ligne de commande et afficher). Cargo reconnaît tout seul ces deux fichiers : le paquet contient alors deux crates, la bibliothèque parc et l'exécutable parc.

text
parc/
├── Cargo.toml
├── Cargo.lock
├── parc.csv
├── src/
│   ├── lib.rs        la crate bibliothèque : déclare les modules
│   ├── main.rs       la crate exécutable : la ligne de commande
│   ├── erreur.rs     le type ErreurParc
│   ├── poste.rs      TypePoste, Statut, Poste et les montants
│   ├── stockage.rs   lecture et écriture du CSV
│   └── stats.rs      calculs sur le parc
└── tests/
    └── fichier.rs    tests d'intégration
clapargumentschronodate du jourmain.rscrate exécutableuse parc::…lib.rscrate bibliothèque parctests/fichier.rstests d'intégrationpub modstockage.rslire, écrire le CSVutilise poste, erreurstats.rscalculs sur le parcutilise posteposte.rsPoste, Statut, eurosutilise erreurerreur.rsErreurParcaucune dépendance
Une crate bibliothèque découpée en modules, une crate exécutable mince qui l'utilise, des tests d'intégration à part

Le fichier src/lib.rs se contente de déclarer les modules :

rust
//! Gestion du parc informatique de Sorbier Logistique.

pub mod erreur;
pub mod poste;
pub mod stats;
pub mod stockage;

Pour utiliser un élément d'un autre module, on écrit son chemin avec ::, ou on l'importe une fois avec use :

CheminDésigne
crate::poste::PosteDepuis la racine de la crate courante (dans la bibliothèque)
super::*Tout ce que contient le module parent (utile dans les tests)
parc::poste::PosteDepuis l'exécutable ou les tests : la bibliothèque s'appelle parc
std::collections::HashMapLa bibliothèque standard
clap::ParserUne crate externe

Les crates et crates.io

Une crate est l'unité de compilation de Rust : une bibliothèque ou un exécutable. Les bibliothèques publiques sont hébergées sur crates.io, l'équivalent de npm pour JavaScript ou de NuGet pour C#, et documentées automatiquement sur docs.rs. Le projet en utilise deux :

bash
cargo add clap --features derive
cargo add chrono
toml
[package]
name = "parc"
version = "0.1.0"
edition = "2024"

[dependencies]
chrono = "0.4.45"
clap = { version = "4.6.7", features = ["derive"] }
  • clap lit les arguments de la ligne de commande et génère l'aide. L'option derive permet de décrire les commandes avec des structs et des enums.
  • chrono donne la date du jour, pour calculer l'âge des postes.
  • "4.6.7" signifie « 4.6.7 ou toute version compatible, jusqu'à 5.0.0 exclue », selon le versionnage sémantique (SemVer). Le fichier Cargo.lock, créé par cargo, fige les versions exactes de toutes les dépendances, y compris indirectes : versionne-le avec Git pour un exécutable, afin que tout le monde compile exactement la même chose.

Le type d'erreur du projet

Le module erreur reprend la méthode du chapitre 5 : une enum, son message Display, et une conversion From pour que ? transforme les erreurs de fichier.

rust
use std::fmt;
use std::io;

/// Toutes les erreurs que l'outil `parc` peut rencontrer.
#[derive(Debug)]
pub enum ErreurParc {
    /// Une ligne du fichier n'a pas ses six champs.
    NombreDeChamps(usize),
    /// Un type de poste autre que fixe, portable ou serveur.
    TypeInconnu(String),
    /// Un statut que l'outil ne connaît pas.
    StatutInconnu(String),
    /// Un prix ou une année qui n'est pas un nombre valable.
    NombreInvalide(String),
    /// Aucun poste ne porte ce numéro.
    PosteInconnu(String),
    /// Un poste porte déjà ce numéro.
    NumeroDejaPris(String),
    /// On ne peut pas affecter un poste réformé.
    PosteReforme(String),
    /// Une erreur dans une ligne précise du fichier.
    Ligne { numero: usize, message: String },
    /// Le fichier ne peut pas être lu ou écrit.
    Fichier(io::Error),
}

impl fmt::Display for ErreurParc {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            ErreurParc::NombreDeChamps(n) => write!(f, "6 champs attendus, {n} trouvés"),
            ErreurParc::TypeInconnu(t) => write!(f, "type de poste inconnu : « {t} »"),
            ErreurParc::StatutInconnu(s) => write!(f, "statut inconnu : « {s} »"),
            ErreurParc::NombreInvalide(n) => write!(f, "nombre invalide : « {n} »"),
            ErreurParc::PosteInconnu(n) => write!(f, "aucun poste ne porte le numéro {n}"),
            ErreurParc::NumeroDejaPris(n) => write!(f, "le numéro {n} est déjà pris"),
            ErreurParc::PosteReforme(n) => write!(f, "le poste {n} est réformé"),
            ErreurParc::Ligne { numero, message } => write!(f, "ligne {numero} : {message}"),
            ErreurParc::Fichier(e) => write!(f, "fichier illisible : {e}"),
        }
    }
}

impl std::error::Error for ErreurParc {}

/// Permet à `?` de convertir une erreur d'entrée-sortie en `ErreurParc`.
impl From<io::Error> for ErreurParc {
    fn from(e: io::Error) -> Self {
        ErreurParc::Fichier(e)
    }
}

pub enum rend le type visible hors du module ; les variantes d'une enum publique sont publiques d'office.

Le modèle : le module poste

Ce module rassemble les types des chapitres 4 et 7, avec deux nouveautés. Il implémente le trait FromStr pour TypePoste et Statut, ce qui permet d'écrire "portable".parse::<TypePoste>(), et clap s'en sert aussi pour convertir les arguments. Et il fournit deux fonctions sur les montants : formater_euros pour l'affichage, lire_euros pour la saisie en euros.

rust
use std::fmt;
use std::str::FromStr;

use crate::erreur::ErreurParc;

/// Taux normal de TVA, en pour cent.
pub const TVA: u64 = 20;
/// Âge, en années, à partir duquel un poste est à renouveler.
pub const AGE_RENOUVELLEMENT: i32 = 5;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum TypePoste {
    Fixe,
    Portable,
    Serveur,
}

impl FromStr for TypePoste {
    type Err = ErreurParc;

    fn from_str(texte: &str) -> Result<Self, Self::Err> {
        match texte.trim().to_lowercase().as_str() {
            "fixe" => Ok(TypePoste::Fixe),
            "portable" => Ok(TypePoste::Portable),
            "serveur" => Ok(TypePoste::Serveur),
            autre => Err(ErreurParc::TypeInconnu(autre.to_string())),
        }
    }
}

impl fmt::Display for TypePoste {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let nom = match self {
            TypePoste::Fixe => "fixe",
            TypePoste::Portable => "portable",
            TypePoste::Serveur => "serveur",
        };
        // `pad` respecte la largeur demandée, comme dans {:<9}.
        f.pad(nom)
    }
}

#[derive(Debug, Clone, PartialEq)]
pub enum Statut {
    EnStock,
    Affecte(String),
    EnReparation,
    Reforme,
}

impl Statut {
    /// Le statut tel qu'il est écrit dans le fichier CSV.
    pub fn vers_csv(&self) -> String {
        match self {
            Statut::EnStock => "stock".to_string(),
            Statut::Affecte(nom) => format!("affecte:{nom}"),
            Statut::EnReparation => "reparation".to_string(),
            Statut::Reforme => "reforme".to_string(),
        }
    }
}

impl FromStr for Statut {
    type Err = ErreurParc;

    fn from_str(texte: &str) -> Result<Self, Self::Err> {
        match texte.trim() {
            "stock" => Ok(Statut::EnStock),
            "reparation" => Ok(Statut::EnReparation),
            "reforme" => Ok(Statut::Reforme),
            autre => match autre.strip_prefix("affecte:") {
                Some(nom) if !nom.trim().is_empty() => Ok(Statut::Affecte(nom.trim().to_string())),
                _ => Err(ErreurParc::StatutInconnu(autre.to_string())),
            },
        }
    }
}

impl fmt::Display for Statut {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            Statut::EnStock => write!(f, "en stock"),
            Statut::Affecte(nom) => write!(f, "affecté à {nom}"),
            Statut::EnReparation => write!(f, "en réparation"),
            Statut::Reforme => write!(f, "réformé"),
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct Poste {
    pub numero: String,
    pub type_poste: TypePoste,
    pub marque: String,
    /// Prix d'achat hors taxes, en centimes.
    pub prix_ht: u32,
    pub annee_achat: i32,
    pub statut: Statut,
}

impl Poste {
    /// Un poste neuf arrive toujours en stock.
    pub fn new(numero: &str, type_poste: TypePoste, marque: &str, prix_ht: u32, annee_achat: i32) -> Poste {
        Poste {
            numero: numero.to_string(),
            type_poste,
            marque: marque.to_string(),
            prix_ht,
            annee_achat,
            statut: Statut::EnStock,
        }
    }

    pub fn prix_ttc(&self) -> u64 {
        u64::from(self.prix_ht) * (100 + TVA) / 100
    }

    pub fn age(&self, annee_courante: i32) -> i32 {
        annee_courante - self.annee_achat
    }

    pub fn est_a_renouveler(&self, annee_courante: i32) -> bool {
        self.statut != Statut::Reforme && self.age(annee_courante) >= AGE_RENOUVELLEMENT
    }

    pub fn affecter(&mut self, utilisateur: &str) -> Result<(), ErreurParc> {
        if self.statut == Statut::Reforme {
            return Err(ErreurParc::PosteReforme(self.numero.clone()));
        }
        self.statut = Statut::Affecte(utilisateur.to_string());
        Ok(())
    }
}

impl fmt::Display for Poste {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "{:<8} {:<9} {:<7} {:>12} HT  {}  {}",
            self.numero,
            self.type_poste,
            self.marque,
            formater_euros(u64::from(self.prix_ht)),
            self.annee_achat,
            self.statut
        )
    }
}

/// 89900 centimes → « 899,00 € ».
pub fn formater_euros(centimes: u64) -> String {
    format!("{},{:02} €", centimes / 100, centimes % 100)
}

/// « 899 », « 899,9 » ou « 899.90 » → 89990 centimes.
pub fn lire_euros(texte: &str) -> Result<u32, ErreurParc> {
    let invalide = || ErreurParc::NombreInvalide(texte.to_string());
    let normalise = texte.trim().replace(',', ".");
    let (euros, decimales) = match normalise.split_once('.') {
        Some((e, d)) => (e, d),
        None => (normalise.as_str(), ""),
    };
    if euros.is_empty() || decimales.len() > 2 || !decimales.chars().all(|c| c.is_ascii_digit()) {
        return Err(invalide());
    }
    let euros: u32 = euros.parse().map_err(|_| invalide())?;
    let centimes: u32 = format!("{decimales:0<2}").parse().map_err(|_| invalide())?;
    euros
        .checked_mul(100)
        .and_then(|c| c.checked_add(centimes))
        .ok_or_else(invalide)
}

Quelques points nouveaux :

  • pub sur chaque champ de Poste : sans lui, les autres modules ne pourraient pas lire poste.numero. Rendre les champs publics est acceptable pour une simple structure de données ; pour protéger une règle (« un poste réformé ne peut pas être affecté »), on passe par une méthode comme affecter.
  • type Err = ErreurParc; est un type associé : le trait FromStr laisse chaque implémentation choisir son type d'erreur.
  • Some(nom) if !nom.trim().is_empty() : une garde refuse affecte: sans nom.
  • lire_euros accepte la virgule française, refuse plus de deux décimales et vérifie chaque calcul : checked_mul et checked_add renvoient None en cas de dépassement, au lieu de paniquer ou de « faire le tour » (chapitre 2). format!("{decimales:0<2}") complète à droite par des zéros : « 9 » devient « 90 » centimes. La fermeture invalide évite de répéter la construction de l'erreur.

Lire et écrire le CSV : le module stockage

rust
use std::fs;
use std::io::ErrorKind;
use std::path::Path;

use crate::erreur::ErreurParc;
use crate::poste::Poste;

pub const ENTETE: &str = "numero;type;marque;prix_ht;annee;statut";

/// Transforme une ligne du CSV en poste.
pub fn lire_ligne(ligne: &str) -> Result<Poste, ErreurParc> {
    let champs: Vec<&str> = ligne.split(';').map(str::trim).collect();
    if champs.len() != 6 {
        return Err(ErreurParc::NombreDeChamps(champs.len()));
    }
    Ok(Poste {
        numero: champs[0].to_string(),
        type_poste: champs[1].parse()?,
        marque: champs[2].to_string(),
        prix_ht: champs[3]
            .parse()
            .map_err(|_| ErreurParc::NombreInvalide(champs[3].to_string()))?,
        annee_achat: champs[4]
            .parse()
            .map_err(|_| ErreurParc::NombreInvalide(champs[4].to_string()))?,
        statut: champs[5].parse()?,
    })
}

/// Transforme un poste en ligne du CSV.
pub fn ecrire_ligne(poste: &Poste) -> String {
    format!(
        "{};{};{};{};{};{}",
        poste.numero,
        poste.type_poste,
        poste.marque,
        poste.prix_ht,
        poste.annee_achat,
        poste.statut.vers_csv()
    )
}

/// Lit tout le contenu du fichier : l'en-tête, puis un poste par ligne.
pub fn lire_parc(contenu: &str) -> Result<Vec<Poste>, ErreurParc> {
    let mut postes = Vec::new();
    for (index, ligne) in contenu.lines().enumerate().skip(1) {
        if ligne.trim().is_empty() {
            continue;
        }
        let poste = lire_ligne(ligne).map_err(|e| ErreurParc::Ligne {
            numero: index + 1,
            message: e.to_string(),
        })?;
        postes.push(poste);
    }
    Ok(postes)
}

/// Charge le parc ; un fichier absent donne un parc vide.
pub fn charger(chemin: &Path) -> Result<Vec<Poste>, ErreurParc> {
    match fs::read_to_string(chemin) {
        Ok(contenu) => lire_parc(&contenu),
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(Vec::new()),
        Err(e) => Err(ErreurParc::Fichier(e)),
    }
}

pub fn enregistrer(chemin: &Path, postes: &[Poste]) -> Result<(), ErreurParc> {
    let mut contenu = String::from(ENTETE);
    contenu.push('\n');
    for poste in postes {
        contenu.push_str(&ecrire_ligne(poste));
        contenu.push('\n');
    }
    fs::write(chemin, contenu)?;
    Ok(())
}
  • champs[1].parse()? : le type attendu par le champ type_poste indique à parse d'utiliser le FromStr de TypePoste, et l'erreur est déjà une ErreurParc.
  • lire_parc travaille sur une chaîne, pas sur un fichier : on peut la tester sans toucher au disque. charger ne fait que lire le fichier et lui passer le contenu.
  • enumerate().skip(1) saute l'en-tête tout en gardant le bon numéro de ligne (index + 1, car les éditeurs comptent à partir de 1).
  • fs::write(chemin, contenu)? utilise la conversion From<io::Error> du module erreur.
  • .map(str::trim) passe une fonction existante à la place d'une fermeture |c| c.trim().

Les calculs : le module stats

rust
use std::collections::BTreeMap;

use crate::poste::{Poste, Statut, TypePoste};

/// Valeur d'achat HT des postes encore utilisables, en centimes.
pub fn valeur_totale_ht(postes: &[Poste]) -> u64 {
    postes
        .iter()
        .filter(|p| p.statut != Statut::Reforme)
        .map(|p| u64::from(p.prix_ht))
        .sum()
}

/// Nombre de postes de chaque type, triés par type.
pub fn nombre_par_type(postes: &[Poste]) -> BTreeMap<TypePoste, usize> {
    let mut compteurs = BTreeMap::new();
    for poste in postes {
        *compteurs.entry(poste.type_poste).or_insert(0) += 1;
    }
    compteurs
}

/// Les postes à renouveler cette année.
pub fn a_renouveler(postes: &[Poste], annee: i32) -> Vec<&Poste> {
    postes.iter().filter(|p| p.est_a_renouveler(annee)).collect()
}

/// Le poste qui porte ce numéro, s'il existe.
pub fn chercher<'a>(postes: &'a [Poste], numero: &str) -> Option<&'a Poste> {
    postes.iter().find(|p| p.numero == numero)
}

Tu reconnais les itérateurs du chapitre 6 et la durée de vie 'a du chapitre 3 : le poste renvoyé par chercher est emprunté à postes, pas à numero. a_renouveler renvoie des références (Vec<&Poste>) plutôt que des copies : aucun poste n'est cloné. La BTreeMap peut utiliser TypePoste comme clé parce que l'enum dérive Ord.

Lire la ligne de commande avec clap

src/main.rs décrit les commandes avec des types, et clap fait le reste : lecture des arguments, conversion, messages d'erreur, aide.

rust
use std::path::PathBuf;
use std::process::ExitCode;

use chrono::Datelike;
use clap::{Parser, Subcommand};

use parc::erreur::ErreurParc;
use parc::poste::{self, Poste, TypePoste};
use parc::{stats, stockage};

/// Gestion du parc informatique de Sorbier Logistique.
#[derive(Parser)]
#[command(version)]
struct Cli {
    /// Fichier CSV du parc
    #[arg(short, long, default_value = "parc.csv")]
    fichier: PathBuf,

    #[command(subcommand)]
    commande: Commande,
}

#[derive(Subcommand)]
enum Commande {
    /// Liste les postes, ou seulement ceux d'un type
    Lister {
        /// fixe, portable ou serveur
        #[arg(short, long = "type")]
        type_poste: Option<TypePoste>,
    },
    /// Affiche la valeur du parc et les postes à renouveler
    Stats,
    /// Ajoute un poste neuf, en stock
    Ajouter {
        numero: String,
        type_poste: TypePoste,
        marque: String,
        /// Prix d'achat HT en euros, par exemple 899,90
        prix_ht: String,
        annee: i32,
    },
    /// Affecte un poste à un utilisateur
    Affecter { numero: String, utilisateur: String },
}

fn main() -> ExitCode {
    let cli = Cli::parse();
    match executer(cli) {
        Ok(()) => ExitCode::SUCCESS,
        Err(erreur) => {
            eprintln!("erreur : {erreur}");
            ExitCode::FAILURE
        }
    }
}

fn executer(cli: Cli) -> Result<(), ErreurParc> {
    let mut postes = stockage::charger(&cli.fichier)?;

    match cli.commande {
        Commande::Lister { type_poste } => {
            for p in postes.iter().filter(|p| type_poste.is_none_or(|t| p.type_poste == t)) {
                println!("{p}");
            }
        }
        Commande::Stats => {
            let annee = chrono::Local::now().year();
            println!("Postes : {}", postes.len());
            for (type_poste, nombre) in stats::nombre_par_type(&postes) {
                println!("  {type_poste:<9} {nombre}");
            }
            let total = stats::valeur_totale_ht(&postes);
            println!("Valeur d'achat HT (hors réformés) : {}", poste::formater_euros(total));
            println!("À renouveler en {annee} :");
            for p in stats::a_renouveler(&postes, annee) {
                println!("  {} ({} ans)", p.numero, p.age(annee));
            }
        }
        Commande::Ajouter { numero, type_poste, marque, prix_ht, annee } => {
            if stats::chercher(&postes, &numero).is_some() {
                return Err(ErreurParc::NumeroDejaPris(numero));
            }
            let prix_ht = poste::lire_euros(&prix_ht)?;
            postes.push(Poste::new(&numero, type_poste, &marque, prix_ht, annee));
            stockage::enregistrer(&cli.fichier, &postes)?;
            println!("Poste {numero} ajouté en stock.");
        }
        Commande::Affecter { numero, utilisateur } => {
            let poste = postes
                .iter_mut()
                .find(|p| p.numero == numero)
                .ok_or_else(|| ErreurParc::PosteInconnu(numero.clone()))?;
            poste.affecter(&utilisateur)?;
            stockage::enregistrer(&cli.fichier, &postes)?;
            println!("Poste {numero} affecté à {utilisateur}.");
        }
    }
    Ok(())
}
  • #[derive(Parser)] et #[derive(Subcommand)] génèrent la lecture des arguments. Les commentaires /// deviennent le texte de l'aide, et les noms de variantes deviennent les sous-commandes (Lister → lister).
  • Option<TypePoste> rend l'option --type facultative ; clap convertit le texte grâce à notre FromStr, et affiche notre message d'erreur s'il échoue.
  • use chrono::Datelike; : la méthode year() vient du trait Datelike, et une méthode de trait n'est utilisable que si le trait est importé. Sans cette ligne, le compilateur signale que la méthode n'existe pas et propose l'import.
  • main renvoie un ExitCode : elle délègue tout à executer, qui peut utiliser ?, puis affiche l'erreur avec son message Display sur la sortie d'erreur (eprintln!) et renvoie le code 1.
  • is_none_or (Rust 1.82 et plus) vaut true si l'option est vide, ou si la condition est vraie pour sa valeur : « pas de filtre, ou bon type ».
  • iter_mut().find(…) donne une référence mutable vers le poste trouvé, pour le modifier sur place. ok_or_else transforme l'Option en Result.

Utiliser l'outil

bash
cargo run -- lister --type portable
text
PC-0001  portable  Dell        899,00 € HT  2023  affecté à Julie Lefèvre
PC-0003  portable  Lenovo     1049,00 € HT  2024  affecté à Sofiane Benali
PC-0006  portable  Lenovo      999,00 € HT  2021  en réparation

Le -- sépare les options de cargo de celles de ton programme. Une fois compilé, on lance directement l'exécutable : ./target/release/parc stats.

text
Postes : 7
  fixe      3
  portable  3
  serveur   1
Valeur d'achat HT (hors réformés) : 7815,00 €
À renouveler en 2026 :
  PC-0002 (6 ans)
  PC-0005 (5 ans)
  PC-0006 (5 ans)

La valeur exclut le PC-0004, réformé : 89 900 + 64 900 + 104 900 + 349 000 + 99 900 + 72 900 = 781 500 centimes. Les erreurs sont claires et le code de sortie vaut 1 (2 pour une erreur de syntaxe détectée par clap) :

text
$ parc ajouter PC-0008 portable Lenovo 799,90 2026
Poste PC-0008 ajouté en stock.
$ parc ajouter PC-0008 fixe HP 500 2026
erreur : le numéro PC-0008 est déjà pris
$ parc affecter PC-0004 "Camille Roux"
erreur : le poste PC-0004 est réformé
$ parc lister --type tablette
error: invalid value 'tablette' for '--type <TYPE_POSTE>': type de poste inconnu : « tablette »

For more information, try '--help'.

Et l'aide est générée à partir des commentaires :

text
$ parc --help
Gestion du parc informatique de Sorbier Logistique

Usage: parc [OPTIONS] <COMMAND>

Commands:
  lister    Liste les postes, ou seulement ceux d'un type
  stats     Affiche la valeur du parc et les postes à renouveler
  ajouter   Ajoute un poste neuf, en stock
  affecter  Affecte un poste à un utilisateur
  help      Print this message or the help of the given subcommand(s)

Options:
  -f, --fichier <FICHIER>  Fichier CSV du parc [default: parc.csv]
  -h, --help               Print help
  -V, --version            Print version

Les tests avec cargo test

Rust intègre les tests au langage : pas de framework à installer. Un test unitaire est une fonction marquée #[test], rangée dans un module tests à la fin du fichier qu'elle teste. Voici ceux du module poste :

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

    fn poste_exemple() -> Poste {
        Poste::new("PC-0001", TypePoste::Portable, "Dell", 89900, 2023)
    }

    #[test]
    fn un_poste_neuf_est_en_stock() {
        assert_eq!(poste_exemple().statut, Statut::EnStock);
    }

    #[test]
    fn prix_ttc_ajoute_vingt_pour_cent() {
        assert_eq!(poste_exemple().prix_ttc(), 107880);
    }

    #[test]
    fn renouvellement_a_partir_de_cinq_ans() {
        let poste = poste_exemple();
        assert!(!poste.est_a_renouveler(2027));
        assert!(poste.est_a_renouveler(2028));
    }

    #[test]
    fn un_poste_reforme_ne_peut_pas_etre_affecte() {
        let mut poste = poste_exemple();
        poste.statut = Statut::Reforme;
        assert!(poste.affecter("Julie Lefèvre").is_err());
        assert_eq!(poste.statut, Statut::Reforme);
    }

    #[test]
    fn lire_euros_accepte_virgule_et_point() {
        assert_eq!(lire_euros("899").unwrap(), 89900);
        assert_eq!(lire_euros("899,9").unwrap(), 89990);
        assert_eq!(lire_euros("899.90").unwrap(), 89990);
        assert_eq!(lire_euros(" 0,05 ").unwrap(), 5);
    }

    #[test]
    fn lire_euros_refuse_les_saisies_invalides() {
        for saisie in ["", "abc", "12,345", "-5", "12,a", ",50", "50000000"] {
            assert!(lire_euros(saisie).is_err(), "« {saisie} » aurait dû être refusé");
        }
    }

    #[test]
    fn formater_euros_garde_deux_decimales() {
        assert_eq!(formater_euros(89900), "899,00 €");
        assert_eq!(formater_euros(5), "0,05 €");
    }
}
ÉlémentRôle
#[cfg(test)]Ce module n'est compilé que pour les tests : il n'alourdit pas l'exécutable
use super::*;Importe tout le module testé, y compris ce qui n'est pas pub : on peut tester les fonctions privées
#[test]Marque une fonction de test, sans paramètre
assert_eq!(obtenu, attendu)Échoue si les deux valeurs diffèrent, en affichant les deux
assert!(condition, "message")Échoue si la condition est fausse, avec ton message
#[should_panic]Le test réussit seulement si la fonction panique

Les modules stockage et stats ont aussi leurs tests, sur le même modèle. Ceux de stockage.rs vérifient la lecture d'une ligne du fichier, le refus d'une ligne incomplète et l'aller-retour écriture puis lecture ; ils se placent à la fin du fichier :

rust
#[cfg(test)]
mod tests {
    use super::*;
    use crate::poste::{Statut, TypePoste};

    #[test]
    fn lit_une_ligne_correcte() {
        let poste = lire_ligne("PC-0001;portable;Dell;89900;2023;affecte:Julie Lefèvre").unwrap();
        assert_eq!(poste.type_poste, TypePoste::Portable);
        assert_eq!(poste.statut, Statut::Affecte("Julie Lefèvre".to_string()));
    }

    #[test]
    fn refuse_une_ligne_incomplete() {
        let erreur = lire_ligne("PC-0001;portable;Dell").unwrap_err();
        assert!(matches!(erreur, ErreurParc::NombreDeChamps(3)));
    }

    #[test]
    fn ecrire_puis_relire_redonne_le_meme_poste() {
        let poste = lire_ligne("PC-0006;portable;Lenovo;99900;2021;reparation").unwrap();
        assert_eq!(lire_ligne(&ecrire_ligne(&poste)).unwrap(), poste);
    }
}

Ceux de stats.rs s'appuient sur un petit parc d'exemple, construit par une fonction parc_exemple() qui n'existe que dans le module de tests :

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

    fn parc_exemple() -> Vec<Poste> {
        let mut vieux = Poste::new("PC-0004", TypePoste::Fixe, "HP", 61900, 2019);
        vieux.statut = Statut::Reforme;
        vec![
            Poste::new("PC-0001", TypePoste::Portable, "Dell", 89900, 2023),
            Poste::new("PC-0002", TypePoste::Fixe, "HP", 64900, 2020),
            vieux,
        ]
    }

    #[test]
    fn la_valeur_ignore_les_postes_reformes() {
        assert_eq!(valeur_totale_ht(&parc_exemple()), 89900 + 64900);
    }

    #[test]
    fn compte_les_postes_par_type() {
        let compteurs = nombre_par_type(&parc_exemple());
        assert_eq!(compteurs[&TypePoste::Fixe], 2);
        assert_eq!(compteurs[&TypePoste::Portable], 1);
        assert_eq!(compteurs.get(&TypePoste::Serveur), None);
    }

    #[test]
    fn un_poste_reforme_n_est_pas_a_renouveler() {
        let parc = parc_exemple();
        let numeros: Vec<&str> = a_renouveler(&parc, 2026).iter().map(|p| p.numero.as_str()).collect();
        assert_eq!(numeros, ["PC-0002"]);
    }

    #[test]
    fn chercher_un_numero_absent_donne_none() {
        assert!(chercher(&parc_exemple(), "PC-9999").is_none());
    }
}

Le test lire_euros_refuse_les_saisies_invalides vérifie au passage le cas « 50000000 » : 50 millions d'euros ne tiennent pas en centimes dans un u32, et checked_mul le détecte. C'est un cas limite, celui que l'on oublie quand on ne teste que l'exemple nominal.

Les tests d'intégration se rangent dans le dossier tests/. Chaque fichier y est compilé comme un programme séparé qui utilise la bibliothèque de l'extérieur, uniquement par ce qui est pub :

rust
use parc::poste::{Poste, Statut, TypePoste};
use parc::stockage;

#[test]
fn enregistrer_puis_charger_redonne_le_meme_parc() {
    let chemin = std::env::temp_dir().join("parc-test-integration.csv");
    let mut poste = Poste::new("PC-0010", TypePoste::Portable, "Lenovo", 79900, 2026);
    poste.statut = Statut::Affecte("Chloé Lambert".to_string());
    let parc = vec![poste, Poste::new("PC-0011", TypePoste::Fixe, "HP", 64900, 2026)];

    stockage::enregistrer(&chemin, &parc).unwrap();
    let relu = stockage::charger(&chemin).unwrap();
    std::fs::remove_file(&chemin).unwrap();

    assert_eq!(relu, parc);
}

#[test]
fn une_ligne_fausse_donne_son_numero() {
    let contenu = "numero;type;marque;prix_ht;annee;statut\n\
                   PC-0001;portable;Dell;89900;2023;stock\n\
                   PC-0002;tablette;Samsung;45000;2024;stock\n";
    let erreur = stockage::lire_parc(contenu).unwrap_err();
    assert_eq!(erreur.to_string(), "ligne 3 : type de poste inconnu : « tablette »");
}

Le premier test écrit un fichier dans le dossier temporaire du système, le relit et vérifie que rien ne s'est perdu en route : c'est un aller-retour, le test le plus utile pour un format de fichier. Le second vérifie le message d'erreur complet, avec le bon numéro de ligne. Dans un test, unwrap() est le bon choix : si quelque chose échoue, le test échoue.

bash
cargo test
text
     Running unittests src/lib.rs (target/debug/deps/parc-6e080b936515dd12)

running 14 tests
test poste::tests::formater_euros_garde_deux_decimales ... ok
test poste::tests::lire_euros_accepte_virgule_et_point ... ok
test poste::tests::lire_euros_refuse_les_saisies_invalides ... ok
test poste::tests::prix_ttc_ajoute_vingt_pour_cent ... ok
test poste::tests::renouvellement_a_partir_de_cinq_ans ... ok
test poste::tests::un_poste_neuf_est_en_stock ... ok
test poste::tests::un_poste_reforme_ne_peut_pas_etre_affecte ... ok
test stats::tests::chercher_un_numero_absent_donne_none ... ok
test stats::tests::compte_les_postes_par_type ... ok
test stats::tests::la_valeur_ignore_les_postes_reformes ... ok
test stats::tests::un_poste_reforme_n_est_pas_a_renouveler ... ok
test stockage::tests::ecrire_puis_relire_redonne_le_meme_poste ... ok
test stockage::tests::lit_une_ligne_correcte ... ok
test stockage::tests::refuse_une_ligne_incomplete ... ok

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

     Running tests/fichier.rs (target/debug/deps/fichier-6a4ec51b99b87bb1)

running 2 tests
test une_ligne_fausse_donne_son_numero ... ok
test enregistrer_puis_charger_redonne_le_meme_parc ... ok

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

Les tests tournent en parallèle, donc dans un ordre qui varie : ils ne doivent pas dépendre les uns des autres. cargo test lire_euros ne lance que les tests dont le nom contient « lire_euros ». La sortie complète liste aussi les tests de main.rs et les tests de documentation (des exemples de code placés dans les commentaires ///, que cargo test compile et exécute) : zéro ici.

Livrer l'exécutable

bash
cargo build --release

L'exécutable optimisé est dans target/release/parc : un seul fichier, sans dépendance à installer sur le poste cible, pour le même système d'exploitation. On peut aussi le placer dans une image Docker minimale, comme le montre le cours Docker, ou l'installer sur sa machine avec cargo install --path .. Avant de livrer, trois commandes : cargo fmt (mise en forme), cargo clippy (analyse), cargo test (tests).

Exercices

Exercice 1 sur 4 La commande reformer Réussi À revoir

Ajoute une commande parc reformer PC-0002 qui passe un poste au statut Reforme. Elle doit refuser un numéro inconnu. Écris d'abord une méthode reformer(&mut self) dans Poste, puis la variante de Commande et sa branche dans executer.

Voir la réponseMasquer la réponse de l'exercice 1

Dans poste.rs, dans le bloc impl Poste :

rust
pub fn reformer(&mut self) {
    self.statut = Statut::Reforme;
}

Dans main.rs, une variante de plus :

rust
/// Passe un poste au statut réformé
Reformer { numero: String },

et sa branche dans le match de executer :

rust
Commande::Reformer { numero } => {
    let poste = postes
        .iter_mut()
        .find(|p| p.numero == numero)
        .ok_or_else(|| ErreurParc::PosteInconnu(numero.clone()))?;
    poste.reformer();
    stockage::enregistrer(&cli.fichier, &postes)?;
    println!("Poste {numero} réformé.");
}

Si tu oublies la branche, le match n'est plus exhaustif et le programme ne compile pas : c'est l'erreur E0004 du chapitre 4, qui te rappelle chaque endroit à compléter.

Tu avais trouvé ?

Exercice 2 sur 4 Écrire les tests de reformer Réussi À revoir

Écris deux tests unitaires dans le module tests de poste.rs :

  1. un poste réformé n'est plus à renouveler, même s'il a 10 ans ;
  2. reformer un poste affecté fait disparaître l'utilisateur.
Voir la réponseMasquer la réponse de l'exercice 2
rust
#[test]
fn un_poste_reforme_n_est_plus_a_renouveler() {
    let mut poste = Poste::new("PC-0002", TypePoste::Fixe, "HP", 64900, 2016);
    assert!(poste.est_a_renouveler(2026));
    poste.reformer();
    assert!(!poste.est_a_renouveler(2026));
}

#[test]
fn reformer_retire_l_utilisateur() {
    let mut poste = poste_exemple();
    poste.affecter("Julie Lefèvre").unwrap();
    poste.reformer();
    assert_eq!(poste.statut, Statut::Reforme);
}

Le premier test vérifie d'abord la situation de départ (le poste de 2016 a 10 ans en 2026, donc il est à renouveler) : sans cette ligne, le test passerait aussi si est_a_renouveler renvoyait toujours false. Le second n'a pas besoin de tester l'absence d'utilisateur : avec l'enum Statut, un poste réformé ne peut pas avoir d'utilisateur.

Tu avais trouvé ?

Exercice 3 sur 4 Un test qui échoue Réussi À revoir

Un collègue change la constante AGE_RENOUVELLEMENT de 5 à 4 ans. Quel test du module poste échoue, et que t'affiche cargo test ? Est-ce un bug du code ou du test ?

Voir la réponseMasquer la réponse de l'exercice 3

renouvellement_a_partir_de_cinq_ans échoue : avec 4 ans, le poste de 2023 est déjà à renouveler en 2027, et assert!(!poste.est_a_renouveler(2027)) échoue. cargo test affiche le nom du test, assertion failed: !poste.est_a_renouveler(2027) avec le fichier et la ligne, puis test result: FAILED. 13 passed; 1 failed.

Ce n'est pas forcément un bug : le test documente la règle métier des 5 ans. Si la règle a vraiment changé (décision de la direction), on met le test à jour en même temps que la constante ; sinon, le test vient d'éviter une régression. C'est exactement le rôle d'un test de non-régression.

Tu avais trouvé ?

Exercice 4 sur 4 Valeur TTC dans les stats Réussi À revoir

Ajoute dans stats.rs une fonction valeur_totale_ttc(postes: &[Poste]) -> u64 qui additionne les prix_ttc() des postes non réformés, et un test qui vérifie le résultat sur parc_exemple() (PC-0001 à 89 900, PC-0002 à 64 900, PC-0004 réformé).

Voir la réponseMasquer la réponse de l'exercice 4
rust
pub fn valeur_totale_ttc(postes: &[Poste]) -> u64 {
    postes
        .iter()
        .filter(|p| p.statut != Statut::Reforme)
        .map(|p| p.prix_ttc())
        .sum()
}

et dans le module tests de stats.rs :

rust
#[test]
fn la_valeur_ttc_ignore_les_postes_reformes() {
    assert_eq!(valeur_totale_ttc(&parc_exemple()), 107880 + 77880);
}

89 900 × 1,2 = 107 880 et 64 900 × 1,2 = 77 880, soit 185 760 centimes. Écrire l'attendu sous forme d'addition rend le test lisible : on voit d'où vient chaque nombre.

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Une API web en Rust avec axum, comparée à ExpressRevoir le sommaire du cours