Rust est devenu le langage de reference pour les outils en ligne de commande. Ripgrep, fd, bat, exa, starship, zoxide — les CLI les plus populaires de l'ecosysteme open source sont ecrites en Rust, et pour de bonnes raisons : binaires statiques sans runtime, performances proches du C, gestion memoire sans garbage collector, et un ecosysteme de crates mature pour tout ce dont vous avez besoin. Au coeur de cette revolution, Clap est la bibliotheque de parsing d'arguments la plus utilisee, avec plus de 120 millions de telechargements sur crates.io. Ce guide vous accompagne de la creation du projet jusqu'a la publication en open source. Si Rust vous interesse aussi pour le WebAssembly, consultez notre guide sur la configuration d'un environnement Rust + Wasm.
Etape 1 — Initialiser le projet Rust avec cargo init et ajouter Clap
Commencez par creer un nouveau projet Rust. Si vous n'avez pas encore Rust installe, executez curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh pour installer rustup, cargo et le compilateur Rust. Ensuite, creez votre projet CLI.
# Creer un nouveau projet binaire
cargo init mon-outil --name mon-outil
cd mon-outil
# Ajouter les dependances essentielles
cargo add clap --features derive,env
cargo add serde --features derive
cargo add serde_json
cargo add coloredVotre Cargo.toml doit contenir les metadonnees necessaires a la publication. Voici la configuration recommandee.
[package]
name = "mon-outil"
version = "0.1.0"
edition = "2024"
authors = ["Votre Nom <votre@email.fr>"]
description = "Un outil CLI pour [votre use case]"
license = "MIT OR Apache-2.0"
repository = "https://github.com/votre-user/mon-outil"
readme = "README.md"
keywords = ["cli", "tool", "utility"]
categories = ["command-line-utilities"]
[dependencies]
clap = { version = "4.5", features = ["derive", "env"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
colored = "3.0"
[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.1"
tempfile = "3.15"
# Optimisations pour le binaire de release
[profile.release]
opt-level = "z" # Optimiser pour la taille
lto = true # Link-Time Optimization
codegen-units = 1 # Meilleure optimisation, compilation plus lente
panic = "abort" # Pas de stack unwinding
strip = true # Supprimer les symboles de debugLes options du profil release sont cruciales pour une CLI. L'option opt-level = "z" optimise pour la taille du binaire plutot que pour la vitesse pure — c'est le bon choix pour un outil en ligne de commande ou le temps de demarrage compte plus que la vitesse de calcul. lto = true active la Link-Time Optimization qui permet au compilateur d'optimiser a travers les frontieres des crates. strip = true supprime les symboles de debug, reduisant typiquement la taille du binaire de 30 a 50 pourcent.
Etape 2 — Definir la structure des commandes et arguments avec #[derive(Parser)]
Clap 4.x utilise un systeme de macros derivees qui transforme une simple structure Rust en un parser d'arguments complet. C'est l'approche la plus ergonomique : vous definissez votre structure de donnees, et Clap genere automatiquement le parsing, la validation, le message d'aide et la completion shell.
// src/main.rs
use clap::Parser;
use std::path::PathBuf;
/// Mon outil CLI — description courte affichee dans --help
#[derive(Parser, Debug)]
#[command(
name = "mon-outil",
version,
author,
about = "Un outil CLI pour analyser et transformer des fichiers",
long_about = "Mon outil analyse des fichiers de donnees (JSON, CSV, TOML)\n\
et genere des rapports structures. Supporte le traitement\n\
par lot et la sortie dans plusieurs formats.",
after_help = "Exemples:\n\
mon-outil analyze fichier.json\n\
mon-outil convert --from json --to csv data.json\n\
mon-outil check --strict config.toml"
)]
struct Cli {
/// Sous-commande a executer
#[command(subcommand)]
command: Commands,
/// Activer la sortie detaillee (-v, -vv, -vvv)
#[arg(short, long, action = clap::ArgAction::Count, global = true)]
verbose: u8,
/// Chemin vers le fichier de configuration
#[arg(short, long, global = true, env = "MON_OUTIL_CONFIG")]
config: Option<PathBuf>,
/// Desactiver les couleurs dans la sortie
#[arg(long, global = true)]
no_color: bool,
}
fn main() {
let cli = Cli::parse();
// Configurer le niveau de log selon --verbose
let log_level = match cli.verbose {
0 => "error",
1 => "warn",
2 => "info",
_ => "debug",
};
if cli.no_color {
colored::control::set_override(false);
}
// Dispatcher vers la sous-commande
match &cli.command {
Commands::Analyze { path, format } => {
println!("Analyse de {:?} en format {:?}", path, format);
}
Commands::Convert { input, from, to } => {
println!("Conversion de {:?} de {} vers {}", input, from, to);
}
Commands::Check { path, strict } => {
println!("Verification de {:?} (strict: {})", path, strict);
}
}
}Les points cles de cette structure. L'attribut #[arg(action = clap::ArgAction::Count)] permet de compter le nombre de repetitions d'un flag : -v pour warn, -vv pour info, -vvv pour debug. C'est un pattern standard dans les CLI Unix. L'attribut global = true rend l'argument disponible sur toutes les sous-commandes. L'attribut env = "MON_OUTIL_CONFIG" permet de definir la valeur par defaut via une variable d'environnement — Clap priorise l'argument en ligne de commande, puis la variable d'environnement, puis la valeur par defaut.
Etape 3 — Implementer les sous-commandes avec enum et #[derive(Subcommand)]
Les sous-commandes sont le mecanisme principal pour organiser une CLI avec plusieurs fonctionnalites. En Clap, chaque sous-commande est une variante d'un enum annote avec #[derive(Subcommand)]. Chaque variante peut avoir ses propres arguments et options.
use clap::Subcommand;
use std::path::PathBuf;
#[derive(Subcommand, Debug)]
enum Commands {
/// Analyser un fichier et generer un rapport
#[command(alias = "a")]
Analyze {
/// Chemin vers le fichier a analyser
#[arg(value_name = "FICHIER")]
path: PathBuf,
/// Format de sortie du rapport
#[arg(short, long, default_value = "text",
value_parser = ["text", "json", "csv", "markdown"])]
format: String,
/// Inclure les statistiques detaillees
#[arg(short = 's', long)]
stats: bool,
},
/// Convertir un fichier d'un format a un autre
#[command(alias = "c")]
Convert {
/// Fichier d'entree
#[arg(value_name = "FICHIER")]
input: PathBuf,
/// Format source
#[arg(long, value_parser = ["json", "csv", "toml", "yaml"])]
from: String,
/// Format de destination
#[arg(long, value_parser = ["json", "csv", "toml", "yaml"])]
to: String,
/// Fichier de sortie (stdout si omis)
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Verifier la validite d'un fichier de configuration
#[command(alias = "k")]
Check {
/// Chemin vers le fichier a verifier
#[arg(value_name = "FICHIER")]
path: PathBuf,
/// Activer la verification stricte
#[arg(long)]
strict: bool,
/// Schema de validation (optionnel)
#[arg(long)]
schema: Option<PathBuf>,
},
}Les alias (#[command(alias = "a")]) permettent aux utilisateurs de taper mon-outil a fichier.json au lieu de mon-outil analyze fichier.json — un raccourci apprecie pour les commandes frequentes. Le value_parser avec un tableau de chaines limite les valeurs acceptees et genere un message d'erreur clair si l'utilisateur entre une valeur invalide.
Implementons maintenant la logique de chaque sous-commande dans des modules separes. C'est une bonne pratique pour garder main.rs propre.
// src/commands/analyze.rs
use std::path::Path;
use std::fs;
use crate::errors::AppError;
pub fn run(path: &Path, format: &str, stats: bool) -> Result<(), AppError> {
// Verifier que le fichier existe
if !path.exists() {
return Err(AppError::FileNotFound(path.display().to_string()));
}
let content = fs::read_to_string(path)
.map_err(|e| AppError::IoError(e))?;
let lines = content.lines().count();
let chars = content.chars().count();
let words = content.split_whitespace().count();
let non_empty = content.lines().filter(|l| !l.trim().is_empty()).count();
match format {
"json" => {
let report = serde_json::json!({
"file": path.display().to_string(),
"lines": lines,
"non_empty_lines": non_empty,
"words": words,
"characters": chars,
});
println!("{}", serde_json::to_string_pretty(&report)?);
}
"csv" => {
println!("fichier,lignes,lignes_non_vides,mots,caracteres");
println!("{},{},{},{},{}", path.display(), lines, non_empty, words, chars);
}
_ => {
println!("Analyse de : {}", path.display());
println!(" Lignes : {}", lines);
println!(" Lignes non vides: {}", non_empty);
println!(" Mots : {}", words);
println!(" Caracteres : {}", chars);
if stats {
let avg_line_len = if lines > 0 { chars / lines } else { 0 };
println!(" Longueur moy. : {} chars/ligne", avg_line_len);
}
}
}
Ok(())
}Etape 4 — Ajouter la gestion des erreurs avec thiserror et anyhow
La gestion des erreurs est l'un des points forts de Rust, mais aussi l'un des plus intimidants pour les debutants. Pour une CLI, vous avez besoin de deux crates complementaires : thiserror pour definir vos types d'erreur custom, et anyhow pour la propagation simplifiee dans le code applicatif.
# Ajouter les dependances
cargo add thiserror
cargo add anyhow// src/errors.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Fichier non trouve : {0}")]
FileNotFound(String),
#[error("Format non supporte : {0}. Formats valides : json, csv, toml, yaml")]
UnsupportedFormat(String),
#[error("Erreur de parsing {format} dans {file} a la ligne {line}: {message}")]
ParseError {
format: String,
file: String,
line: usize,
message: String,
},
#[error("Erreur de validation : {0}")]
ValidationError(String),
#[error("Erreur d'entree/sortie : {0}")]
IoError(#[from] std::io::Error),
#[error("Erreur JSON : {0}")]
JsonError(#[from] serde_json::Error),
#[error("Erreur de configuration : {0}")]
ConfigError(String),
}
// Code de sortie pour chaque type d'erreur
impl AppError {
pub fn exit_code(&self) -> i32 {
match self {
AppError::FileNotFound(_) => 2,
AppError::UnsupportedFormat(_) => 3,
AppError::ParseError { .. } => 4,
AppError::ValidationError(_) => 5,
AppError::IoError(_) => 1,
AppError::JsonError(_) => 4,
AppError::ConfigError(_) => 6,
}
}
}Avec thiserror, l'attribut #[error("...")] genere automatiquement l'implementation Display pour chaque variante. L'attribut #[from] genere l'implementation From qui permet la conversion automatique avec l'operateur ?. C'est exactement ce dont vous avez besoin pour une CLI : des messages d'erreur clairs pour l'utilisateur et une propagation facile pour le developpeur.
Maintenant, mettez a jour votre main.rs pour utiliser anyhow dans la fonction principale.
// src/main.rs (version mise a jour)
use anyhow::Context;
use clap::Parser;
use colored::Colorize;
mod commands;
mod errors;
use errors::AppError;
fn main() {
let cli = Cli::parse();
if let Err(err) = run(cli) {
// Afficher l'erreur avec couleur
eprintln!("{} {}", "erreur:".red().bold(), err);
// En mode verbose, afficher la chaine de causes
if let Some(source) = err.source() {
eprintln!("{} {}", "cause:".yellow(), source);
}
// Utiliser le code de sortie specifique si c'est une AppError
let code = err.downcast_ref::<AppError>()
.map(|e| e.exit_code())
.unwrap_or(1);
std::process::exit(code);
}
}
fn run(cli: Cli) -> anyhow::Result<()> {
match cli.command {
Commands::Analyze { path, format, stats } => {
commands::analyze::run(&path, &format, stats)
.context("Echec de l'analyse du fichier")?;
}
Commands::Convert { input, from, to, output } => {
commands::convert::run(&input, &from, &to, output.as_deref())
.context("Echec de la conversion")?;
}
Commands::Check { path, strict, schema } => {
commands::check::run(&path, strict, schema.as_deref())
.context("Echec de la verification")?;
}
}
Ok(())
}Le pattern ici est canonique en Rust : main() appelle une fonction run() qui retourne un anyhow::Result. Si run() echoue, main() affiche l'erreur en rouge, affiche la cause si disponible, et quitte avec un code de sortie specifique. La methode .context() d'anyhow ajoute du contexte a l'erreur pour le debug — l'utilisateur voit le message principal, le developpeur voit la chaine complete.
Etape 5 — Ecrire les tests unitaires et d'integration avec assert_cmd
Pour une CLI, les tests d'integration de bout en bout sont plus importants que les tests unitaires. Vous voulez verifier que le binaire compile, qu'il parse correctement les arguments, qu'il produit la bonne sortie et qu'il retourne le bon code de sortie. Le crate assert_cmd est concu exactement pour ca.
// tests/cli.rs
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::NamedTempFile;
use std::io::Write;
// Helper pour creer un fichier temporaire avec du contenu
fn create_temp_file(content: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
write!(file, "{}", content).unwrap();
file
}
#[test]
fn test_help_affiche_usage() {
Command::cargo_bin("mon-outil")
.unwrap()
.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("analyser et transformer"));
}
#[test]
fn test_version_affiche_numero() {
Command::cargo_bin("mon-outil")
.unwrap()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn test_analyze_fichier_json() {
let file = create_temp_file(r#"{"name": "test", "value": 42}"#);
Command::cargo_bin("mon-outil")
.unwrap()
.args(["analyze", file.path().to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("Lignes"));
}
#[test]
fn test_analyze_format_json_output() {
let file = create_temp_file("ligne 1\nligne 2\nligne 3\n");
Command::cargo_bin("mon-outil")
.unwrap()
.args(["analyze", file.path().to_str().unwrap(), "--format", "json"])
.assert()
.success()
.stdout(predicate::str::contains(r#""lines": 3"#));
}
#[test]
fn test_analyze_fichier_inexistant_echoue() {
Command::cargo_bin("mon-outil")
.unwrap()
.args(["analyze", "/chemin/qui/nexiste/pas.txt"])
.assert()
.failure()
.stderr(predicate::str::contains("Fichier non trouve"));
}
#[test]
fn test_commande_sans_argument_affiche_aide() {
Command::cargo_bin("mon-outil")
.unwrap()
.assert()
.failure()
.stderr(predicate::str::contains("Usage"));
}
#[test]
fn test_analyze_alias_fonctionne() {
let file = create_temp_file("test content");
Command::cargo_bin("mon-outil")
.unwrap()
.args(["a", file.path().to_str().unwrap()])
.assert()
.success();
}
#[test]
fn test_verbose_flag_multiple() {
let file = create_temp_file("test");
// -vvv ne doit pas provoquer d'erreur
Command::cargo_bin("mon-outil")
.unwrap()
.args(["-vvv", "analyze", file.path().to_str().unwrap()])
.assert()
.success();
}Executez les tests avec cargo test. Les tests d'integration dans le dossier tests/ compilent votre binaire et l'executent comme un processus externe — exactement comme un utilisateur le ferait. C'est la couverture de test la plus realiste pour une CLI.
Etape 6 — Configurer la CI GitHub Actions pour multi-plateforme
Une CLI Rust doit fonctionner sur les trois plateformes principales : Linux, macOS et Windows. Votre pipeline CI doit compiler et tester sur les trois. Voici un workflow GitHub Actions complet.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -Dwarnings
jobs:
check:
name: Lint & Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Formatage (rustfmt)
run: cargo fmt --all -- --check
- name: Linting (clippy)
run: cargo clippy --all-targets --all-features
test:
name: Test ${{ matrix.os }}
needs: check
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Tests unitaires et integration
run: cargo test --all-features --verbose
- name: Build release
run: cargo build --release
- name: Verifier le binaire
run: ./target/release/mon-outil --version
shell: bash
security:
name: Audit securite
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: rustsec/audit-check@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}Ce workflow fait trois choses essentielles. Le job check verifie le formatage avec rustfmt et le linting avec clippy — si un avertissement est detecte, le build echoue (RUSTFLAGS: -Dwarnings). Le job test compile et teste sur les trois OS en parallele. Le job security utilise cargo-audit pour verifier que vos dependances n'ont pas de vulnerabilites connues. Pour aller plus loin dans la securisation de vos pipelines CI/CD, consultez notre guide sur la configuration de pipelines CI/CD avec GitHub Actions.
Etape 7 — Publier sur crates.io et creer les releases GitHub avec cargo-dist
La publication est l'etape finale — celle ou votre CLI devient accessible a l'ensemble de la communaute Rust et au-dela. Il y a deux canaux de distribution : crates.io pour les developpeurs Rust (installation via cargo install) et les GitHub Releases avec des binaires pre-compiles pour tous les utilisateurs (sans avoir besoin de Rust installe).
Publication sur crates.io. Commencez par creer un compte sur crates.io et generer un token API. Puis publiez votre crate.
# Se connecter a crates.io
cargo login
# Verifier que le package est valide
cargo package --list
# Dry-run (teste sans publier)
cargo publish --dry-run
# Publier pour de vrai
cargo publishBinaires pre-compiles avec cargo-dist. Pour les utilisateurs qui n'ont pas Rust installe, vous devez fournir des binaires pre-compiles. cargo-dist est l'outil officiel recommande par la communaute Rust. Il s'integre avec GitHub Actions pour generer automatiquement des binaires pour Linux (x86_64, aarch64), macOS (Intel et Apple Silicon) et Windows a chaque release.
# Installer cargo-dist
cargo install cargo-dist
# Initialiser la configuration
cargo dist init
# Cela ajoute a votre Cargo.toml :
# [workspace.metadata.dist]
# cargo-dist-version = "0.27.0"
# ci = "github"
# installers = ["shell", "powershell", "homebrew"]
# targets = [
# "aarch64-apple-darwin",
# "x86_64-apple-darwin",
# "x86_64-unknown-linux-gnu",
# "aarch64-unknown-linux-gnu",
# "x86_64-pc-windows-msvc",
# ]
# Generer le workflow GitHub Actions
cargo dist generate
# Creer une release
git tag v0.1.0
git push --tags
# -> GitHub Actions compile pour toutes les plateformes
# -> Cree une GitHub Release avec les binairesApres le push du tag, GitHub Actions va compiler votre CLI pour les 5 cibles configurees, generer les checksums SHA256, creer les scripts d'installation (shell pour Unix, PowerShell pour Windows), et publier le tout comme assets sur la GitHub Release. Vos utilisateurs pourront ensuite installer votre outil avec une seule commande.
# Installation depuis les releases GitHub
# Linux/macOS :
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/votre-user/mon-outil/releases/latest/download/mon-outil-installer.sh \
| sh
# Windows (PowerShell) :
powershell -ExecutionPolicy ByPass -c "irm \
https://github.com/votre-user/mon-outil/releases/latest/download/mon-outil-installer.ps1 \
| iex"
# Depuis crates.io (necessite Rust) :
cargo install mon-outil
# Ou avec cargo-binstall (binaire pre-compile depuis crates.io) :
cargo binstall mon-outilN'oubliez pas de creer un README.md de qualite avec un badge CI, des exemples d'utilisation, des GIF d'installation et une section de contribution. Pour les projets open source en Rust, le standard est d'utiliser la double licence MIT OR Apache-2.0 qui offre la compatibilite maximale avec l'ecosysteme. Si votre CLI concerne des outils de developpement, consultez aussi notre article sur le recrutement de developpeurs Rust en France et la tendance Microsoft et les coreutils Rust.
Besoin d'un developpeur Rust pour votre projet CLI ?
D-Open met a votre disposition des developpeurs Rust experimentes pour vos outils en ligne de commande, vos libraries systemes et vos projets open source.
Recevez votre devis en 24hQuestions frequentes
Pourquoi utiliser Clap plutot que structopt pour une CLI Rust ?▼
structopt a ete fusionne dans Clap a partir de la version 4. Depuis Clap 4.x, le macro derive(Parser) remplace directement structopt. Il n'y a plus de raison d'utiliser structopt — Clap offre les memes fonctionnalites derivees plus des capacites supplementaires comme le support natif des sous-commandes imbriquees, les groupes d'arguments et la generation de completions shell. Tous les nouveaux projets devraient utiliser Clap 4.x directement.
Comment distribuer des binaires pre-compiles pour une CLI Rust ?▼
La methode recommandee est d'utiliser cargo-dist, un outil qui s'integre avec GitHub Actions pour generer automatiquement des binaires pre-compiles pour Linux (x86_64, aarch64), macOS (Intel et Apple Silicon) et Windows a chaque release. cargo-dist cree les archives, genere les checksums SHA256, et publie le tout comme assets sur une GitHub Release. Les utilisateurs peuvent ensuite installer avec curl, wget, ou un gestionnaire de paquets comme cargo-binstall.
Quelle est la difference entre thiserror et anyhow en Rust ?▼
thiserror sert a definir des types d'erreur custom avec des messages structures et des implementations Display automatiques. C'est ideal pour les crates de bibliotheque ou vous voulez exposer des erreurs typees. anyhow fournit un type d'erreur generique (anyhow::Result) qui simplifie la propagation des erreurs dans le code applicatif avec l'operateur ?. En pratique, utilisez thiserror pour definir vos erreurs et anyhow dans votre main() et vos fonctions applicatives.
Comment tester une CLI Rust de bout en bout ?▼
Utilisez le crate assert_cmd qui compile votre binaire et permet de tester le code de sortie, la sortie standard (stdout) et la sortie d'erreur (stderr). Combinez-le avec predicates pour des assertions expressives sur le contenu de la sortie. Creez des fichiers temporaires avec tempfile pour les tests qui necessitent des fichiers d'entree. Les tests se placent dans le dossier tests/ et s'executent avec cargo test.
Un projet CLI ou outil open source en Rust ?
D-Open met a votre disposition des developpeurs Rust seniors pour vos outils en ligne de commande, vos workers systemes et vos projets open source critiques.
Parlons de votre projet