Renomear arquivos em lote é uma tarefa comum que aparece em diversos cenários: organizar fotos por data, padronizar nomes de documentos, adicionar prefixos a arquivos de projeto, ou substituir padrões em lote. Neste projeto, vamos construir uma ferramenta robusta de renomeação de arquivos que usa expressões regulares para máxima flexibilidade, oferece um modo de preview (dry-run) para segurança e mantém um log de operações que permite desfazer as mudanças.
Este walkthrough vai reforçar seus conhecimentos sobre o módulo std::path, manipulação do sistema de arquivos, expressões regulares e design de ferramentas CLI seguras em Rust.
O Que Vamos Construir
Nosso renomear terá os seguintes recursos:
- Substituição de padrões usando regex
- Adição de prefixos e sufixos
- Numeração sequencial de arquivos
- Modo preview (dry-run) que mostra as mudanças sem executar
- Log de operações para suporte a desfazer (undo)
- Busca recursiva em diretórios
- Filtro por extensão de arquivo
Estrutura do Projeto
renomear/
├── Cargo.toml
└── src/
├── main.rs
├── cli.rs
├── renomeador.rs
└── historico.rs
Configurando o Projeto
cargo new renomear
cd renomear
Configure o Cargo.toml:
[package]
name = "renomear"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] }
regex = "1"
walkdir = "2"
colored = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
Passo 1: Interface de Linha de Comando
// src/cli.rs
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(name = "renomear")]
#[command(about = "Renomeador de arquivos em lote com suporte a regex")]
pub struct Cli {
#[command(subcommand)]
pub comando: Comando,
}
#[derive(Subcommand, Debug)]
pub enum Comando {
/// Substitui padrão regex nos nomes dos arquivos
Substituir {
/// Diretório alvo
#[arg(short, long, default_value = ".")]
diretorio: String,
/// Padrão regex a buscar
#[arg(short, long)]
padrao: String,
/// Texto de substituição (suporta $1, $2 para grupos de captura)
#[arg(short = 's', long)]
substituto: String,
/// Busca recursiva em subdiretórios
#[arg(short, long)]
recursivo: bool,
/// Filtrar por extensão (ex: jpg, png)
#[arg(short, long)]
extensao: Option<String>,
/// Apenas exibir preview sem renomear
#[arg(long)]
preview: bool,
},
/// Adiciona prefixo ou sufixo aos nomes
Prefixar {
/// Diretório alvo
#[arg(short, long, default_value = ".")]
diretorio: String,
/// Prefixo a adicionar
#[arg(long)]
prefixo: Option<String>,
/// Sufixo a adicionar (antes da extensão)
#[arg(long)]
sufixo: Option<String>,
/// Filtrar por extensão
#[arg(short, long)]
extensao: Option<String>,
/// Apenas exibir preview sem renomear
#[arg(long)]
preview: bool,
},
/// Numera os arquivos sequencialmente
Numerar {
/// Diretório alvo
#[arg(short, long, default_value = ".")]
diretorio: String,
/// Padrão do nome (ex: "foto_" gera foto_001, foto_002...)
#[arg(short, long)]
padrao_nome: String,
/// Número inicial
#[arg(short = 'i', long, default_value_t = 1)]
inicio: usize,
/// Quantidade de dígitos (zero-padding)
#[arg(long, default_value_t = 3)]
digitos: usize,
/// Filtrar por extensão
#[arg(short, long)]
extensao: Option<String>,
/// Apenas exibir preview sem renomear
#[arg(long)]
preview: bool,
},
/// Desfaz a última operação de renomeação
Desfazer,
}
Passo 2: Motor de Renomeação
// src/renomeador.rs
use colored::*;
use regex::Regex;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
/// Representa uma operação de renomeação planejada
#[derive(Debug, Clone)]
pub struct Operacao {
pub caminho_antigo: PathBuf,
pub caminho_novo: PathBuf,
}
/// Coleta os arquivos no diretório, aplicando filtros
pub fn coletar_arquivos(
diretorio: &str,
recursivo: bool,
extensao: &Option<String>,
) -> Vec<PathBuf> {
let max_profundidade = if recursivo { usize::MAX } else { 1 };
let mut arquivos: Vec<PathBuf> = WalkDir::new(diretorio)
.max_depth(max_profundidade)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.map(|e| e.into_path())
.collect();
// Filtrar por extensão se especificada
if let Some(ext) = extensao {
let ext_lower = ext.to_lowercase();
arquivos.retain(|p| {
p.extension()
.and_then(OsStr::to_str)
.map(|e| e.to_lowercase() == ext_lower)
.unwrap_or(false)
});
}
arquivos.sort();
arquivos
}
/// Planeja operações de substituição por regex
pub fn planejar_substituicao(
arquivos: &[PathBuf],
padrao: &str,
substituto: &str,
) -> Result<Vec<Operacao>, regex::Error> {
let regex = Regex::new(padrao)?;
let mut operacoes = Vec::new();
for caminho in arquivos {
let nome = match caminho.file_name().and_then(OsStr::to_str) {
Some(n) => n,
None => continue,
};
let novo_nome = regex.replace_all(nome, substituto);
if novo_nome != nome {
let mut novo_caminho = caminho.parent().unwrap_or(Path::new(".")).to_path_buf();
novo_caminho.push(novo_nome.as_ref());
operacoes.push(Operacao {
caminho_antigo: caminho.clone(),
caminho_novo: novo_caminho,
});
}
}
Ok(operacoes)
}
/// Planeja operações de adição de prefixo/sufixo
pub fn planejar_prefixo_sufixo(
arquivos: &[PathBuf],
prefixo: &Option<String>,
sufixo: &Option<String>,
) -> Vec<Operacao> {
let mut operacoes = Vec::new();
for caminho in arquivos {
let nome_stem = caminho
.file_stem()
.and_then(OsStr::to_str)
.unwrap_or("");
let extensao = caminho
.extension()
.and_then(OsStr::to_str)
.map(|e| format!(".{}", e))
.unwrap_or_default();
let novo_nome = format!(
"{}{}{}{}",
prefixo.as_deref().unwrap_or(""),
nome_stem,
sufixo.as_deref().unwrap_or(""),
extensao
);
let nome_atual = caminho
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("");
if novo_nome != nome_atual {
let mut novo_caminho = caminho.parent().unwrap_or(Path::new(".")).to_path_buf();
novo_caminho.push(&novo_nome);
operacoes.push(Operacao {
caminho_antigo: caminho.clone(),
caminho_novo: novo_caminho,
});
}
}
operacoes
}
/// Planeja operações de numeração sequencial
pub fn planejar_numeracao(
arquivos: &[PathBuf],
padrao_nome: &str,
inicio: usize,
digitos: usize,
) -> Vec<Operacao> {
let mut operacoes = Vec::new();
for (i, caminho) in arquivos.iter().enumerate() {
let extensao = caminho
.extension()
.and_then(OsStr::to_str)
.map(|e| format!(".{}", e))
.unwrap_or_default();
let numero = inicio + i;
let novo_nome = format!(
"{}{:0largura$}{}",
padrao_nome,
numero,
extensao,
largura = digitos
);
let mut novo_caminho = caminho.parent().unwrap_or(Path::new(".")).to_path_buf();
novo_caminho.push(&novo_nome);
operacoes.push(Operacao {
caminho_antigo: caminho.clone(),
caminho_novo: novo_caminho,
});
}
operacoes
}
/// Exibe preview das operações planejadas
pub fn exibir_preview(operacoes: &[Operacao]) {
if operacoes.is_empty() {
println!("{}", "Nenhuma renomeação necessária.".yellow());
return;
}
println!("{} {} arquivo(s) serão renomeados:\n",
"PREVIEW:".blue().bold(),
operacoes.len()
);
for op in operacoes {
let antigo = op.caminho_antigo.file_name().unwrap_or_default().to_string_lossy();
let novo = op.caminho_novo.file_name().unwrap_or_default().to_string_lossy();
println!(" {} {} {}",
antigo.red(),
"→".bold(),
novo.green()
);
}
}
/// Executa as operações de renomeação
pub fn executar(operacoes: &[Operacao]) -> Vec<Operacao> {
let mut executadas = Vec::new();
for op in operacoes {
match fs::rename(&op.caminho_antigo, &op.caminho_novo) {
Ok(()) => {
let antigo = op.caminho_antigo.file_name().unwrap_or_default().to_string_lossy();
let novo = op.caminho_novo.file_name().unwrap_or_default().to_string_lossy();
println!(" {} {} → {}",
"OK".green().bold(),
antigo,
novo
);
executadas.push(op.clone());
}
Err(e) => {
eprintln!(" {} {}: {}",
"ERRO".red().bold(),
op.caminho_antigo.display(),
e
);
}
}
}
executadas
}
Passo 3: Histórico para Desfazer Operações
// src/historico.rs
use serde::{Deserialize, Serialize};
use std::fs;
use std::io;
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct EntradaHistorico {
pub caminho_antigo: String,
pub caminho_novo: String,
}
const ARQUIVO_HISTORICO: &str = ".renomear_historico.json";
/// Salva o histórico de uma operação
pub fn salvar(operacoes: &[(PathBuf, PathBuf)]) -> io::Result<()> {
let entradas: Vec<EntradaHistorico> = operacoes
.iter()
.map(|(antigo, novo)| EntradaHistorico {
caminho_antigo: antigo.display().to_string(),
caminho_novo: novo.display().to_string(),
})
.collect();
let json = serde_json::to_string_pretty(&entradas)
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
fs::write(ARQUIVO_HISTORICO, json)?;
Ok(())
}
/// Carrega e desfaz a última operação
pub fn desfazer() -> io::Result<usize> {
let conteudo = fs::read_to_string(ARQUIVO_HISTORICO)?;
let entradas: Vec<EntradaHistorico> = serde_json::from_str(&conteudo)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let mut desfeitas = 0;
for entrada in &entradas {
let novo = PathBuf::from(&entrada.caminho_novo);
let antigo = PathBuf::from(&entrada.caminho_antigo);
if novo.exists() {
fs::rename(&novo, &antigo)?;
desfeitas += 1;
}
}
// Remover o arquivo de histórico após desfazer
fs::remove_file(ARQUIVO_HISTORICO)?;
Ok(desfeitas)
}
Passo 4: Integrando no main.rs
// src/main.rs
mod cli;
mod historico;
mod renomeador;
use clap::Parser;
use cli::{Cli, Comando};
use colored::*;
fn main() {
let cli = Cli::parse();
match cli.comando {
Comando::Substituir {
diretorio,
padrao,
substituto,
recursivo,
extensao,
preview,
} => {
let arquivos = renomeador::coletar_arquivos(&diretorio, recursivo, &extensao);
let operacoes = match renomeador::planejar_substituicao(&arquivos, &padrao, &substituto) {
Ok(ops) => ops,
Err(e) => {
eprintln!("{} Regex inválida '{}': {}", "ERRO:".red().bold(), padrao, e);
std::process::exit(1);
}
};
if preview {
renomeador::exibir_preview(&operacoes);
} else {
renomeador::exibir_preview(&operacoes);
println!("\n{}", "Executando renomeações...".bold());
let executadas = renomeador::executar(&operacoes);
salvar_historico(&executadas);
}
}
Comando::Prefixar {
diretorio,
prefixo,
sufixo,
extensao,
preview,
} => {
let arquivos = renomeador::coletar_arquivos(&diretorio, false, &extensao);
let operacoes = renomeador::planejar_prefixo_sufixo(&arquivos, &prefixo, &sufixo);
if preview {
renomeador::exibir_preview(&operacoes);
} else {
renomeador::exibir_preview(&operacoes);
println!("\n{}", "Executando renomeações...".bold());
let executadas = renomeador::executar(&operacoes);
salvar_historico(&executadas);
}
}
Comando::Numerar {
diretorio,
padrao_nome,
inicio,
digitos,
extensao,
preview,
} => {
let arquivos = renomeador::coletar_arquivos(&diretorio, false, &extensao);
let operacoes = renomeador::planejar_numeracao(&arquivos, &padrao_nome, inicio, digitos);
if preview {
renomeador::exibir_preview(&operacoes);
} else {
renomeador::exibir_preview(&operacoes);
println!("\n{}", "Executando renomeações...".bold());
let executadas = renomeador::executar(&operacoes);
salvar_historico(&executadas);
}
}
Comando::Desfazer => {
match historico::desfazer() {
Ok(n) => println!(
"{} {} renomeação(ões) desfeita(s).",
"OK".green().bold(),
n
),
Err(e) => {
eprintln!(
"{} Não foi possível desfazer: {}",
"ERRO:".red().bold(),
e
);
std::process::exit(1);
}
}
}
}
}
fn salvar_historico(executadas: &[renomeador::Operacao]) {
if executadas.is_empty() {
return;
}
let pares: Vec<(std::path::PathBuf, std::path::PathBuf)> = executadas
.iter()
.map(|op| (op.caminho_antigo.clone(), op.caminho_novo.clone()))
.collect();
if let Err(e) = historico::salvar(&pares) {
eprintln!(
"{} Não foi possível salvar histórico: {}",
"AVISO:".yellow().bold(),
e
);
}
}
Como Executar
cargo build --release
Exemplos de uso:
# Substituir espaços por underscores (preview primeiro)
./target/release/renomear substituir -d ./fotos -p " " -s "_" --preview
# PREVIEW: 5 arquivo(s) serão renomeados:
# minha foto.jpg → minha_foto.jpg
# foto final.png → foto_final.png
# Executar a substituição
./target/release/renomear substituir -d ./fotos -p " " -s "_"
# Adicionar prefixo de data
./target/release/renomear prefixar -d ./docs --prefixo "2026-02-" -e pdf --preview
# Numerar fotos sequencialmente
./target/release/renomear numerar -d ./fotos -p "ferias_" -i 1 --digitos 3 -e jpg
# ferias_001.jpg, ferias_002.jpg, ferias_003.jpg...
# Substituir com regex e grupos de captura
./target/release/renomear substituir -d ./docs -p "(\d{4})-(\d{2})" -s "$2-$1"
# 2026-02_relatorio.pdf → 02-2026_relatorio.pdf
# Busca recursiva com filtro de extensão
./target/release/renomear substituir -d ./projeto -p "test_" -s "teste_" -r -e rs
# Desfazer última operação
./target/release/renomear desfazer
# OK 5 renomeação(ões) desfeita(s).
Desafios para Expandir
Modo interativo: Adicione uma flag
--interativoque peça confirmação para cada arquivo individualmente, permitindo renomear seletivamente.Renomeação por data de modificação: Crie um subcomando que renomeie arquivos usando a data de modificação do sistema de arquivos (ex:
foto_2026-02-24_14h30.jpg).Templates personalizados: Implemente um sistema de templates com variáveis como
{nome},{ext},{num},{data}para criar padrões de renomeação flexíveis.Tratamento de colisões: Detecte quando dois arquivos seriam renomeados para o mesmo nome e resolva adicionando um sufixo numérico automaticamente.
Histórico persistente com múltiplas entradas: Mantenha um histórico completo (não só a última operação) que permita desfazer múltiplas operações em sequência.
Veja Também
- Módulo Path — trabalhando com caminhos de arquivos
- Módulo fs — operações de sistema de arquivos
- Manipulação de Strings — operações com
Stringe&str - Usando Expressões Regulares — receita prática com a crate
regex