1use std::fs;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum CaseTransform {
10 Lowercase,
12 Uppercase,
14 Capitalize,
16 None,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum SpaceReplace {
23 Underscore,
25 Hyphen,
27 None,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq)]
33pub enum TimestampFormat {
34 Long,
36 Short,
38 None,
40}
41
42#[derive(Debug, Clone)]
44pub struct RenameOptions {
45 pub case_transform: CaseTransform,
47 pub space_replace: SpaceReplace,
49 pub add_prefix: Option<String>,
51 pub remove_prefix: Option<String>,
53 pub add_suffix: Option<String>,
55 pub remove_suffix: Option<String>,
57 pub replace_prefix: Option<(String, String)>,
59 pub replace_suffix: Option<(String, String)>,
61 pub timestamp_format: TimestampFormat,
63 pub recursive: bool,
65 pub dry_run: bool,
67 pub include_symlinks: bool,
69}
70
71impl Default for RenameOptions {
72 fn default() -> Self {
73 RenameOptions {
74 case_transform: CaseTransform::None,
75 space_replace: SpaceReplace::None,
76 add_prefix: None,
77 remove_prefix: None,
78 add_suffix: None,
79 remove_suffix: None,
80 replace_prefix: None,
81 replace_suffix: None,
82 timestamp_format: TimestampFormat::None,
83 recursive: true,
84 dry_run: false,
85 include_symlinks: false,
86 }
87 }
88}
89
90pub struct FileRenamer {
92 options: RenameOptions,
93}
94
95impl FileRenamer {
96 pub fn new(options: RenameOptions) -> Self {
98 FileRenamer { options }
99 }
100
101 pub fn with_defaults() -> Self {
103 FileRenamer {
104 options: RenameOptions::default(),
105 }
106 }
107
108 fn should_process(&self, path: &Path, is_symlink: bool) -> bool {
110 if is_symlink && !self.options.include_symlinks {
112 return false;
113 }
114
115 if !is_symlink && !path.is_file() {
118 return false;
119 }
120
121 if let Some(name) = path.file_name() {
123 if name.to_str().map(|s| s.starts_with('.')).unwrap_or(false) {
124 return false;
125 }
126 }
127
128 true
129 }
130
131 fn detect_separator(name: &str) -> char {
134 let hyphen_count = name.chars().filter(|&c| c == '-').count();
135 let underscore_count = name.chars().filter(|&c| c == '_').count();
136 let space_count = name.chars().filter(|&c| c == ' ').count();
137
138 if space_count > 0 {
140 return '-';
141 }
142
143 if hyphen_count > underscore_count {
145 '-'
146 } else if underscore_count > hyphen_count {
147 '_'
148 } else {
149 '-'
151 }
152 }
153
154 fn format_timestamp(&self, path: &Path, separator: char) -> Option<String> {
156 use std::time::SystemTime;
157
158 match self.options.timestamp_format {
159 TimestampFormat::None => None,
160 TimestampFormat::Long | TimestampFormat::Short => {
161 let metadata = fs::metadata(path).ok()?;
163
164 let created = metadata.created().or_else(|_| metadata.modified()).ok()?;
166
167 let duration = created.duration_since(SystemTime::UNIX_EPOCH).ok()?;
169 let secs = duration.as_secs();
170
171 let days = secs / 86400;
174
175 let mut year = 1970;
177 let mut remaining_days = days;
178
179 loop {
180 let days_in_year = if Self::is_leap_year(year) { 366 } else { 365 };
181 if remaining_days < days_in_year {
182 break;
183 }
184 remaining_days -= days_in_year;
185 year += 1;
186 }
187
188 let days_in_months = if Self::is_leap_year(year) {
189 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
190 } else {
191 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
192 };
193
194 let mut month = 1;
195 let mut day_of_month = remaining_days + 1;
196
197 for days_in_month in days_in_months.iter() {
198 if day_of_month <= *days_in_month as u64 {
199 break;
200 }
201 day_of_month -= *days_in_month as u64;
202 month += 1;
203 }
204
205 match self.options.timestamp_format {
207 TimestampFormat::Long => Some(format!(
208 "{:04}{:02}{:02}{}",
209 year, month, day_of_month, separator
210 )),
211 TimestampFormat::Short => Some(format!(
212 "{:02}{:02}{:02}{}",
213 year % 100,
214 month,
215 day_of_month,
216 separator
217 )),
218 TimestampFormat::None => None,
219 }
220 }
221 }
222 }
223
224 fn is_leap_year(year: u64) -> bool {
226 year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
227 }
228
229 fn transform_name(
231 &self,
232 name: &str,
233 extension: Option<&str>,
234 timestamp: Option<String>,
235 ) -> String {
236 let mut result = name.to_string();
237
238 if let Some(prefix) = &self.options.remove_prefix {
240 if result.starts_with(prefix) {
241 result = result[prefix.len()..].to_string();
242 }
243 }
244
245 if let Some(suffix) = &self.options.remove_suffix {
247 if result.ends_with(suffix) {
248 result = result[..result.len() - suffix.len()].to_string();
249 }
250 }
251
252 if let Some((old_prefix, new_prefix)) = &self.options.replace_prefix {
254 if result.starts_with(old_prefix) {
255 result = format!("{}{}", new_prefix, &result[old_prefix.len()..]);
256 }
257 }
258
259 if let Some((old_suffix, new_suffix)) = &self.options.replace_suffix {
261 if result.ends_with(old_suffix) {
262 result = format!(
263 "{}{}",
264 &result[..result.len() - old_suffix.len()],
265 new_suffix
266 );
267 }
268 }
269
270 match self.options.space_replace {
272 SpaceReplace::Underscore => {
273 result = result.replace([' ', '-'], "_");
275 }
276 SpaceReplace::Hyphen => {
277 result = result.replace([' ', '_'], "-");
279 }
280 SpaceReplace::None => {}
281 }
282
283 match self.options.case_transform {
285 CaseTransform::Lowercase => {
286 result = result.to_lowercase();
287 }
288 CaseTransform::Uppercase => {
289 result = result.to_uppercase();
290 }
291 CaseTransform::Capitalize => {
292 if !result.is_empty() {
293 let mut chars = result.chars();
294 if let Some(first) = chars.next() {
295 result = first.to_uppercase().collect::<String>()
296 + &chars.as_str().to_lowercase();
297 }
298 }
299 }
300 CaseTransform::None => {}
301 }
302
303 if let Some(ts) = timestamp {
305 result = format!("{}{}", ts, result);
306 }
307
308 if let Some(prefix) = &self.options.add_prefix {
310 result = format!("{}{}", prefix, result);
311 }
312
313 if let Some(suffix) = &self.options.add_suffix {
315 result = format!("{}{}", result, suffix);
316 }
317
318 if let Some(ext) = extension {
320 result = format!("{}.{}", result, ext);
321 }
322
323 result
324 }
325
326 pub fn rename_file(&self, path: &Path, is_symlink: bool) -> crate::Result<bool> {
328 if !self.should_process(path, is_symlink) {
329 return Ok(false);
330 }
331
332 let file_name = path
333 .file_name()
334 .and_then(|n| n.to_str())
335 .ok_or_else(|| anyhow::anyhow!("Invalid filename"))?;
336
337 let (name, extension) = if let Some(pos) = file_name.rfind('.') {
339 let name = &file_name[..pos];
340 let ext = &file_name[pos + 1..];
341 (name, Some(ext))
342 } else {
343 (file_name, None)
344 };
345
346 let separator = Self::detect_separator(name);
348
349 let timestamp = self.format_timestamp(path, separator);
351
352 let new_name = self.transform_name(name, extension, timestamp);
353
354 if new_name == file_name {
356 return Ok(false);
357 }
358
359 let parent = path
360 .parent()
361 .ok_or_else(|| anyhow::anyhow!("No parent directory"))?;
362 let new_path = parent.join(&new_name);
363
364 if new_path.exists() {
366 let same_file = match (path.canonicalize(), new_path.canonicalize()) {
369 (Ok(p1), Ok(p2)) => p1 == p2,
370 _ => false,
371 };
372
373 if !same_file {
374 return Err(anyhow::anyhow!(
375 "Target file already exists: '{}'",
376 new_path.display()
377 ));
378 }
379 }
380
381 if self.options.dry_run {
382 println!(
383 "Would rename '{}' -> '{}'",
384 path.display(),
385 new_path.display()
386 );
387 } else {
388 fs::rename(path, &new_path)?;
389 println!("Renamed '{}' -> '{}'", path.display(), new_path.display());
390 }
391
392 Ok(true)
393 }
394
395 pub fn process(&self, path: &Path) -> crate::Result<usize> {
397 let mut renamed_count = 0;
398
399 let path_is_symlink = path
401 .symlink_metadata()
402 .map(|m| m.file_type().is_symlink())
403 .unwrap_or(false);
404
405 if path.is_file() || path_is_symlink {
406 if self.rename_file(path, path_is_symlink)? {
407 renamed_count = 1;
408 }
409 } else if path.is_dir() {
410 if self.options.recursive {
411 let include_symlinks = self.options.include_symlinks;
413 let mut files: Vec<(PathBuf, bool)> = WalkDir::new(path)
414 .into_iter()
415 .filter_map(|e| e.ok())
416 .filter(|e| {
417 let ft = e.file_type();
418 ft.is_file() || (include_symlinks && ft.is_symlink())
419 })
420 .map(|e| {
421 let is_symlink = e.file_type().is_symlink();
422 (e.path().to_path_buf(), is_symlink)
423 })
424 .collect();
425
426 files.sort_by(|a, b| b.0.components().count().cmp(&a.0.components().count()));
428
429 for (file_path, is_symlink) in files {
430 if self.rename_file(&file_path, is_symlink)? {
431 renamed_count += 1;
432 }
433 }
434 } else {
435 let include_symlinks = self.options.include_symlinks;
436 let mut files: Vec<(PathBuf, bool)> = fs::read_dir(path)?
437 .filter_map(|e| e.ok())
438 .filter_map(|e| {
439 let path = e.path();
440 let ft = e.file_type().ok()?;
441 let is_symlink = ft.is_symlink();
442 if ft.is_file() || (include_symlinks && is_symlink) {
443 Some((path, is_symlink))
444 } else {
445 None
446 }
447 })
448 .collect();
449
450 files.sort_by(|a, b| a.0.cmp(&b.0));
452
453 for (file_path, is_symlink) in files {
454 if self.rename_file(&file_path, is_symlink)? {
455 renamed_count += 1;
456 }
457 }
458 }
459 }
460
461 Ok(renamed_count)
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use std::fs;
469
470 #[test]
471 fn test_lowercase_transform() {
472 let test_dir = std::env::temp_dir().join("reformat_rename_lowercase");
473 fs::create_dir_all(&test_dir).unwrap();
474
475 let test_file = test_dir.join("TestFile.txt");
476 fs::write(&test_file, "content").unwrap();
477
478 let mut opts = RenameOptions::default();
479 opts.case_transform = CaseTransform::Lowercase;
480
481 let renamer = FileRenamer::new(opts);
482 let count = renamer.process(&test_file).unwrap();
483
484 assert_eq!(count, 1);
485 let new_file = test_dir.join("testfile.txt");
486 assert!(new_file.exists());
487 assert_eq!(fs::read_to_string(&new_file).unwrap(), "content");
488
489 fs::remove_dir_all(&test_dir).unwrap();
490 }
491
492 #[test]
493 fn test_uppercase_transform() {
494 let test_dir = std::env::temp_dir().join("reformat_rename_uppercase");
495 fs::create_dir_all(&test_dir).unwrap();
496
497 let test_file = test_dir.join("testfile.txt");
498 fs::write(&test_file, "content").unwrap();
499
500 let mut opts = RenameOptions::default();
501 opts.case_transform = CaseTransform::Uppercase;
502
503 let renamer = FileRenamer::new(opts);
504 let count = renamer.process(&test_file).unwrap();
505
506 assert_eq!(count, 1);
507 let new_file = test_dir.join("TESTFILE.txt");
508 assert!(new_file.exists());
509 assert_eq!(fs::read_to_string(&new_file).unwrap(), "content");
510
511 fs::remove_dir_all(&test_dir).unwrap();
512 }
513
514 #[test]
515 fn test_capitalize_transform() {
516 let test_dir = std::env::temp_dir().join("reformat_rename_capitalize");
517 fs::create_dir_all(&test_dir).unwrap();
518
519 let test_file = test_dir.join("testFile.txt");
520 fs::write(&test_file, "content").unwrap();
521
522 let mut opts = RenameOptions::default();
523 opts.case_transform = CaseTransform::Capitalize;
524
525 let renamer = FileRenamer::new(opts);
526 let count = renamer.process(&test_file).unwrap();
527
528 assert_eq!(count, 1);
529 let new_file = test_dir.join("Testfile.txt");
530 assert!(new_file.exists());
531 assert_eq!(fs::read_to_string(&new_file).unwrap(), "content");
532
533 fs::remove_dir_all(&test_dir).unwrap();
534 }
535
536 #[test]
537 fn test_separators_to_underscore() {
538 let test_dir = std::env::temp_dir().join("reformat_rename_underscore");
539 fs::create_dir_all(&test_dir).unwrap();
540
541 let test_file1 = test_dir.join("test file.txt");
543 fs::write(&test_file1, "content").unwrap();
544
545 let test_file2 = test_dir.join("test-file2.txt");
547 fs::write(&test_file2, "content").unwrap();
548
549 let test_file3 = test_dir.join("test-file 3.txt");
551 fs::write(&test_file3, "content").unwrap();
552
553 let mut opts = RenameOptions::default();
554 opts.space_replace = SpaceReplace::Underscore;
555
556 let renamer = FileRenamer::new(opts);
557 let count = renamer.process(&test_dir).unwrap();
558
559 assert_eq!(count, 3);
560 assert!(test_dir.join("test_file.txt").exists());
561 assert!(test_dir.join("test_file2.txt").exists());
562 assert!(test_dir.join("test_file_3.txt").exists());
563
564 fs::remove_dir_all(&test_dir).unwrap();
565 }
566
567 #[test]
568 fn test_separators_to_hyphen() {
569 let test_dir = std::env::temp_dir().join("reformat_rename_hyphen");
570 fs::create_dir_all(&test_dir).unwrap();
571
572 let test_file1 = test_dir.join("test file.txt");
574 fs::write(&test_file1, "content").unwrap();
575
576 let test_file2 = test_dir.join("test_file2.txt");
578 fs::write(&test_file2, "content").unwrap();
579
580 let test_file3 = test_dir.join("test_file 3.txt");
582 fs::write(&test_file3, "content").unwrap();
583
584 let mut opts = RenameOptions::default();
585 opts.space_replace = SpaceReplace::Hyphen;
586
587 let renamer = FileRenamer::new(opts);
588 let count = renamer.process(&test_dir).unwrap();
589
590 assert_eq!(count, 3);
591 assert!(test_dir.join("test-file.txt").exists());
592 assert!(test_dir.join("test-file2.txt").exists());
593 assert!(test_dir.join("test-file-3.txt").exists());
594
595 fs::remove_dir_all(&test_dir).unwrap();
596 }
597
598 #[test]
599 fn test_add_prefix() {
600 let test_dir = std::env::temp_dir().join("reformat_rename_add_prefix");
601 fs::create_dir_all(&test_dir).unwrap();
602
603 let test_file = test_dir.join("file.txt");
604 fs::write(&test_file, "content").unwrap();
605
606 let mut opts = RenameOptions::default();
607 opts.add_prefix = Some("new_".to_string());
608
609 let renamer = FileRenamer::new(opts);
610 let count = renamer.process(&test_file).unwrap();
611
612 assert_eq!(count, 1);
613 assert!(test_dir.join("new_file.txt").exists());
614 assert!(!test_file.exists());
615
616 fs::remove_dir_all(&test_dir).unwrap();
617 }
618
619 #[test]
620 fn test_remove_prefix() {
621 let test_dir = std::env::temp_dir().join("reformat_rename_rm_prefix");
622 fs::create_dir_all(&test_dir).unwrap();
623
624 let test_file = test_dir.join("old_file.txt");
625 fs::write(&test_file, "content").unwrap();
626
627 let mut opts = RenameOptions::default();
628 opts.remove_prefix = Some("old_".to_string());
629
630 let renamer = FileRenamer::new(opts);
631 let count = renamer.process(&test_file).unwrap();
632
633 assert_eq!(count, 1);
634 assert!(test_dir.join("file.txt").exists());
635 assert!(!test_file.exists());
636
637 fs::remove_dir_all(&test_dir).unwrap();
638 }
639
640 #[test]
641 fn test_add_suffix() {
642 let test_dir = std::env::temp_dir().join("reformat_rename_add_suffix");
643 fs::create_dir_all(&test_dir).unwrap();
644
645 let test_file = test_dir.join("file.txt");
646 fs::write(&test_file, "content").unwrap();
647
648 let mut opts = RenameOptions::default();
649 opts.add_suffix = Some("_backup".to_string());
650
651 let renamer = FileRenamer::new(opts);
652 let count = renamer.process(&test_file).unwrap();
653
654 assert_eq!(count, 1);
655 assert!(test_dir.join("file_backup.txt").exists());
656 assert!(!test_file.exists());
657
658 fs::remove_dir_all(&test_dir).unwrap();
659 }
660
661 #[test]
662 fn test_remove_suffix() {
663 let test_dir = std::env::temp_dir().join("reformat_rename_rm_suffix");
664 fs::create_dir_all(&test_dir).unwrap();
665
666 let test_file = test_dir.join("file_old.txt");
667 fs::write(&test_file, "content").unwrap();
668
669 let mut opts = RenameOptions::default();
670 opts.remove_suffix = Some("_old".to_string());
671
672 let renamer = FileRenamer::new(opts);
673 let count = renamer.process(&test_file).unwrap();
674
675 assert_eq!(count, 1);
676 assert!(test_dir.join("file.txt").exists());
677 assert!(!test_file.exists());
678
679 fs::remove_dir_all(&test_dir).unwrap();
680 }
681
682 #[test]
683 fn test_combined_transforms() {
684 let test_dir = std::env::temp_dir().join("reformat_rename_combined");
685 fs::create_dir_all(&test_dir).unwrap();
686
687 let test_file = test_dir.join("old_Test File.txt");
688 fs::write(&test_file, "content").unwrap();
689
690 let mut opts = RenameOptions::default();
691 opts.remove_prefix = Some("old_".to_string());
692 opts.space_replace = SpaceReplace::Underscore;
693 opts.case_transform = CaseTransform::Lowercase;
694 opts.add_suffix = Some("_new".to_string());
695
696 let renamer = FileRenamer::new(opts);
697 let count = renamer.process(&test_file).unwrap();
698
699 assert_eq!(count, 1);
700 assert!(test_dir.join("test_file_new.txt").exists());
701 assert!(!test_file.exists());
702
703 fs::remove_dir_all(&test_dir).unwrap();
704 }
705
706 #[test]
707 fn test_dry_run_mode() {
708 let test_dir = std::env::temp_dir().join("reformat_rename_dry");
709 fs::create_dir_all(&test_dir).unwrap();
710
711 let test_file = test_dir.join("TestFile.txt");
712 let original_content = "content";
713 fs::write(&test_file, original_content).unwrap();
714
715 let mut opts = RenameOptions::default();
716 opts.case_transform = CaseTransform::Lowercase;
717 opts.dry_run = true;
718
719 let renamer = FileRenamer::new(opts);
720 let count = renamer.process(&test_file).unwrap();
721
722 assert_eq!(count, 1);
723 assert!(test_file.exists());
725 assert_eq!(fs::read_to_string(&test_file).unwrap(), original_content);
726
727 fs::remove_dir_all(&test_dir).unwrap();
728 }
729
730 #[test]
731 fn test_skip_hidden_files() {
732 let test_dir = std::env::temp_dir().join("reformat_rename_hidden");
733 fs::create_dir_all(&test_dir).unwrap();
734
735 let hidden_file = test_dir.join(".hidden.txt");
736 fs::write(&hidden_file, "content").unwrap();
737
738 let mut opts = RenameOptions::default();
739 opts.case_transform = CaseTransform::Uppercase;
740
741 let renamer = FileRenamer::new(opts);
742 let count = renamer.process(&hidden_file).unwrap();
743
744 assert_eq!(count, 0);
746 assert!(hidden_file.exists());
747
748 fs::remove_dir_all(&test_dir).unwrap();
749 }
750
751 #[test]
752 fn test_recursive_processing() {
753 let test_dir = std::env::temp_dir().join("reformat_rename_recursive");
754 fs::create_dir_all(&test_dir).unwrap();
755
756 let sub_dir = test_dir.join("subdir");
757 fs::create_dir_all(&sub_dir).unwrap();
758
759 let file1 = test_dir.join("File1.txt");
760 let file2 = sub_dir.join("File2.txt");
761
762 fs::write(&file1, "content1").unwrap();
763 fs::write(&file2, "content2").unwrap();
764
765 let mut opts = RenameOptions::default();
766 opts.case_transform = CaseTransform::Lowercase;
767 opts.recursive = true;
768
769 let renamer = FileRenamer::new(opts);
770 let count = renamer.process(&test_dir).unwrap();
771
772 assert_eq!(count, 2);
773 assert!(test_dir.join("file1.txt").exists());
774 assert!(sub_dir.join("file2.txt").exists());
775
776 fs::remove_dir_all(&test_dir).unwrap();
777 }
778
779 #[test]
780 fn test_no_extension_file() {
781 let test_dir = std::env::temp_dir().join("reformat_rename_no_ext");
782 fs::create_dir_all(&test_dir).unwrap();
783
784 let test_file = test_dir.join("TestFile");
785 fs::write(&test_file, "content").unwrap();
786
787 let mut opts = RenameOptions::default();
788 opts.case_transform = CaseTransform::Lowercase;
789
790 let renamer = FileRenamer::new(opts);
791 let count = renamer.process(&test_file).unwrap();
792
793 assert_eq!(count, 1);
794 let new_file = test_dir.join("testfile");
795 assert!(new_file.exists());
796 assert_eq!(fs::read_to_string(&new_file).unwrap(), "content");
797
798 fs::remove_dir_all(&test_dir).unwrap();
799 }
800
801 #[test]
802 fn test_timestamp_long_format() {
803 let test_dir = std::env::temp_dir().join("reformat_rename_timestamp_long");
804 let _ = fs::remove_dir_all(&test_dir); fs::create_dir_all(&test_dir).unwrap();
806
807 let test_file = test_dir.join("document.txt");
808 fs::write(&test_file, "content").unwrap();
809
810 let mut opts = RenameOptions::default();
811 opts.timestamp_format = TimestampFormat::Long;
812
813 let renamer = FileRenamer::new(opts);
814 let count = renamer.process(&test_file).unwrap();
815
816 assert_eq!(count, 1);
817
818 let entries: Vec<_> = fs::read_dir(&test_dir).unwrap().collect();
821 assert_eq!(entries.len(), 1);
822
823 let renamed_file = entries[0].as_ref().unwrap().path();
824 let file_name = renamed_file.file_name().unwrap().to_str().unwrap();
825
826 assert!(
828 file_name.len() >= 9,
829 "Filename should have at least 9 characters (YYYYMMDD-)"
830 );
831 assert!(
832 file_name.starts_with(|c: char| c.is_ascii_digit()),
833 "Should start with digit"
834 );
835 assert_eq!(
836 &file_name[8..9],
837 "-",
838 "Should have hyphen after date (default separator)"
839 );
840 assert!(
841 file_name.ends_with("document.txt"),
842 "Should end with original name"
843 );
844
845 fs::remove_dir_all(&test_dir).unwrap();
846 }
847
848 #[test]
849 fn test_timestamp_short_format() {
850 let test_dir = std::env::temp_dir().join("reformat_rename_timestamp_short");
851 let _ = fs::remove_dir_all(&test_dir); fs::create_dir_all(&test_dir).unwrap();
853
854 let test_file = test_dir.join("notes.md");
855 fs::write(&test_file, "content").unwrap();
856
857 let mut opts = RenameOptions::default();
858 opts.timestamp_format = TimestampFormat::Short;
859
860 let renamer = FileRenamer::new(opts);
861 let count = renamer.process(&test_file).unwrap();
862
863 assert_eq!(count, 1);
864
865 let entries: Vec<_> = fs::read_dir(&test_dir).unwrap().collect();
868 assert_eq!(entries.len(), 1);
869
870 let renamed_file = entries[0].as_ref().unwrap().path();
871 let file_name = renamed_file.file_name().unwrap().to_str().unwrap();
872
873 assert!(
875 file_name.len() >= 7,
876 "Filename should have at least 7 characters (YYMMDD-)"
877 );
878 assert!(
879 file_name.starts_with(|c: char| c.is_ascii_digit()),
880 "Should start with digit"
881 );
882 assert_eq!(
883 &file_name[6..7],
884 "-",
885 "Should have hyphen after date (default separator)"
886 );
887 assert!(
888 file_name.ends_with("notes.md"),
889 "Should end with original name"
890 );
891
892 fs::remove_dir_all(&test_dir).unwrap();
893 }
894
895 #[test]
896 fn test_timestamp_with_other_transforms() {
897 let test_dir = std::env::temp_dir().join("reformat_rename_timestamp_combined");
898 fs::create_dir_all(&test_dir).unwrap();
899
900 let test_file = test_dir.join("My Document.txt");
901 fs::write(&test_file, "content").unwrap();
902
903 let mut opts = RenameOptions::default();
904 opts.timestamp_format = TimestampFormat::Long;
905 opts.space_replace = SpaceReplace::Underscore;
906 opts.case_transform = CaseTransform::Lowercase;
907
908 let renamer = FileRenamer::new(opts);
909 let count = renamer.process(&test_file).unwrap();
910
911 assert_eq!(count, 1);
912
913 let entries: Vec<_> = fs::read_dir(&test_dir).unwrap().collect();
915 assert_eq!(entries.len(), 1);
916
917 let renamed_file = entries[0].as_ref().unwrap().path();
918 let file_name = renamed_file.file_name().unwrap().to_str().unwrap();
919
920 assert!(file_name.starts_with(|c: char| c.is_ascii_digit()));
922 assert!(file_name.contains("my_document.txt"));
923 assert!(!file_name.contains(" "));
924 assert!(!file_name.contains("My"));
925
926 fs::remove_dir_all(&test_dir).unwrap();
927 }
928
929 #[test]
930 fn test_timestamp_separator_detection_hyphen() {
931 let test_dir = std::env::temp_dir().join("reformat_rename_timestamp_hyphen");
932 fs::create_dir_all(&test_dir).unwrap();
933
934 let test_file = test_dir.join("my-document-file.txt");
935 fs::write(&test_file, "content").unwrap();
936
937 let mut opts = RenameOptions::default();
938 opts.timestamp_format = TimestampFormat::Long;
939
940 let renamer = FileRenamer::new(opts);
941 let count = renamer.process(&test_file).unwrap();
942
943 assert_eq!(count, 1);
944
945 let entries: Vec<_> = fs::read_dir(&test_dir).unwrap().collect();
946 assert_eq!(entries.len(), 1);
947
948 let renamed_file = entries[0].as_ref().unwrap().path();
949 let file_name = renamed_file.file_name().unwrap().to_str().unwrap();
950
951 assert!(file_name.starts_with(|c: char| c.is_ascii_digit()));
953 assert_eq!(
954 &file_name[8..9],
955 "-",
956 "Timestamp should use hyphen separator"
957 );
958 assert!(file_name.ends_with("my-document-file.txt"));
959
960 fs::remove_dir_all(&test_dir).unwrap();
961 }
962
963 #[test]
964 fn test_timestamp_separator_detection_underscore() {
965 let test_dir = std::env::temp_dir().join("reformat_rename_timestamp_underscore");
966 fs::create_dir_all(&test_dir).unwrap();
967
968 let test_file = test_dir.join("my_document_file.txt");
969 fs::write(&test_file, "content").unwrap();
970
971 let mut opts = RenameOptions::default();
972 opts.timestamp_format = TimestampFormat::Short;
973
974 let renamer = FileRenamer::new(opts);
975 let count = renamer.process(&test_file).unwrap();
976
977 assert_eq!(count, 1);
978
979 let entries: Vec<_> = fs::read_dir(&test_dir).unwrap().collect();
980 assert_eq!(entries.len(), 1);
981
982 let renamed_file = entries[0].as_ref().unwrap().path();
983 let file_name = renamed_file.file_name().unwrap().to_str().unwrap();
984
985 assert!(file_name.starts_with(|c: char| c.is_ascii_digit()));
987 assert_eq!(
988 &file_name[6..7],
989 "_",
990 "Timestamp should use underscore separator"
991 );
992 assert!(file_name.ends_with("my_document_file.txt"));
993
994 fs::remove_dir_all(&test_dir).unwrap();
995 }
996
997 #[test]
998 fn test_timestamp_separator_detection_mixed() {
999 let test_dir = std::env::temp_dir().join("reformat_rename_timestamp_mixed");
1000 let _ = fs::remove_dir_all(&test_dir); fs::create_dir_all(&test_dir).unwrap();
1002
1003 let test_file1 = test_dir.join("my-document-file_v2.txt");
1005 fs::write(&test_file1, "content").unwrap();
1006
1007 let mut opts = RenameOptions::default();
1008 opts.timestamp_format = TimestampFormat::Long;
1009
1010 let renamer = FileRenamer::new(opts);
1011 let count = renamer.process(&test_file1).unwrap();
1012
1013 assert_eq!(count, 1);
1014
1015 let entries: Vec<_> = fs::read_dir(&test_dir).unwrap().collect();
1016 assert_eq!(entries.len(), 1);
1017
1018 let renamed_file = entries[0].as_ref().unwrap().path();
1019 let file_name = renamed_file.file_name().unwrap().to_str().unwrap();
1020
1021 assert_eq!(
1023 &file_name[8..9],
1024 "-",
1025 "Should use hyphen for mixed with more hyphens"
1026 );
1027
1028 fs::remove_dir_all(&test_dir).unwrap();
1029 }
1030
1031 #[test]
1032 fn test_timestamp_separator_detection_no_separator() {
1033 let test_dir = std::env::temp_dir().join("reformat_rename_timestamp_nosep");
1034 fs::create_dir_all(&test_dir).unwrap();
1035
1036 let test_file = test_dir.join("mydocument.txt");
1037 fs::write(&test_file, "content").unwrap();
1038
1039 let mut opts = RenameOptions::default();
1040 opts.timestamp_format = TimestampFormat::Long;
1041
1042 let renamer = FileRenamer::new(opts);
1043 let count = renamer.process(&test_file).unwrap();
1044
1045 assert_eq!(count, 1);
1046
1047 let entries: Vec<_> = fs::read_dir(&test_dir).unwrap().collect();
1048 assert_eq!(entries.len(), 1);
1049
1050 let renamed_file = entries[0].as_ref().unwrap().path();
1051 let file_name = renamed_file.file_name().unwrap().to_str().unwrap();
1052
1053 assert_eq!(&file_name[8..9], "-", "Should default to hyphen");
1055 assert!(file_name.ends_with("mydocument.txt"));
1056
1057 fs::remove_dir_all(&test_dir).unwrap();
1058 }
1059
1060 #[test]
1061 fn test_timestamp_separator_detection_spaces() {
1062 let test_dir = std::env::temp_dir().join("reformat_rename_timestamp_spaces");
1063 fs::create_dir_all(&test_dir).unwrap();
1064
1065 let test_file = test_dir.join("my document file.txt");
1066 fs::write(&test_file, "content").unwrap();
1067
1068 let mut opts = RenameOptions::default();
1069 opts.timestamp_format = TimestampFormat::Long;
1070
1071 let renamer = FileRenamer::new(opts);
1072 let count = renamer.process(&test_file).unwrap();
1073
1074 assert_eq!(count, 1);
1075
1076 let entries: Vec<_> = fs::read_dir(&test_dir).unwrap().collect();
1077 assert_eq!(entries.len(), 1);
1078
1079 let renamed_file = entries[0].as_ref().unwrap().path();
1080 let file_name = renamed_file.file_name().unwrap().to_str().unwrap();
1081
1082 assert_eq!(
1084 &file_name[8..9],
1085 "-",
1086 "Should use hyphen for space-separated files"
1087 );
1088 assert!(file_name.ends_with("my document file.txt"));
1089
1090 fs::remove_dir_all(&test_dir).unwrap();
1091 }
1092
1093 #[test]
1094 fn test_replace_prefix() {
1095 let test_dir = std::env::temp_dir().join("reformat_rename_replace_prefix");
1096 fs::create_dir_all(&test_dir).unwrap();
1097
1098 let test_file = test_dir.join("old_file.txt");
1099 fs::write(&test_file, "content").unwrap();
1100
1101 let mut opts = RenameOptions::default();
1102 opts.replace_prefix = Some(("old_".to_string(), "new_".to_string()));
1103
1104 let renamer = FileRenamer::new(opts);
1105 let count = renamer.process(&test_file).unwrap();
1106
1107 assert_eq!(count, 1);
1108 assert!(test_dir.join("new_file.txt").exists());
1109 assert!(!test_file.exists());
1110
1111 fs::remove_dir_all(&test_dir).unwrap();
1112 }
1113
1114 #[test]
1115 fn test_replace_prefix_no_match() {
1116 let test_dir = std::env::temp_dir().join("reformat_rename_replace_prefix_nomatch");
1117 fs::create_dir_all(&test_dir).unwrap();
1118
1119 let test_file = test_dir.join("other_file.txt");
1120 fs::write(&test_file, "content").unwrap();
1121
1122 let mut opts = RenameOptions::default();
1123 opts.replace_prefix = Some(("old_".to_string(), "new_".to_string()));
1124
1125 let renamer = FileRenamer::new(opts);
1126 let count = renamer.process(&test_file).unwrap();
1127
1128 assert_eq!(count, 0);
1130 assert!(test_file.exists());
1131
1132 fs::remove_dir_all(&test_dir).unwrap();
1133 }
1134
1135 #[test]
1136 fn test_replace_suffix() {
1137 let test_dir = std::env::temp_dir().join("reformat_rename_replace_suffix");
1138 fs::create_dir_all(&test_dir).unwrap();
1139
1140 let test_file = test_dir.join("file_old.txt");
1141 fs::write(&test_file, "content").unwrap();
1142
1143 let mut opts = RenameOptions::default();
1144 opts.replace_suffix = Some(("_old".to_string(), "_new".to_string()));
1145
1146 let renamer = FileRenamer::new(opts);
1147 let count = renamer.process(&test_file).unwrap();
1148
1149 assert_eq!(count, 1);
1150 assert!(test_dir.join("file_new.txt").exists());
1151 assert!(!test_file.exists());
1152
1153 fs::remove_dir_all(&test_dir).unwrap();
1154 }
1155
1156 #[test]
1157 fn test_replace_suffix_no_match() {
1158 let test_dir = std::env::temp_dir().join("reformat_rename_replace_suffix_nomatch");
1159 fs::create_dir_all(&test_dir).unwrap();
1160
1161 let test_file = test_dir.join("file_other.txt");
1162 fs::write(&test_file, "content").unwrap();
1163
1164 let mut opts = RenameOptions::default();
1165 opts.replace_suffix = Some(("_old".to_string(), "_new".to_string()));
1166
1167 let renamer = FileRenamer::new(opts);
1168 let count = renamer.process(&test_file).unwrap();
1169
1170 assert_eq!(count, 0);
1172 assert!(test_file.exists());
1173
1174 fs::remove_dir_all(&test_dir).unwrap();
1175 }
1176
1177 #[test]
1178 fn test_replace_prefix_and_suffix_combined() {
1179 let test_dir = std::env::temp_dir().join("reformat_rename_replace_both");
1180 fs::create_dir_all(&test_dir).unwrap();
1181
1182 let test_file = test_dir.join("old_file_v1.txt");
1183 fs::write(&test_file, "content").unwrap();
1184
1185 let mut opts = RenameOptions::default();
1186 opts.replace_prefix = Some(("old_".to_string(), "new_".to_string()));
1187 opts.replace_suffix = Some(("_v1".to_string(), "_v2".to_string()));
1188
1189 let renamer = FileRenamer::new(opts);
1190 let count = renamer.process(&test_file).unwrap();
1191
1192 assert_eq!(count, 1);
1193 assert!(test_dir.join("new_file_v2.txt").exists());
1194 assert!(!test_file.exists());
1195
1196 fs::remove_dir_all(&test_dir).unwrap();
1197 }
1198
1199 #[cfg(unix)]
1200 #[test]
1201 fn test_symlinks_skipped_by_default() {
1202 use std::os::unix::fs::symlink;
1203
1204 let test_dir = std::env::temp_dir().join("reformat_rename_symlink_skip");
1205 let _ = fs::remove_dir_all(&test_dir);
1206 fs::create_dir_all(&test_dir).unwrap();
1207
1208 let target_file = test_dir.join("Target.txt");
1210 fs::write(&target_file, "content").unwrap();
1211
1212 let symlink_file = test_dir.join("SymLink.txt");
1214 symlink(&target_file, &symlink_file).unwrap();
1215
1216 let mut opts = RenameOptions::default();
1217 opts.case_transform = CaseTransform::Lowercase;
1218 opts.include_symlinks = false; let renamer = FileRenamer::new(opts);
1221 let count = renamer.process(&test_dir).unwrap();
1222
1223 assert_eq!(count, 1);
1225 assert!(test_dir.join("target.txt").exists());
1226
1227 let entries: Vec<_> = fs::read_dir(&test_dir)
1229 .unwrap()
1230 .filter_map(|e| e.ok())
1231 .map(|e| e.file_name().to_string_lossy().to_string())
1232 .collect();
1233 assert!(
1234 entries.iter().any(|n| n == "SymLink.txt"),
1235 "Symlink should retain original uppercase name"
1236 );
1237
1238 fs::remove_dir_all(&test_dir).unwrap();
1239 }
1240
1241 #[cfg(unix)]
1242 #[test]
1243 fn test_symlinks_included_when_enabled() {
1244 use std::os::unix::fs::symlink;
1245
1246 let test_dir = std::env::temp_dir().join("reformat_rename_symlink_include");
1247 let _ = fs::remove_dir_all(&test_dir);
1248 fs::create_dir_all(&test_dir).unwrap();
1249
1250 let target_file = test_dir.join("target.txt");
1252 fs::write(&target_file, "content").unwrap();
1253
1254 let symlink_file = test_dir.join("SymLink.txt");
1256 symlink(&target_file, &symlink_file).unwrap();
1257
1258 let mut opts = RenameOptions::default();
1259 opts.case_transform = CaseTransform::Lowercase;
1260 opts.include_symlinks = true;
1261
1262 let renamer = FileRenamer::new(opts);
1263 let count = renamer.process(&test_dir).unwrap();
1264
1265 assert_eq!(count, 1);
1267 assert!(test_dir.join("target.txt").exists());
1268
1269 let entries: Vec<_> = fs::read_dir(&test_dir)
1271 .unwrap()
1272 .filter_map(|e| e.ok())
1273 .map(|e| e.file_name().to_string_lossy().to_string())
1274 .collect();
1275 assert!(
1276 entries.iter().any(|n| n == "symlink.txt"),
1277 "Symlink should be renamed to lowercase"
1278 );
1279 assert!(
1280 !entries.iter().any(|n| n == "SymLink.txt"),
1281 "Original uppercase symlink name should be gone"
1282 );
1283
1284 fs::remove_dir_all(&test_dir).unwrap();
1285 }
1286
1287 #[cfg(unix)]
1288 #[test]
1289 fn test_symlink_with_uppercase_target() {
1290 use std::os::unix::fs::symlink;
1291
1292 let test_dir = std::env::temp_dir().join("reformat_rename_symlink_both");
1293 let _ = fs::remove_dir_all(&test_dir);
1294 fs::create_dir_all(&test_dir).unwrap();
1295
1296 let target_file = test_dir.join("Target.txt");
1298 fs::write(&target_file, "content").unwrap();
1299
1300 let symlink_file = test_dir.join("SymLink.txt");
1302 symlink(&target_file, &symlink_file).unwrap();
1303
1304 let mut opts = RenameOptions::default();
1305 opts.case_transform = CaseTransform::Lowercase;
1306 opts.include_symlinks = true;
1307
1308 let renamer = FileRenamer::new(opts);
1309 let count = renamer.process(&test_dir).unwrap();
1310
1311 assert_eq!(count, 2);
1313
1314 let entries: Vec<_> = fs::read_dir(&test_dir)
1316 .unwrap()
1317 .filter_map(|e| e.ok())
1318 .map(|e| e.file_name().to_string_lossy().to_string())
1319 .collect();
1320 assert!(
1321 entries.iter().any(|n| n == "target.txt"),
1322 "Target file should be renamed to lowercase"
1323 );
1324 assert!(
1325 entries.iter().any(|n| n == "symlink.txt"),
1326 "Symlink should be renamed to lowercase"
1327 );
1328
1329 fs::remove_dir_all(&test_dir).unwrap();
1330 }
1331}