diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..0636af3 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,274 @@ +use crate::extensions::{find_extensions_from_content, has_correct_extension}; +use colored::Colorize; +use regex::Regex; +use std::fmt::{Display, Formatter}; +use std::path::{Path, PathBuf}; +use unidecode::unidecode; + +#[derive(Clone)] +pub struct RenameIntent { + pub old_name: PathBuf, + pub new_name: PathBuf, +} + +impl RenameIntent { + /// Is the new name different from the old one? + pub fn is_changed(&self) -> bool { + self.old_name != self.new_name + } +} + +impl Display for RenameIntent { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + if self.is_changed() { + write!( + f, + "{0} → {1}", + self.old_name.to_string_lossy().red(), + self.new_name.to_string_lossy().green() + ) + } else { + write!(f, "{0} =", self.old_name.to_string_lossy(),) + } + } +} + +pub trait RenameCommand { + fn suggest_new_name(&self, old_name: &Path) -> PathBuf; + + fn suggest_renames(&self, files: &[PathBuf]) -> Vec { + files + .iter() + .map(|path| RenameIntent { + old_name: path.clone(), + new_name: self.suggest_new_name(path), + }) + .collect() + } +} + +pub struct Normalize; + +impl RenameCommand for Normalize { + fn suggest_new_name(&self, old_name: &Path) -> PathBuf { + let path_str = old_name.to_string_lossy().to_string(); + let new_name = unidecode(&path_str).replace(' ', "_"); //#.to_lowercase(); + PathBuf::from(new_name) + } +} + +pub struct SetExtension { + pub extension: String, +} + +impl RenameCommand for SetExtension { + fn suggest_new_name(&self, old_name: &Path) -> PathBuf { + let mut new_name = old_name.to_path_buf(); + new_name.set_extension(&self.extension); + new_name + } +} + +pub struct Remove { + pub pattern: String, +} + +impl RenameCommand for Remove { + fn suggest_new_name(&self, old_name: &Path) -> PathBuf { + let new_name = old_name.to_string_lossy().replace(&self.pattern, ""); + PathBuf::from(new_name) + } +} + +pub struct Replace { + pub pattern: String, + pub replacement: String, + pub is_regex: bool, +} + +impl RenameCommand for Replace { + fn suggest_new_name(&self, old_name: &Path) -> PathBuf { + let path_str = old_name.to_string_lossy().to_string(); + let new_name = if self.is_regex { + let re = Regex::new(&self.pattern).unwrap(); + re.replace_all(&path_str, &self.replacement).to_string() + } else { + path_str.replace(&self.pattern, &self.replacement) + }; + PathBuf::from(new_name) + } +} + +pub struct ChangeCase { + pub upper: bool, +} + +impl RenameCommand for ChangeCase { + fn suggest_new_name(&self, old_name: &Path) -> PathBuf { + let path_str = old_name.to_string_lossy().to_string(); + let new_name = match self.upper { + true => path_str.to_uppercase(), + false => path_str.to_lowercase(), + }; + PathBuf::from(new_name) + } +} + +pub struct FixExtension { + pub append: bool, +} + +impl RenameCommand for FixExtension { + fn suggest_new_name(&self, old_name: &Path) -> PathBuf { + let possible_extensions = find_extensions_from_content(old_name); + let mut new_name = old_name.to_path_buf(); + if !has_correct_extension(old_name, &possible_extensions) { + let mut new_extension = possible_extensions[0].clone(); + if self.append { + if let Some(old_extension) = new_name.extension() { + new_extension.insert(0, '.'); + new_extension.insert_str(0, old_extension.to_str().unwrap()) + } + } + new_name.set_extension(new_extension); + }; + new_name + } +} + +pub struct Prefix { + pub prefix: String, +} + +impl RenameCommand for Prefix { + fn suggest_new_name(&self, old_name: &Path) -> PathBuf { + let mut new_name = self.prefix.clone(); + new_name.push_str(old_name.to_string_lossy().to_string().as_str()); + PathBuf::from(new_name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + /// Compare whether old_names are converted to new expected_names using command. + fn assert_renames_correctly( + command: &dyn RenameCommand, + old_names: &[&str], + expected_names: &[&str], + ) { + let old: Vec = old_names.iter().map(|&x| PathBuf::from(x)).collect(); + let new_intents = command.suggest_renames(&old); + let new: Vec = new_intents + .iter() + .map(|intent| intent.new_name.clone()) + .collect(); + let expected: Vec = expected_names.iter().map(|&x| PathBuf::from(x)).collect(); + assert_eq!(expected, new); + } + + #[test] + fn test_prefix() { + assert_renames_correctly( + &Prefix { prefix: String::from("a") }, + &["b", "a"], + &["ab", "aa"] + ); + } + + mod test_replace { + use super::*; + + #[test] + fn test_regex() { + let command = Replace { + pattern: String::from("\\d"), + replacement: String::from("a"), + is_regex: true, + }; + assert_renames_correctly( + &command, + &["222", "abc", "answer_is_42", "\\d2"], + &["aaa", "abc", "answer_is_aa", "\\da"] + ); + } + + #[test] + fn test_non_regex() { + let command = Replace { + pattern: String::from("a.c"), + replacement: String::from("def"), + is_regex: false, + }; + assert_renames_correctly( + &command, + &["a.c", "abc", "ABC"], + &["def", "abc", "ABC"] + ); + } + } + + mod test_change_case { + use super::*; + + #[test] + fn test_upper() { + assert_renames_correctly( + &ChangeCase { upper: true }, + &["Abc", "hnědý", "Αθήνα", "mountAIN🗻"], + &["ABC", "HNĚDÝ", "ΑΘΉΝΑ", "MOUNTAIN🗻"] + ); + } + + #[test] + fn test_lower() { + assert_renames_correctly( + &ChangeCase { upper: false }, + &["Abc", "hnědý", "Αθήνα", "mountAIN🗻"], + &["abc", "hnědý", "αθήνα", "mountain🗻"] + ); + } + } + + #[test] + fn test_normalize() { + assert_renames_correctly( + &Normalize, + &["Abc", "hnědý", "Αθήνα & Σπάρτη", "mountain🗻"], + &["Abc", "hnedy", "Athena_&_Sparte", "mountain"] + ); + } + + mod test_set_extension { + use super::*; + + #[test] + fn test_no_extension() { + assert_renames_correctly( + &SetExtension{ extension: String::from("") }, + &["a", "b", "c.jpg", ".gitignore"], + &["a", "b", "c", ".gitignore"], + ); + } + + #[test] + fn test_some_extension() { + assert_renames_correctly( + &SetExtension{ extension: String::from("jpg") }, + &["a", "b", "c.jpg", ".gitignore"], + &["a.jpg", "b.jpg", "c.jpg", ".gitignore.jpg"], + ); + } + } + + #[test] + fn test_remove() { + assert_renames_correctly( + &Remove{ pattern: String::from("ab") }, + &[".gitignore", "babe", "abABab"], + &[".gitignore", "be", "AB"] + ) + } +} diff --git a/src/extensions.rs b/src/extensions.rs new file mode 100644 index 0000000..64e6ad5 --- /dev/null +++ b/src/extensions.rs @@ -0,0 +1,81 @@ +use std::io::ErrorKind; +use std::path::Path; +use std::process::{self, exit}; + +fn infer_mimetype(path: &Path, mime_type: bool) -> Option { + // TODO: Do something on windows :see_no_evil: + let mut cmd = process::Command::new("file"); + let cmd_with_args = cmd.arg(path).arg("--brief"); + let cmd_with_args = if mime_type { + cmd_with_args.arg("--mime-type") + } else { + cmd_with_args + }; + + let output = cmd_with_args.output(); + match output { + Ok(output) => { + let output_str = String::from_utf8(output.stdout).unwrap(); + let mime_type = match output_str.strip_suffix('\n') { + Some(s) => String::from(s), + None => output_str, + }; + Some(mime_type) + } + Err(e) => match e.kind() { + ErrorKind::NotFound => { + eprintln!("Error: `file` probably not installed"); + exit(-1); + } + _ => panic!("{e}"), + }, + } +} + +pub fn find_extensions_from_content(path: &Path) -> Vec { + let mime_type_based = match infer_mimetype(path, true) { + None => vec![], + Some(mime_type) => { + let mime_type_str = mime_type.as_str(); + match mime_type_str { + "application/pdf" => vec![String::from("pdf")], + "image/jpeg" => vec![String::from("jpeg"), String::from("jpg")], + "image/png" => vec![String::from("png")], + "text/csv" => vec![String::from("csv")], + "text/html" => vec![String::from("html"), String::from("htm")], + "text/x-script.python" => vec![String::from("py"), String::from("pyw")], + _other => vec![], + } + } + }; + + let mut description_based = match infer_mimetype(path, false) { + None => vec![], + Some(description) => { + let description_str = description.as_str(); + match description_str { + "Apache Parquet" => vec![String::from("parquet"), String::from("pq")], + _other => vec![], + } + } + }; + + let mut extensions = mime_type_based.clone(); + extensions.append(&mut description_based); + extensions +} + +pub fn has_correct_extension(path: &Path, possible_extensions: &[String]) -> bool { + if possible_extensions.is_empty() { + true + } else { + let current_extension = path.extension(); + if current_extension.is_none() { + false + } else { + let extension = current_extension.unwrap().to_ascii_lowercase(); + let extension_str = String::from(extension.to_string_lossy()); + possible_extensions.contains(&extension_str) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 806c905..844c66c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,53 +1,17 @@ +pub mod commands; +pub mod extensions; + use colored::Colorize; -use regex::Regex; -use std::fmt::{Display, Formatter, Result}; +use std::collections::HashSet; use std::fs::rename; -use std::io::ErrorKind; +use std::ops::Deref; use std::path::{Path, PathBuf}; -use std::process::{self, exit}; extern crate unidecode; -use unidecode::unidecode; - -pub struct RenameIntent { - old_name: PathBuf, - new_name: PathBuf, -} - -impl RenameIntent { - /// Is the new name different from the old one? - fn is_changed(&self) -> bool { - self.old_name != self.new_name - } -} - -impl Display for RenameIntent { - fn fmt(&self, f: &mut Formatter) -> Result { - if self.is_changed() { - write!( - f, - "{0} → {1}", - self.old_name.to_string_lossy().red(), - self.new_name.to_string_lossy().green() - ) - } else { - write!(f, "{0} =", self.old_name.to_string_lossy(),) - } - } -} - -pub enum RenameCommand { - SetExtension(String), - Remove(String), - Prefix(String), - FixExtension(bool), - Normalize, - Replace(String, String, bool), - ChangeCase(bool), -} +use crate::commands::{RenameCommand, RenameIntent}; pub struct Config { - pub command: RenameCommand, + pub command: Box, pub dry: bool, pub files: Vec, pub auto_confirm: bool, @@ -72,155 +36,6 @@ fn print_intents(intents: &Vec, show_unchanged: bool) { } } -/// Find a new name for a single file -fn suggest_rename(path: &PathBuf, command: &RenameCommand) -> RenameIntent { - RenameIntent { - old_name: path.clone(), - new_name: match &command { - RenameCommand::SetExtension(extension) => { - let mut new_name = path.clone(); - new_name.set_extension(extension); - new_name - } - RenameCommand::Remove(pattern) => { - let new_name = path.to_string_lossy().replace(pattern, ""); - PathBuf::from(new_name) - } - RenameCommand::Prefix(prefix) => { - let mut new_name = prefix.clone(); - new_name.push_str(path.to_string_lossy().to_string().as_str()); - PathBuf::from(new_name) - } - RenameCommand::Normalize => { - let path_str = path.to_string_lossy().to_string(); - let new_name = unidecode(&path_str).replace(' ', "_"); //#.to_lowercase(); - PathBuf::from(new_name) - } - RenameCommand::FixExtension(append) => { - let possible_extensions = find_extensions_from_content(path); - let mut new_name = path.clone(); - if !has_correct_extension(path, &possible_extensions) { - let mut new_extension = possible_extensions[0].clone(); - if *append { - let old_extension = new_name.extension(); - if old_extension.is_some() { - new_extension.insert(0, '.'); - new_extension.insert_str(0, old_extension.unwrap().to_str().unwrap()) - } - } - new_name.set_extension(new_extension); - }; - new_name - } - RenameCommand::Replace(pattern, replacement, is_regex) => { - let path_str = path.to_string_lossy().to_string(); - let new_name = if *is_regex { - let re = Regex::new(pattern).unwrap(); - re.replace_all(&path_str, replacement).to_string() - } else { - path_str.replace(pattern, replacement) - }; - PathBuf::from(new_name) - } - RenameCommand::ChangeCase(upper) => { - let path_str = path.to_string_lossy().to_string(); - let new_name = match upper { - true => path_str.to_uppercase(), - false => path_str.to_lowercase(), - }; - PathBuf::from(new_name) - } - }} -} - -fn suggest_renames(files: &[PathBuf], command: &RenameCommand) -> Vec { - files - .iter() - .map(|path| suggest_rename( - path, - command, - )) - .collect() -} - -fn infer_mimetype(path: &Path, mime_type: bool) -> Option { - // TODO: Do something on windows :see_no_evil: - let mut cmd = process::Command::new("file"); - let cmd_with_args = cmd.arg(path).arg("--brief"); - let cmd_with_args = if mime_type { - cmd_with_args.arg("--mime-type") - } else { - cmd_with_args - }; - - let output = cmd_with_args.output(); - match output { - Ok(output) => { - let output_str = String::from_utf8(output.stdout).unwrap(); - let mime_type = match output_str.strip_suffix('\n') { - Some(s) => String::from(s), - None => output_str, - }; - Some(mime_type) - } - Err(e) => match e.kind() { - ErrorKind::NotFound => { - eprintln!("Error: `file` probably not installed"); - exit(-1); - } - _ => panic!("{e}"), - }, - } -} - -fn find_extensions_from_content(path: &Path) -> Vec { - let mime_type_based = match infer_mimetype(path, true) { - None => vec![], - Some(mime_type) => { - let mime_type_str = mime_type.as_str(); - match mime_type_str { - "application/pdf" => vec![String::from("pdf")], - "image/jpeg" => vec![String::from("jpeg"), String::from("jpg")], - "image/png" => vec![String::from("png")], - "text/csv" => vec![String::from("csv")], - "text/html" => vec![String::from("html"), String::from("htm")], - "text/x-script.python" => vec![String::from("py"), String::from("pyw")], - _other => vec![], - } - } - }; - - let mut description_based = match infer_mimetype(path, false) { - None => vec![], - Some(description) => { - let description_str = description.as_str(); - match description_str { - "Apache Parquet" => vec![String::from("parquet"), String::from("pq")], - _other => vec![], - } - } - }; - - let mut extensions = mime_type_based.clone(); - extensions.append(&mut description_based); - extensions -} - -fn has_correct_extension(path: &Path, possible_extensions: &[String]) -> bool { - if possible_extensions.is_empty() { - true - } else { - let current_extension = path.extension(); - if current_extension.is_none() { - false - } else { - let extension = current_extension.unwrap().to_ascii_lowercase(); - let extension_str = String::from(extension.to_string_lossy()); - possible_extensions.contains(&extension_str) - } - } -} - fn try_rename(path: &Path, new_name: &Path) -> bool { match rename(path, new_name) { Ok(_) => { @@ -245,13 +60,20 @@ fn try_rename(path: &Path, new_name: &Path) -> bool { } fn process_command( - command: &RenameCommand, + command: &dyn RenameCommand, files: &[PathBuf], dry: bool, auto_confirm: bool, show_unchanged: bool, ) { - let intents = suggest_renames(files, command); + let intents = command.suggest_renames(files); + + if contains_duplicates(&intents) { + print!("All target names are not unique!"); + print_intents(&intents, false); + return; + } + if dry { print_intents(&intents, show_unchanged); } else { @@ -276,12 +98,46 @@ fn process_command( }; } +fn contains_duplicates(intents: &[RenameIntent]) -> bool { + let new_names: Vec = intents + .iter() + .map(|intent| intent.new_name.clone()) + .collect(); + let mut uniq = HashSet::new(); + !new_names.into_iter().all(move |x| uniq.insert(x)) +} + pub fn run(config: &Config) { process_command( - &config.command, + config.command.deref(), &config.files, config.dry, config.auto_confirm, config.show_unchanged, ); } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_contains_duplicates() { + let a_to_b = RenameIntent { + old_name: PathBuf::from("a"), + new_name: PathBuf::from("b"), + }; + let b_to_d = RenameIntent { + old_name: PathBuf::from("b"), + new_name: PathBuf::from("d"), + }; + let c_to_d = RenameIntent { + old_name: PathBuf::from("c"), + new_name: PathBuf::from("d"), + }; + + assert!(contains_duplicates(&vec![b_to_d, c_to_d.clone()])); + assert!(!contains_duplicates(&vec![a_to_b, c_to_d])); + assert!(!contains_duplicates(&Vec::new())); + } +} diff --git a/src/main.rs b/src/main.rs index 1535a5d..9dc98c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,10 +2,13 @@ use std::{env, path::PathBuf}; use clap::{arg, command, value_parser, Arg, ArgAction, ArgMatches, Command}; -use muren::{run, Config, RenameCommand}; +use muren::commands::{ + ChangeCase, FixExtension, Normalize, Prefix, Remove, RenameCommand, Replace, SetExtension, +}; +use muren::{run, Config}; fn parse_config(matches: &ArgMatches) -> Config { - let command = extract_command(matches).unwrap(); + let command = extract_command(matches); let files_args = matches.subcommand().unwrap().1.get_many::("path"); let files: Vec = match files_args { Some(args) => args.cloned().collect(), @@ -20,28 +23,33 @@ fn parse_config(matches: &ArgMatches) -> Config { } } -fn extract_command(args_matches: &ArgMatches) -> Option { +fn extract_command(args_matches: &ArgMatches) -> Box { match args_matches.subcommand() { - Some(("set-ext", matches)) => Some(RenameCommand::SetExtension( - matches.get_one::("extension").unwrap().clone(), - )), - Some(("remove", matches)) => Some(RenameCommand::Remove( - matches.get_one::("pattern").unwrap().clone(), - )), - Some(("normalize", _)) => Some(RenameCommand::Normalize), - Some(("fix-ext", matches)) => Some(RenameCommand::FixExtension(matches.get_flag("append"))), - Some(("prefix", matches)) => Some(RenameCommand::Prefix( - matches.get_one::("prefix").unwrap().clone(), - )), - Some(("replace", matches)) => Some(RenameCommand::Replace( - matches.get_one::("pattern").unwrap().clone(), - matches.get_one::("replacement").unwrap().clone(), - matches.get_flag("regex"), - )), - Some(("change-case", matches)) => { - Some(RenameCommand::ChangeCase(matches.get_flag("upper"))) - } - _ => None, + None => panic!("No command provided"), + Some((m, matches)) => match m { + "set-ext" => Box::new(SetExtension { + extension: matches.get_one::("extension").unwrap().clone(), + }), + "remove" => Box::new(Remove { + pattern: matches.get_one::("pattern").unwrap().clone(), + }), + "normalize" => Box::new(Normalize), + "fix-ext" => Box::new(FixExtension { + append: matches.get_flag("append"), + }), + "prefix" => Box::new(Prefix { + prefix: matches.get_one::("prefix").unwrap().clone(), + }), + "replace" => Box::new(Replace { + pattern: matches.get_one::("pattern").unwrap().clone(), + replacement: matches.get_one::("replacement").unwrap().clone(), + is_regex: matches.get_flag("regex"), + }), + "change-case" => Box::new(ChangeCase { + upper: matches.get_flag("upper"), + }), + _ => panic!("Unknown command"), + }, } }