Créer une interface CLI de suivi des tâches

RustBeginner
Pratiquer maintenant

Introduction

Une application en ligne de commande utile relie plusieurs frontières : les arguments typés, les données du domaine, le stockage persistant, un affichage clair, les erreurs récupérables et les vérifications automatisées. Construire tout cela à partir d’un fichier vide masquerait la conception sous une grande quantité de saisie.

Dans ce lab, l’environnement fournit le squelette complet d’un gestionnaire de tâches. Vous allez d’abord examiner sa structure de modules, puis compléter une frontière à la fois : décoder et enregistrer le stockage, ajouter des tâches, lister les tâches, marquer des tâches comme terminées et produire une compilation de production testée. Vous n’aurez jamais besoin de coller un fichier source entier.

Lire la structure du projet et l’interface des commandes

Dans cette étape, vous allez vous familiariser avec le projet préparé et relier ses fichiers au flux de données avant de modifier le code.

Accédez au projet et listez ses fichiers source :

cd /home/labex/project/tasker
ls src
lib.rs  main.rs  model.rs  store.rs

Chaque fichier a une responsabilité principale :

  • main.rs gère la frontière du processus : analyser les arguments, afficher les réussites et signaler les échecs ;
  • lib.rs contient les opérations sur les tâches appelées par la CLI et les tests ;
  • model.rs définit une Task et la convertit vers une ligne de stockage ou depuis celle-ci ;
  • store.rs lit et écrit la collection de tâches.

Cette séparation évite que l’analyse des arguments, les opérations du domaine et les détails des fichiers ne se retrouvent dans une seule grande fonction. L’environnement a déjà écrit les déclarations de modules ainsi que le parseur plus long du modèle ; vous allez uniquement compléter les frontières d’opération indiquées.

Examinez le point d’entrée de la CLI :

nano src/main.rs

#[command(subcommand)] indique à clap que le mot de commande suivant sélectionne une variante de l’énumération Commands. L’option --file est marquée global = true, ce qui permet de la placer avant ou après une sous-commande. Sa valeur de type PathBuf est définie par défaut sur tasks.db. Appuyez sur Ctrl+X sans modifier le fichier.

Affichez l’aide générale générée :

cargo run --quiet -- --help

L’aide répertorie add, list et done. Demandez l’aide détaillée de add :

cargo run --quiet -- add --help

L’argument obligatoire <TITLE> provient du champ title: String de la variante Add. L’interface existe déjà ; les étapes suivantes permettront à l’opération de bibliothèque de chaque commande de fonctionner.

Compléter la couche de stockage

Dans cette étape, vous allez terminer les deux petites transformations qui relient les valeurs des tâches à un fichier texte local.

Ouvrez le module de stockage préparé :

nano src/store.rs

Le format du fichier utilise une tâche par ligne avec trois champs séparés par des tabulations :

id<TAB>status<TAB>title

model.rs fournit déjà Task::encode et Task::decode. Le module de stockage doit simplement appliquer ces fonctions à l’ensemble de la collection.

Remplacez le TODO du chargement et les deux lignes qui le suivent par :

    let tasks = contents
        .lines()
        .filter(|line| !line.is_empty())
        .map(Task::decode)
        .collect::<Result<Vec<_>, _>>()?;
    Ok(tasks)

L’itérateur transforme chaque ligne non vide en Result<Task, String>. La collecte dans Result<Vec<_>, _> s’arrête à la première ligne incorrecte ou renvoie toutes les tâches décodées. Le point d’interrogation propage cette erreur depuis load.

Dans save, remplacez son TODO et les trois dernières lignes par :

    fs::write(path, contents)
        .map_err(|error| format!("could not write {}: {error}", path.display()))

fs::write crée ou remplace le fichier de stockage. map_err ajoute le chemin du fichier en échec tout en conservant un Result récupérable.

Enregistrez le fichier et quittez nano. Exécutez uniquement le test ciblé du stockage :

cargo test store::tests::saves_and_loads_tasks

La réussite de ce test prouve qu’une collection de tâches peut franchir la frontière du fichier et revenir sous forme de valeurs Rust équivalentes.

Ajouter et enregistrer de nouvelles tâches

Dans cette étape, vous allez implémenter l’opération de bibliothèque associée à la sous-commande add.

Ouvrez le point d’entrée de la bibliothèque :

nano src/lib.rs

Remplacez le TODO et le corps provisoire de add_task par :

    let mut tasks = store::load(path)?;
    let next_id = tasks.iter().map(|task| task.id).max().unwrap_or(0) + 1;
    let task = Task::new(next_id, title);
    tasks.push(task.clone());
    store::save(path, &tasks)?;
    Ok(task)

L’opération commence par charger l’état actuel. max().unwrap_or(0) + 1 produit l’identifiant 1 pour un fichier vide et un identifiant supérieur de un au plus grand identifiant existant dans les autres cas. La tâche est clonée une fois, car une copie possédée est ajoutée au vecteur tandis que la copie renvoyée permet à la CLI de décrire ce qui a été ajouté.

Enregistrez le fichier et quittez nano. Ajoutez deux tâches dans un fichier réservé à la démonstration :

cargo run --quiet -- --file add-demo.db add "Write release notes"
Added 1: Write release notes
cargo run --quiet -- --file add-demo.db add "Tag version"
Added 2: Tag version

Le deuxième identifiant prouve que la commande a chargé le premier enregistrement avant de choisir et d’enregistrer le suivant.

Formater la liste des tâches

Dans cette étape, vous allez convertir les tâches enregistrées en un affichage stable et lisible pour l’utilisateur.

Ouvrez à nouveau src/lib.rs :

nano src/lib.rs

Remplacez le TODO et le corps provisoire de list_tasks par :

    let tasks = store::load(path)?;
    Ok(tasks
        .iter()
        .map(|task| {
            let marker = if task.done { "x" } else { " " };
            format!("[{marker}] {}: {}", task.id, task.title)
        })
        .collect())

Le marqueur fournit une vue compacte de l’état : [ ] signifie que la tâche est ouverte et [x] qu’elle est terminée. Cette fonction renvoie des lignes d’affichage au lieu de les imprimer ; les tests et les autres appelants peuvent ainsi examiner le résultat sans capturer la sortie du terminal.

Enregistrez le fichier et quittez nano. Réutilisez le fichier créé à l’étape précédente :

cargo run --quiet -- --file add-demo.db list
[ ] 1: Write release notes
[ ] 2: Tag version

La bibliothèque se charge de formater les lignes des tâches, tandis que main.rs reste uniquement responsable de l’affichage des lignes renvoyées.

Marquer une tâche comme terminée

Dans cette étape, vous allez mettre à jour une tâche tout en conservant le reste de la collection enregistrée.

Ouvrez le code source de la bibliothèque :

nano src/lib.rs

Remplacez le TODO et le corps provisoire de complete_task par :

    let mut tasks = store::load(path)?;
    let task = tasks
        .iter_mut()
        .find(|task| task.id == id)
        .ok_or_else(|| format!("task {id} was not found"))?;
    task.done = true;
    let completed = task.clone();
    store::save(path, &tasks)?;
    Ok(completed)

iter_mut() fournit des références mutables afin que l’enregistrement correspondant puisse être modifié sur place. find renvoie None lorsque l’identifiant est absent ; ok_or_else transforme cette absence en erreur explicite de la fonction. La tâche terminée est clonée avant l’enregistrement, car l’emprunt mutable appartient au vecteur qui doit être enregistré.

Enregistrez le fichier et quittez nano. Marquez la tâche 1 comme terminée dans le fichier de démonstration :

cargo run --quiet -- --file add-demo.db done 1
Completed 1: Write release notes

Affichez à nouveau l’état enregistré :

cargo run --quiet -- --file add-demo.db list
[x] 1: Write release notes
[ ] 2: Tag version

Essayez un identifiant absent :

cargo run --quiet -- --file add-demo.db done 99

Cette commande doit échouer. Le message est envoyé vers stderr et le processus se termine avec un code différent de zéro, car main.rs convertit déjà l’erreur Err à la frontière du processus.

Tester et compiler la version de production

Dans cette étape, vous allez appliquer la boucle de qualité avant livraison et produire un exécutable de production à partir du projet terminé.

Commencez par formater les modifications effectuées dans les modules de stockage et de bibliothèque :

cargo fmt

Vérifiez que le formateur n’a plus aucune modification à effectuer :

cargo fmt -- --check

Exécutez Clippy en mode strict :

cargo clippy -- -D warnings

Exécutez toute la suite de tests :

cargo test

Deux tests doivent réussir : le test ciblé d’aller-retour du stockage et le scénario complet de la bibliothèque add-list-done. Ces tests utilisent des fichiers temporaires ; ils vérifient donc une persistance réelle sans dépendre de votre base de démonstration.

Compilez la cible optimisée de production :

cargo build --release --locked

--release sélectionne le profil de production optimisé de Cargo au lieu du profil de développement, plus rapide à compiler. --locked exige le graphe exact des dépendances déjà enregistré dans Cargo.lock. Exécutez directement l’exécutable obtenu :

./target/release/tasker --file release-demo.db add "Publish tasker"
Added 1: Publish tasker
./target/release/tasker --file release-demo.db list
[ ] 1: Publish tasker

Le chemin direct prouve que vous exécutez l’artefact compilé au lieu de demander à Cargo de le compiler et de le lancer pour vous.

Résumé

Vous avez terminé une CLI Rust à plusieurs commandes sans avoir à retaper son architecture. Le projet terminé analyse des sous-commandes typées avec clap, conserve les modèles de tâches au moyen d’un module de stockage dédié, propage des erreurs contextualisées, garde la sortie du processus dans main, réussit les tests ciblés et de bout en bout de la bibliothèque, respecte la boucle de qualité et produit un exécutable de production verrouillé.