Construindo CLIs do Jeito Rust
Uma CLI Rust bem construída não é apenas uma função main que analisa std::env::args e imprime coisas. É um pequeno programa construído em torno de três disciplinas que aparecem em todas as ferramentas maduras do ecossistema: um modelo de análise de argumentos declarativo onde você descreve a forma da entrada válida e deixa uma biblioteca gerar o parser, tratando códigos de saída como um contrato real do qual outros programas e scripts dependem em vez de uma reflexão tardia, e aplicando a mesma disciplina de propriedade que Rust usa em todos os outros lugares ao problema específico e fácil de errar de I/O de terminal.
Esta página é o modelo mental no qual o restante desta seção se baseia - clap, relatórios de erro, saída de terminal, completações de shell e empacotamento são todas aplicações dessas três ideias, não preocupações separadas inventadas do zero.
Resumo
- CLIs Rust idiomáticas são construídas declarativamente (descreva a interface, deixe o
clapgerar o parser), tratam códigos de saída como um contrato real com o shell e aplicam disciplina de propriedade ao I/O de terminal. - Por que Importa: Uma CLI é um limite de processo - outros programas, scripts e pipelines de CI dependem de seu código de saída e da divisão stdout/stderr se comportando de maneira previsível, não apenas da aparência agradável de sua saída legível por humanos.
- Conceitos Chave: parsing declarativo, macro derive, código de saída, separação stdout/stderr, detecção de TTY, divisão biblioteca/binário.
- Quando Usar: Iniciando uma nova ferramenta CLI, decidindo entre
std::env::argseclap, ou projetando como uma ferramenta deve se comportar dentro de um pipeline de shell ou script de CI. - Limitações / Trade-offs: O parsing declarativo custa alguma flexibilidade para conjuntos de argumentos verdadeiramente dinâmicos; a disciplina estrita de stdout/stderr requer um design deliberado, não apenas
println!em todos os lugares. - Tópicos Relacionados: convenções de scripting de shell, status de saída de processo, detecção de terminal.
Fundamentos
A análise de argumentos em CLIs Rust é geralmente declarativa em vez de imperativa: em vez de escrever código que percorre std::env::args() e ramifica manualmente em cada flag, você descreve a forma de uma linha de comando válida como um tipo Rust, e uma biblioteca deriva o parser, o texto de --help e a lógica de validação dessa descrição. A API derive do clap é o exemplo dominante - uma struct com #[derive(Parser)] e campos anotados é a interface da CLI; não há uma função de parsing separada escrita à mão para manter sincronizada com ela.
Um código de saída é o único inteiro que um processo retorna para o que o lançou - um shell, um script ou CI. 0 convencionalmente significa sucesso e qualquer outra coisa significa algum tipo de falha, e esta não é uma convenção Rust, é uma convenção de sistema operacional de décadas da qual todos os shells, todos os sistemas de CI e todas as cadeias &&/|| em um script dependem. Uma CLI que sempre sai com 0 independentemente do que aconteceu é invisível para todos esses consumidores, mesmo que tenha impresso uma mensagem de erro clara que um humano teria percebido.
O I/O de terminal em uma CLI se divide em dois fluxos genuinamente diferentes com públicos genuinamente diferentes: stdout é para saída que outro programa pode consumir (dados destinados a serem canalizados, analisados ou redirecionados), e stderr é para saída que um humano deve ler (logs, avisos, progresso, mensagens de erro). Misturá-los - imprimir uma barra de progresso para stdout, por exemplo - quebra no momento em que alguém canaliza a saída da sua ferramenta para jq ou um arquivo.
Mecânicas e Interações
O modelo declarativo muda o que significa "adicionar uma flag" na prática. Com as macros derive do clap, adicionar uma nova opção é adicionar um campo a uma struct com um atributo, e o parser, a conversão de tipo, o texto de --help e a validação são atualizados a partir dessa única declaração - não há um switch separado para manter sincronizado manualmente. É também por isso que os erros do clap são consistentes em todas as ferramentas que o utilizam: dicas de uso, código de saída 2 em falha de parsing e formatação de --help vêm do mesmo caminho de código gerado em vez da lógica de parsing de cada autor.
// A forma da struct *é* a interface da CLI - o clap deriva todo o resto dela.
#[derive(clap::Parser)]
struct Cli {
/// Aumenta a verbosidade (-v, -vv)
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[command(subcommand)]
command: Command,
}
// Não existe um match escrito à mão sobre argv bruto em lugar nenhum - o clap gera essa lógica
// a partir desta declaração, incluindo o texto --help e o caminho de erro de código de saída 2.Códigos de saída e a divisão stdout/stderr interagem diretamente com como uma CLI se compõe em um pipeline. cmd1 | cmd2 conecta o stdout de cmd1 ao stdin de cmd2 - o stderr de qualquer lado vai direto para o terminal, não afetado pelo pipe. Um script de shell verificando if mytool; then ... está realmente verificando o código de saída, não analisando nenhum texto impresso, que é por que uma ferramenta que imprime "Erro: arquivo não encontrado" para stdout, mas ainda sai com 0, passará silenciosamente por essa verificação if, mesmo que um humano lendo a saída perceba imediatamente a falha.
std::process::ExitCode é a maneira tipada do Rust de retornar um código específico e significativo de main, distinto de pânico (que sempre sai com o código 101 e imprime uma mensagem no estilo backtrace, apropriada para bugs genuínos, não para condições de falha esperadas como um arquivo ausente). Distinguir "esta é uma falha normal e esperada que o chamador deve lidar" (um Result retornado para main, mapeado para um código de saída específico) de "este é um bug no meu programa" (um panic!) é em si uma decisão adjacente à propriedade: trata-se de quem é responsável por lidar com a falha, o chamador ou o programador.
Considerações Avançadas e Aplicações
O I/O de terminal tem sua própria preocupação de propriedade que é fácil de perder: stdout e stderr são recursos compartilhados, bufferizados, às vezes bloqueados, e escrever neles de múltiplos lugares (uma biblioteca de barra de progresso e suas próprias chamadas println!, por exemplo) sem coordenação produz saída intercalada e confusa. O MultiProgress do indicatif existe especificamente para gerenciar essa coordenação - uma vez que uma barra de progresso é registrada nele, chamadas println! ad hoc ao redor dela podem corromper a exibição, porque ambos estão tentando controlar a mesma posição do cursor do terminal sem saber um do outro.
A detecção de TTY é onde uma CLI decide quanta de sua "boa" aparência - cor, barras de progresso, spinners - manter, com base em quem está realmente consumindo sua saída. std::io::IsTerminal informa se stdout ou stderr está conectado a um terminal interativo versus um arquivo ou pipe; uma ferramenta que imprime incondicionalmente códigos de cor ANSI em um arquivo de log ou um pipeline jq está produzindo ruído que seu consumidor nunca pediu. Respeitar NO_COLOR e verificar is_terminal() antes de estilizar a saída não é cosmético, é a mesma disciplina de "conheça seu público" da divisão stdout/stderr, aplicada um nível mais profundo.
A divisão biblioteca-mais-binário-fino (src/lib.rs contendo a lógica real, src/main.rs fazendo apenas a análise de argumentos e chamando-a) existe para testabilidade: testes unitários podem chamar funções da biblioteca diretamente sem iniciar um subprocesso, capturar stdout ou lidar com códigos de saída, enquanto testes de integração exercitam o binário compilado de ponta a ponta quando a superfície real da CLI (sua análise de argumentos, seus códigos de saída) é o que precisa ser verificado.
| Abordagem | Força | Fraqueza | Melhor Ajuste |
|---|---|---|---|
clap derive | Declarativo, gera ajuda/validação de uma única struct, padrão do ecossistema | Alguma indireção para conjuntos de argumentos altamente dinâmicos | A grande maioria das CLIs, de ferramentas de flag única a suítes multi-comando |
clap builder API | Argumentos construídos em tempo de execução | Mais verboso que derive para CLIs estáticas | CLIs cujas flags só são conhecidas em tempo de execução (ferramentas baseadas em plugins) |
Manual std::env::args | Zero dependências | Sem texto de ajuda gerado, sem validação, fácil de se desviar da documentação | Apenas scripts descartáveis e protótipos rápidos |
Conceitos Errôneos Comuns
- "Código de saída 0 com uma mensagem de erro impressa está bom, desde que um humano a veja." - Scripts e pipelines de CI verificam o código de saída, não o texto impresso. Uma saída
0em caso de falha passa silenciosamente em todas as verificações automatizadas que dependem dela. - "
println!está bom para tudo que uma CLI emite." - Ele envia a saída para stdout, que é destinado a dados que um pipeline pode consumir. Progresso, logs e erros pertencem ao stderr para que canalizar a saída real da ferramenta não seja poluído. - "Entrar em pânico e retornar um código de saída de erro são basicamente a mesma coisa." - Um pânico sinaliza um bug inesperado e sempre sai com o código
101; um erro tratado retornando umExitCodeespecífico sinaliza uma condição de falha esperada e recuperável. Confundir os dois torna os códigos de saída sem sentido para os chamadores. - "O parsing declarativo com clap é menos flexível do que escrevê-lo manualmente." - Para a vasta maioria das CLIs, é igualmente expressivo e muito menos propenso a erros, pois a validação e o texto de ajuda nunca podem se desviar de um parser escrito manualmente do qual nunca foram derivados.
- "Cores e barras de progresso são extras inofensivos." - Imprimir incondicionalmente códigos ANSI ou barras de progresso animadas em um pipe não interativo ou arquivo de log corrompe a saída para exatamente os consumidores automatizados que o contrato stdout de uma CLI existe para servir.
FAQs
Por que a API derive do clap é chamada de "declarativa"?
Porque você descreve a forma da CLI como uma struct Rust com atributos, e o clap gera a lógica de parsing real, o texto --help e a validação a partir dessa descrição, em vez de você escrever a lógica de parsing manualmente.
Por que os códigos de saída importam se minha ferramenta já imprime um erro claro?
Scripts de shell, pipelines de CI e cadeias de processos como && verificam o código de saída programaticamente - eles não leem o texto impresso. Uma mensagem clara com o código de saída errado é invisível para todos os consumidores automatizados da ferramenta.
Qual é a diferença real entre stdout e stderr na prática?
stdout é o que cmd1 | cmd2 conecta entre processos e o que o redirecionamento com > captura - trate-o como dados. stderr vai direto para o terminal independentemente de piping ou redirecionamento - trate-o como mensagens para um humano.
Devo usar panic! ou retornar um Result para um arquivo ausente?
Retorne um Result e mapeie-o para um código de saída específico. Um arquivo ausente é uma condição de falha esperada e recuperável, não um bug no seu programa - entrar em pânico lá confunde as duas coisas e sempre produz o código de saída 101, independentemente do que realmente deu errado.
Por que dividir uma CLI em uma biblioteca e um binário fino?
Para que a lógica real possa ser testada unitariamente diretamente, sem iniciar um subprocesso ou analisar stdout capturado. O crate binário, então, só precisa analisar os argumentos e chamar a biblioteca, o que é fácil de manter fino.
Como uma CLI sabe se deve imprimir cor ou uma barra de progresso?
Verificando std::io::IsTerminal no stream relevante e respeitando a convenção NO_COLOR. Se stdout ou stderr não estiver conectado a um terminal interativo, a estilização e a animação geralmente devem ser suprimidas.
O que acontece se duas coisas escrevem no terminal ao mesmo tempo sem coordenar?
Saída intercalada e confusa - uma barra de progresso sendo redesenhada enquanto um println! não relacionado é disparado no meio do frame corrompe a exibição. Bibliotecas como MultiProgress do indicatif existem especificamente para gerenciar essa coordenação.
A API builder é alguma vez melhor do que as macros derive do clap?
Sim, quando o conjunto de argumentos só é conhecido em tempo de execução - um sistema de plugins que registra suas próprias flags, por exemplo - já que as macros derive descrevem uma struct fixa conhecida em tempo de compilação.
Por que o clap usa o código de saída 2 por padrão para um erro de parsing?
Ele segue uma convenção de longa data do shell que distingue "o próprio comando falhou" (geralmente código 1) de "o comando foi invocado incorretamente" (código 2), que é a situação que uma flag incorreta ou argumento ausente representa.
Variáveis de ambiente e flags precisam de uma precedência definida?
Sim - o clap pode vincular uma flag a uma variável de ambiente como fallback (env = "..."), mas a ferramenta deve documentar e impor qual delas vence quando ambas são definidas, para que o comportamento seja previsível em vez de dependente da ordem.
Por que uma barra de progresso errante quebra o pipe para jq?
Se a saída de progresso for para stdout em vez de stderr, ela se mistura no mesmo stream que jq está tentando analisar como JSON, quebrando o pipeline, mesmo que os dados reais pudessem ter sido válidos de outra forma.
std::env::args é alguma vez a escolha certa em vez do clap?
Para um script genuinamente único ou um exercício de aprendizado, sim. Para qualquer coisa distribuída para outras pessoas ou usada em um script, a falta de texto de ajuda gerado e validação se torna um custo de manutenção real rapidamente.
Relacionados
- Noções Básicas de CLI - o guia prático no qual esta página se baseia
- clap - o modelo de parsing declarativo em profundidade
- Saída de Terminal - disciplina stdout/stderr e renderização de progresso
- Relatórios de Erro - transformando falhas em códigos de saída que os usuários entendem
Versões da Stack: Esta página foi escrita para Rust 1.97.0 (edição 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, e Polars 0.46+.