Dans ce projet, vous construisez un gestionnaire de tâches en ligne de commande
qui se souvient de vos tâches d’une exécution à l’autre. Chaque tâche est un
struct ; la liste entière est rangée dans une slice et persistée dans un fichier
tasks.json grâce au paquet standard encoding/json. Vous gérerez quatre
commandes — add, list, done et remove — appelées comme
go run . add "écrire un article".
Compétences travaillées
- Modéliser des enregistrements avec un
structet des tags JSON. - Lire et écrire des fichiers avec le paquet
os. - Sérialiser et analyser des données avec
encoding/json. - Gérer proprement le cas « le fichier n’existe pas encore » avec
errors.Is. - Analyser les arguments de la ligne de commande depuis
os.Args.
Prérequis
C’est un projet intermédiaire. Assurez-vous d’être à l’aise avec :
- Les structs et les méthodes en Go — une tâche est un struct.
- Les tableaux et les slices en Go — la liste de tâches est une slice que l’on agrandit et réduit.
- La gestion des erreurs en Go — les opérations de fichier et d’analyse renvoient des erreurs à vérifier.
Si les CLI en Go sont nouvelles pour vous, commencez d’abord par le projet quiz en ligne de commande, plus simple.
Arborescence finale
task-manager/
├── go.mod
├── main.go
└── tasks.json (créé à la première sauvegarde)
Étapes
1. Initialiser le module et modéliser une tâche
Lancez go mod init taskmanager, puis décrivez une tâche. Les tags JSON
contrôlent le nom de chaque champ sur le disque.
package main
// Task représente une tâche persistée sur le disque.
type Task struct {
ID int `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
}
const storeFile = "tasks.json"
2. Charger et sauvegarder la liste en JSON
Le chargement doit tolérer un fichier absent lors de la toute première
exécution : errors.Is permet de détecter ce cas précis et de renvoyer une
liste vide plutôt qu’une erreur.
import (
"encoding/json"
"errors"
"os"
)
// load lit la liste de tâches depuis path. Un fichier absent donne une liste vide.
func load(path string) ([]Task, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return []Task{}, nil
}
if err != nil {
return nil, err
}
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
// save écrit la liste de tâches dans path au format JSON indenté.
func save(path string, tasks []Task) error {
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
3. Ajouter, terminer et supprimer des tâches
Ces fonctions opèrent uniquement sur des slices, ce qui les rend faciles à comprendre et à tester. Les nouveaux identifiants valent un de plus que le plus grand identifiant existant.
import "fmt"
// nextID renvoie le plus petit identifiant libre (plus grand existant + 1).
func nextID(tasks []Task) int {
highest := 0
for _, t := range tasks {
if t.ID > highest {
highest = t.ID
}
}
return highest + 1
}
// add ajoute une nouvelle tâche avec le texte donné.
func add(tasks []Task, text string) []Task {
return append(tasks, Task{ID: nextID(tasks), Text: text})
}
// setDone marque comme terminée la tâche portant l'identifiant donné.
func setDone(tasks []Task, id int) ([]Task, error) {
for i := range tasks {
if tasks[i].ID == id {
tasks[i].Done = true
return tasks, nil
}
}
return tasks, fmt.Errorf("tâche %d introuvable", id)
}
// remove supprime la tâche portant l'identifiant donné.
func remove(tasks []Task, id int) ([]Task, error) {
for i := range tasks {
if tasks[i].ID == id {
return append(tasks[:i], tasks[i+1:]...), nil
}
}
return tasks, fmt.Errorf("tâche %d introuvable", id)
}
4. Brancher la ligne de commande et assembler le programme
main lit la commande depuis os.Args, charge la liste, applique le changement
et la sauvegarde. Voici le programme complet et exécutable.
package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
)
// Task représente une tâche persistée sur le disque.
type Task struct {
ID int `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
}
const storeFile = "tasks.json"
// load lit la liste de tâches depuis path. Un fichier absent donne une liste vide.
func load(path string) ([]Task, error) {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return []Task{}, nil
}
if err != nil {
return nil, err
}
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
// save écrit la liste de tâches dans path au format JSON indenté.
func save(path string, tasks []Task) error {
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o644)
}
// nextID renvoie le plus petit identifiant libre (plus grand existant + 1).
func nextID(tasks []Task) int {
highest := 0
for _, t := range tasks {
if t.ID > highest {
highest = t.ID
}
}
return highest + 1
}
// add ajoute une nouvelle tâche avec le texte donné.
func add(tasks []Task, text string) []Task {
return append(tasks, Task{ID: nextID(tasks), Text: text})
}
// setDone marque comme terminée la tâche portant l'identifiant donné.
func setDone(tasks []Task, id int) ([]Task, error) {
for i := range tasks {
if tasks[i].ID == id {
tasks[i].Done = true
return tasks, nil
}
}
return tasks, fmt.Errorf("tâche %d introuvable", id)
}
// remove supprime la tâche portant l'identifiant donné.
func remove(tasks []Task, id int) ([]Task, error) {
for i := range tasks {
if tasks[i].ID == id {
return append(tasks[:i], tasks[i+1:]...), nil
}
}
return tasks, fmt.Errorf("tâche %d introuvable", id)
}
func main() {
if len(os.Args) < 2 {
fmt.Println("usage : tasks add|list|done|remove [args]")
os.Exit(1)
}
tasks, err := load(storeFile)
if err != nil {
fmt.Fprintln(os.Stderr, "erreur :", err)
os.Exit(1)
}
switch os.Args[1] {
case "add":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage : tasks add <texte>")
os.Exit(1)
}
tasks = add(tasks, os.Args[2])
case "list":
for _, t := range tasks {
status := " "
if t.Done {
status = "x"
}
fmt.Printf("[%s] %d %s\n", status, t.ID, t.Text)
}
case "done", "remove":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage : tasks", os.Args[1], "<id>")
os.Exit(1)
}
id, err := strconv.Atoi(os.Args[2])
if err != nil {
fmt.Fprintln(os.Stderr, "id invalide :", os.Args[2])
os.Exit(1)
}
if os.Args[1] == "done" {
tasks, err = setDone(tasks, id)
} else {
tasks, err = remove(tasks, id)
}
if err != nil {
fmt.Fprintln(os.Stderr, "erreur :", err)
os.Exit(1)
}
default:
fmt.Fprintln(os.Stderr, "commande inconnue :", os.Args[1])
os.Exit(1)
}
if err := save(storeFile, tasks); err != nil {
fmt.Fprintln(os.Stderr, "erreur :", err)
os.Exit(1)
}
}
Essayez-le :
go run . add "écrire un article"
go run . add "relire la PR"
go run . done 1
go run . list
Critères de validation
-
addajoute une tâche et lui donne un identifiant unique et croissant. -
listaffiche chaque tâche avec un marqueur[ ]ou[x]. -
done <id>marque une tâche terminée ;remove <id>la supprime. - L’état survit entre les exécutions grâce à
tasks.json. - La première exécution fonctionne même si
tasks.jsonn’existe pas encore. - Une commande inconnue ou un id invalide affiche une erreur et sort en code non nul.
Pour aller plus loin
- Ajoutez une commande
clearqui supprime toutes les tâches terminées. - Stockez une date de création sur chaque tâche avec
time.Time. - Gérez des échéances et triez la liste selon celles-ci.
- Remplacez les arguments positionnels par le paquet
flagpour des options plus riches.
Tests
Les fonctions sur les slices sont pures, donc faciles à tester isolément.
Ajoutez un fichier main_test.go et lancez go test ./....
package main
import "testing"
func TestNextID(t *testing.T) {
cases := []struct {
name string
tasks []Task
want int
}{
{"liste vide", nil, 1},
{"identifiants successifs", []Task{{ID: 1}, {ID: 2}}, 3},
{"identifiants avec trous", []Task{{ID: 1}, {ID: 5}}, 6},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := nextID(c.tasks); got != c.want {
t.Errorf("nextID() = %d, want %d", got, c.want)
}
})
}
}
Conclusion
Vous avez construit un outil en ligne de commande persistant et testable, et pratiqué les structs, les slices, les entrées/sorties fichier et la sérialisation JSON — les briques du quotidien en Go. À partir de là, tentez les extensions ci-dessus, ou revenez au projet quiz en ligne de commande pour lui faire charger ses questions depuis un fichier JSON avec ce que vous venez d’apprendre.