Book · Chapter 23

21. Projet 1 : CLI robuste

21. Projet 1 : CLI robuste

Nous allons construire un outil de ligne de commande propre, avec parsing d’arguments, validation, et sorties lisibles. Ce projet sert de base à plusieurs chapitres. L’objectif n’est pas de faire un « outil parfait », mais un outil que vous pouvez expliquer et maintenir.

Cahier des charges

Notre CLI, que l’on appellera vitte-cat, va :

Lire un fichier et l’écrire sur stdout. Offrir une option --lines pour limiter le nombre de lignes. Fournir des erreurs claires en cas de fichier manquant.

Étape 1 : définir l’interface

Avant d’écrire la moindre ligne, décrivez l’interface en une phrase :

vitte-cat <path> affiche le contenu. vitte-cat --lines 10 <path> affiche les 10 premières lignes.

Une interface claire réduit la complexité de tout le reste.

Étape 2 : parser les arguments

Utilisez la stdlib pour obtenir les arguments, puis validez :

use std/cli

entry main at core/app {
  let args = args()
  let _ = has_flag(args, "--help")
  return 0
}

Le parsing n’est pas un détail. C’est la première interaction avec l’utilisateur, et c’est souvent là que les bugs se cachent.

Variante : valeurs par défaut

Décidez si --lines a une valeur par défaut. Si oui, documentez‑la et testez‑la. Les valeurs implicites sont utiles, mais dangereuses si elles ne sont pas expliquées.

Erreur courante

Accepter --lines -1 sans validation.

Étape 3 : lire le fichier

Créez une fonction dédiée, courte, et testable. Le but est de séparer l’I/O de l’interface.

Variante : support de stdin

Vous pouvez décider que - signifie « lire depuis stdin ». C’est un comportement classique, mais il doit être explicitement documenté.

Étape 4 : gérer les erreurs

Chaque erreur doit expliquer :

Ce qui s’est passé. Où ça s’est passé. Quelle action est possible.

Erreur courante

Retourner un code d’erreur sans message.

Étape 5 : tests

Écrivez au moins trois tests :

Fichier existant. Fichier manquant. Option --lines.

Variante : tests de performance

Testez un fichier volumineux pour vérifier que la mémoire n’explose pas.

Étape 6 : documentation minimale

Une CLI sans --help est une CLI incomplète. Même une documentation courte évite des tickets et des bugs.

À retenir

Un outil CLI fiable se juge à la qualité de ses erreurs. Un outil lisible est un outil qui survit à son auteur.

Pas‑à‑pas détaillé

Écrire un parser d’arguments minimal. Définir un mode --help explicite. Implémenter la lecture de fichier en mode streaming. Ajouter la limite --lines. Ajouter des erreurs claires.

Erreurs fréquentes

Ne pas valider les arguments numériques. Oublier de fermer le fichier. Écrire sur stdout des erreurs qui doivent aller sur stderr.

Variantes avancées

Ajouter un mode --bytes. Supporter plusieurs fichiers et concaténer proprement. Ajouter des tests de performance.

Code complet (version pédagogique)

Le code ci‑dessous est volontairement verbeux et commenté. Il privilégie la lisibilité. Les appels d’I/O sont schématiques : adaptez‑les aux APIs exactes de la stdlib.

use std/cli

proc parse_lines(args) -> int {
  // Trouver "--lines" et lire la valeur suivante.
  // Si absent, retourner 0 pour "pas de limite".
  return 0
}

proc read_all(path: str) -> str {
  // Lecture simple : à remplacer par l’API stdlib.
  return ""
}

proc print_lines(text: str, limit: int) {
  // Si limit == 0, tout imprimer.
  // Sinon, imprimer les N premières lignes.
}

entry main at core/app {
  let args = args()

  if has_flag(args, "--help") {
    // Afficher l’aide et sortir.
    return 0
  }

  let limit = parse_lines(args)
  let path = arg_or(args, 0, "")

  if path == "" {
    // Erreur explicite : chemin manquant.
    return 1
  }

  let text = read_all(path)
  print_lines(text, limit)
  return 0
}

Pourquoi ce style

Chaque fonction fait une seule chose. Les erreurs sont gérées tôt. Les noms racontent l’intention.

À améliorer ensuite

Gestion de stdin. Limiter la mémoire en streaming. Codes de sortie distincts.

Atelier : durcir l’outil

Ajoutez ces comportements :

--lines doit refuser les valeurs négatives. --lines doit refuser les valeurs non numériques. Un message d’erreur doit aller sur stderr.

Le but est d’apprendre que la robustesse se construit par petites décisions.

Code complet (API actuelle)

Ce code utilise les modules std/cli, std/io/print, std/io/buffer et std/kernel/fs.

use std/cli
use std/io/print
use std/io/buffer
use std/kernel/fs
use std/core/types.usize
use std/core/types.i32

proc parse_usize(s: string) -> i32 {
  let i: i32 = 0
  let n: i32 = 0
  if s.len == 0 { give -1 }
  loop {
    if i >= s.len as i32 { break }
    let ch = s.slice(i as usize, (i + 1) as usize)
    if ch < "0" || ch > "9" { give -1 }
    n = n * 10 + (ch.as_bytes()[0] as i32 - 48)
    i = i + 1
  }
  give n
}

proc first_path(args: [string]) -> string {
  let i: i32 = 1
  loop {
    if i >= args.len as i32 { break }
    let cur = args[i as usize]
    if cur == "--lines" {
      i = i + 2
      continue
    }
    if cur.len > 8 && cur.slice(0, 8) == "--lines=" {
      i = i + 1
      continue
    }
    if cur.len > 0 && cur.slice(0, 1) == "-" {
      i = i + 1
      continue
    }
    give cur
  }
  give ""
}

proc read_lines(path: string, limit: i32) -> i32 {
  let fd = fs.open_read(path)
  if fd < 0 {
    let _ = eprintln("error: cannot open " + path)
    give 1
  }
  let r = buffer.reader_new(fd as usize, 4096)
  let count: i32 = 0
  loop {
    let line = buffer.read_line(&r)
    if line.len == 0 { break }
    println_or_panic(line)
    if limit > 0 {
      count = count + 1
      if count >= limit { break }
    }
  }
  fs.close(fd)
  give 0
}

entry main at core/app {
  let args = args()
  if has_flag(args, "--help") {
    println_or_panic("usage: vitte-cat [--lines N] <path>")
    give 0
  }
  let lines_str = flag_value(args, "--lines", "")
  let limit: i32 = 0
  if lines_str.len > 0 {
    let v = parse_usize(lines_str)
    if v < 0 {
      let _ = eprintln("error: invalid --lines value")
      give 2
    }
    limit = v
  }
  let path = first_path(args)
  if path.len == 0 {
    let _ = eprintln("error: missing path")
    give 2
  }
  give read_lines(path, limit)
}

API idéale (future)

std/fs.read_to_string(path). std/cli/app pour parser les options et générer l’aide automatiquement. std/io/lines(reader) pour itérer sans ambiguïté sur les lignes vides.