Skip to main content

reformat_core/
rename.rs

1//! File renaming transformer
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use walkdir::WalkDir;
6
7/// Case transformation options
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum CaseTransform {
10    /// Convert to lowercase
11    Lowercase,
12    /// Convert to UPPERCASE
13    Uppercase,
14    /// Capitalize first letter only
15    Capitalize,
16    /// No case transformation
17    None,
18}
19
20/// Space replacement options
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum SpaceReplace {
23    /// Replace spaces with underscores
24    Underscore,
25    /// Replace spaces with hyphens
26    Hyphen,
27    /// No space replacement
28    None,
29}
30
31/// Timestamp format options
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub enum TimestampFormat {
34    /// YYYYMMDD format (e.g., 20250915)
35    Long,
36    /// YYMMDD format (e.g., 250915)
37    Short,
38    /// No timestamp
39    None,
40}
41
42/// Options for file renaming
43#[derive(Debug, Clone)]
44pub struct RenameOptions {
45    /// Case transformation to apply
46    pub case_transform: CaseTransform,
47    /// Space replacement to apply
48    pub space_replace: SpaceReplace,
49    /// Prefix to add
50    pub add_prefix: Option<String>,
51    /// Prefix to remove
52    pub remove_prefix: Option<String>,
53    /// Suffix to add (before extension)
54    pub add_suffix: Option<String>,
55    /// Suffix to remove (before extension)
56    pub remove_suffix: Option<String>,
57    /// Replace prefix (old, new)
58    pub replace_prefix: Option<(String, String)>,
59    /// Replace suffix (old, new)
60    pub replace_suffix: Option<(String, String)>,
61    /// Timestamp format for prefix (based on file creation time)
62    pub timestamp_format: TimestampFormat,
63    /// Process directories recursively
64    pub recursive: bool,
65    /// Dry run mode (don't rename files)
66    pub dry_run: bool,
67    /// Include symbolic links in processing
68    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
90/// File renamer for transforming file names
91pub struct FileRenamer {
92    options: RenameOptions,
93}
94
95impl FileRenamer {
96    /// Creates a new file renamer with the given options
97    pub fn new(options: RenameOptions) -> Self {
98        FileRenamer { options }
99    }
100
101    /// Creates a renamer with default options
102    pub fn with_defaults() -> Self {
103        FileRenamer {
104            options: RenameOptions::default(),
105        }
106    }
107
108    /// Checks if a path should be processed
109    fn should_process(&self, path: &Path, is_symlink: bool) -> bool {
110        // Skip symlinks unless include_symlinks is enabled
111        if is_symlink && !self.options.include_symlinks {
112            return false;
113        }
114
115        // For symlinks, check if it's a symlink (not a directory symlink)
116        // For regular files, check is_file()
117        if !is_symlink && !path.is_file() {
118            return false;
119        }
120
121        // Skip hidden files
122        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    /// Detects the separator style used in a filename
132    /// Returns '-' for hyphenated or space-separated, '_' for underscored, or '-' as default
133    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 spaces are present, default to hyphen (unless overridden by user transformations)
139        if space_count > 0 {
140            return '-';
141        }
142
143        // If hyphens are more common, use hyphen
144        if hyphen_count > underscore_count {
145            '-'
146        } else if underscore_count > hyphen_count {
147            '_'
148        } else {
149            // When equal or no separators, default to hyphen
150            '-'
151        }
152    }
153
154    /// Formats a timestamp based on file creation time
155    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                // Get file metadata
162                let metadata = fs::metadata(path).ok()?;
163
164                // Try to get creation time, fall back to modified time
165                let created = metadata.created().or_else(|_| metadata.modified()).ok()?;
166
167                // Convert to duration since epoch
168                let duration = created.duration_since(SystemTime::UNIX_EPOCH).ok()?;
169                let secs = duration.as_secs();
170
171                // Calculate date components (simplified UTC conversion)
172                // Days since Unix epoch
173                let days = secs / 86400;
174
175                // Calculate year, month, day
176                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                // Format based on timestamp format with detected separator
206                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    /// Checks if a year is a leap year
225    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    /// Applies all transformations to a filename
230    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        // 1. Remove prefix
239        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        // 2. Remove suffix (before extension)
246        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        // 3. Replace prefix
253        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        // 4. Replace suffix
260        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        // 5. Separator replacement (replace spaces, hyphens, underscores with desired separator)
271        match self.options.space_replace {
272            SpaceReplace::Underscore => {
273                // Replace all separators (spaces, hyphens) with underscores
274                result = result.replace([' ', '-'], "_");
275            }
276            SpaceReplace::Hyphen => {
277                // Replace all separators (spaces, underscores) with hyphens
278                result = result.replace([' ', '_'], "-");
279            }
280            SpaceReplace::None => {}
281        }
282
283        // 6. Case transformation
284        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        // 7. Add timestamp prefix (if specified)
304        if let Some(ts) = timestamp {
305            result = format!("{}{}", ts, result);
306        }
307
308        // 8. Add prefix
309        if let Some(prefix) = &self.options.add_prefix {
310            result = format!("{}{}", prefix, result);
311        }
312
313        // 9. Add suffix (before extension)
314        if let Some(suffix) = &self.options.add_suffix {
315            result = format!("{}{}", result, suffix);
316        }
317
318        // 10. Add extension back
319        if let Some(ext) = extension {
320            result = format!("{}.{}", result, ext);
321        }
322
323        result
324    }
325
326    /// Renames a single file or symlink
327    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        // Split filename and extension
338        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        // Detect separator style from the filename
347        let separator = Self::detect_separator(name);
348
349        // Get timestamp if needed (with detected separator)
350        let timestamp = self.format_timestamp(path, separator);
351
352        let new_name = self.transform_name(name, extension, timestamp);
353
354        // If name didn't change, nothing to do
355        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        // Check if target already exists (but allow case-only renames on case-insensitive filesystems)
365        if new_path.exists() {
366            // Check if this is the same file (case-insensitive filesystems)
367            // Use canonicalize to resolve to the actual path
368            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    /// Processes a directory or file
396    pub fn process(&self, path: &Path) -> crate::Result<usize> {
397        let mut renamed_count = 0;
398
399        // Check if path itself is a symlink
400        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                // Collect all files (and optionally symlinks) first to avoid issues with renaming while iterating
412                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                // Sort by depth (deepest first) to avoid parent directory rename issues
427                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                // Sort for consistent processing
451                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        // Test space to underscore
542        let test_file1 = test_dir.join("test file.txt");
543        fs::write(&test_file1, "content").unwrap();
544
545        // Test hyphen to underscore
546        let test_file2 = test_dir.join("test-file2.txt");
547        fs::write(&test_file2, "content").unwrap();
548
549        // Test mixed separators to underscore
550        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        // Test space to hyphen
573        let test_file1 = test_dir.join("test file.txt");
574        fs::write(&test_file1, "content").unwrap();
575
576        // Test underscore to hyphen
577        let test_file2 = test_dir.join("test_file2.txt");
578        fs::write(&test_file2, "content").unwrap();
579
580        // Test mixed separators to hyphen
581        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        // File should still exist and be unchanged in dry run
724        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        // Hidden file should be skipped
745        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); // Clean up first
805        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        // Check that a file with timestamp prefix exists
819        // The name should be like: YYYYMMDD-document.txt (hyphen as default)
820        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        // Verify format: should start with 8 digits followed by hyphen (default separator)
827        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); // Clean up first
852        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        // Check that a file with timestamp prefix exists
866        // The name should be like: YYMMDD-notes.md (hyphen as default)
867        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        // Verify format: should start with 6 digits followed by hyphen (default separator)
874        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        // The file should be renamed with timestamp, spaces replaced, and lowercase
914        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        // Should have format: YYYYMMDD_my_document.txt
921        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        // Should use hyphen as separator: YYYYMMDD-my-document-file.txt
952        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        // Should use underscore as separator: YYMMDD_my_document_file.txt
986        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); // Clean up first
1001        fs::create_dir_all(&test_dir).unwrap();
1002
1003        // More hyphens than underscores (2 hyphens vs 1 underscore)
1004        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        // Should use hyphen (more hyphens than underscores)
1022        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        // Should default to hyphen when no separators
1054        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        // Should use hyphen for space-separated files
1083        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        // File should not be renamed since prefix doesn't match
1129        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        // File should not be renamed since suffix doesn't match
1171        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        // Create a regular file with uppercase name
1209        let target_file = test_dir.join("Target.txt");
1210        fs::write(&target_file, "content").unwrap();
1211
1212        // Create a symlink to the file
1213        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; // default
1219
1220        let renamer = FileRenamer::new(opts);
1221        let count = renamer.process(&test_dir).unwrap();
1222
1223        // Only the target file should be renamed, symlink should be skipped
1224        assert_eq!(count, 1);
1225        assert!(test_dir.join("target.txt").exists());
1226
1227        // Check that symlink still has uppercase name by listing directory
1228        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        // Create a regular file (already lowercase)
1251        let target_file = test_dir.join("target.txt");
1252        fs::write(&target_file, "content").unwrap();
1253
1254        // Create a symlink to the file with uppercase name
1255        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        // Only symlink should be renamed (target is already lowercase)
1266        assert_eq!(count, 1);
1267        assert!(test_dir.join("target.txt").exists());
1268
1269        // Check that symlink was renamed to lowercase by listing directory
1270        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        // Create a regular file with uppercase
1297        let target_file = test_dir.join("Target.txt");
1298        fs::write(&target_file, "content").unwrap();
1299
1300        // Create a symlink to the file
1301        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        // Both should be renamed
1312        assert_eq!(count, 2);
1313
1314        // Check files by listing directory (handles case-insensitive filesystems)
1315        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}