Apprendre Rust de zéro jusqu'à un vrai outil en ligne de commande : ownership, emprunts, enums, gestion d'erreurs, traits, génériques, durées de vie, itérateurs, tests, threads et async avec Tokio.
Ce dernier chapitre assemble tout le cours dans un programme réel : taches, un gestionnaire de tâches en ligne de commande. Il lit ses arguments avec clap, enregistre les tâches dans un fichier JSON avec serde, gère ses erreurs avec anyhow et thiserror, et sépare logique et interface entre lib.rs et main.rs. Le résultat est un exécutable unique, sans dépendance, qu'on copie sur n'importe quelle machine.
taches ajouter "Écrire le chapitre 11" --priorite haute
taches lister
taches terminer 1
taches supprimer 1cargo new taches
cd taches
cargo add clap --features derive,env
cargo add serde --features derive
cargo add serde_json anyhow thiserrortaches/
├── Cargo.toml
└── src/
├── main.rs interface : arguments, affichage
├── lib.rs déclaration des modules
├── tache.rs le modèle de données
└── stockage.rs lecture et écriture du fichieruse serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum Priorite {
Basse,
Normale,
Haute,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Tache {
pub id: u32,
pub titre: String,
pub priorite: Priorite,
pub terminee: bool,
}
impl fmt::Display for Tache {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let case = if self.terminee { "[x]" } else { "[ ]" };
let marque = match self.priorite {
Priorite::Haute => " (!)",
Priorite::Normale => "",
Priorite::Basse => " (basse)",
};
write!(f, "{case} {:>3} {}{marque}", self.id, self.titre)
}
}Tout le cours se retrouve ici. Un enum pour la priorité (chapitre 4), des traits dérivés (chapitre 6) : Serialize et Deserialize génèrent la conversion JSON, Ord permet de trier par priorité dans l'ordre de déclaration, ValueEnum laisse clap accepter --priorite haute en argument. Display décide de l'affichage d'une tâche.
use crate::tache::{Priorite, Tache};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::ErrorKind;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ErreurTaches {
#[error("aucune tâche avec l'identifiant {0}")]
Introuvable(u32),
#[error("le titre ne peut pas être vide")]
TitreVide,
#[error("lecture ou écriture du fichier impossible")]
Fichier(#[from] std::io::Error),
#[error("fichier de tâches corrompu")]
Format(#[from] serde_json::Error),
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct ListeTaches {
prochain_id: u32,
taches: Vec<Tache>,
}
impl ListeTaches {
pub fn charger(chemin: &Path) -> Result<Self, ErreurTaches> {
match fs::read_to_string(chemin) {
Ok(contenu) => Ok(serde_json::from_str(&contenu)?),
Err(e) if e.kind() == ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e.into()),
}
}
pub fn sauver(&self, chemin: &Path) -> Result<(), ErreurTaches> {
let json = serde_json::to_string_pretty(self)?;
let temporaire = chemin.with_extension("json.tmp");
fs::write(&temporaire, json)?;
fs::rename(&temporaire, chemin)?;
Ok(())
}
pub fn ajouter(&mut self, titre: &str, priorite: Priorite) -> Result<u32, ErreurTaches> {
let titre = titre.trim();
if titre.is_empty() {
return Err(ErreurTaches::TitreVide);
}
self.prochain_id += 1;
self.taches.push(Tache {
id: self.prochain_id,
titre: titre.to_string(),
priorite,
terminee: false,
});
Ok(self.prochain_id)
}
pub fn terminer(&mut self, id: u32) -> Result<&Tache, ErreurTaches> {
let tache = self
.taches
.iter_mut()
.find(|t| t.id == id)
.ok_or(ErreurTaches::Introuvable(id))?;
tache.terminee = true;
Ok(tache)
}
pub fn supprimer(&mut self, id: u32) -> Result<Tache, ErreurTaches> {
let index = self
.taches
.iter()
.position(|t| t.id == id)
.ok_or(ErreurTaches::Introuvable(id))?;
Ok(self.taches.remove(index))
}
/// Tâches triées : non terminées d'abord, puis par priorité décroissante.
pub fn triees(&self, inclure_terminees: bool) -> Vec<&Tache> {
let mut liste: Vec<&Tache> = self
.taches
.iter()
.filter(|t| inclure_terminees || !t.terminee)
.collect();
liste.sort_by(|a, b| {
a.terminee
.cmp(&b.terminee)
.then(b.priorite.cmp(&a.priorite))
.then(a.id.cmp(&b.id))
});
liste
}
}Quelques choix à expliquer :
#[from] génère les implémentations From vues au chapitre 6. Un ? sur une erreur d'entrée-sortie ou de JSON la convertit automatiquement en ErreurTaches.match avec une garde (if e.kind() == ...) distingue ce cas des vraies erreurs de lecture.terminer renvoie &Tache, une référence dont la durée de vie est liée à &mut self par la règle d'élision 3 du chapitre 7. Aucune annotation à écrire.triees renvoie des références, pas des copies : l'affichage n'a besoin que de lire.pub mod stockage;
pub mod tache;
pub use stockage::{ErreurTaches, ListeTaches};
pub use tache::{Priorite, Tache};Les pub use réexportent les types principaux : un utilisateur écrit taches::ListeTaches plutôt que taches::stockage::ListeTaches.
clap génère l'analyse des arguments, l'aide et les messages d'erreur à partir d'une struct et d'un enum :
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
use taches::{ListeTaches, Priorite};
/// Un gestionnaire de tâches minimaliste.
#[derive(Parser)]
#[command(version)]
struct Cli {
/// Fichier de stockage
#[arg(short, long, default_value = "taches.json", env = "TACHES_FICHIER")]
fichier: PathBuf,
#[command(subcommand)]
commande: Commande,
}
#[derive(Subcommand)]
enum Commande {
/// Ajoute une tâche
Ajouter {
titre: String,
#[arg(short, long, value_enum, default_value_t = Priorite::Normale)]
priorite: Priorite,
},
/// Affiche les tâches
Lister {
/// Inclut les tâches terminées
#[arg(short, long)]
tout: bool,
},
/// Marque une tâche comme terminée
Terminer { id: u32 },
/// Supprime une tâche
Supprimer { id: u32 },
}
fn main() -> Result<()> {
let cli = Cli::parse();
let mut liste = ListeTaches::charger(&cli.fichier)
.with_context(|| format!("chargement de {}", cli.fichier.display()))?;
match cli.commande {
Commande::Ajouter { titre, priorite } => {
let id = liste.ajouter(&titre, priorite)?;
println!("tâche {id} ajoutée");
}
Commande::Lister { tout } => {
let taches = liste.triees(tout);
if taches.is_empty() {
println!("rien à faire");
}
for t in taches {
println!("{t}");
}
return Ok(()); // lecture seule : pas besoin de sauver
}
Commande::Terminer { id } => {
let t = liste.terminer(id)?;
println!("terminée : {}", t.titre);
}
Commande::Supprimer { id } => {
let t = liste.supprimer(id)?;
println!("supprimée : {}", t.titre);
}
}
liste
.sauver(&cli.fichier)
.with_context(|| format!("sauvegarde de {}", cli.fichier.display()))?;
Ok(())
}Les commentaires /// deviennent le texte de l'aide. env = "TACHES_FICHIER" permet de choisir le fichier par variable d'environnement ; c'est pour cette option que clap est installé avec la feature env. Le match sur Commande est exhaustif : ajoutez une sous-commande, et le compilateur indique qu'il faut la traiter.
cargo run -- ajouter "Relire le chapitre sur l'ownership" --priorite haute
cargo run -- ajouter "Installer rust-analyzer" -p basse
cargo run -- ajouter "Écrire des tests"
cargo run -- terminer 2
cargo run -- lister --toutLe -- sépare les options de Cargo de celles du programme.
[ ] 1 Relire le chapitre sur l'ownership (!)
[ ] 3 Écrire des tests
[x] 2 Installer rust-analyzer (basse)Les erreurs sont lisibles, sans trace de pile :
cargo run -- terminer 42Error: aucune tâche avec l'identifiant 42Et l'aide est générée :
cargo run -- --helpUn gestionnaire de tâches minimaliste
Usage: taches [OPTIONS] <COMMAND>
Commands:
ajouter Ajoute une tâche
lister Affiche les tâches
terminer Marque une tâche comme terminée
supprimer Supprime une tâche
help Print this message or the help of the given subcommand(s)
Options:
-f, --fichier <FICHIER> Fichier de stockage [env: TACHES_FICHIER=] [default: taches.json]
-h, --help Print help
-V, --version Print versionLa logique est dans la bibliothèque, on la teste sans toucher au disque, à la fin de src/stockage.rs :
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ajouter_attribue_des_ids_croissants() {
let mut l = ListeTaches::default();
assert_eq!(l.ajouter("a", Priorite::Normale).unwrap(), 1);
assert_eq!(l.ajouter("b", Priorite::Normale).unwrap(), 2);
}
#[test]
fn titre_vide_refuse() {
let mut l = ListeTaches::default();
assert!(matches!(l.ajouter(" ", Priorite::Basse), Err(ErreurTaches::TitreVide)));
}
#[test]
fn terminer_un_id_inconnu() {
let mut l = ListeTaches::default();
assert!(matches!(l.terminer(7), Err(ErreurTaches::Introuvable(7))));
}
#[test]
fn tri_par_etat_puis_priorite() {
let mut l = ListeTaches::default();
l.ajouter("basse", Priorite::Basse).unwrap();
l.ajouter("haute", Priorite::Haute).unwrap();
l.ajouter("faite", Priorite::Haute).unwrap();
l.terminer(3).unwrap();
let titres: Vec<&str> = l.triees(true).iter().map(|t| t.titre.as_str()).collect();
assert_eq!(titres, ["haute", "basse", "faite"]);
}
#[test]
fn aller_retour_json() {
let mut l = ListeTaches::default();
l.ajouter("persister", Priorite::Haute).unwrap();
let json = serde_json::to_string(&l).unwrap();
let relue: ListeTaches = serde_json::from_str(&json).unwrap();
assert_eq!(relue.taches, l.taches);
}
}matches! teste qu'une valeur correspond à un motif, pratique pour vérifier une variante d'erreur sans implémenter PartialEq.
cargo test
cargo clippy -- -D warningscargo build --releaseL'exécutable target/release/taches (ou taches.exe) pèse environ un mégaoctet et ne dépend de rien. Copiez-le dans un dossier du PATH, ou installez-le directement :
cargo install --path .Pour le distribuer à d'autres, deux options courantes : publier sur crates.io avec cargo publish (les utilisateurs font cargo install taches), ou attacher les binaires de chaque plateforme à une release GitHub. Pour un service plutôt qu'un outil, une image Docker multi-étapes produit une image finale de quelques mégaoctets.
Quelques améliorations pour pratiquer, de la plus simple à la plus ambitieuse :
modifier <id> --titre ... --priorite ....Option<String> au format AAAA-MM-JJ, ou la crate chrono), et un affichage en rouge des tâches en retard avec la crate owo-colors.directories.rusqlite.axum et Tokio : main.rs change, lib.rs reste identique. C'est là que la séparation paie.Vous avez parcouru le chemin complet : installer Rust, comprendre l'ownership, modéliser avec des enums, traiter les erreurs, écrire du code générique, raisonner sur les durées de vie, enchaîner des itérateurs, organiser et tester un projet, paralléliser, puis livrer un exécutable. Le compilateur restera exigeant. Mais vous savez maintenant lire ses messages comme des conseils, et c'est ce qui sépare ceux qui abandonnent Rust de ceux qui ne veulent plus revenir en arrière. Si vous débutez aussi en Git, l'article sur Git et GitHub pour débutants vous aidera à versionner ce premier projet.