在日常檔案管理中,我們經常需要批次重新命名大量檔案。
可能是整理照片、統一檔案命名格式,或是處理下載檔案的命名規則。
今天我們要用 Rust 建立一個強大的批次檔案重新命名器,支援正規表達式匹配和替換功能。
這樣的功能在一些檔案分類中相當好用,自己也常常為了 D 槽的資料做分類那會需要這樣的東西
cargo new file_renamer
cd file_renamer
[dependencies]
regex = "1.10"
clap = { version = "4.4", features = ["derive"] }
colored = "2.0"
walkdir = "2.4"
基本上都是見過的老朋友
src/lib.rs
use regex::Regex;
use std::path::{Path, PathBuf};
use std::error::Error;
use std::fmt;
#[derive(Debug)]
pub struct RenameRule {
pub pattern: Regex,
pub replacement: String,
}
#[derive(Debug)]
pub struct RenameResult {
pub original_path: PathBuf,
pub new_path: PathBuf,
pub success: bool,
pub error: Option<String>,
}
#[derive(Debug)]
pub enum RenameError {
RegexError(regex::Error),
IoError(std::io::Error),
InvalidPath(String),
FileExists(String),
}
impl fmt::Display for RenameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RenameError::RegexError(e) => write!(f, "正規表達式錯誤: {}", e),
RenameError::IoError(e) => write!(f, "檔案操作錯誤: {}", e),
RenameError::InvalidPath(path) => write!(f, "無效路徑: {}", path),
RenameError::FileExists(path) => write!(f, "檔案已存在: {}", path),
}
}
}
impl Error for RenameError {}
impl From<regex::Error> for RenameError {
fn from(err: regex::Error) -> Self {
RenameError::RegexError(err)
}
}
impl From<std::io::Error> for RenameError {
fn from(err: std::io::Error) -> Self {
RenameError::IoError(err)
}
}
我們接續把 src/lib.rs
完成
pub struct FileRenamer {
rules: Vec<RenameRule>,
dry_run: bool,
recursive: bool,
case_sensitive: bool,
}
impl FileRenamer {
pub fn new() -> Self {
Self {
rules: Vec::new(),
dry_run: false,
recursive: false,
case_sensitive: true,
}
}
pub fn add_rule(&mut self, pattern: &str, replacement: &str) -> Result<(), RenameError> {
let flags = if self.case_sensitive { "" } else { "(?i)" };
let full_pattern = format!("{}{}", flags, pattern);
let regex = Regex::new(&full_pattern)?;
self.rules.push(RenameRule {
pattern: regex,
replacement: replacement.to_string(),
});
Ok(())
}
pub fn set_dry_run(&mut self, dry_run: bool) {
self.dry_run = dry_run;
}
pub fn set_recursive(&mut self, recursive: bool) {
self.recursive = recursive;
}
pub fn set_case_sensitive(&mut self, case_sensitive: bool) {
self.case_sensitive = case_sensitive;
}
pub fn rename_files<P: AsRef<Path>>(&self, directory: P) -> Result<Vec<RenameResult>, RenameError> {
let mut results = Vec::new();
if self.recursive {
for entry in walkdir::WalkDir::new(&directory) {
let entry = entry.map_err(RenameError::IoError)?;
if entry.file_type().is_file() {
if let Some(result) = self.process_file(entry.path())? {
results.push(result);
}
}
}
} else {
let dir = std::fs::read_dir(&directory)?;
for entry in dir {
let entry = entry?;
if entry.file_type()?.is_file() {
if let Some(result) = self.process_file(&entry.path())? {
results.push(result);
}
}
}
}
Ok(results)
}
fn process_file(&self, file_path: &Path) -> Result<Option<RenameResult>, RenameError> {
let file_name = file_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| RenameError::InvalidPath(file_path.display().to_string()))?;
let mut new_name = file_name.to_string();
let mut matched = false;
// 套用所有規則
for rule in &self.rules {
if rule.pattern.is_match(&new_name) {
new_name = rule.pattern.replace_all(&new_name, &rule.replacement).to_string();
matched = true;
}
}
if !matched {
return Ok(None);
}
let new_path = file_path.with_file_name(&new_name);
// 檢查新檔案是否已存在
if new_path.exists() && new_path != file_path {
return Ok(Some(RenameResult {
original_path: file_path.to_path_buf(),
new_path,
success: false,
error: Some("目標檔案已存在".to_string()),
}));
}
let success = if self.dry_run {
true
} else {
match std::fs::rename(file_path, &new_path) {
Ok(_) => true,
Err(e) => return Ok(Some(RenameResult {
original_path: file_path.to_path_buf(),
new_path,
success: false,
error: Some(e.to_string()),
})),
}
};
Ok(Some(RenameResult {
original_path: file_path.to_path_buf(),
new_path,
success,
error: None,
}))
}
}
use clap::Parser;
use colored::*;
use file_renamer::{FileRenamer, RenameError};
use std::path::PathBuf;
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
/// 要處理的目錄路徑
#[arg(value_name = "DIRECTORY")]
directory: PathBuf,
/// 正規表達式匹配模式
#[arg(short, long)]
pattern: String,
/// 替換字串(支援捕獲群組 $1, $2...)
#[arg(short, long)]
replacement: String,
/// 預覽模式,不實際重新命名
#[arg(short, long)]
dry_run: bool,
/// 遞迴處理子目錄
#[arg(short, long)]
recursive: bool,
/// 大小寫不敏感匹配
#[arg(short, long)]
ignore_case: bool,
/// 顯示詳細資訊
#[arg(short, long)]
verbose: bool,
}
fn main() {
let args = Args::parse();
if let Err(e) = run(args) {
eprintln!("{}", format!("錯誤: {}", e).red());
std::process::exit(1);
}
}
fn run(args: Args) -> Result<(), RenameError> {
let mut renamer = FileRenamer::new();
renamer.add_rule(&args.pattern, &args.replacement)?;
renamer.set_dry_run(args.dry_run);
renamer.set_recursive(args.recursive);
renamer.set_case_sensitive(!args.ignore_case);
if args.dry_run {
println!("{}", "=== 預覽模式 ===".yellow().bold());
}
let results = renamer.rename_files(&args.directory)?;
if results.is_empty() {
println!("{}", "沒有符合條件的檔案".yellow());
return Ok(());
}
let mut success_count = 0;
let mut error_count = 0;
for result in &results {
if result.success {
success_count += 1;
if args.verbose {
println!(
"{} {} {} {}",
"✓".green(),
result.original_path.file_name().unwrap().to_str().unwrap().dim(),
"→".blue(),
result.new_path.file_name().unwrap().to_str().unwrap().green()
);
}
} else {
error_count += 1;
println!(
"{} {} {} {} ({})",
"✗".red(),
result.original_path.file_name().unwrap().to_str().unwrap().dim(),
"→".blue(),
result.new_path.file_name().unwrap().to_str().unwrap().red(),
result.error.as_ref().unwrap().red()
);
}
}
println!("\n{}", "=== 處理結果 ===".cyan().bold());
println!("{}: {}", "成功".green(), success_count);
if error_count > 0 {
println!("{}: {}", "失敗".red(), error_count);
}
if args.dry_run && success_count > 0 {
println!("\n{}", "使用 --dry-run=false 執行實際重新命名".yellow());
}
Ok(())
}
# 1. 統一檔案擴展名為小寫
./file_renamer ./photos -p "\.([A-Z]+)$" -r ".\L$1"
# 2. 為檔案加上日期前綴
./file_renamer ./docs -p "^(.+)$" -r "2024-01-01_$1"
# 3. 移除檔名中的數字和特殊字符
./file_renamer ./files -p "[0-9\-_]+" -r ""
# 4. 批次處理影片檔案,統一命名格式
./file_renamer ./videos -p "^(.+)\.(mp4|avi|mov)$" -r "video_$1.$2"
# 5. 處理下載檔案,移除括號和版本號
./file_renamer ./downloads -p "^(.+)\s\(\d+\)(\..+)$" -r "$1$2"
可以用在 D 槽的文件了!