Skip to main content

reformat_core/
group.rs

1//! File grouping transformer - organizes files by common prefix into subdirectories
2
3use crate::changes::ChangeRecord;
4use serde::Serialize;
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// Options for file grouping
10#[derive(Debug, Clone, Serialize)]
11pub struct GroupOptions {
12    /// Separator character that divides prefix from the rest of the filename (default: '_')
13    pub separator: char,
14    /// Minimum number of files with same prefix to create a group (default: 2)
15    pub min_count: usize,
16    /// Remove the prefix from filenames after moving to subdirectory
17    pub strip_prefix: bool,
18    /// Use the suffix (part after last separator) as filename, rest as directory
19    /// When true, splits at the LAST separator instead of the first
20    pub from_suffix: bool,
21    /// Process directories recursively
22    pub recursive: bool,
23    /// Dry run mode (don't move files or create directories)
24    pub dry_run: bool,
25}
26
27impl Default for GroupOptions {
28    fn default() -> Self {
29        GroupOptions {
30            separator: '_',
31            min_count: 2,
32            strip_prefix: false,
33            from_suffix: false,
34            recursive: false,
35            dry_run: false,
36        }
37    }
38}
39
40/// Statistics from a grouping operation
41#[derive(Debug, Clone, Default)]
42pub struct GroupStats {
43    /// Number of directories created
44    pub dirs_created: usize,
45    /// Number of files moved
46    pub files_moved: usize,
47    /// Number of files renamed (prefix stripped)
48    pub files_renamed: usize,
49}
50
51/// Result of a grouping operation including change tracking
52#[derive(Debug, Clone)]
53pub struct GroupResult {
54    /// Statistics from the operation
55    pub stats: GroupStats,
56    /// Record of all changes made (for reference fixing)
57    pub changes: ChangeRecord,
58}
59
60/// File grouper for organizing files by prefix into subdirectories
61pub struct FileGrouper {
62    options: GroupOptions,
63}
64
65impl FileGrouper {
66    /// Creates a new file grouper with the given options
67    pub fn new(options: GroupOptions) -> Self {
68        FileGrouper { options }
69    }
70
71    /// Creates a grouper with default options
72    pub fn with_defaults() -> Self {
73        FileGrouper {
74            options: GroupOptions::default(),
75        }
76    }
77
78    /// Extracts the prefix from a filename based on the separator
79    /// When from_suffix is true, splits at the LAST separator (e.g., "a_b_c" -> "a_b")
80    /// Otherwise splits at the FIRST separator (e.g., "a_b_c" -> "a")
81    /// Returns None if no separator is found
82    fn extract_prefix(&self, filename: &str) -> Option<String> {
83        // Get the stem (filename without extension) for suffix-based splitting
84        let (stem, _ext) = if self.options.from_suffix {
85            // For from_suffix mode, we need to work with the stem to find the last separator
86            // before the extension
87            if let Some(dot_pos) = filename.rfind('.') {
88                (&filename[..dot_pos], Some(&filename[dot_pos..]))
89            } else {
90                (filename, None)
91            }
92        } else {
93            (filename, None)
94        };
95
96        let search_str = if self.options.from_suffix {
97            stem
98        } else {
99            filename
100        };
101
102        let pos = if self.options.from_suffix {
103            // Find the LAST occurrence of the separator in the stem
104            search_str.rfind(self.options.separator)
105        } else {
106            // Find the FIRST occurrence of the separator
107            search_str.find(self.options.separator)
108        };
109
110        if let Some(pos) = pos {
111            let prefix = &search_str[..pos];
112            // Only return prefix if it's not empty and there's something after the separator
113            if !prefix.is_empty() && pos + 1 < search_str.len() {
114                return Some(prefix.to_string());
115            }
116        }
117        None
118    }
119
120    /// Strips the prefix and separator from a filename
121    /// When from_suffix is true, this strips everything up to and including the last separator
122    fn strip_prefix_from_name(&self, filename: &str, prefix: &str) -> String {
123        let prefix_with_sep = format!("{}{}", prefix, self.options.separator);
124        if filename.starts_with(&prefix_with_sep) {
125            filename[prefix_with_sep.len()..].to_string()
126        } else {
127            filename.to_string()
128        }
129    }
130
131    /// Analyzes a directory and returns a map of prefix -> list of files
132    fn analyze_directory(&self, dir: &Path) -> crate::Result<HashMap<String, Vec<PathBuf>>> {
133        let mut prefix_map: HashMap<String, Vec<PathBuf>> = HashMap::new();
134
135        let entries = fs::read_dir(dir)?;
136
137        for entry in entries {
138            let entry = entry?;
139            let path = entry.path();
140
141            // Only process files, not directories
142            if !path.is_file() {
143                continue;
144            }
145
146            // Skip hidden files
147            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
148                if name.starts_with('.') {
149                    continue;
150                }
151
152                // Extract prefix
153                if let Some(prefix) = self.extract_prefix(name) {
154                    prefix_map.entry(prefix).or_default().push(path);
155                }
156            }
157        }
158
159        Ok(prefix_map)
160    }
161
162    /// Processes a single directory (non-recursive part)
163    fn process_directory_single(
164        &self,
165        dir: &Path,
166        base_dir: &Path,
167        changes: &mut ChangeRecord,
168    ) -> crate::Result<GroupStats> {
169        let mut stats = GroupStats::default();
170
171        // Analyze directory for prefixes
172        let prefix_map = self.analyze_directory(dir)?;
173
174        // Process each prefix group that meets the minimum count
175        for (prefix, files) in prefix_map {
176            if files.len() < self.options.min_count {
177                continue;
178            }
179
180            // Create subdirectory path
181            let subdir = dir.join(&prefix);
182
183            // Create directory if it doesn't exist
184            if !subdir.exists() {
185                if self.options.dry_run {
186                    println!("Would create directory: {}", subdir.display());
187                } else {
188                    fs::create_dir(&subdir)?;
189                    println!("Created directory: {}", subdir.display());
190                }
191                // Record the directory creation (relative to base_dir)
192                let rel_path = subdir.strip_prefix(base_dir).unwrap_or(&subdir);
193                changes.add_directory_created(&rel_path.to_string_lossy());
194                stats.dirs_created += 1;
195            }
196
197            // Move each file to the subdirectory
198            for file_path in files {
199                let filename = file_path
200                    .file_name()
201                    .and_then(|n| n.to_str())
202                    .ok_or_else(|| anyhow::anyhow!("Invalid filename"))?;
203
204                // Determine the new filename (with or without prefix)
205                let new_filename = if self.options.strip_prefix {
206                    self.strip_prefix_from_name(filename, &prefix)
207                } else {
208                    filename.to_string()
209                };
210
211                let new_path = subdir.join(&new_filename);
212
213                // Check if target already exists
214                if new_path.exists() {
215                    eprintln!(
216                        "Warning: Target file already exists, skipping: {}",
217                        new_path.display()
218                    );
219                    continue;
220                }
221
222                // Calculate relative paths for change tracking
223                let old_rel = file_path.strip_prefix(base_dir).unwrap_or(&file_path);
224                let new_rel = new_path.strip_prefix(base_dir).unwrap_or(&new_path);
225
226                if self.options.dry_run {
227                    if self.options.strip_prefix && new_filename != filename {
228                        println!(
229                            "Would move and rename '{}' -> '{}'",
230                            file_path.display(),
231                            new_path.display()
232                        );
233                        stats.files_renamed += 1;
234                    } else {
235                        println!(
236                            "Would move '{}' -> '{}'",
237                            file_path.display(),
238                            new_path.display()
239                        );
240                    }
241                } else {
242                    fs::rename(&file_path, &new_path)?;
243                    if self.options.strip_prefix && new_filename != filename {
244                        println!(
245                            "Moved and renamed '{}' -> '{}'",
246                            file_path.display(),
247                            new_path.display()
248                        );
249                        stats.files_renamed += 1;
250                    } else {
251                        println!(
252                            "Moved '{}' -> '{}'",
253                            file_path.display(),
254                            new_path.display()
255                        );
256                    }
257                }
258
259                // Record the file move
260                changes.add_file_moved(&old_rel.to_string_lossy(), &new_rel.to_string_lossy());
261                stats.files_moved += 1;
262            }
263        }
264
265        Ok(stats)
266    }
267
268    /// Processes a directory, grouping files by prefix into subdirectories
269    /// Returns GroupStats for backward compatibility
270    pub fn process(&self, path: &Path) -> crate::Result<GroupStats> {
271        let result = self.process_with_changes(path)?;
272        Ok(result.stats)
273    }
274
275    /// Processes a directory and returns full result with change tracking
276    pub fn process_with_changes(&self, path: &Path) -> crate::Result<GroupResult> {
277        let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
278        let mut changes = ChangeRecord::new("group", &path).with_options(&self.options);
279        let mut total_stats = GroupStats::default();
280
281        if !path.is_dir() {
282            return Err(anyhow::anyhow!(
283                "Path is not a directory: {}",
284                path.display()
285            ));
286        }
287
288        // If recursive, collect subdirectories BEFORE processing
289        // This prevents processing newly created group directories
290        let subdirs_to_process: Vec<PathBuf> = if self.options.recursive {
291            fs::read_dir(&path)?
292                .filter_map(|e| e.ok())
293                .filter(|e| e.path().is_dir())
294                .filter(|e| {
295                    // Skip hidden directories
296                    e.file_name()
297                        .to_str()
298                        .map(|s| !s.starts_with('.'))
299                        .unwrap_or(false)
300                })
301                .map(|e| e.path())
302                .collect()
303        } else {
304            Vec::new()
305        };
306
307        // Process the target directory
308        let stats = self.process_directory_single(&path, &path, &mut changes)?;
309        total_stats.dirs_created += stats.dirs_created;
310        total_stats.files_moved += stats.files_moved;
311        total_stats.files_renamed += stats.files_renamed;
312
313        // Process pre-existing subdirectories (not newly created ones)
314        for subdir_path in subdirs_to_process {
315            // Skip if the directory was removed or doesn't exist anymore
316            if !subdir_path.is_dir() {
317                continue;
318            }
319            let stats = self.process_directory_single(&subdir_path, &path, &mut changes)?;
320            total_stats.dirs_created += stats.dirs_created;
321            total_stats.files_moved += stats.files_moved;
322            total_stats.files_renamed += stats.files_renamed;
323        }
324
325        Ok(GroupResult {
326            stats: total_stats,
327            changes,
328        })
329    }
330
331    /// Preview what groups would be created without making changes
332    pub fn preview(&self, path: &Path) -> crate::Result<HashMap<String, Vec<String>>> {
333        if !path.is_dir() {
334            return Err(anyhow::anyhow!(
335                "Path is not a directory: {}",
336                path.display()
337            ));
338        }
339
340        let prefix_map = self.analyze_directory(path)?;
341
342        // Filter by min_count and convert PathBuf to String
343        let result: HashMap<String, Vec<String>> = prefix_map
344            .into_iter()
345            .filter(|(_, files)| files.len() >= self.options.min_count)
346            .map(|(prefix, files)| {
347                let filenames: Vec<String> = files
348                    .iter()
349                    .filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(String::from))
350                    .collect();
351                (prefix, filenames)
352            })
353            .collect();
354
355        Ok(result)
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use std::fs;
363    use std::sync::atomic::{AtomicU64, Ordering};
364
365    // Counter to ensure unique test directories even when tests run in parallel
366    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
367
368    fn create_test_dir(test_name: &str) -> PathBuf {
369        let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
370        let test_dir = std::env::temp_dir().join(format!(
371            "reformat_group_{}_{}_{}",
372            test_name,
373            std::process::id(),
374            counter
375        ));
376        // Clean up any existing directory first
377        let _ = fs::remove_dir_all(&test_dir);
378        fs::create_dir_all(&test_dir).unwrap();
379        test_dir
380    }
381
382    #[test]
383    fn test_extract_prefix() {
384        let grouper = FileGrouper::with_defaults();
385
386        assert_eq!(
387            grouper.extract_prefix("wbs_create.tmpl"),
388            Some("wbs".to_string())
389        );
390        assert_eq!(
391            grouper.extract_prefix("work_package_list.tmpl"),
392            Some("work".to_string())
393        );
394        assert_eq!(grouper.extract_prefix("noprefix.txt"), None);
395        assert_eq!(grouper.extract_prefix("_leadingunderscore.txt"), None);
396        assert_eq!(grouper.extract_prefix("trailing_"), None);
397    }
398
399    #[test]
400    fn test_extract_prefix_custom_separator() {
401        let mut options = GroupOptions::default();
402        options.separator = '-';
403        let grouper = FileGrouper::new(options);
404
405        assert_eq!(
406            grouper.extract_prefix("wbs-create.tmpl"),
407            Some("wbs".to_string())
408        );
409        assert_eq!(grouper.extract_prefix("wbs_create.tmpl"), None);
410    }
411
412    #[test]
413    fn test_strip_prefix_from_name() {
414        let grouper = FileGrouper::with_defaults();
415
416        assert_eq!(
417            grouper.strip_prefix_from_name("wbs_create.tmpl", "wbs"),
418            "create.tmpl"
419        );
420        assert_eq!(
421            grouper.strip_prefix_from_name("work_package_list.tmpl", "work"),
422            "package_list.tmpl"
423        );
424    }
425
426    #[test]
427    fn test_basic_grouping() {
428        let test_dir = create_test_dir("basic");
429
430        // Create test files
431        fs::write(test_dir.join("wbs_create.tmpl"), "content").unwrap();
432        fs::write(test_dir.join("wbs_delete.tmpl"), "content").unwrap();
433        fs::write(test_dir.join("wbs_list.tmpl"), "content").unwrap();
434        fs::write(test_dir.join("other_file.txt"), "content").unwrap();
435
436        let mut options = GroupOptions::default();
437        options.min_count = 2;
438
439        let grouper = FileGrouper::new(options);
440        let stats = grouper.process(&test_dir).unwrap();
441
442        assert_eq!(stats.dirs_created, 1);
443        assert_eq!(stats.files_moved, 3);
444        assert!(test_dir.join("wbs").is_dir());
445        assert!(test_dir.join("wbs").join("wbs_create.tmpl").exists());
446        assert!(test_dir.join("wbs").join("wbs_delete.tmpl").exists());
447        assert!(test_dir.join("wbs").join("wbs_list.tmpl").exists());
448        // other_file.txt should not be moved (only 1 file with "other" prefix)
449        assert!(test_dir.join("other_file.txt").exists());
450
451        let _ = fs::remove_dir_all(&test_dir);
452    }
453
454    #[test]
455    fn test_grouping_with_strip_prefix() {
456        let test_dir = create_test_dir("strip");
457
458        // Create test files
459        fs::write(test_dir.join("wbs_create.tmpl"), "content").unwrap();
460        fs::write(test_dir.join("wbs_delete.tmpl"), "content").unwrap();
461
462        let mut options = GroupOptions::default();
463        options.strip_prefix = true;
464
465        let grouper = FileGrouper::new(options);
466        let stats = grouper.process(&test_dir).unwrap();
467
468        assert_eq!(stats.dirs_created, 1);
469        assert_eq!(stats.files_moved, 2);
470        assert_eq!(stats.files_renamed, 2);
471        assert!(test_dir.join("wbs").join("create.tmpl").exists());
472        assert!(test_dir.join("wbs").join("delete.tmpl").exists());
473
474        let _ = fs::remove_dir_all(&test_dir);
475    }
476
477    #[test]
478    fn test_dry_run_mode() {
479        let test_dir = create_test_dir("dryrun");
480
481        // Create test files
482        fs::write(test_dir.join("abc_create.tmpl"), "content").unwrap();
483        fs::write(test_dir.join("abc_delete.tmpl"), "content").unwrap();
484
485        let mut options = GroupOptions::default();
486        options.dry_run = true;
487
488        let grouper = FileGrouper::new(options);
489        let stats = grouper.process(&test_dir).unwrap();
490
491        assert_eq!(stats.dirs_created, 1);
492        assert_eq!(stats.files_moved, 2);
493        // Directory should NOT exist in dry run
494        assert!(!test_dir.join("abc").exists());
495        // Files should still be in original location
496        assert!(test_dir.join("abc_create.tmpl").exists());
497        assert!(test_dir.join("abc_delete.tmpl").exists());
498
499        let _ = fs::remove_dir_all(&test_dir);
500    }
501
502    #[test]
503    fn test_min_count_threshold() {
504        let test_dir = create_test_dir("mincount");
505
506        // Create test files - only 2 with same prefix
507        fs::write(test_dir.join("xyz_create.tmpl"), "content").unwrap();
508        fs::write(test_dir.join("xyz_delete.tmpl"), "content").unwrap();
509
510        let mut options = GroupOptions::default();
511        options.min_count = 3; // Require at least 3 files
512
513        let grouper = FileGrouper::new(options);
514        let stats = grouper.process(&test_dir).unwrap();
515
516        // Nothing should be grouped since min_count is 3
517        assert_eq!(stats.dirs_created, 0);
518        assert_eq!(stats.files_moved, 0);
519
520        let _ = fs::remove_dir_all(&test_dir);
521    }
522
523    #[test]
524    fn test_multiple_prefixes() {
525        let test_dir = create_test_dir("multiple");
526
527        // Create test files with different prefixes
528        fs::write(test_dir.join("aaa_create.tmpl"), "content").unwrap();
529        fs::write(test_dir.join("aaa_delete.tmpl"), "content").unwrap();
530        fs::write(test_dir.join("bbb_create.tmpl"), "content").unwrap();
531        fs::write(test_dir.join("bbb_delete.tmpl"), "content").unwrap();
532
533        let grouper = FileGrouper::with_defaults();
534        let stats = grouper.process(&test_dir).unwrap();
535
536        assert_eq!(stats.dirs_created, 2);
537        assert_eq!(stats.files_moved, 4);
538        assert!(test_dir.join("aaa").is_dir());
539        assert!(test_dir.join("bbb").is_dir());
540
541        let _ = fs::remove_dir_all(&test_dir);
542    }
543
544    #[test]
545    fn test_preview() {
546        let test_dir = create_test_dir("preview");
547
548        // Create test files
549        fs::write(test_dir.join("pre_create.tmpl"), "content").unwrap();
550        fs::write(test_dir.join("pre_delete.tmpl"), "content").unwrap();
551        fs::write(test_dir.join("other.txt"), "content").unwrap();
552
553        let grouper = FileGrouper::with_defaults();
554        let preview = grouper.preview(&test_dir).unwrap();
555
556        assert_eq!(preview.len(), 1);
557        assert!(preview.contains_key("pre"));
558        assert_eq!(preview.get("pre").unwrap().len(), 2);
559
560        let _ = fs::remove_dir_all(&test_dir);
561    }
562
563    #[test]
564    fn test_skip_hidden_files() {
565        let test_dir = create_test_dir("hidden");
566
567        // Create test files including hidden ones
568        fs::write(test_dir.join("hid_create.tmpl"), "content").unwrap();
569        fs::write(test_dir.join("hid_delete.tmpl"), "content").unwrap();
570        fs::write(test_dir.join(".hid_hidden.tmpl"), "content").unwrap();
571
572        let grouper = FileGrouper::with_defaults();
573        let stats = grouper.process(&test_dir).unwrap();
574
575        assert_eq!(stats.files_moved, 2);
576        // Hidden file should still exist in original location
577        assert!(test_dir.join(".hid_hidden.tmpl").exists());
578
579        let _ = fs::remove_dir_all(&test_dir);
580    }
581
582    #[test]
583    fn test_from_suffix_basic() {
584        let test_dir = create_test_dir("from_suffix");
585
586        // Create test files with multi-part prefix
587        fs::write(test_dir.join("activity_relationships_list.tmpl"), "content").unwrap();
588        fs::write(
589            test_dir.join("activity_relationships_create.tmpl"),
590            "content",
591        )
592        .unwrap();
593        fs::write(
594            test_dir.join("activity_relationships_delete.tmpl"),
595            "content",
596        )
597        .unwrap();
598
599        let mut options = GroupOptions::default();
600        options.from_suffix = true;
601        // from_suffix implies strip_prefix for proper behavior
602        options.strip_prefix = true;
603
604        let grouper = FileGrouper::new(options);
605        let stats = grouper.process(&test_dir).unwrap();
606
607        assert_eq!(stats.dirs_created, 1);
608        assert_eq!(stats.files_moved, 3);
609        assert_eq!(stats.files_renamed, 3);
610
611        // Directory should be named after the full prefix (everything before last separator)
612        assert!(test_dir.join("activity_relationships").is_dir());
613
614        // Files should be renamed to just the suffix + extension
615        assert!(test_dir
616            .join("activity_relationships")
617            .join("list.tmpl")
618            .exists());
619        assert!(test_dir
620            .join("activity_relationships")
621            .join("create.tmpl")
622            .exists());
623        assert!(test_dir
624            .join("activity_relationships")
625            .join("delete.tmpl")
626            .exists());
627
628        let _ = fs::remove_dir_all(&test_dir);
629    }
630
631    #[test]
632    fn test_from_suffix_mixed_prefixes() {
633        let test_dir = create_test_dir("from_suffix_mixed");
634
635        // Create test files with different multi-part prefixes
636        fs::write(test_dir.join("user_profile_edit.tmpl"), "content").unwrap();
637        fs::write(test_dir.join("user_profile_view.tmpl"), "content").unwrap();
638        fs::write(test_dir.join("project_settings_edit.tmpl"), "content").unwrap();
639        fs::write(test_dir.join("project_settings_view.tmpl"), "content").unwrap();
640
641        let mut options = GroupOptions::default();
642        options.from_suffix = true;
643        options.strip_prefix = true;
644
645        let grouper = FileGrouper::new(options);
646        let stats = grouper.process(&test_dir).unwrap();
647
648        assert_eq!(stats.dirs_created, 2);
649        assert_eq!(stats.files_moved, 4);
650
651        // Check both directories were created
652        assert!(test_dir.join("user_profile").is_dir());
653        assert!(test_dir.join("project_settings").is_dir());
654
655        // Check files are in the right places
656        assert!(test_dir.join("user_profile").join("edit.tmpl").exists());
657        assert!(test_dir.join("user_profile").join("view.tmpl").exists());
658        assert!(test_dir.join("project_settings").join("edit.tmpl").exists());
659        assert!(test_dir.join("project_settings").join("view.tmpl").exists());
660
661        let _ = fs::remove_dir_all(&test_dir);
662    }
663
664    #[test]
665    fn test_from_suffix_vs_default() {
666        // Test that from_suffix produces different results than default
667        let test_dir = create_test_dir("suffix_vs_default");
668
669        // Create test files
670        fs::write(test_dir.join("a_b_c.txt"), "content").unwrap();
671        fs::write(test_dir.join("a_b_d.txt"), "content").unwrap();
672
673        // With default behavior (split at first separator)
674        let mut options = GroupOptions::default();
675        options.strip_prefix = true;
676
677        let grouper = FileGrouper::new(options);
678        let stats = grouper.process(&test_dir).unwrap();
679
680        assert_eq!(stats.dirs_created, 1);
681        // Directory is "a", files are "b_c.txt" and "b_d.txt"
682        assert!(test_dir.join("a").is_dir());
683        assert!(test_dir.join("a").join("b_c.txt").exists());
684        assert!(test_dir.join("a").join("b_d.txt").exists());
685
686        let _ = fs::remove_dir_all(&test_dir);
687
688        // Now with from_suffix (split at last separator)
689        let test_dir2 = create_test_dir("suffix_vs_default2");
690        fs::write(test_dir2.join("a_b_c.txt"), "content").unwrap();
691        fs::write(test_dir2.join("a_b_d.txt"), "content").unwrap();
692
693        let mut options2 = GroupOptions::default();
694        options2.from_suffix = true;
695        options2.strip_prefix = true;
696
697        let grouper2 = FileGrouper::new(options2);
698        let stats2 = grouper2.process(&test_dir2).unwrap();
699
700        assert_eq!(stats2.dirs_created, 1);
701        // Directory is "a_b", files are "c.txt" and "d.txt"
702        assert!(test_dir2.join("a_b").is_dir());
703        assert!(test_dir2.join("a_b").join("c.txt").exists());
704        assert!(test_dir2.join("a_b").join("d.txt").exists());
705
706        let _ = fs::remove_dir_all(&test_dir2);
707    }
708
709    #[test]
710    fn test_extract_prefix_from_suffix() {
711        let mut options = GroupOptions::default();
712        options.from_suffix = true;
713        let grouper = FileGrouper::new(options);
714
715        // With from_suffix, should return everything before the LAST separator
716        assert_eq!(
717            grouper.extract_prefix("activity_relationships_list.tmpl"),
718            Some("activity_relationships".to_string())
719        );
720        assert_eq!(grouper.extract_prefix("a_b_c.txt"), Some("a_b".to_string()));
721        assert_eq!(
722            grouper.extract_prefix("single_part.txt"),
723            Some("single".to_string())
724        );
725        // No separator
726        assert_eq!(grouper.extract_prefix("noseparator.txt"), None);
727    }
728
729    #[test]
730    fn test_existing_directory() {
731        let test_dir = create_test_dir("existing");
732
733        // Create the target directory first
734        fs::create_dir(test_dir.join("exist")).unwrap();
735
736        // Create test files
737        fs::write(test_dir.join("exist_create.tmpl"), "content").unwrap();
738        fs::write(test_dir.join("exist_delete.tmpl"), "content").unwrap();
739
740        let grouper = FileGrouper::with_defaults();
741        let stats = grouper.process(&test_dir).unwrap();
742
743        // Directory already existed, so dirs_created should be 0
744        assert_eq!(stats.dirs_created, 0);
745        assert_eq!(stats.files_moved, 2);
746        assert!(test_dir.join("exist").join("exist_create.tmpl").exists());
747
748        let _ = fs::remove_dir_all(&test_dir);
749    }
750
751    #[test]
752    fn test_recursive_processing() {
753        let test_dir = create_test_dir("recursive");
754
755        // Create a subdirectory with files
756        let sub_dir = test_dir.join("templates");
757        fs::create_dir_all(&sub_dir).unwrap();
758
759        // Create files in root with prefix that won't conflict
760        fs::write(test_dir.join("top_file1.txt"), "content").unwrap();
761        fs::write(test_dir.join("top_file2.txt"), "content").unwrap();
762
763        // Create files in subdirectory
764        fs::write(sub_dir.join("sub_create.tmpl"), "content").unwrap();
765        fs::write(sub_dir.join("sub_delete.tmpl"), "content").unwrap();
766
767        let mut options = GroupOptions::default();
768        options.recursive = true;
769
770        let grouper = FileGrouper::new(options);
771        let stats = grouper.process(&test_dir).unwrap();
772
773        // Should create groups in both directories
774        assert_eq!(stats.dirs_created, 2); // "top" in root, "sub" in templates
775        assert!(test_dir.join("top").is_dir());
776        assert!(sub_dir.join("sub").is_dir());
777        // Verify files are in the right places
778        assert!(test_dir.join("top").join("top_file1.txt").exists());
779        assert!(sub_dir.join("sub").join("sub_create.tmpl").exists());
780
781        let _ = fs::remove_dir_all(&test_dir);
782    }
783}