iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
iducationducation
IndexArticlesFormationsProfilOutilsBibliothech
N°014 — 2026
Navigation
01Index02Articles03Formations04Profil05Outils06Bibliothech
N°014 — 2026
Formations
Rust · Intermédiaire

Rust

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.

RustCargoédition 2024serdeclapTokio
01Introduction et installation02Variables, types, fonctions et contrôle de flux03Ownership et emprunts04Structs, enums et pattern matching05Collections et gestion d'erreurs06Traits et génériques07Durées de vie et pointeurs intelligents08Closures et itérateurs09Modules, crates et tests10Concurrence : threads, canaux et async11Projet : un gestionnaire de tâches en ligne de commande
Chapitre 11·45 min

Projet : un gestionnaire de tâches en ligne de commande

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 1

Créer le projet

cargo new taches
cd taches
cargo add clap --features derive,env
cargo add serde --features derive
cargo add serde_json anyhow thiserror
taches/
├── 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 fichier

Le modèle de données

src/tache.rs
use 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.

La liste et ses erreurs

src/stockage.rs
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.
  • Fichier absent = liste vide. Le premier lancement ne doit pas être une erreur. Le match avec une garde (if e.kind() == ...) distingue ce cas des vraies erreurs de lecture.
  • Écriture atomique. On écrit dans un fichier temporaire, puis on le renomme. Si le programme est interrompu en pleine écriture, l'ancien fichier reste intact au lieu d'être à moitié écrit.
  • 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.

La bibliothèque

src/lib.rs
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.

L'interface en ligne de commande

clap génère l'analyse des arguments, l'aide et les messages d'erreur à partir d'une struct et d'un enum :

src/main.rs
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.

Essayer

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 --tout

Le -- 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 42
Error: aucune tâche avec l'identifiant 42

Et l'aide est générée :

cargo run -- --help
Un 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 version

Les tests

La logique est dans la bibliothèque, on la teste sans toucher au disque, à la fin de src/stockage.rs :

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 warnings

Compiler et distribuer

cargo build --release

L'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.

Pour aller plus loin

Quelques améliorations pour pratiquer, de la plus simple à la plus ambitieuse :

  1. Une sous-commande modifier <id> --titre ... --priorite ....
  2. Une date d'échéance optionnelle (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.
  3. Ranger le fichier dans le dossier de données de l'utilisateur plutôt que dans le dossier courant, avec la crate directories.
  4. Remplacer le fichier JSON par une base SQLite avec rusqlite.
  5. Exposer la même bibliothèque derrière une petite API web avec 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.

Précédent
Concurrence : threads, canaux et async

Développeur fullstack passionné. J'apprends en construisant et je documente tout — front, back, outils. Le code s'apprend mieux en public.

Naviguer

IndexTous les articlesFormationsProfilOutilsBibliothech

Ailleurs

GitHub RSS

Newsletter

Les articles, libs et découvertes. Une fois par semaine, pas plus.

© 2026 William LoreeConçu & codé à la main