CLI com Clap em Rust: Tutorial Completo | Rust Brasil

Tutorial de CLI com Clap em Rust: argumentos, flags, subcomandos e validação. Crie ferramentas de linha de comando profissionais.

Introdução

Rust é uma das melhores linguagens para criar ferramentas de linha de comando (CLI). O ecossistema oferece crates excelentes, e o Clap se destaca como a biblioteca mais popular e completa para parsing de argumentos. Neste tutorial, vamos desde o básico do Clap até a construção de uma ferramenta real de busca em arquivos.

Configurando o Projeto

Crie um novo projeto e adicione as dependências:

cargo new buscador
cd buscador
# Cargo.toml
[package]
name = "buscador"
version = "0.1.0"
edition = "2021"
description = "Ferramenta de busca em arquivos"

[dependencies]
clap = { version = "4", features = ["derive", "color"] }
colored = "2"
regex = "1"
walkdir = "2"
anyhow = "1"

Clap com Derive Macros

A forma mais ergonômica de usar o Clap é com as macros derive, que geram o parser a partir de structs anotadas:

Exemplo Básico

use clap::Parser;

/// Uma ferramenta simples de saudação
#[derive(Parser, Debug)]
#[command(name = "saudar")]
#[command(version = "1.0")]
#[command(about = "Saúda uma pessoa pelo nome")]
struct Args {
    /// Nome da pessoa a ser saudada
    #[arg(short, long)]
    nome: String,

    /// Número de vezes para repetir a saudação
    #[arg(short, long, default_value_t = 1)]
    repeticoes: u8,

    /// Usar saudação formal
    #[arg(short, long)]
    formal: bool,
}

fn main() {
    let args = Args::parse();

    for _ in 0..args.repeticoes {
        if args.formal {
            println!("Prezado(a) Sr(a). {}, bom dia!", args.nome);
        } else {
            println!("E aí, {}!", args.nome);
        }
    }
}

Executando:

$ cargo run -- --nome Maria --repeticoes 2 --formal
Prezado(a) Sr(a). Maria, bom dia!
Prezado(a) Sr(a). Maria, bom dia!

$ cargo run -- -n João
E aí, João!

$ cargo run -- --help
Uma ferramenta simples de saudação

Usage: saudar [OPTIONS] --nome <NOME>

Options:
  -n, --nome <NOME>              Nome da pessoa a ser saudada
  -r, --repeticoes <REPETICOES>  Número de vezes para repetir a saudação [default: 1]
  -f, --formal                   Usar saudação formal
  -h, --help                     Print help
  -V, --version                  Print version

Tipos de Argumentos

O Clap suporta diversos tipos de argumentos:

Argumentos Posicionais

use clap::Parser;

#[derive(Parser, Debug)]
struct Args {
    /// Arquivo de entrada
    entrada: String,

    /// Arquivo de saída (opcional)
    saida: Option<String>,
}

Argumentos com Valores Possíveis (Enum)

use clap::{Parser, ValueEnum};

#[derive(Debug, Clone, ValueEnum)]
enum Formato {
    Json,
    Csv,
    Texto,
}

#[derive(Parser, Debug)]
struct Args {
    /// Formato de saída
    #[arg(short, long, value_enum, default_value_t = Formato::Texto)]
    formato: Formato,
}

Argumentos com Validação Personalizada

use clap::Parser;
use std::path::PathBuf;

#[derive(Parser, Debug)]
struct Args {
    /// Diretório para buscar (deve existir)
    #[arg(short, long, value_parser = validar_diretorio)]
    diretorio: PathBuf,

    /// Porta do servidor (1024-65535)
    #[arg(short, long, value_parser = clap::value_parser!(u16).range(1024..=65535))]
    porta: Option<u16>,
}

fn validar_diretorio(s: &str) -> Result<PathBuf, String> {
    let caminho = PathBuf::from(s);
    if caminho.is_dir() {
        Ok(caminho)
    } else {
        Err(format!("'{}' não é um diretório válido", s))
    }
}

Subcomandos

Subcomandos permitem criar ferramentas com múltiplas funcionalidades, como git commit, git push, etc.:

use clap::{Parser, Subcommand};

#[derive(Parser, Debug)]
#[command(name = "gerenciador")]
#[command(about = "Gerenciador de projetos Rust")]
struct Cli {
    /// Ativar modo verbose
    #[arg(short, long, global = true)]
    verbose: bool,

    #[command(subcommand)]
    comando: Comandos,
}

#[derive(Subcommand, Debug)]
enum Comandos {
    /// Criar um novo projeto
    Novo {
        /// Nome do projeto
        nome: String,

        /// Template a ser usado
        #[arg(short, long, default_value = "basico")]
        template: String,
    },

    /// Compilar o projeto atual
    Compilar {
        /// Compilar em modo release
        #[arg(short, long)]
        release: bool,
    },

    /// Executar testes
    Testar {
        /// Filtro para nome dos testes
        filtro: Option<String>,

        /// Número de threads para testes paralelos
        #[arg(short, long)]
        threads: Option<usize>,
    },

    /// Limpar artefatos de compilação
    Limpar,
}

fn main() {
    let cli = Cli::parse();

    if cli.verbose {
        println!("[VERBOSE] Modo detalhado ativado");
    }

    match cli.comando {
        Comandos::Novo { nome, template } => {
            println!("Criando projeto '{}' com template '{}'", nome, template);
        }
        Comandos::Compilar { release } => {
            let modo = if release { "release" } else { "debug" };
            println!("Compilando em modo {}", modo);
        }
        Comandos::Testar { filtro, threads } => {
            println!(
                "Executando testes{} com {} threads",
                filtro.map_or(String::new(), |f| format!(" (filtro: {})", f)),
                threads.unwrap_or(num_cpus())
            );
        }
        Comandos::Limpar => {
            println!("Limpando artefatos de compilação...");
        }
    }
}

fn num_cpus() -> usize {
    std::thread::available_parallelism()
        .map(|n| n.get())
        .unwrap_or(1)
}

Saída Colorida

A crate colored torna simples adicionar cores à saída:

use colored::*;

fn exibir_resultado(arquivo: &str, linha: usize, conteudo: &str, termo: &str) {
    print!("{}", arquivo.green().bold());
    print!("{}", ":".dimmed());
    print!("{}", linha.to_string().yellow());
    print!("{}", ":".dimmed());

    // Destacar o termo encontrado
    let conteudo_destacado = conteudo.replace(
        termo,
        &termo.red().bold().to_string(),
    );
    println!(" {}", conteudo_destacado);
}

fn exibir_erro(mensagem: &str) {
    eprintln!("{} {}", "ERRO:".red().bold(), mensagem);
}

fn exibir_sucesso(mensagem: &str) {
    println!("{} {}", "OK:".green().bold(), mensagem);
}

fn exibir_aviso(mensagem: &str) {
    println!("{} {}", "AVISO:".yellow().bold(), mensagem);
}

Projeto Completo: Ferramenta de Busca em Arquivos

Agora vamos juntar tudo para construir o Buscador, uma ferramenta real de busca em arquivos:

// src/main.rs
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use colored::*;
use regex::Regex;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

/// Buscador - Ferramenta de busca em arquivos escrita em Rust
#[derive(Parser, Debug)]
#[command(name = "buscador")]
#[command(version)]
#[command(about = "Busca padrões de texto em arquivos de forma rápida e colorida")]
struct Args {
    /// Padrão de busca (suporta regex)
    padrao: String,

    /// Diretório ou arquivo para buscar
    #[arg(default_value = ".")]
    caminho: PathBuf,

    /// Tipo de busca
    #[arg(short, long, value_enum, default_value_t = TipoBusca::Texto)]
    tipo: TipoBusca,

    /// Ignorar maiúsculas/minúsculas
    #[arg(short, long)]
    ignorar_caso: bool,

    /// Extensões de arquivo para filtrar (ex: rs,toml,md)
    #[arg(short, long, value_delimiter = ',')]
    extensoes: Option<Vec<String>>,

    /// Número máximo de resultados
    #[arg(short, long)]
    maximo: Option<usize>,

    /// Mostrar contexto (linhas antes e depois)
    #[arg(short = 'C', long, default_value_t = 0)]
    contexto: usize,

    /// Mostrar apenas nomes de arquivos com correspondências
    #[arg(short = 'l', long)]
    apenas_arquivos: bool,

    /// Contar correspondências em vez de exibi-las
    #[arg(long)]
    contar: bool,

    /// Profundidade máxima de diretórios
    #[arg(short = 'd', long)]
    profundidade: Option<usize>,
}

#[derive(Debug, Clone, ValueEnum)]
enum TipoBusca {
    /// Busca texto literal
    Texto,
    /// Busca com expressão regular
    Regex,
}

struct Resultado {
    arquivo: PathBuf,
    linha: usize,
    conteudo: String,
}

fn main() -> Result<()> {
    let args = Args::parse();

    let regex = construir_regex(&args)?;
    let mut total_resultados = 0usize;
    let mut arquivos_com_match = Vec::new();

    let walker = construir_walker(&args);

    for entrada in walker {
        let entrada = entrada.context("Erro ao percorrer diretório")?;

        if !entrada.file_type().is_file() {
            continue;
        }

        let caminho = entrada.path();

        // Filtrar por extensão
        if let Some(ref exts) = args.extensoes {
            match caminho.extension().and_then(|e| e.to_str()) {
                Some(ext) if exts.iter().any(|e| e == ext) => {}
                _ => continue,
            }
        }

        // Tentar ler o arquivo (ignorar arquivos binários)
        let conteudo = match fs::read_to_string(caminho) {
            Ok(c) => c,
            Err(_) => continue,
        };

        let resultados = buscar_no_arquivo(caminho, &conteudo, &regex, &args);

        if !resultados.is_empty() {
            arquivos_com_match.push(caminho.to_path_buf());
            total_resultados += resultados.len();

            if !args.contar && !args.apenas_arquivos {
                exibir_resultados(&resultados, &args.padrao, &conteudo, args.contexto);
            }

            if let Some(max) = args.maximo {
                if total_resultados >= max {
                    break;
                }
            }
        }
    }

    // Exibir resumo
    if args.apenas_arquivos {
        for arquivo in &arquivos_com_match {
            println!("{}", arquivo.display().to_string().green());
        }
    }

    if args.contar {
        println!(
            "{} correspondência(s) em {} arquivo(s)",
            total_resultados.to_string().yellow().bold(),
            arquivos_com_match.len().to_string().green().bold()
        );
    } else {
        eprintln!(
            "\n{} resultado(s) encontrado(s) em {} arquivo(s)",
            total_resultados.to_string().yellow().bold(),
            arquivos_com_match.len().to_string().green().bold()
        );
    }

    Ok(())
}

fn construir_regex(args: &Args) -> Result<Regex> {
    let padrao = match args.tipo {
        TipoBusca::Texto => regex::escape(&args.padrao),
        TipoBusca::Regex => args.padrao.clone(),
    };

    let padrao = if args.ignorar_caso {
        format!("(?i){}", padrao)
    } else {
        padrao
    };

    Regex::new(&padrao).context("Padrão de regex inválido")
}

fn construir_walker(args: &Args) -> walkdir::IntoIter {
    let mut walker = WalkDir::new(&args.caminho);
    if let Some(prof) = args.profundidade {
        walker = walker.max_depth(prof);
    }
    walker.into_iter().filter_entry(|e| {
        // Ignorar diretórios ocultos e .git
        let nome = e.file_name().to_str().unwrap_or("");
        !nome.starts_with('.') || nome == "."
    })
}

fn buscar_no_arquivo(
    caminho: &Path,
    conteudo: &str,
    regex: &Regex,
    _args: &Args,
) -> Vec<Resultado> {
    conteudo
        .lines()
        .enumerate()
        .filter(|(_, linha)| regex.is_match(linha))
        .map(|(num, linha)| Resultado {
            arquivo: caminho.to_path_buf(),
            linha: num + 1,
            conteudo: linha.to_string(),
        })
        .collect()
}

fn exibir_resultados(
    resultados: &[Resultado],
    termo: &str,
    conteudo_completo: &str,
    contexto: usize,
) {
    let linhas: Vec<&str> = conteudo_completo.lines().collect();

    for resultado in resultados {
        // Cabeçalho do arquivo
        print!("{}", resultado.arquivo.display().to_string().green().bold());
        print!("{}", ":".dimmed());
        print!("{}", resultado.linha.to_string().yellow());
        print!("{}", ":".dimmed());

        // Destacar o termo encontrado na linha
        let conteudo_destacado = resultado
            .conteudo
            .replace(termo, &termo.red().bold().underline().to_string());
        println!(" {}", conteudo_destacado);

        // Mostrar linhas de contexto
        if contexto > 0 {
            let inicio = resultado.linha.saturating_sub(contexto + 1);
            let fim = (resultado.linha + contexto).min(linhas.len());

            for i in inicio..fim {
                if i + 1 != resultado.linha {
                    println!(
                        "  {} {} {}",
                        " ".dimmed(),
                        (i + 1).to_string().dimmed(),
                        linhas[i].dimmed()
                    );
                }
            }
            println!();
        }
    }
}

Testando a Ferramenta

# Busca simples
cargo run -- "fn main" src/

# Busca ignorando maiúsculas/minúsculas
cargo run -- "todo" . -i

# Busca com regex
cargo run -- "\bfn\s+\w+" src/ --tipo regex

# Filtrar por extensão
cargo run -- "use" . -e rs,toml

# Mostrar contexto
cargo run -- "Parser" src/ -C 2

# Apenas nomes de arquivos
cargo run -- "struct" . -l -e rs

# Contar resultados
cargo run -- "let" src/ --contar

# Limitar profundidade
cargo run -- "fn" . -d 2

Dicas para Distribuição

Para compilar e distribuir sua ferramenta CLI:

# Compilar em modo release (otimizado)
cargo build --release

# O binário estará em target/release/buscador
ls -lh target/release/buscador

# Instalar localmente
cargo install --path .

Para compilação cruzada (cross-compilation), considere usar a ferramenta cross:

cargo install cross
cross build --release --target x86_64-unknown-linux-musl
cross build --release --target x86_64-pc-windows-gnu

Conclusão

Neste tutorial, construímos uma ferramenta CLI completa em Rust usando o Clap. Cobrimos:

  • Derive macros para definição declarativa de argumentos
  • Argumentos posicionais e nomeados com valores padrão
  • Subcomandos para ferramentas com múltiplas funcionalidades
  • Validação personalizada de argumentos
  • Enums como valores de argumentos
  • Saída colorida com a crate colored
  • Um projeto real com busca em arquivos usando regex e walkdir

O Rust é ideal para ferramentas CLI: compila para um binário único, tem performance excelente e o sistema de tipos garante robustez. Ferramentas como ripgrep, fd, bat e exa são todas escritas em Rust e demonstram o potencial da linguagem nesse domínio.