use crate::changes::ChangeRecord;
use serde::Serialize;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize)]
pub struct GroupOptions {
pub separator: char,
pub min_count: usize,
pub strip_prefix: bool,
pub from_suffix: bool,
pub recursive: bool,
pub dry_run: bool,
}
impl Default for GroupOptions {
fn default() -> Self {
GroupOptions {
separator: '_',
min_count: 2,
strip_prefix: false,
from_suffix: false,
recursive: false,
dry_run: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct GroupStats {
pub dirs_created: usize,
pub files_moved: usize,
pub files_renamed: usize,
}
#[derive(Debug, Clone)]
pub struct GroupResult {
pub stats: GroupStats,
pub changes: ChangeRecord,
}
pub struct FileGrouper {
options: GroupOptions,
}
impl FileGrouper {
pub fn new(options: GroupOptions) -> Self {
FileGrouper { options }
}
pub fn with_defaults() -> Self {
FileGrouper {
options: GroupOptions::default(),
}
}
fn extract_prefix(&self, filename: &str) -> Option<String> {
let (stem, _ext) = if self.options.from_suffix {
if let Some(dot_pos) = filename.rfind('.') {
(&filename[..dot_pos], Some(&filename[dot_pos..]))
} else {
(filename, None)
}
} else {
(filename, None)
};
let search_str = if self.options.from_suffix {
stem
} else {
filename
};
let pos = if self.options.from_suffix {
search_str.rfind(self.options.separator)
} else {
search_str.find(self.options.separator)
};
if let Some(pos) = pos {
let prefix = &search_str[..pos];
if !prefix.is_empty() && pos + 1 < search_str.len() {
return Some(prefix.to_string());
}
}
None
}
fn strip_prefix_from_name(&self, filename: &str, prefix: &str) -> String {
let prefix_with_sep = format!("{}{}", prefix, self.options.separator);
if filename.starts_with(&prefix_with_sep) {
filename[prefix_with_sep.len()..].to_string()
} else {
filename.to_string()
}
}
fn analyze_directory(&self, dir: &Path) -> crate::Result<HashMap<String, Vec<PathBuf>>> {
let mut prefix_map: HashMap<String, Vec<PathBuf>> = HashMap::new();
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
continue;
}
if let Some(prefix) = self.extract_prefix(name) {
prefix_map.entry(prefix).or_default().push(path);
}
}
}
Ok(prefix_map)
}
fn process_directory_single(
&self,
dir: &Path,
base_dir: &Path,
changes: &mut ChangeRecord,
) -> crate::Result<GroupStats> {
let mut stats = GroupStats::default();
let prefix_map = self.analyze_directory(dir)?;
for (prefix, files) in prefix_map {
if files.len() < self.options.min_count {
continue;
}
let subdir = dir.join(&prefix);
if !subdir.exists() {
if self.options.dry_run {
println!("Would create directory: {}", subdir.display());
} else {
fs::create_dir(&subdir)?;
println!("Created directory: {}", subdir.display());
}
let rel_path = subdir.strip_prefix(base_dir).unwrap_or(&subdir);
changes.add_directory_created(&rel_path.to_string_lossy());
stats.dirs_created += 1;
}
for file_path in files {
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| anyhow::anyhow!("Invalid filename"))?;
let new_filename = if self.options.strip_prefix {
self.strip_prefix_from_name(filename, &prefix)
} else {
filename.to_string()
};
let new_path = subdir.join(&new_filename);
if new_path.exists() {
eprintln!(
"Warning: Target file already exists, skipping: {}",
new_path.display()
);
continue;
}
let old_rel = file_path.strip_prefix(base_dir).unwrap_or(&file_path);
let new_rel = new_path.strip_prefix(base_dir).unwrap_or(&new_path);
if self.options.dry_run {
if self.options.strip_prefix && new_filename != filename {
println!(
"Would move and rename '{}' -> '{}'",
file_path.display(),
new_path.display()
);
stats.files_renamed += 1;
} else {
println!(
"Would move '{}' -> '{}'",
file_path.display(),
new_path.display()
);
}
} else {
fs::rename(&file_path, &new_path)?;
if self.options.strip_prefix && new_filename != filename {
println!(
"Moved and renamed '{}' -> '{}'",
file_path.display(),
new_path.display()
);
stats.files_renamed += 1;
} else {
println!(
"Moved '{}' -> '{}'",
file_path.display(),
new_path.display()
);
}
}
changes.add_file_moved(&old_rel.to_string_lossy(), &new_rel.to_string_lossy());
stats.files_moved += 1;
}
}
Ok(stats)
}
pub fn process(&self, path: &Path) -> crate::Result<GroupStats> {
let result = self.process_with_changes(path)?;
Ok(result.stats)
}
pub fn process_with_changes(&self, path: &Path) -> crate::Result<GroupResult> {
let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let mut changes = ChangeRecord::new("group", &path).with_options(&self.options);
let mut total_stats = GroupStats::default();
if !path.is_dir() {
return Err(anyhow::anyhow!(
"Path is not a directory: {}",
path.display()
));
}
let subdirs_to_process: Vec<PathBuf> = if self.options.recursive {
fs::read_dir(&path)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter(|e| {
e.file_name()
.to_str()
.map(|s| !s.starts_with('.'))
.unwrap_or(false)
})
.map(|e| e.path())
.collect()
} else {
Vec::new()
};
let stats = self.process_directory_single(&path, &path, &mut changes)?;
total_stats.dirs_created += stats.dirs_created;
total_stats.files_moved += stats.files_moved;
total_stats.files_renamed += stats.files_renamed;
for subdir_path in subdirs_to_process {
if !subdir_path.is_dir() {
continue;
}
let stats = self.process_directory_single(&subdir_path, &path, &mut changes)?;
total_stats.dirs_created += stats.dirs_created;
total_stats.files_moved += stats.files_moved;
total_stats.files_renamed += stats.files_renamed;
}
Ok(GroupResult {
stats: total_stats,
changes,
})
}
pub fn preview(&self, path: &Path) -> crate::Result<HashMap<String, Vec<String>>> {
if !path.is_dir() {
return Err(anyhow::anyhow!(
"Path is not a directory: {}",
path.display()
));
}
let prefix_map = self.analyze_directory(path)?;
let result: HashMap<String, Vec<String>> = prefix_map
.into_iter()
.filter(|(_, files)| files.len() >= self.options.min_count)
.map(|(prefix, files)| {
let filenames: Vec<String> = files
.iter()
.filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(String::from))
.collect();
(prefix, filenames)
})
.collect();
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
fn create_test_dir(test_name: &str) -> PathBuf {
let counter = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let test_dir = std::env::temp_dir().join(format!(
"reformat_group_{}_{}_{}",
test_name,
std::process::id(),
counter
));
let _ = fs::remove_dir_all(&test_dir);
fs::create_dir_all(&test_dir).unwrap();
test_dir
}
#[test]
fn test_extract_prefix() {
let grouper = FileGrouper::with_defaults();
assert_eq!(
grouper.extract_prefix("wbs_create.tmpl"),
Some("wbs".to_string())
);
assert_eq!(
grouper.extract_prefix("work_package_list.tmpl"),
Some("work".to_string())
);
assert_eq!(grouper.extract_prefix("noprefix.txt"), None);
assert_eq!(grouper.extract_prefix("_leadingunderscore.txt"), None);
assert_eq!(grouper.extract_prefix("trailing_"), None);
}
#[test]
fn test_extract_prefix_custom_separator() {
let mut options = GroupOptions::default();
options.separator = '-';
let grouper = FileGrouper::new(options);
assert_eq!(
grouper.extract_prefix("wbs-create.tmpl"),
Some("wbs".to_string())
);
assert_eq!(grouper.extract_prefix("wbs_create.tmpl"), None);
}
#[test]
fn test_strip_prefix_from_name() {
let grouper = FileGrouper::with_defaults();
assert_eq!(
grouper.strip_prefix_from_name("wbs_create.tmpl", "wbs"),
"create.tmpl"
);
assert_eq!(
grouper.strip_prefix_from_name("work_package_list.tmpl", "work"),
"package_list.tmpl"
);
}
#[test]
fn test_basic_grouping() {
let test_dir = create_test_dir("basic");
fs::write(test_dir.join("wbs_create.tmpl"), "content").unwrap();
fs::write(test_dir.join("wbs_delete.tmpl"), "content").unwrap();
fs::write(test_dir.join("wbs_list.tmpl"), "content").unwrap();
fs::write(test_dir.join("other_file.txt"), "content").unwrap();
let mut options = GroupOptions::default();
options.min_count = 2;
let grouper = FileGrouper::new(options);
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.dirs_created, 1);
assert_eq!(stats.files_moved, 3);
assert!(test_dir.join("wbs").is_dir());
assert!(test_dir.join("wbs").join("wbs_create.tmpl").exists());
assert!(test_dir.join("wbs").join("wbs_delete.tmpl").exists());
assert!(test_dir.join("wbs").join("wbs_list.tmpl").exists());
assert!(test_dir.join("other_file.txt").exists());
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_grouping_with_strip_prefix() {
let test_dir = create_test_dir("strip");
fs::write(test_dir.join("wbs_create.tmpl"), "content").unwrap();
fs::write(test_dir.join("wbs_delete.tmpl"), "content").unwrap();
let mut options = GroupOptions::default();
options.strip_prefix = true;
let grouper = FileGrouper::new(options);
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.dirs_created, 1);
assert_eq!(stats.files_moved, 2);
assert_eq!(stats.files_renamed, 2);
assert!(test_dir.join("wbs").join("create.tmpl").exists());
assert!(test_dir.join("wbs").join("delete.tmpl").exists());
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_dry_run_mode() {
let test_dir = create_test_dir("dryrun");
fs::write(test_dir.join("abc_create.tmpl"), "content").unwrap();
fs::write(test_dir.join("abc_delete.tmpl"), "content").unwrap();
let mut options = GroupOptions::default();
options.dry_run = true;
let grouper = FileGrouper::new(options);
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.dirs_created, 1);
assert_eq!(stats.files_moved, 2);
assert!(!test_dir.join("abc").exists());
assert!(test_dir.join("abc_create.tmpl").exists());
assert!(test_dir.join("abc_delete.tmpl").exists());
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_min_count_threshold() {
let test_dir = create_test_dir("mincount");
fs::write(test_dir.join("xyz_create.tmpl"), "content").unwrap();
fs::write(test_dir.join("xyz_delete.tmpl"), "content").unwrap();
let mut options = GroupOptions::default();
options.min_count = 3;
let grouper = FileGrouper::new(options);
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.dirs_created, 0);
assert_eq!(stats.files_moved, 0);
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_multiple_prefixes() {
let test_dir = create_test_dir("multiple");
fs::write(test_dir.join("aaa_create.tmpl"), "content").unwrap();
fs::write(test_dir.join("aaa_delete.tmpl"), "content").unwrap();
fs::write(test_dir.join("bbb_create.tmpl"), "content").unwrap();
fs::write(test_dir.join("bbb_delete.tmpl"), "content").unwrap();
let grouper = FileGrouper::with_defaults();
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.dirs_created, 2);
assert_eq!(stats.files_moved, 4);
assert!(test_dir.join("aaa").is_dir());
assert!(test_dir.join("bbb").is_dir());
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_preview() {
let test_dir = create_test_dir("preview");
fs::write(test_dir.join("pre_create.tmpl"), "content").unwrap();
fs::write(test_dir.join("pre_delete.tmpl"), "content").unwrap();
fs::write(test_dir.join("other.txt"), "content").unwrap();
let grouper = FileGrouper::with_defaults();
let preview = grouper.preview(&test_dir).unwrap();
assert_eq!(preview.len(), 1);
assert!(preview.contains_key("pre"));
assert_eq!(preview.get("pre").unwrap().len(), 2);
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_skip_hidden_files() {
let test_dir = create_test_dir("hidden");
fs::write(test_dir.join("hid_create.tmpl"), "content").unwrap();
fs::write(test_dir.join("hid_delete.tmpl"), "content").unwrap();
fs::write(test_dir.join(".hid_hidden.tmpl"), "content").unwrap();
let grouper = FileGrouper::with_defaults();
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.files_moved, 2);
assert!(test_dir.join(".hid_hidden.tmpl").exists());
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_from_suffix_basic() {
let test_dir = create_test_dir("from_suffix");
fs::write(test_dir.join("activity_relationships_list.tmpl"), "content").unwrap();
fs::write(
test_dir.join("activity_relationships_create.tmpl"),
"content",
)
.unwrap();
fs::write(
test_dir.join("activity_relationships_delete.tmpl"),
"content",
)
.unwrap();
let mut options = GroupOptions::default();
options.from_suffix = true;
options.strip_prefix = true;
let grouper = FileGrouper::new(options);
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.dirs_created, 1);
assert_eq!(stats.files_moved, 3);
assert_eq!(stats.files_renamed, 3);
assert!(test_dir.join("activity_relationships").is_dir());
assert!(test_dir
.join("activity_relationships")
.join("list.tmpl")
.exists());
assert!(test_dir
.join("activity_relationships")
.join("create.tmpl")
.exists());
assert!(test_dir
.join("activity_relationships")
.join("delete.tmpl")
.exists());
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_from_suffix_mixed_prefixes() {
let test_dir = create_test_dir("from_suffix_mixed");
fs::write(test_dir.join("user_profile_edit.tmpl"), "content").unwrap();
fs::write(test_dir.join("user_profile_view.tmpl"), "content").unwrap();
fs::write(test_dir.join("project_settings_edit.tmpl"), "content").unwrap();
fs::write(test_dir.join("project_settings_view.tmpl"), "content").unwrap();
let mut options = GroupOptions::default();
options.from_suffix = true;
options.strip_prefix = true;
let grouper = FileGrouper::new(options);
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.dirs_created, 2);
assert_eq!(stats.files_moved, 4);
assert!(test_dir.join("user_profile").is_dir());
assert!(test_dir.join("project_settings").is_dir());
assert!(test_dir.join("user_profile").join("edit.tmpl").exists());
assert!(test_dir.join("user_profile").join("view.tmpl").exists());
assert!(test_dir.join("project_settings").join("edit.tmpl").exists());
assert!(test_dir.join("project_settings").join("view.tmpl").exists());
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_from_suffix_vs_default() {
let test_dir = create_test_dir("suffix_vs_default");
fs::write(test_dir.join("a_b_c.txt"), "content").unwrap();
fs::write(test_dir.join("a_b_d.txt"), "content").unwrap();
let mut options = GroupOptions::default();
options.strip_prefix = true;
let grouper = FileGrouper::new(options);
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.dirs_created, 1);
assert!(test_dir.join("a").is_dir());
assert!(test_dir.join("a").join("b_c.txt").exists());
assert!(test_dir.join("a").join("b_d.txt").exists());
let _ = fs::remove_dir_all(&test_dir);
let test_dir2 = create_test_dir("suffix_vs_default2");
fs::write(test_dir2.join("a_b_c.txt"), "content").unwrap();
fs::write(test_dir2.join("a_b_d.txt"), "content").unwrap();
let mut options2 = GroupOptions::default();
options2.from_suffix = true;
options2.strip_prefix = true;
let grouper2 = FileGrouper::new(options2);
let stats2 = grouper2.process(&test_dir2).unwrap();
assert_eq!(stats2.dirs_created, 1);
assert!(test_dir2.join("a_b").is_dir());
assert!(test_dir2.join("a_b").join("c.txt").exists());
assert!(test_dir2.join("a_b").join("d.txt").exists());
let _ = fs::remove_dir_all(&test_dir2);
}
#[test]
fn test_extract_prefix_from_suffix() {
let mut options = GroupOptions::default();
options.from_suffix = true;
let grouper = FileGrouper::new(options);
assert_eq!(
grouper.extract_prefix("activity_relationships_list.tmpl"),
Some("activity_relationships".to_string())
);
assert_eq!(grouper.extract_prefix("a_b_c.txt"), Some("a_b".to_string()));
assert_eq!(
grouper.extract_prefix("single_part.txt"),
Some("single".to_string())
);
assert_eq!(grouper.extract_prefix("noseparator.txt"), None);
}
#[test]
fn test_existing_directory() {
let test_dir = create_test_dir("existing");
fs::create_dir(test_dir.join("exist")).unwrap();
fs::write(test_dir.join("exist_create.tmpl"), "content").unwrap();
fs::write(test_dir.join("exist_delete.tmpl"), "content").unwrap();
let grouper = FileGrouper::with_defaults();
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.dirs_created, 0);
assert_eq!(stats.files_moved, 2);
assert!(test_dir.join("exist").join("exist_create.tmpl").exists());
let _ = fs::remove_dir_all(&test_dir);
}
#[test]
fn test_recursive_processing() {
let test_dir = create_test_dir("recursive");
let sub_dir = test_dir.join("templates");
fs::create_dir_all(&sub_dir).unwrap();
fs::write(test_dir.join("top_file1.txt"), "content").unwrap();
fs::write(test_dir.join("top_file2.txt"), "content").unwrap();
fs::write(sub_dir.join("sub_create.tmpl"), "content").unwrap();
fs::write(sub_dir.join("sub_delete.tmpl"), "content").unwrap();
let mut options = GroupOptions::default();
options.recursive = true;
let grouper = FileGrouper::new(options);
let stats = grouper.process(&test_dir).unwrap();
assert_eq!(stats.dirs_created, 2); assert!(test_dir.join("top").is_dir());
assert!(sub_dir.join("sub").is_dir());
assert!(test_dir.join("top").join("top_file1.txt").exists());
assert!(sub_dir.join("sub").join("sub_create.tmpl").exists());
let _ = fs::remove_dir_all(&test_dir);
}
}