Aller au contenu

À distanceHeures locales: Kigali, Nairobi, Dubaï

5 min de lectureRust

De petits outils CLI en Rust pour une équipe web

Quand une équipe qui livre du React et du Laravel gagne à remplacer un script de plus par un binaire compilé, et comment en écrire un agréable à utiliser : clap, anyhow et thiserror, serde, et un seul fichier à installer.

Toute équipe web accumule des scripts. Un script shell qui vérifie le .env avant un déploiement, un script Node qui renomme des fichiers, une commande Artisan dont plus personne ne se rappelle les options. Ils marchent sur la machine de la personne qui les a écrits et cassent sur la suivante, à cause d'un runtime absent, d'un autre shell ou d'un paquet que quelqu'un a un jour installé en global.

D'août 2021 à mars 2023, j'ai été développeur full-stack chez Cryptnail, un projet d'Un Jour Nouveau à Goma. Le travail produit se faisait en React et en Laravel. À côté, j'ai écrit des outils internes en ligne de commande en Rust. Cet article parle de ce genre d'outil en général : quand un binaire compilé sert mieux une équipe web qu'un script, et les quelques crates qui le rendent agréable à utiliser.

À propos de l'exemple

Les outils que j'ai écrits chez Cryptnail étaient internes, et cet article ne les décrit pas. L'exemple env-check ci-dessous a été écrit pour cet article et n'en fait pas partie.

Quand un binaire bat un script

Un script est la bonne première réponse. Il cesse de l'être dès que d'autres personnes en dépendent. Les signaux sont connus : la section « avant de lancer ceci, installez... » du README s'allonge, le script est copié d'un dépôt à l'autre avec de petites retouches, ou il tourne en CI et sur plusieurs portables avec autant de résultats différents.

Un binaire compilé sort le runtime de la discussion. Un fichier par plateforme, aucune version d'interpréteur à faire correspondre, rien à installer à l'exécution, et un démarrage assez rapide pour un hook Git que personne ne remarque. Rust ajoute un système de types qui rend les cas d'échec explicites, ce qui compte pour un outil dont tout le travail consiste à dire ce qui ne va pas.

Le coût est réel aussi. Moins de personnes dans une équipe web peuvent modifier un outil en Rust qu'un outil en PHP ou en JavaScript, et une étape de compilation remplace la boucle immédiate modifier-lancer d'un script. Un outil que seul son auteur sait maintenir devient un poids le jour où cet auteur s'en va, donc le code doit rester petit et sans surprise.

Des arguments avec clap

Avec l'API derive de clap, une structure suffit à décrire l'analyseur d'arguments. Les commentaires de documentation deviennent le texte d'aide, les valeurs par défaut se placent à côté du champ concerné, et --help comme --version viennent gratuitement. L'exemple compare un fichier .env au .env.example versionné dans le dépôt.

tomlIllustration
# Cargo.toml
[package]
name = "env-check"
version = "0.1.0"
edition = "2024"
 
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rustIllustration
// src/main.rs
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
 
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use serde::Serialize;
 
/// Compare un fichier .env au .env.example versionné dans le dépôt.
#[derive(Parser)]
#[command(version)]
struct Args {
    /// Le fichier que l'application lit à l'exécution.
    #[arg(long, default_value = ".env")]
    env: PathBuf,
    /// Le modèle qui liste toutes les clés attendues.
    #[arg(long, default_value = ".env.example")]
    example: PathBuf,
    /// Sortie pour les humains (text) ou pour les scripts (json).
    #[arg(long, value_enum, default_value_t = Format::Text)]
    format: Format,
}
 
#[derive(Clone, Copy, ValueEnum)]
enum Format {
    Text,
    Json,
}

Des erreurs qui disent ce qui ne va pas

Pour un binaire, anyhow est le chemin le plus court vers des messages utiles. Chaque appel faillible reçoit un with_context qui dit ce que l'outil essayait de faire, et afficher l'erreur avec le format alternatif, {error:#}, montre toute la chaîne sur une ligne : cannot read .env: No such file or directory (os error 2). L'utilisateur apprend quel fichier et pourquoi, sans trace de pile.

rustIllustration
// src/main.rs (suite)
fn keys(path: &Path) -> Result<BTreeSet<String>> {
    let text = fs::read_to_string(path)
        .with_context(|| format!("cannot read {}", path.display()))?;
    Ok(text
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .filter_map(|line| line.split_once('='))
        .map(|(key, _)| key.trim_start_matches("export ").trim().to_string())
        .collect())
}

thiserror répond à un autre besoin. Quand le code est une bibliothèque, ou quand l'appelant doit réagir différemment selon l'échec, une enum avec une variante par échec lui donne de quoi faire un match. Un partage courant : thiserror dans la crate bibliothèque, anyhow dans main.

rustIllustration
// Dans une crate bibliothèque, avec thiserror dans ses dépendances.
#[derive(Debug, thiserror::Error)]
pub enum CheckError {
    #[error("cannot read {path}")]
    Read {
        path: String,
        #[source]
        source: std::io::Error,
    },
    #[error("line {line} of {path} has no '='")]
    Malformed { path: String, line: usize },
}

Les codes de sortie font aussi partie de l'interface. La convention que suivent diff et grep convient bien aux vérifications : 0 quand tout va bien, 1 quand la vérification a trouvé un problème, 2 quand l'outil lui-même a échoué. Une étape de CI peut alors distinguer « votre .env est incomplet » de « l'outil n'a pas pu lire le fichier ».

Une sortie structurée avec serde

La sortie texte s'adresse aux humains. Une option --format json rend le même résultat exploitable depuis une étape de CI ou un autre script, et avec serde elle coûte un derive.

rustIllustration
// src/main.rs (suite)
#[derive(Serialize)]
struct Report {
    missing: Vec<String>,
    unknown: Vec<String>,
}
 
fn run(args: &Args) -> Result<Report> {
    let actual = keys(&args.env)?;
    let expected = keys(&args.example)?;
    Ok(Report {
        missing: expected.difference(&actual).cloned().collect(),
        unknown: actual.difference(&expected).cloned().collect(),
    })
}
 
fn print(report: &Report, format: Format) -> Result<()> {
    match format {
        Format::Json => println!("{}", serde_json::to_string_pretty(report)?),
        Format::Text => {
            for key in &report.missing {
                println!("missing: {key}");
            }
            for key in &report.unknown {
                println!("unknown: {key}");
            }
        }
    }
    Ok(())
}
 
fn main() -> ExitCode {
    let args = Args::parse();
    let outcome = run(&args).and_then(|report| {
        print(&report, args.format)?;
        Ok(report.missing.is_empty())
    });
    match outcome {
        Ok(true) => ExitCode::SUCCESS,
        Ok(false) => ExitCode::from(1),
        Err(error) => {
            eprintln!("env-check: {error:#}");
            ExitCode::from(2)
        }
    }
}

Les clés inconnues sont signalées sans faire échouer la vérification. Ce sont en général des restes, qui méritent un coup d'œil et rarement un déploiement bloqué.

Livrer un seul fichier

Pour les collègues qui ont une chaîne d'outils Rust, cargo install --git <dépôt> --locked compile et installe l'outil en une commande. L'option --locked compte : elle utilise le Cargo.lock versionné au lieu de résoudre de nouvelles versions des dépendances sur chaque machine.

Pour tous les autres, un workflow de release compile un binaire par cible et les attache à une release GitHub. Les cibles dont une équipe web a généralement besoin sont Linux pour la CI, où x86_64-unknown-linux-musl donne un binaire statique qui tourne sur n'importe quelle distribution, macOS sur Apple silicon avec aarch64-apple-darwin, et Windows avec x86_64-pc-windows-msvc. Des outils comme cargo-dist génèrent ce workflow, installeurs et sommes de contrôle compris. Quel que soit l'outil qui compile, une somme de contrôle publiée à côté de chaque binaire permet à un job de CI de vérifier ce qu'il télécharge avant de l'exécuter.

Si j'en écrivais un aujourd'hui

Je donnerais à chaque outil une seule tâche, un nom qui la dit, et un --help dont un nouveau venu peut se servir sans rien demander à personne. Je livrerais la sortie JSON dès la première version, parce que le deuxième utilisateur d'un outil est souvent un job de CI.

La partie que je ne sauterais pas, c'est la responsabilité. Une ligne dans le README du dépôt qui utilise l'outil, qui dit qui le maintient et comment le compiler, permet à un petit binaire de survivre au départ de son auteur.