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, ®ex, &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.