1use crate::changes::ChangeRecord;
4use serde::Serialize;
5use std::collections::HashMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize)]
11pub struct GroupOptions {
12 pub separator: char,
14 pub min_count: usize,
16 pub strip_prefix: bool,
18 pub from_suffix: bool,
21 pub recursive: bool,
23 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#[derive(Debug, Clone, Default)]
42pub struct GroupStats {
43 pub dirs_created: usize,
45 pub files_moved: usize,
47 pub files_renamed: usize,
49}
50
51#[derive(Debug, Clone)]
53pub struct GroupResult {
54 pub stats: GroupStats,
56 pub changes: ChangeRecord,
58}
59
60pub struct FileGrouper {
62 options: GroupOptions,
63}
64
65impl FileGrouper {
66 pub fn new(options: GroupOptions) -> Self {
68 FileGrouper { options }
69 }
70
71 pub fn with_defaults() -> Self {
73 FileGrouper {
74 options: GroupOptions::default(),
75 }
76 }
77
78 fn extract_prefix(&self, filename: &str) -> Option<String> {
83 let (stem, _ext) = if self.options.from_suffix {
85 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 search_str.rfind(self.options.separator)
105 } else {
106 search_str.find(self.options.separator)
108 };
109
110 if let Some(pos) = pos {
111 let prefix = &search_str[..pos];
112 if !prefix.is_empty() && pos + 1 < search_str.len() {
114 return Some(prefix.to_string());
115 }
116 }
117 None
118 }
119
120 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 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 if !path.is_file() {
143 continue;
144 }
145
146 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
148 if name.starts_with('.') {
149 continue;
150 }
151
152 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 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 let prefix_map = self.analyze_directory(dir)?;
173
174 for (prefix, files) in prefix_map {
176 if files.len() < self.options.min_count {
177 continue;
178 }
179
180 let subdir = dir.join(&prefix);
182
183 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 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 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 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 if new_path.exists() {
215 eprintln!(
216 "Warning: Target file already exists, skipping: {}",
217 new_path.display()
218 );
219 continue;
220 }
221
222 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 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 pub fn process(&self, path: &Path) -> crate::Result<GroupStats> {
271 let result = self.process_with_changes(path)?;
272 Ok(result.stats)
273 }
274
275 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 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 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 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 for subdir_path in subdirs_to_process {
315 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 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 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 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 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 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 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 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 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 assert!(!test_dir.join("abc").exists());
495 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 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; let grouper = FileGrouper::new(options);
514 let stats = grouper.process(&test_dir).unwrap();
515
516 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 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 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 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 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 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 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 assert!(test_dir.join("activity_relationships").is_dir());
613
614 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 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 assert!(test_dir.join("user_profile").is_dir());
653 assert!(test_dir.join("project_settings").is_dir());
654
655 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 let test_dir = create_test_dir("suffix_vs_default");
668
669 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 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 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 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 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 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 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 fs::create_dir(test_dir.join("exist")).unwrap();
735
736 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 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 let sub_dir = test_dir.join("templates");
757 fs::create_dir_all(&sub_dir).unwrap();
758
759 fs::write(test_dir.join("top_file1.txt"), "content").unwrap();
761 fs::write(test_dir.join("top_file2.txt"), "content").unwrap();
762
763 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 assert_eq!(stats.dirs_created, 2); assert!(test_dir.join("top").is_dir());
776 assert!(sub_dir.join("sub").is_dir());
777 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}