Rust

Rust para CLI — Construindo Ferramentas de Linha de Comando Profissionais com clap Já leu

16 min de leitura

Rust para CLI — Construindo Ferramentas de Linha de Comando Profissionais com clap
Rust — Artigo #40 Rust para CLI — Construindo Ferramentas de Linha de Comando Profissionais com clap Por Prof. Dr. Marcelo Fontes | Série: Dominando Rust em 1 A

Rust — Artigo #40

Rust para CLI — Construindo Ferramentas de Linha de Comando Profissionais com clap

Por Prof. Dr. Marcelo Fontes | Série: Dominando Rust em 1 Ano


Ferramentas de linha de comando são onde Rust brilha de forma imediata e visível. Binários pequenos, startup instantâneo, sem runtime, sem JVM, sem interpretador — você distribui um único executável que roda em qualquer máquina com a mesma arquitetura. Ferramentas como ripgrep, fd, bat, exa, delta, e hyperfine demonstraram que CLIs em Rust podem ser simultaneamente mais rápidas e mais agradáveis de usar que suas equivalentes em C ou scripts shell.

Neste artigo construímos uma CLI completa e profissional usando clap, com subcomandos, configuração, output colorido, barras de progresso, e instalação via cargo install.


Dependências do projeto

[package]
name = "projctl"
version = "0.1.0"
edition = "2021"
description = "Gerenciador de projetos de desenvolvimento"
license = "MIT OR Apache-2.0"

[[bin]]
name = "projctl"
path = "src/main.rs"

[dependencies]
# Parser de argumentos — a crate mais popular para CLIs
clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] }

# Output colorido e formatado
colored = "2"
indicatif = "0.17"   # barras de progresso
console = "0.15"     # terminal utilities

# Serialização de configuração
serde = { version = "1", features = ["derive"] }
toml = "0.8"
serde_json = "1"

# Erros
anyhow = "1"
thiserror = "1"

# Utilitários
chrono = { version = "0.4", features = ["serde"] }
dirs = "5"           # diretórios do sistema (home, config, etc.)
glob = "0.3"
walkdir = "2"
dialoguer = "0.11"   # prompts interativos
tabled = "0.15"      # tabelas formatadas

Estrutura do projeto

projctl/
├── Cargo.toml
├── src/
│   ├── main.rs          — ponto de entrada
│   ├── cli.rs           — definição da CLI com clap
│   ├── config.rs        — configuração persistente
│   ├── commands/
│   │   ├── mod.rs
│   │   ├── init.rs
│   │   ├── list.rs
│   │   ├── status.rs
│   │   └── build.rs
│   ├── output.rs        — helpers de output
│   └── error.rs         — tipos de erro

Definindo a CLI com clap derive

// src/cli.rs
use clap::{Args, Parser, Subcommand, ValueEnum};
use std::path::PathBuf;

/// projctl — Gerenciador de projetos de desenvolvimento
///
/// Gerencie seus projetos Rust com facilidade: inicialize,
/// liste, verifique status e construa projetos.
#[derive(Parser, Debug)]
#[command(
    name = "projctl",
    version,                    // lê de Cargo.toml automaticamente
    author,                     // lê de Cargo.toml
    about,                      // lê de Cargo.toml
    long_about = None,
    propagate_version = true,   // --version disponível em subcomandos
    subcommand_required = true,
    arg_required_else_help = true,  // mostra help se sem argumentos
)]
pub struct Cli {
    /// Nível de verbosidade (-v, -vv, -vvv)
    #[arg(short, long, action = clap::ArgAction::Count, global = true)]
    pub verbose: u8,

    /// Saída em formato JSON (para scripts)
    #[arg(long, global = true)]
    pub json: bool,

    /// Desabilita cores no output
    #[arg(long, global = true, env = "NO_COLOR")]
    pub no_color: bool,

    #[command(subcommand)]
    pub comando: Comando,
}

#[derive(Subcommand, Debug)]
pub enum Comando {
    /// Inicializa um novo projeto
    Init(ArgsInit),

    /// Lista projetos gerenciados
    #[command(alias = "ls")]
    List(ArgsList),

    /// Verifica o status de um projeto
    Status(ArgsStatus),

    /// Constrói o projeto
    Build(ArgsBuild),

    /// Gerencia a configuração
    Config(ArgsConfig),

    /// Exibe estatísticas dos projetos
    Stats,
}

// ── Init ─────────────────────────────────────────────

/// Argumentos para o comando init
#[derive(Args, Debug)]
pub struct ArgsInit {
    /// Nome do projeto
    pub nome: String,

    /// Diretório onde criar o projeto
    /// [padrão: diretório atual]
    #[arg(short, long)]
    pub dir: Option<PathBuf>,

    /// Template a usar
    #[arg(short, long, value_enum, default_value_t = Template::Lib)]
    pub template: Template,

    /// Inicializa repositório git
    #[arg(long, default_value_t = true)]
    pub git: bool,

    /// Abre o projeto no editor após criar
    #[arg(long)]
    pub abrir: bool,
}

#[derive(ValueEnum, Debug, Clone)]
pub enum Template {
    /// Biblioteca (lib)
    Lib,
    /// Binário (bin)
    Bin,
    /// Workspace com lib e CLI
    Workspace,
}

impl std::fmt::Display for Template {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        match self {
            Template::Lib       => write!(f, "lib"),
            Template::Bin       => write!(f, "bin"),
            Template::Workspace => write!(f, "workspace"),
        }
    }
}

// ── List ─────────────────────────────────────────────

#[derive(Args, Debug)]
pub struct ArgsList {
    /// Filtrar por nome (suporta glob)
    pub filtro: Option<String>,

    /// Ordenar por campo
    #[arg(short, long, value_enum, default_value_t = OrdenarPor::Nome)]
    pub ordenar: OrdenarPor,

    /// Ordem inversa
    #[arg(short, long)]
    pub reverso: bool,

    /// Exibir apenas projetos com mudanças pendentes
    #[arg(long)]
    pub pendentes: bool,
}

#[derive(ValueEnum, Debug, Clone)]
pub enum OrdenarPor {
    Nome,
    Data,
    Tamanho,
}

// ── Status ────────────────────────────────────────────

#[derive(Args, Debug)]
pub struct ArgsStatus {
    /// Nome do projeto (padrão: projeto no diretório atual)
    pub projeto: Option<String>,

    /// Verifica dependências desatualizadas
    #[arg(long)]
    pub deps: bool,

    /// Roda testes e inclui resultado
    #[arg(long)]
    pub testes: bool,
}

// ── Build ─────────────────────────────────────────────

#[derive(Args, Debug)]
pub struct ArgsBuild {
    /// Nome do projeto
    pub projeto: Option<String>,

    /// Perfil de compilação
    #[arg(short, long, value_enum, default_value_t = Perfil::Dev)]
    pub perfil: Perfil,

    /// Features a ativar
    #[arg(long, value_delimiter = ',')]
    pub features: Vec<String>,

    /// Ativa todas as features
    #[arg(long)]
    pub all_features: bool,

    /// Número de jobs paralelos
    #[arg(short, long)]
    pub jobs: Option<u32>,
}

#[derive(ValueEnum, Debug, Clone)]
pub enum Perfil {
    Dev,
    Release,
    #[value(name = "release-debug")]
    ReleaseDebug,
}

// ── Config ────────────────────────────────────────────

#[derive(Args, Debug)]
pub struct ArgsConfig {
    #[command(subcommand)]
    pub acao: AcaoConfig,
}

#[derive(Subcommand, Debug)]
pub enum AcaoConfig {
    /// Exibe a configuração atual
    Show,
    /// Define um valor
    Set {
        chave: String,
        valor: String,
    },
    /// Remove um valor
    Unset {
        chave: String,
    },
    /// Abre o arquivo de configuração no editor
    Edit,
}

Configuração persistente

// src/config.rs
use anyhow::{Context, Result};
use dirs::config_dir;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(default)]
pub struct Config {
    pub editor: String,
    pub dir_projetos: PathBuf,
    pub git_usuario: Option<String>,
    pub git_email: Option<String>,
    pub template_padrao: String,
    pub mostrar_ocultos: bool,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            editor: std::env::var("EDITOR")
                .unwrap_or_else(|_| "vim".to_string()),
            dir_projetos: dirs::home_dir()
                .unwrap_or_else(|| PathBuf::from("."))
                .join("projetos"),
            git_usuario: None,
            git_email: None,
            template_padrao: "lib".to_string(),
            mostrar_ocultos: false,
        }
    }
}

impl Config {
    pub fn caminho() -> PathBuf {
        config_dir()
            .unwrap_or_else(|| PathBuf::from("."))
            .join("projctl")
            .join("config.toml")
    }

    pub fn carregar() -> Result<Self> {
        let caminho = Self::caminho();

        if !caminho.exists() {
            return Ok(Config::default());
        }

        let conteudo = std::fs::read_to_string(&caminho)
            .with_context(|| format!("Falha ao ler config: {}", caminho.display()))?;

        toml::from_str(&conteudo)
            .with_context(|| "Configuração inválida")
    }

    pub fn salvar(&self) -> Result<()> {
        let caminho = Self::caminho();

        if let Some(dir) = caminho.parent() {
            std::fs::create_dir_all(dir)
                .with_context(|| format!("Falha ao criar dir: {}", dir.display()))?;
        }

        let conteudo = toml::to_string_pretty(self)
            .context("Falha ao serializar configuração")?;

        std::fs::write(&caminho, conteudo)
            .with_context(|| format!("Falha ao salvar config: {}", caminho.display()))
    }

    pub fn set(&mut self, chave: &str, valor: &str) -> Result<()> {
        match chave {
            "editor"          => self.editor = valor.to_string(),
            "git.usuario"     => self.git_usuario = Some(valor.to_string()),
            "git.email"       => self.git_email = Some(valor.to_string()),
            "template_padrao" => self.template_padrao = valor.to_string(),
            "mostrar_ocultos" => {
                self.mostrar_ocultos = valor.parse()
                    .context("Valor deve ser 'true' ou 'false'")?;
            }
            _ => anyhow::bail!("Chave desconhecida: '{chave}'"),
        }
        Ok(())
    }

    pub fn get(&self, chave: &str) -> Result<String> {
        Ok(match chave {
            "editor"          => self.editor.clone(),
            "git.usuario"     => self.git_usuario.clone().unwrap_or_default(),
            "git.email"       => self.git_email.clone().unwrap_or_default(),
            "template_padrao" => self.template_padrao.clone(),
            "mostrar_ocultos" => self.mostrar_ocultos.to_string(),
            _ => anyhow::bail!("Chave desconhecida: '{chave}'"),
        })
    }
}

Output colorido e tabelas

// src/output.rs
use colored::*;
use indicatif::{ProgressBar, ProgressStyle};
use std::time::Duration;

pub struct Output {
    pub json: bool,
    pub verbose: u8,
    pub cores: bool,
}

impl Output {
    pub fn novo(json: bool, verbose: u8, no_color: bool) -> Self {
        if no_color {
            colored::control::set_override(false);
        }
        Output { json, verbose, cores: !no_color }
    }

    pub fn sucesso(&self, msg: &str) {
        if !self.json {
            println!("{} {}", "✓".green().bold(), msg);
        }
    }

    pub fn erro(&self, msg: &str) {
        eprintln!("{} {}", "✗".red().bold(), msg.red());
    }

    pub fn aviso(&self, msg: &str) {
        if !self.json {
            eprintln!("{} {}", "⚠".yellow().bold(), msg.yellow());
        }
    }

    pub fn info(&self, msg: &str) {
        if !self.json {
            println!("{} {}", "→".blue(), msg);
        }
    }

    pub fn detalhe(&self, msg: &str) {
        if !self.json && self.verbose > 0 {
            println!("  {}", msg.dimmed());
        }
    }

    pub fn secao(&self, titulo: &str) {
        if !self.json {
            println!("
{}", titulo.bold().underline());
        }
    }

    pub fn barra_progresso(&self, tamanho: u64, msg: &str) -> ProgressBar {
        let pb = ProgressBar::new(tamanho);
        pb.set_style(
            ProgressStyle::with_template(
                "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} {msg}"
            )
            .unwrap()
            .progress_chars("=>-"),
        );
        pb.set_message(msg.to_string());
        pb.enable_steady_tick(Duration::from_millis(100));
        pb
    }

    pub fn spinner(&self, msg: &str) -> ProgressBar {
        let pb = ProgressBar::new_spinner();
        pb.set_style(
            ProgressStyle::with_template("{spinner:.green} {msg}")
                .unwrap()
                .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "),
        );
        pb.set_message(msg.to_string());
        pb.enable_steady_tick(Duration::from_millis(80));
        pb
    }

    pub fn tabela<T: tabled::Tabled>(&self, dados: &[T]) {
        if self.json { return; }
        if dados.is_empty() {
            self.aviso("Nenhum resultado encontrado.");
            return;
        }
        use tabled::Table;
        println!("{}", Table::new(dados));
    }
}

Implementando os comandos

// src/commands/init.rs
use crate::{cli::{ArgsInit, Template}, config::Config, output::Output};
use anyhow::{Context, Result};
use dialoguer::{Confirm, Input, Select};
use std::process::Command;

pub fn executar(args: ArgsInit, config: &Config, out: &Output) -> Result<()> {
    let dir_base = args.dir.unwrap_or_else(|| config.dir_projetos.clone());
    let dir_projeto = dir_base.join(&args.nome);

    if dir_projeto.exists() {
        anyhow::bail!(
            "Diretório já existe: {}",
            dir_projeto.display()
        );
    }

    out.info(&format!("Criando projeto '{}'...", args.nome.bold()));
    out.detalhe(&format!("Diretório: {}", dir_projeto.display()));
    out.detalhe(&format!("Template: {}", args.template));

    // Cria com cargo
    let spinner = out.spinner("Inicializando com cargo...");

    let tipo_flag = match args.template {
        Template::Lib       => "--lib",
        Template::Bin       => "--bin",
        Template::Workspace => "--lib", // customizado depois
    };

    let status = Command::new("cargo")
        .args(["new", tipo_flag, dir_projeto.to_str().unwrap()])
        .output()
        .context("Falha ao executar cargo new")?;

    spinner.finish_and_clear();

    if !status.status.success() {
        let stderr = String::from_utf8_lossy(&status.stderr);
        anyhow::bail!("cargo new falhou: {}", stderr);
    }

    // Git
    if args.git {
        out.detalhe("Inicializando repositório git...");
        let _ = Command::new("git")
            .args(["init", dir_projeto.to_str().unwrap()])
            .output();

        // .gitignore básico
        std::fs::write(
            dir_projeto.join(".gitignore"),
            "/target
.env
*.log
",
        )?;
    }

    // README básico
    std::fs::write(
        dir_projeto.join("README.md"),
        format!("# {}

Descrição do projeto.
", args.nome),
    )?;

    out.sucesso(&format!(
        "Projeto '{}' criado em {}",
        args.nome.bold(),
        dir_projeto.display()
    ));

    if args.abrir {
        let editor = &config.editor;
        out.info(&format!("Abrindo com {editor}..."));
        Command::new(editor)
            .arg(&dir_projeto)
            .spawn()
            .with_context(|| format!("Falha ao abrir editor '{editor}'"))?;
    }

    Ok(())
}
// src/commands/list.rs
use crate::{cli::{ArgsList, OrdenarPor}, config::Config, output::Output};
use anyhow::Result;
use chrono::{DateTime, Local};
use serde::Serialize;
use std::path::PathBuf;
use tabled::Tabled;

#[derive(Debug, Serialize, Tabled)]
pub struct EntradaProjeto {
    #[tabled(rename = "Nome")]
    pub nome: String,

    #[tabled(rename = "Caminho")]
    pub caminho: String,

    #[tabled(rename = "Modificado")]
    pub modificado: String,

    #[tabled(rename = "Tamanho")]
    pub tamanho: String,
}

pub fn executar(args: ArgsList, config: &Config, out: &Output) -> Result<()> {
    let dir = &config.dir_projetos;

    if !dir.exists() {
        out.aviso(&format!(
            "Diretório de projetos não existe: {}",
            dir.display()
        ));
        out.info("Configure com: projctl config set dir_projetos /caminho");
        return Ok(());
    }

    let spinner = out.spinner("Escaneando projetos...");

    let mut projetos: Vec<EntradaProjeto> = std::fs::read_dir(dir)?
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_dir())
        .filter(|e| {
            // É um projeto Rust?
            e.path().join("Cargo.toml").exists()
        })
        .filter(|e| {
            // Aplica filtro de nome
            if let Some(ref filtro) = args.filtro {
                let nome = e.file_name().to_string_lossy().to_lowercase();
                nome.contains(&filtro.to_lowercase())
            } else {
                true
            }
        })
        .filter_map(|e| {
            let path = e.path();
            let nome = path.file_name()?.to_string_lossy().to_string();
            let meta = std::fs::metadata(&path).ok()?;

            let modificado = meta.modified().ok()
                .map(|t| {
                    let dt: DateTime<Local> = t.into();
                    dt.format("%Y-%m-%d %H:%M").to_string()
                })
                .unwrap_or_else(|| "—".to_string());

            let tamanho = tamanho_dir(&path);

            Some(EntradaProjeto {
                nome,
                caminho: path.to_string_lossy().to_string(),
                modificado,
                tamanho,
            })
        })
        .collect();

    spinner.finish_and_clear();

    // Ordena
    match args.ordenar {
        OrdenarPor::Nome  => projetos.sort_by(|a, b| a.nome.cmp(&b.nome)),
        OrdenarPor::Data  => projetos.sort_by(|a, b| b.modificado.cmp(&a.modificado)),
        OrdenarPor::Tamanho => projetos.sort_by(|a, b| b.tamanho.cmp(&a.tamanho)),
    }

    if args.reverso {
        projetos.reverse();
    }

    if out.json {
        println!("{}", serde_json::to_string_pretty(&projetos)?);
        return Ok(());
    }

    if projetos.is_empty() {
        out.aviso("Nenhum projeto encontrado.");
    } else {
        out.secao(&format!("Projetos ({})", projetos.len()));
        out.tabela(&projetos);
    }

    Ok(())
}

fn tamanho_dir(path: &PathBuf) -> String {
    let bytes: u64 = walkdir::WalkDir::new(path)
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_file())
        .filter_map(|e| e.metadata().ok())
        .map(|m| m.len())
        .sum();

    formatar_bytes(bytes)
}

fn formatar_bytes(bytes: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = KB * 1024;
    const GB: u64 = MB * 1024;

    if bytes >= GB {
        format!("{:.1}GB", bytes as f64 / GB as f64)
    } else if bytes >= MB {
        format!("{:.1}MB", bytes as f64 / MB as f64)
    } else if bytes >= KB {
        format!("{:.1}KB", bytes as f64 / KB as f64)
    } else {
        format!("{bytes}B")
    }
}

Ponto de entrada principal

// src/main.rs
mod cli;
mod commands;
mod config;
mod error;
mod output;

use anyhow::Result;
use clap::Parser;
use cli::{Cli, Comando};
use output::Output;

fn main() {
    if let Err(e) = executar() {
        // Formata erros anyhow com toda a cadeia de contexto
        eprintln!("{}: {}", "erro".red().bold(), e);

        // Em modo verbose, mostra o backtrace
        let verbose = std::env::args().any(|a| a == "-v" || a == "--verbose");
        if verbose {
            for causa in e.chain().skip(1) {
                eprintln!("  {} {}", "causado por:".dimmed(), causa);
            }
        }

        std::process::exit(1);
    }
}

fn executar() -> Result<()> {
    let cli = Cli::parse();

    let config = config::Config::carregar()
        .unwrap_or_else(|e| {
            eprintln!("Aviso: falha ao carregar config: {e}");
            config::Config::default()
        });

    let out = Output::novo(cli.json, cli.verbose, cli.no_color);

    match cli.comando {
        Comando::Init(args) => {
            commands::init::executar(args, &config, &out)
        }

        Comando::List(args) => {
            commands::list::executar(args, &config, &out)
        }

        Comando::Status(args) => {
            commands::status::executar(args, &config, &out)
        }

        Comando::Build(args) => {
            commands::build::executar(args, &config, &out)
        }

        Comando::Config(args) => {
            executar_config(args, config, &out)
        }

        Comando::Stats => {
            commands::stats::executar(&config, &out)
        }
    }
}

fn executar_config(
    args: cli::ArgsConfig,
    mut config: config::Config,
    out: &Output,
) -> Result<()> {
    use cli::AcaoConfig;

    match args.acao {
        AcaoConfig::Show => {
            let conteudo = toml::to_string_pretty(&config)?;
            if out.json {
                println!("{}", serde_json::to_string_pretty(&config)?);
            } else {
                println!("{}", conteudo);
            }
        }

        AcaoConfig::Set { chave, valor } => {
            config.set(&chave, &valor)?;
            config.salvar()?;
            out.sucesso(&format!("{chave} = {valor}"));
        }

        AcaoConfig::Unset { chave } => {
            config.set(&chave, "")?;
            config.salvar()?;
            out.sucesso(&format!("{chave} removida"));
        }

        AcaoConfig::Edit => {
            let caminho = config::Config::caminho();
            if !caminho.exists() {
                config.salvar()?;
            }
            std::process::Command::new(&config.editor)
                .arg(&caminho)
                .status()?;
        }
    }

    Ok(())
}

Testes para CLIs

// tests/cli_integration.rs
use assert_cmd::Command;
use predicates::prelude::*;

#[test]
fn sem_argumentos_mostra_ajuda() {
    let mut cmd = Command::cargo_bin("projctl").unwrap();
    cmd.assert()
        .failure()
        .stderr(predicate::str::contains("Usage"));
}

#[test]
fn versao_disponivel() {
    Command::cargo_bin("projctl").unwrap()
        .arg("--version")
        .assert()
        .success()
        .stdout(predicate::str::contains("projctl"));
}

#[test]
fn ajuda_disponivel() {
    Command::cargo_bin("projctl").unwrap()
        .arg("--help")
        .assert()
        .success()
        .stdout(predicate::str::contains("Gerenciador"));
}

#[test]
fn list_json_output() {
    Command::cargo_bin("projctl").unwrap()
        .args(["--json", "list"])
        .assert()
        .success()
        .stdout(predicate::str::starts_with("["));
}

#[test]
fn config_show() {
    Command::cargo_bin("projctl").unwrap()
        .args(["config", "show"])
        .assert()
        .success();
}

#[test]
fn subcomando_invalido() {
    Command::cargo_bin("projctl").unwrap()
        .arg("nao-existe")
        .assert()
        .failure()
        .stderr(predicate::str::contains("error"));
}

Adicione ao Cargo.toml:

[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3"

Shell completion

clap pode gerar scripts de completion para bash, zsh, fish, e PowerShell:

// src/bin/completions.rs — binário auxiliar para gerar completions
use clap::CommandFactory;
use clap_complete::{generate, Shell};
use std::io;

// Inclui a CLI do projeto principal
include!("../cli.rs");

fn main() {
    let shell: Shell = std::env::args()
        .nth(1)
        .and_then(|s| s.parse().ok())
        .unwrap_or(Shell::Bash);

    let mut cmd = Cli::command();
    generate(shell, &mut cmd, "projctl", &mut io::stdout());
}
# Cargo.toml
[dependencies]
clap_complete = "4"
# Instalar completion no bash
projctl-completions bash >> ~/.bashrc

# Instalar no zsh
projctl-completions zsh >> ~/.zshrc

# Fish
projctl-completions fish > ~/.config/fish/completions/projctl.fish

Distribuição e instalação

# Instalar diretamente do crates.io
cargo install projctl

# Instalar de repositório git
cargo install --git https://github.com/usuario/projctl

# Instalar de path local (desenvolvimento)
cargo install --path .

# Compilar binário otimizado manualmente
RUSTFLAGS="-C target-cpu=native" cargo build --release
# Binário em: target/release/projctl

# Cross-compilação para Linux em macOS
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
# Binário estático — roda em qualquer Linux sem dependências

GitHub Actions para releases automáticos

# .github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*']

jobs:
  build:
    name: Build ${{ matrix.target }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            target: x86_64-unknown-linux-musl
            suffix: ""
          - os: macos-latest
            target: x86_64-apple-darwin
            suffix: ""
          - os: macos-latest
            target: aarch64-apple-darwin
            suffix: ""
          - os: windows-latest
            target: x86_64-pc-windows-msvc
            suffix: ".exe"

    steps:
      - uses: actions/checkout@v4

      - uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.target }}

      - name: Build
        run: cargo build --release --target ${{ matrix.target }}

      - name: Renomear binário
        shell: bash
        run: |
          cp target/${{ matrix.target }}/release/projctl${{ matrix.suffix }} 
             projctl-${{ matrix.target }}${{ matrix.suffix }}

      - uses: softprops/action-gh-release@v1
        with:
          files: projctl-${{ matrix.target }}${{ matrix.suffix }}

Boas práticas para CLIs em Rust

Siga as convenções Unix. Exit code 0 para sucesso, não-zero para erro. Use stdout para dados, stderr para erros e diagnósticos. Respeite NO_COLOR. Suporte a pipes (stdin/stdout).

Mensagens de erro úteis. Não apenas "erro: operação falhou" — inclua o que falhou, por que falhou, e como corrigir. anyhow facilita muito isso com contexto encadeado.

Flags globais para comportamento universal. --verbose, --quiet, --json, --no-color devem funcionar em qualquer subcomando com global = true.

Progresso para operações longas. Qualquer operação que dure mais de 300ms deve ter um indicador de progresso. indicatif é excelente para isso.

Modo não-interativo para scripts. Se a CLI vai ser usada em scripts, --json para output parseable e evitar prompts são essenciais.


Fontes e leituras recomendadas

  • clap documentation — https://docs.rs/clap
  • clap derive guide — https://docs.rs/clap/latest/clap/_derive/
  • "Command Line Interface Guidelines" — convenções de UX para CLIs — https://clig.dev
  • indicatif crate — barras de progresso — https://docs.rs/indicatif
  • dialoguer crate — prompts interativos — https://docs.rs/dialoguer
  • assert_cmd crate — testes de integração para CLIs — https://docs.rs/assert_cmd
  • "Awesome CLI Rust" — curadoria de ferramentas e bibliotecas — https://github.com/rust-unofficial/awesome-rust#command-line
  • ripgrep source code — referência de CLI profissional em Rust — https://github.com/BurntSushi/ripgrep

Artigo #40 de 52 | Série: Dominando Rust em 1 Ano Próximo → Artigo #41: Rust para Data Science — Processamento de dados com Polars e ndarray


Comentários

Mais em Rust

WebAssembly com Rust — Executando Rust no Navegador
WebAssembly com Rust — Executando Rust no Navegador

Rust e WebAssembly formam uma das combinações mais empolgantes da programação...

Contribuindo com o Ecossistema Rust — Open Source, RFCs e a Comunidade
Contribuindo com o Ecossistema Rust — Open Source, RFCs e a Comunidade

Rust — Artigo #49 Contribuindo com o Ecossistema Rust — Open Source, RFCs e a...

Macros Procedurais — Criando Derive Macros e Atributos Customizados
Macros Procedurais — Criando Derive Macros e Atributos Customizados

No Artigo #21 exploramos macros declarativas com macro_rules! — padrões que t...