Aller au contenu

Projet : liste de tâches en Rust

Dans ce projet, vous allez construire une petite liste de tâches qui vit dans votre terminal. Vous tapez todo add Acheter du lait, et elle s’en souvient. Vous fermez le terminal, vous revenez le lendemain, vous tapez todo list, et vos tâches sont toujours là.

C’est un excellent deuxième projet, parce qu’il ressemble à un vrai outil. Il utilise des structs (structures), des enums (énumérations), des vecteurs, les arguments de ligne de commande, la lecture et l’écriture de fichiers et Result avec ?. Nous allons construire chaque morceau séparément, le tester dans le Playground, puis tout assembler.

Voici à quoi ressemble l’outil terminé quand on l’utilise :

Terminal window
$ cargo run -- add Acheter du lait
Tâche 1 ajoutée.
$ cargo run -- add Appeler mamie
Tâche 2 ajoutée.
$ cargo run -- done 1
Tâche 1 marquée comme terminée.
$ cargo run -- list
[x] 1: Acheter du lait
[ ] 2: Appeler mamie
$ cargo run -- remove 1
Tâche 1 supprimée : Acheter du lait

Tout ce qui suit -- est transmis à votre programme, pas à Cargo. Les noms des commandes (add, list, done et remove) restent en anglais, comme dans la plupart des outils en ligne de commande. Si vous préférez ajouter ou lister, il suffit de les changer dans parse_command (étape 2).

Les tâches sont enregistrées dans un fichier appelé tasks.txt, une tâche par ligne :

2|0|Appeler mamie

Chaque ligne a la forme id|done|text, où done vaut 1 si la tâche est terminée et 0 sinon.

Créez un nouveau projet avec :

Terminal window
cargo new todo
cd todo

Chaque tâche a un numéro (pour pouvoir y faire référence), un indicateur « terminée » et un texte. Une struct convient parfaitement. Nous lui ajoutons aussi deux méthodes : l’une transforme une tâche en ligne de texte pour le fichier, l’autre relit une tâche à partir d’une ligne.

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
// Transforme une tâche en une ligne, par exemple "1|0|Acheter du lait".
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
// Relit une tâche à partir d'une ligne. Renvoie None si la ligne est abîmée.
fn from_line(line: &str) -> Option<Task> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.parse().ok()?;
let done = parts.next()? == "1";
let text = parts.next()?.to_string();
Some(Task { id, done, text })
}
}
fn main() {
let task = Task { id: 1, done: false, text: String::from("Acheter du lait") };
let line = task.to_line();
println!("{line}");
println!("{:?}", Task::from_line("2|1|Appeler mamie"));
println!("{:?}", Task::from_line("3|0|Se renseigner sur les tests A|B"));
println!("{:?}", Task::from_line("cette ligne est abîmée"));
}

Sortie :

1|0|Acheter du lait
Some(Task { id: 2, done: true, text: "Appeler mamie" })
Some(Task { id: 3, done: false, text: "Se renseigner sur les tests A|B" })
None
▶ Essayez par vous-même

Deux détails méritent qu’on s’y arrête :

  • splitn(3, '|') découpe en au plus 3 morceaux. Les deux premiers | séparent les champs, et tout ce qui suit est le texte, même si ce texte contient lui-même un |. Avec un simple split('|'), la tâche « Se renseigner sur les tests A|B » perdrait une partie de son texte.
  • from_line renvoie une Option et utilise ? à chaque étape. S’il manque un morceau ou si l’id n’est pas un nombre, toute la fonction renvoie None. Le .ok() transforme le Result de parse() en Option pour que ? puisse s’appliquer.

L’outil comprend quatre commandes. Chacune est une variante d’un enum, et certaines variantes transportent des données : Add a besoin du texte, Done et Remove ont besoin d’un numéro de tâche.

#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("veuillez indiquer un numéro de tâche"))?;
arg.parse().map_err(|_| format!("'{arg}' n'est pas un numéro de tâche"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("utilisation : todo add <texte de la tâche>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("commande inconnue '{other}'")),
}
}
fn main() {
let examples = [
vec!["add", "Acheter", "du", "lait"],
vec!["list"],
vec![],
vec!["done", "2"],
vec!["done", "deux"],
vec!["remove"],
vec!["danser"],
];
for words in examples {
let args: Vec<String> = words.iter().map(|w| w.to_string()).collect();
println!("{:?} -> {:?}", words, parse_command(&args));
}
}

Sortie :

["add", "Acheter", "du", "lait"] -> Ok(Add("Acheter du lait"))
["list"] -> Ok(List)
[] -> Ok(List)
["done", "2"] -> Ok(Done(2))
["done", "deux"] -> Err("'deux' n'est pas un numéro de tâche")
["remove"] -> Err("veuillez indiquer un numéro de tâche")
["danser"] -> Err("commande inconnue 'danser'")
▶ Essayez par vous-même

Dans le vrai programme, args vient de la ligne de commande. Comme vous l’avez vu sur la page Arguments de ligne de commande, le premier élément de std::env::args() est le nom du programme lui-même, donc on le saute :

let args: Vec<String> = std::env::args().skip(1).collect();

Pourquoi add utilise-t-il args[1..].join(" ") ? Votre shell découpe todo add Acheter du lait en mots séparés : add, Acheter, du, lait. En rassemblant tout ce qui suit add, on reconstitue le texte de la tâche, et vous n’avez pas besoin de guillemets.

Remarquez que parse_command ne plante jamais. Une saisie incorrecte devient un Err avec un message utile. C’est l’appelant qui décide quoi en faire.

Toutes les tâches vivent dans un Vec<Task>. Chaque commande est une petite fonction qui modifie le vecteur :

  • Add : trouver le plus grand id existant, ajouter 1, et pousser une nouvelle tâche.
  • Done : trouver la tâche qui a cet id et mettre done = true.
  • Remove : trouver la position de la tâche et la retirer.
  • List : afficher toutes les tâches.
#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("la tâche {id} n'existe pas")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("la tâche {id} n'existe pas")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Rien à faire. Profitez de votre journée !");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn main() {
let mut tasks = Vec::new();
add_task(&mut tasks, String::from("Acheter du lait"));
add_task(&mut tasks, String::from("Appeler mamie"));
add_task(&mut tasks, String::from("Apprendre Rust"));
mark_done(&mut tasks, 3).unwrap();
let removed = remove_task(&mut tasks, 1).unwrap();
println!("Supprimée : {}", removed.text);
if let Err(message) = mark_done(&mut tasks, 99) {
println!("Erreur : {message}");
}
print_tasks(&tasks);
}

Sortie :

Supprimée : Acheter du lait
Erreur : la tâche 99 n'existe pas
[ ] 2: Appeler mamie
[x] 3: Apprendre Rust
▶ Essayez par vous-même

Certains choix sont faits exprès :

  • Les ids viennent du plus grand id existant, et non de tasks.len() + 1. Si vous supprimez la tâche 1 puis en ajoutez une nouvelle, utiliser la longueur vous donnerait un id en double.
  • mark_done prend un &mut [Task] (une slice, c’est-à-dire une tranche) parce qu’elle ne fait que modifier des éléments. add_task et remove_task prennent un &mut Vec<Task> parce qu’elles changent la longueur.
  • Les erreurs sont des Result<_, String>. Une erreur de type String, c’est la solution la plus simple qui fonctionne. Pour des programmes plus gros, voyez Erreurs personnalisées.

iter_mut().find(...) vous donne une référence mutable vers la tâche trouvée : c’est pour cela que task.done = true modifie la tâche à l’intérieur du vecteur. Voyez Itérateurs pour en savoir plus sur find et position.

Il faut maintenant que les tâches survivent à la fin du programme. On les enregistre avec std::fs::write et on les charge avec std::fs::read_to_string. Les deux peuvent échouer, donc les deux renvoient un Result, et l’opérateur ? fait remonter toute erreur à l’appelant :

use std::fs;
use std::io;
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)?;
Ok(())
}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
// La première fois que vous lancez le programme, le fichier n'existe pas encore.
// Ce n'est pas une erreur : cela veut simplement dire « aucune tâche ».
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let tasks = text.lines().filter_map(Task::from_line).collect();
Ok(tasks)
}

Le cas NotFound compte plus qu’il n’y paraît. Sans lui, le tout premier todo add échouerait avec « No such file or directory » (le message du système, en anglais : « fichier ou dossier introuvable »), parce que vous n’avez encore rien enregistré. Toutes les autres erreurs (comme « permission denied », accès refusé) remontent quand même.

filter_map(Task::from_line) lit chaque ligne et ne garde que celles qui se lisent correctement. Attention : cela ignore en silence les lignes abîmées. C’est pratique si quelqu’un modifie le fichier à la main et fait une faute de frappe, mais cela veut aussi dire qu’une erreur peut passer inaperçue. L’une des idées d’extension plus bas consiste à afficher un avertissement à la place.

Voyez Lire et écrire des fichiers pour en savoir plus.

Voici tout le code réuni. Mettez-le dans src/main.rs et lancez-le avec cargo run -- add Acheter du lait, cargo run -- list, et ainsi de suite.

La logique principale se trouve dans run_command, qui travaille sur un Vec<Task> et ne sait rien des fichiers. main se contente de charger le fichier, d’appeler run_command et d’enregistrer. Séparer les deux rend la logique facile à tester, et c’est ce qui nous permet de faire une version pour le Playground à l’étape suivante.

use std::env;
use std::error::Error;
use std::fs;
use std::io;
use std::process;
const FILE: &str = "tasks.txt";
#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
fn from_line(line: &str) -> Option<Task> {
let mut parts = line.splitn(3, '|');
let id = parts.next()?.parse().ok()?;
let done = parts.next()? == "1";
let text = parts.next()?.to_string();
Some(Task { id, done, text })
}
}
#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("veuillez indiquer un numéro de tâche"))?;
arg.parse().map_err(|_| format!("'{arg}' n'est pas un numéro de tâche"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("utilisation : todo add <texte de la tâche>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("commande inconnue '{other}'")),
}
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("la tâche {id} n'existe pas")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("la tâche {id} n'existe pas")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Rien à faire. Profitez de votre journée !");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> {
match parse_command(args)? {
Command::Add(text) => {
let id = add_task(tasks, text);
println!("Tâche {id} ajoutée.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Tâche {id} marquée comme terminée.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Tâche {id} supprimée : {}", task.text);
}
}
Ok(())
}
fn save_tasks(path: &str, tasks: &[Task]) -> Result<(), io::Error> {
let mut text = String::new();
for task in tasks {
text.push_str(&task.to_line());
text.push('\n');
}
fs::write(path, text)?;
Ok(())
}
fn load_tasks(path: &str) -> Result<Vec<Task>, io::Error> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let tasks = text.lines().filter_map(Task::from_line).collect();
Ok(tasks)
}
fn run() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().skip(1).collect();
let mut tasks = load_tasks(FILE)?;
run_command(&args, &mut tasks)?;
save_tasks(FILE, &tasks)?;
Ok(())
}
fn main() {
if let Err(e) = run() {
eprintln!("Erreur : {e}");
process::exit(1);
}
}

Un mot sur run() -> Result<(), Box<dyn Error>>. Nos fonctions renvoient deux types d’erreur différents : io::Error pour les fonctions de fichiers et String pour les fonctions de commandes. Box<dyn Error> est une boîte qui peut contenir n’importe quel type d’erreur, donc ? fonctionne avec les deux. main affiche ensuite l’erreur avec eprintln! (qui écrit sur la sortie d’erreur) et quitte avec le code 1, le signal habituel pour dire que quelque chose s’est mal passé.

Étape 6 : une version à lancer dans le Playground

Section intitulée « Étape 6 : une version à lancer dans le Playground »

Le Playground ne peut pas passer d’arguments de ligne de commande, et nous ne voulons pas dépendre de fichiers. Cette version garde donc le même run_command, mais lui fournit une liste de commandes écrite à l’avance, et affiche à la fin ce que contiendrait tasks.txt :

#[derive(Debug)]
struct Task {
id: u32,
done: bool,
text: String,
}
impl Task {
fn to_line(&self) -> String {
let done = if self.done { 1 } else { 0 };
format!("{}|{}|{}", self.id, done, self.text)
}
}
#[derive(Debug)]
enum Command {
Add(String),
List,
Done(u32),
Remove(u32),
}
fn parse_id(arg: Option<&String>) -> Result<u32, String> {
let arg = arg.ok_or(String::from("veuillez indiquer un numéro de tâche"))?;
arg.parse().map_err(|_| format!("'{arg}' n'est pas un numéro de tâche"))
}
fn parse_command(args: &[String]) -> Result<Command, String> {
match args.first().map(|s| s.as_str()) {
None | Some("list") => Ok(Command::List),
Some("add") => {
let text = args[1..].join(" ");
if text.is_empty() {
Err(String::from("utilisation : todo add <texte de la tâche>"))
} else {
Ok(Command::Add(text))
}
}
Some("done") => parse_id(args.get(1)).map(Command::Done),
Some("remove") => parse_id(args.get(1)).map(Command::Remove),
Some(other) => Err(format!("commande inconnue '{other}'")),
}
}
fn add_task(tasks: &mut Vec<Task>, text: String) -> u32 {
let id = tasks.iter().map(|t| t.id).max().unwrap_or(0) + 1;
tasks.push(Task { id, done: false, text });
id
}
fn mark_done(tasks: &mut [Task], id: u32) -> Result<(), String> {
match tasks.iter_mut().find(|t| t.id == id) {
Some(task) => {
task.done = true;
Ok(())
}
None => Err(format!("la tâche {id} n'existe pas")),
}
}
fn remove_task(tasks: &mut Vec<Task>, id: u32) -> Result<Task, String> {
match tasks.iter().position(|t| t.id == id) {
Some(index) => Ok(tasks.remove(index)),
None => Err(format!("la tâche {id} n'existe pas")),
}
}
fn print_tasks(tasks: &[Task]) {
if tasks.is_empty() {
println!("Rien à faire. Profitez de votre journée !");
return;
}
for task in tasks {
let mark = if task.done { "x" } else { " " };
println!("[{mark}] {}: {}", task.id, task.text);
}
}
fn run_command(args: &[String], tasks: &mut Vec<Task>) -> Result<(), String> {
match parse_command(args)? {
Command::Add(text) => {
let id = add_task(tasks, text);
println!("Tâche {id} ajoutée.");
}
Command::List => print_tasks(tasks),
Command::Done(id) => {
mark_done(tasks, id)?;
println!("Tâche {id} marquée comme terminée.");
}
Command::Remove(id) => {
let task = remove_task(tasks, id)?;
println!("Tâche {id} supprimée : {}", task.text);
}
}
Ok(())
}
fn main() {
// Chaque ligne correspond à ce que vous taperiez après `todo`.
let script = [
"list",
"add Acheter du lait",
"add Appeler mamie",
"add Apprendre Rust",
"done 3",
"remove 1",
"done 9",
"sauter",
"list",
];
let mut tasks: Vec<Task> = Vec::new();
for line in script {
println!("$ todo {line}");
let args: Vec<String> = line.split_whitespace().map(String::from).collect();
if let Err(message) = run_command(&args, &mut tasks) {
println!("Erreur : {message}");
}
}
println!();
println!("tasks.txt contiendrait :");
for task in &tasks {
println!("{}", task.to_line());
}
}

Sortie :

$ todo list
Rien à faire. Profitez de votre journée !
$ todo add Acheter du lait
Tâche 1 ajoutée.
$ todo add Appeler mamie
Tâche 2 ajoutée.
$ todo add Apprendre Rust
Tâche 3 ajoutée.
$ todo done 3
Tâche 3 marquée comme terminée.
$ todo remove 1
Tâche 1 supprimée : Acheter du lait
$ todo done 9
Erreur : la tâche 9 n'existe pas
$ todo sauter
Erreur : commande inconnue 'sauter'
$ todo list
[ ] 2: Appeler mamie
[x] 3: Apprendre Rust
tasks.txt contiendrait :
2|0|Appeler mamie
3|1|Apprendre Rust
▶ Essayez par vous-même

Modifiez le script et relancez-le. Essayez d’ajouter une tâche après en avoir supprimé une, et vérifiez que le nouvel id n’est pas un doublon.

Notre format id|done|text est facile à lire, mais vous avez dû écrire to_line et from_line à la main. À mesure que vos tâches s’enrichissent (dates d’échéance, priorités, étiquettes), ce code écrit à la main grossit aussi.

Dans les vrais projets, on laisse généralement les crates serde (en anglais) faire ce travail. Ajoutez-les avec :

Terminal window
cargo add serde --features derive
cargo add serde_json

Ensuite, une seule ligne derive vous offre l’enregistrement et le chargement :

use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Task {
id: u32,
done: bool,
text: String,
}
fn main() {
let tasks = vec![
Task { id: 1, done: false, text: String::from("Acheter du lait") },
Task { id: 2, done: true, text: String::from("Appeler mamie") },
];
let json = serde_json::to_string(&tasks).unwrap();
println!("{json}");
let loaded: Vec<Task> = serde_json::from_str(&json).unwrap();
println!("{} tâches rechargées", loaded.len());
}

Sortie :

[{"id":1,"done":false,"text":"Acheter du lait"},{"id":2,"done":true,"text":"Appeler mamie"}]
2 tâches rechargées
▶ Essayez par vous-même

Il vous suffirait d’écrire cette chaîne JSON dans tasks.json avec fs::write, exactement comme avant. La page JSON avec serde couvre le sujet en détail.

Utiliser ? dans un main qui ne renvoie rien. On est tenté d’écrire ceci :

use std::fs;
fn main() {
let text = fs::read_to_string("tasks.txt")?;
println!("{text}");
}
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:47
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let text = fs::read_to_string("tasks.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`

Le compilateur dit que l’opérateur ? ne peut être utilisé que dans une fonction qui renvoie Result ou Option. ? signifie « si c’est une erreur, renvoie-la depuis la fonction actuelle ». Un main simple renvoie (), il n’y a donc nulle part où envoyer l’erreur. Soit vous donnez à main un type de retour comme Result<(), Box<dyn Error>>, soit vous faites comme notre programme : vous mettez le travail dans une fonction run() qui renvoie un Result, et vous gérez l’erreur une seule fois dans main.

Considérer un fichier absent comme un échec. Si load_tasks faisait simplement fs::read_to_string(path)?, le programme échouerait dès le tout premier lancement, avant qu’un fichier existe. Testez io::ErrorKind::NotFound et commencez plutôt avec une liste vide.

Utiliser la longueur de la liste comme prochain id. Avec les tâches 1, 2 et 3, supprimer la tâche 1 laisse deux tâches. len() + 1 redonnerait 3, et deux tâches partageraient le même id. Utilisez le plus grand id plus un.

  • Annuler « terminée ». Ajoutez une commande undone <id> qui remet done à false.
  • Modifier une tâche. edit 2 Appeler mamie ce soir remplace le texte de la tâche 2.
  • Effacer les tâches terminées. clear supprime toutes les tâches terminées. Jetez un œil à Vec::retain (en anglais).
  • Signaler les lignes abîmées. Au lieu d’ignorer les mauvaises lignes en silence, affichez un avertissement avec le numéro de la ligne.
  • Une commande help qui affiche toutes les commandes et la façon de les utiliser.
  • Écrire des tests. parse_command et run_command ne touchent pas aux fichiers, elles sont donc faciles à tester.
  • Utiliser un vrai analyseur d’arguments. La crate clap (en anglais), mentionnée sur la page Arguments de ligne de commande, vous offre --help et des sous-commandes sans effort.
  • Une struct Task contient les données. to_line et from_line la transforment en texte et inversement.
  • Un enum Command décrit ce que l’utilisateur a demandé. parse_command transforme les arguments en Command, ou en Err avec un message utile.
  • Un Vec<Task> contient la liste. De petites fonctions ajoutent, cherchent, modifient et suppriment des tâches.
  • fs::read_to_string et fs::write renvoient un Result, et ? fait remonter les erreurs. Un fichier absent au premier lancement est traité comme « aucune tâche ».
  • Séparer la logique (run_command) des fichiers et des arguments la rend facile à tester et à lancer dans le Playground.

Petit quiz

1. Pourquoi from_line utilise-t-elle splitn(3, '|') plutôt que split('|') ?

2. Que fait load_tasks quand tasks.txt n'existe pas encore ?

3. Pourquoi ne peut-on pas utiliser ? directement dans un fn main() sans type de retour ?

4. Quelle crate est l'amélioration habituelle pour enregistrer des structs en JSON ?

Score : 0 / 4