1use crate::ExifTool;
6use crate::error::Result;
7use crate::types::TagId;
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone)]
12pub enum RenamePattern {
13 DateTime { format: String },
15 Tag { tag: TagId, suffix: Option<String> },
17 Custom(String),
19}
20
21impl RenamePattern {
22 pub fn datetime(format: impl Into<String>) -> Self {
24 Self::DateTime {
25 format: format.into(),
26 }
27 }
28
29 pub fn tag(tag: TagId) -> Self {
31 Self::Tag { tag, suffix: None }
32 }
33
34 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 pub fn custom(format: impl Into<String>) -> Self {
44 Self::Custom(format.into())
45 }
46
47 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#[derive(Debug, Clone)]
67pub struct OrganizeOptions {
68 pub target_dir: PathBuf,
70 pub subdir_pattern: Option<RenamePattern>,
72 pub filename_pattern: RenamePattern,
74 pub extension: Option<String>,
76}
77
78impl OrganizeOptions {
79 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 pub fn subdir(mut self, pattern: RenamePattern) -> Self {
91 self.subdir_pattern = Some(pattern);
92 self
93 }
94
95 pub fn filename(mut self, pattern: RenamePattern) -> Self {
97 self.filename_pattern = pattern;
98 self
99 }
100
101 pub fn extension(mut self, ext: impl Into<String>) -> Self {
103 self.extension = Some(ext.into());
104 self
105 }
106}
107
108pub trait FileOperations {
110 fn rename_file<P: AsRef<Path>>(&self, path: P, pattern: &RenamePattern) -> Result<PathBuf>;
112
113 fn rename_files<P: AsRef<Path>>(
115 &self,
116 paths: &[P],
117 pattern: &RenamePattern,
118 ) -> Result<Vec<PathBuf>>;
119
120 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 fn organize<P: AsRef<Path>>(&self, path: P, options: &OrganizeOptions) -> Result<PathBuf>;
130
131 fn create_metadata_backup<P: AsRef<Path>, Q: AsRef<Path>>(
133 &self,
134 source: P,
135 backup_path: Q,
136 ) -> Result<()>;
137
138 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 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 let result = self
168 .write(path.as_ref())
169 .arg(format!("-FileName<{}", format))
170 .execute()?;
171
172 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 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 if files_before.contains(&name) {
186 if original_name.as_deref() != Some(&name) {
188 continue;
189 }
190 continue;
192 }
193 return Ok(parent.join(name));
195 }
196 }
197
198 if !path.as_ref().exists() {
200 return Err(crate::error::Error::parse(
201 "ExifTool 重命名成功但无法确定新文件路径",
202 ));
203 }
204
205 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 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 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 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
303fn parse_rename_output(line: &str) -> Option<String> {
308 let arrow_pos = line.find("-->")?;
310 let after_arrow = &line[arrow_pos + 3..];
311
312 let trimmed = after_arrow.trim();
314 if trimmed.starts_with('\'') && trimmed.ends_with('\'') {
315 let name = &trimmed[1..trimmed.len() - 1];
317 if !name.is_empty() {
318 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 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 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 let line = " 'photo.jpg' --> '/some/path/new_name.jpg'";
382 assert_eq!(parse_rename_output(line), Some("new_name.jpg".to_string()));
383
384 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 let et = match ExifTool::new() {
393 Ok(et) => et,
394 Err(Error::ExifToolNotFound) => return,
395 Err(e) => panic!("创建 ExifTool 实例时出现意外错误: {:?}", e),
396 };
397
398 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 et.write(&src_path)
405 .tag("DateTimeOriginal", "2026:01:15 10:30:00")
406 .overwrite_original(true)
407 .execute()
408 .expect("写入 DateTimeOriginal 标签失败");
409
410 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 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 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 assert!(
435 !src_path.exists(),
436 "原文件在重命名后不应继续存在: {:?}",
437 src_path
438 );
439 }
440 Err(e) => {
441 eprintln!(
443 "重命名操作返回错误(可能是 ExifTool 输出格式差异): {:?}",
444 e
445 );
446 }
447 }
448 }
449
450 #[test]
451 fn test_organize_by_date_actual() {
452 let et = match ExifTool::new() {
454 Ok(et) => et,
455 Err(Error::ExifToolNotFound) => return,
456 Err(e) => panic!("创建 ExifTool 实例时出现意外错误: {:?}", e),
457 };
458
459 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 et.write(&src_path)
467 .tag("DateTimeOriginal", "2026:03:28 14:00:00")
468 .overwrite_original(true)
469 .execute()
470 .expect("写入 DateTimeOriginal 标签失败");
471
472 let result = et.organize_by_date(&src_path, target_dir.path(), "%Y/%m");
474
475 match result {
476 Ok(result_path) => {
477 assert_eq!(
479 result_path,
480 target_dir.path().to_path_buf(),
481 "organize_by_date 应返回目标目录路径"
482 );
483 let year_dir = target_dir.path().join("2026");
486 let month_dir = year_dir.join("03");
487 assert!(
490 target_dir.path().exists(),
491 "目标目录应继续存在: {:?}",
492 target_dir.path()
493 );
494 if year_dir.exists() {
496 assert!(
497 month_dir.exists(),
498 "应创建月份子目录 '03',路径: {:?}",
499 month_dir
500 );
501 }
502 }
503 Err(e) => {
504 eprintln!("organize_by_date 操作返回错误: {:?}", e);
506 }
507 }
508 }
509}