iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Rust

Rust 實戰專案集:30 個漸進式專案從工具到服務系列 第 8

批次檔案重新命名器 - 支援正規表達式的檔案重新命名

  • 分享至 

  • xImage
  •  

前言

在日常檔案管理中,我們經常需要批次重新命名大量檔案。
可能是整理照片、統一檔案命名格式,或是處理下載檔案的命名規則。
今天我們要用 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,
        }))
    }
}

實作 main.rs

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 槽的文件了!


上一篇
系統監控器 - 即時顯示 CPU、記憶體、磁碟使用率
系列文
Rust 實戰專案集:30 個漸進式專案從工具到服務8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言