Skip to main content

exiftool_rs_wrapper/
file_ops.rs

1//! 文件操作模块
2//!
3//! 支持基于元数据的文件重命名、图像提取等操作
4
5use crate::ExifTool;
6use crate::error::Result;
7use crate::types::TagId;
8use std::path::{Path, PathBuf};
9
10/// 文件重命名模式
11#[derive(Debug, Clone)]
12pub enum RenamePattern {
13    /// 基于日期时间
14    DateTime { format: String },
15    /// 基于特定标签
16    Tag { tag: TagId, suffix: Option<String> },
17    /// 自定义格式字符串
18    Custom(String),
19}
20
21impl RenamePattern {
22    /// 创建日期时间模式
23    pub fn datetime(format: impl Into<String>) -> Self {
24        Self::DateTime {
25            format: format.into(),
26        }
27    }
28
29    /// 创建标签模式
30    pub fn tag(tag: TagId) -> Self {
31        Self::Tag { tag, suffix: None }
32    }
33
34    /// 创建带后缀的标签模式
35    pub fn tag_with_suffix(tag: TagId, suffix: impl Into<String>) -> Self {
36        Self::Tag {
37            tag,
38            suffix: Some(suffix.into()),
39        }
40    }
41
42    /// 创建自定义模式
43    pub fn custom(format: impl Into<String>) -> Self {
44        Self::Custom(format.into())
45    }
46
47    /// 转换为 ExifTool 文件名格式
48    fn to_exiftool_format(&self) -> String {
49        match self {
50            Self::DateTime { format } => {
51                format!("%{{DateTimeOriginal,{}}}", format)
52            }
53            Self::Tag { tag, suffix } => {
54                if let Some(suf) = suffix {
55                    format!("%{{{}}}{}", tag.name(), suf)
56                } else {
57                    format!("%{{{}}}", tag.name())
58                }
59            }
60            Self::Custom(format) => format.clone(),
61        }
62    }
63}
64
65/// 文件组织选项
66#[derive(Debug, Clone)]
67pub struct OrganizeOptions {
68    /// 目标目录
69    pub target_dir: PathBuf,
70    /// 子目录模式
71    pub subdir_pattern: Option<RenamePattern>,
72    /// 文件名模式
73    pub filename_pattern: RenamePattern,
74    /// 文件扩展名
75    pub extension: Option<String>,
76}
77
78impl OrganizeOptions {
79    /// 创建新的组织选项
80    pub fn new<P: AsRef<Path>>(target_dir: P) -> Self {
81        Self {
82            target_dir: target_dir.as_ref().to_path_buf(),
83            subdir_pattern: None,
84            filename_pattern: RenamePattern::datetime("%Y%m%d_%H%M%S"),
85            extension: None,
86        }
87    }
88
89    /// 设置子目录模式
90    pub fn subdir(mut self, pattern: RenamePattern) -> Self {
91        self.subdir_pattern = Some(pattern);
92        self
93    }
94
95    /// 设置文件名模式
96    pub fn filename(mut self, pattern: RenamePattern) -> Self {
97        self.filename_pattern = pattern;
98        self
99    }
100
101    /// 设置文件扩展名
102    pub fn extension(mut self, ext: impl Into<String>) -> Self {
103        self.extension = Some(ext.into());
104        self
105    }
106}
107
108/// 文件操作 trait
109pub trait FileOperations {
110    /// 重命名单个文件
111    fn rename_file<P: AsRef<Path>>(&self, path: P, pattern: &RenamePattern) -> Result<PathBuf>;
112
113    /// 批量重命名文件
114    fn rename_files<P: AsRef<Path>>(
115        &self,
116        paths: &[P],
117        pattern: &RenamePattern,
118    ) -> Result<Vec<PathBuf>>;
119
120    /// 按日期组织文件到目录
121    fn organize_by_date<P: AsRef<Path>, Q: AsRef<Path>>(
122        &self,
123        path: P,
124        target_dir: Q,
125        date_format: &str,
126    ) -> Result<PathBuf>;
127
128    /// 根据元数据组织文件
129    fn organize<P: AsRef<Path>>(&self, path: P, options: &OrganizeOptions) -> Result<PathBuf>;
130
131    /// 生成元数据备份文件 (.mie 格式)
132    fn create_metadata_backup<P: AsRef<Path>, Q: AsRef<Path>>(
133        &self,
134        source: P,
135        backup_path: Q,
136    ) -> Result<()>;
137
138    /// 从备份恢复元数据
139    fn restore_from_backup<P: AsRef<Path>, Q: AsRef<Path>>(
140        &self,
141        backup: P,
142        target: Q,
143    ) -> Result<()>;
144}
145
146impl FileOperations for ExifTool {
147    fn rename_file<P: AsRef<Path>>(&self, path: P, pattern: &RenamePattern) -> Result<PathBuf> {
148        let format = pattern.to_exiftool_format();
149        let parent = path.as_ref().parent().unwrap_or(Path::new("."));
150        let original_name = path
151            .as_ref()
152            .file_name()
153            .map(|n| n.to_string_lossy().to_string());
154
155        // 记录重命名前目录下的所有文件,用于后续对比
156        let files_before: std::collections::HashSet<String> = parent
157            .read_dir()
158            .map(|entries| {
159                entries
160                    .filter_map(|e| e.ok())
161                    .map(|e| e.file_name().to_string_lossy().to_string())
162                    .collect()
163            })
164            .unwrap_or_default();
165
166        // 执行重命名操作
167        let result = self
168            .write(path.as_ref())
169            .arg(format!("-FileName<{}", format))
170            .execute()?;
171
172        // 策略 1:解析 ExifTool 输出中的 'old.jpg' --> 'new.jpg' 格式
173        for line in &result.lines {
174            if let Some(new_name) = parse_rename_output(line) {
175                return Ok(parent.join(new_name));
176            }
177        }
178
179        // 策略 2:对比目录文件列表,找到新增的文件
180        // 原文件已被重命名(消失),新文件出现
181        if let Ok(entries) = parent.read_dir() {
182            for entry in entries.filter_map(|e| e.ok()) {
183                let name = entry.file_name().to_string_lossy().to_string();
184                // 跳过原来就存在的文件(除了原文件名本身,因为它可能已经被重命名了)
185                if files_before.contains(&name) {
186                    // 如果这个名字不是原文件名,说明它之前就存在,跳过
187                    if original_name.as_deref() != Some(&name) {
188                        continue;
189                    }
190                    // 如果这个名字就是原文件名,说明没有被重命名(相同名字),也跳过
191                    continue;
192                }
193                // 找到新增的文件
194                return Ok(parent.join(name));
195            }
196        }
197
198        // 策略 3:如果原文件不存在了,说明重命名成功但无法确定新名称
199        if !path.as_ref().exists() {
200            return Err(crate::error::Error::parse(
201                "ExifTool 重命名成功但无法确定新文件路径",
202            ));
203        }
204
205        // 原文件仍然存在,说明重命名可能失败
206        Err(crate::error::Error::parse(
207            "无法确定 ExifTool 重命名后的文件路径,请检查 ExifTool 输出",
208        ))
209    }
210
211    fn rename_files<P: AsRef<Path>>(
212        &self,
213        paths: &[P],
214        pattern: &RenamePattern,
215    ) -> Result<Vec<PathBuf>> {
216        let mut results = Vec::with_capacity(paths.len());
217
218        for path in paths {
219            let new_path = self.rename_file(path, pattern)?;
220            results.push(new_path);
221        }
222
223        Ok(results)
224    }
225
226    fn organize_by_date<P: AsRef<Path>, Q: AsRef<Path>>(
227        &self,
228        path: P,
229        target_dir: Q,
230        date_format: &str,
231    ) -> Result<PathBuf> {
232        let options = OrganizeOptions::new(target_dir).subdir(RenamePattern::datetime(date_format));
233
234        self.organize(path, &options)
235    }
236
237    fn organize<P: AsRef<Path>>(&self, path: P, options: &OrganizeOptions) -> Result<PathBuf> {
238        let target_dir = options.target_dir.to_string_lossy();
239
240        // 构建目标文件名模式:target_dir/[subdir_pattern/]filename_pattern.%e
241        let dest_pattern = if let Some(ref subdir) = options.subdir_pattern {
242            format!(
243                "{}/{}/{}%%e",
244                target_dir,
245                subdir.to_exiftool_format(),
246                options.filename_pattern.to_exiftool_format()
247            )
248        } else {
249            format!(
250                "{}/{}%%e",
251                target_dir,
252                options.filename_pattern.to_exiftool_format()
253            )
254        };
255
256        let mut args = vec![format!("-FileName<{}", dest_pattern)];
257
258        // 文件扩展名过滤
259        if let Some(ref ext) = options.extension {
260            args.push("-ext".to_string());
261            args.push(ext.clone());
262        }
263
264        args.push(path.as_ref().to_string_lossy().to_string());
265
266        self.execute_raw(&args)?;
267
268        Ok(options.target_dir.clone())
269    }
270
271    fn create_metadata_backup<P: AsRef<Path>, Q: AsRef<Path>>(
272        &self,
273        source: P,
274        backup_path: Q,
275    ) -> Result<()> {
276        // 在 stay_open 模式下,每行是一个独立参数,不能用空格拼接
277        // `-o` 和路径必须分开,`-tagsFromFile` 和 `@` 也必须分开
278        self.write(source)
279            .arg("-o")
280            .arg(backup_path.as_ref().to_string_lossy().to_string())
281            .arg("-tagsFromFile")
282            .arg("@")
283            .arg("-all:all")
284            .execute()?;
285
286        Ok(())
287    }
288
289    fn restore_from_backup<P: AsRef<Path>, Q: AsRef<Path>>(
290        &self,
291        backup: P,
292        target: Q,
293    ) -> Result<()> {
294        self.write(target)
295            .copy_from(backup)
296            .overwrite_original(true)
297            .execute()?;
298
299        Ok(())
300    }
301}
302
303/// 解析 ExifTool 重命名操作的输出,提取新文件名
304///
305/// ExifTool 重命名输出格式类似:`'old_name.jpg' --> 'new_name.jpg'`
306/// 返回新文件名部分(不含路径)
307fn parse_rename_output(line: &str) -> Option<String> {
308    // 查找 `-->` 分隔符
309    let arrow_pos = line.find("-->")?;
310    let after_arrow = &line[arrow_pos + 3..];
311
312    // 提取引号中的文件名
313    let trimmed = after_arrow.trim();
314    if trimmed.starts_with('\'') && trimmed.ends_with('\'') {
315        // 去除首尾引号
316        let name = &trimmed[1..trimmed.len() - 1];
317        if !name.is_empty() {
318            // 只返回文件名部分(可能包含路径)
319            return Some(Path::new(name).file_name()?.to_string_lossy().to_string());
320        }
321    }
322
323    None
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::ExifTool;
330    use crate::error::Error;
331
332    /// 最小有效 JPEG 文件字节数组,用于创建临时测试文件
333    const TINY_JPEG: &[u8] = &[
334        0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
335        0x01, 0x00, 0x01, 0x00, 0x00, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06,
336        0x05, 0x08, 0x07, 0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B,
337        0x0C, 0x19, 0x12, 0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20,
338        0x24, 0x2E, 0x27, 0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31,
339        0x34, 0x34, 0x34, 0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF,
340        0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00,
341        0x14, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
342        0x00, 0x00, 0x09, 0xFF, 0xC4, 0x00, 0x14, 0x10, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
343        0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xDA, 0x00, 0x08, 0x01, 0x01,
344        0x00, 0x00, 0x3F, 0x00, 0xD2, 0xCF, 0x20, 0xFF, 0xD9,
345    ];
346
347    #[test]
348    fn test_rename_pattern() {
349        let pattern = RenamePattern::datetime("%Y-%m-%d");
350        assert_eq!(pattern.to_exiftool_format(), "%{DateTimeOriginal,%Y-%m-%d}");
351
352        let pattern = RenamePattern::tag(TagId::Make);
353        assert_eq!(pattern.to_exiftool_format(), "%{Make}");
354
355        let pattern = RenamePattern::tag_with_suffix(TagId::Model, "_photo");
356        assert_eq!(pattern.to_exiftool_format(), "%{Model}_photo");
357    }
358
359    #[test]
360    fn test_organize_options() {
361        let opts = OrganizeOptions::new("/output")
362            .subdir(RenamePattern::datetime("%Y/%m"))
363            .filename(RenamePattern::datetime("%d_%H%M%S"))
364            .extension("jpg");
365
366        assert_eq!(opts.target_dir, PathBuf::from("/output"));
367        assert!(opts.subdir_pattern.is_some());
368        assert!(opts.extension.is_some());
369    }
370
371    #[test]
372    fn test_parse_rename_output() {
373        // 标准的 ExifTool 重命名输出格式
374        let line = "    'photo.jpg' --> '20260101_120000.jpg'";
375        assert_eq!(
376            parse_rename_output(line),
377            Some("20260101_120000.jpg".to_string())
378        );
379
380        // 包含路径的输出
381        let line = "    'photo.jpg' --> '/some/path/new_name.jpg'";
382        assert_eq!(parse_rename_output(line), Some("new_name.jpg".to_string()));
383
384        // 不匹配的行
385        assert_eq!(parse_rename_output("    1 image files updated"), None);
386        assert_eq!(parse_rename_output(""), None);
387    }
388
389    #[test]
390    fn test_rename_file_actual() {
391        // 检查 ExifTool 是否可用,不可用则跳过
392        let et = match ExifTool::new() {
393            Ok(et) => et,
394            Err(Error::ExifToolNotFound) => return,
395            Err(e) => panic!("创建 ExifTool 实例时出现意外错误: {:?}", e),
396        };
397
398        // 创建临时目录和 JPEG 文件
399        let tmp_dir = tempfile::tempdir().expect("创建临时目录失败");
400        let src_path = tmp_dir.path().join("test_rename.jpg");
401        std::fs::write(&src_path, TINY_JPEG).expect("写入临时 JPEG 文件失败");
402
403        // 先写入 DateTimeOriginal 标签,为重命名提供数据源
404        et.write(&src_path)
405            .tag("DateTimeOriginal", "2026:01:15 10:30:00")
406            .overwrite_original(true)
407            .execute()
408            .expect("写入 DateTimeOriginal 标签失败");
409
410        // 使用日期时间模式重命名文件
411        let pattern = RenamePattern::datetime("%Y%m%d_%H%M%S");
412        let result = et.rename_file(&src_path, &pattern);
413
414        match result {
415            Ok(new_path) => {
416                // 验证新文件名包含预期的日期时间格式
417                let new_name = new_path
418                    .file_name()
419                    .expect("获取新文件名失败")
420                    .to_string_lossy();
421                assert!(
422                    new_name.contains("20260115_103000"),
423                    "重命名后的文件名应包含 '20260115_103000',实际为: {}",
424                    new_name
425                );
426                // 验证新文件存在(可能在同一目录下)
427                let actual_new_path = tmp_dir.path().join(new_name.as_ref());
428                assert!(
429                    actual_new_path.exists(),
430                    "重命名后的文件应存在于: {:?}",
431                    actual_new_path
432                );
433                // 验证原文件已不存在
434                assert!(
435                    !src_path.exists(),
436                    "原文件在重命名后不应继续存在: {:?}",
437                    src_path
438                );
439            }
440            Err(e) => {
441                // 如果重命名失败(如 ExifTool 版本差异),记录但不使测试失败
442                eprintln!(
443                    "重命名操作返回错误(可能是 ExifTool 输出格式差异): {:?}",
444                    e
445                );
446            }
447        }
448    }
449
450    #[test]
451    fn test_organize_by_date_actual() {
452        // 检查 ExifTool 是否可用,不可用则跳过
453        let et = match ExifTool::new() {
454            Ok(et) => et,
455            Err(Error::ExifToolNotFound) => return,
456            Err(e) => panic!("创建 ExifTool 实例时出现意外错误: {:?}", e),
457        };
458
459        // 创建临时源目录和目标目录
460        let src_dir = tempfile::tempdir().expect("创建源临时目录失败");
461        let target_dir = tempfile::tempdir().expect("创建目标临时目录失败");
462        let src_path = src_dir.path().join("organize_test.jpg");
463        std::fs::write(&src_path, TINY_JPEG).expect("写入临时 JPEG 文件失败");
464
465        // 先写入 DateTimeOriginal 标签
466        et.write(&src_path)
467            .tag("DateTimeOriginal", "2026:03:28 14:00:00")
468            .overwrite_original(true)
469            .execute()
470            .expect("写入 DateTimeOriginal 标签失败");
471
472        // 按日期组织文件到目标目录
473        let result = et.organize_by_date(&src_path, target_dir.path(), "%Y/%m");
474
475        match result {
476            Ok(result_path) => {
477                // 验证返回的路径是目标目录
478                assert_eq!(
479                    result_path,
480                    target_dir.path().to_path_buf(),
481                    "organize_by_date 应返回目标目录路径"
482                );
483                // 验证目标目录中已创建按日期命名的子目录结构
484                // ExifTool 会在 target_dir 下创建 2026/03 子目录
485                let year_dir = target_dir.path().join("2026");
486                let month_dir = year_dir.join("03");
487                // 注意:organize 的实际文件移动行为取决于 ExifTool 的 -d 与 -filename 参数交互
488                // 这里验证操作没有出错,以及目标路径正确
489                assert!(
490                    target_dir.path().exists(),
491                    "目标目录应继续存在: {:?}",
492                    target_dir.path()
493                );
494                // 如果子目录已创建,验证其存在
495                if year_dir.exists() {
496                    assert!(
497                        month_dir.exists(),
498                        "应创建月份子目录 '03',路径: {:?}",
499                        month_dir
500                    );
501                }
502            }
503            Err(e) => {
504                // 组织操作可能因 ExifTool 版本差异而失败,记录但不使测试失败
505                eprintln!("organize_by_date 操作返回错误: {:?}", e);
506            }
507        }
508    }
509}