Construire un gestionnaire de tâches en Go

Construisez un gestionnaire de tâches en ligne de commande en Go : ajout, liste, achèvement et suppression persistés dans un fichier JSON avec encoding/json.

Temps estimé
~6 h
Compétences
CLI, structs, files, JSON
Sur cette page

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 struct et 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 :

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

  • add ajoute une tâche et lui donne un identifiant unique et croissant.
  • list affiche 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.json n’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 clear qui 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 flag pour 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.

Recherche

La recherche s’exécute entièrement dans votre navigateur.