From e7087134daef24facf25290c19e94ffc3102a5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ismael=20Gonz=C3=A1lez?= Date: Mon, 29 Nov 2021 00:15:26 +0100 Subject: [PATCH] feat: add to-ascii new subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Ismael González --- Cargo.lock | 7 ++ Cargo.toml | 1 + src/app.rs | 243 ++++++++++++++++++++++++++++--------------------- src/config.rs | 186 +++++++++++++++++++++---------------- src/main.rs | 1 + src/output.rs | 18 ++-- src/renamer.rs | 96 +++++++++++++++---- 7 files changed, 345 insertions(+), 207 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85970c8..3a160dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "any_ascii" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff4eacbbe7430f589efd00fde89220646ada6e1a81b0ea220bf88ac62b5d615" + [[package]] name = "atty" version = "0.2.14" @@ -261,6 +267,7 @@ name = "rnr" version = "0.3.1" dependencies = [ "ansi_term", + "any_ascii", "atty", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 0ef66a7..893ceae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ serde_json = "1.0" tempfile = "3" walkdir= "2" difference = "2.0.0" +any_ascii = "0.3.0" [build-dependencies] clap = "~2.32" diff --git a/src/app.rs b/src/app.rs index d5781ee..5e31d8b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,98 @@ use clap::{App, AppSettings, Arg, SubCommand}; use std::ffi::{OsStr, OsString}; +/// From file subcommand name. +const FROM_FILE_SUBCOMMAND: &str = "from-file"; + +/// To ASCII subcommand name. +const TO_ASCII_SUBCOMMAND: &str = "to-ascii"; + +/// Application commands +#[derive(Debug, PartialEq)] +pub enum AppCommand { + Root, + FromFile, + ToASCII, +} + +impl AppCommand { + pub fn from_str(name: &str) -> Result { + match name { + "" => Ok(AppCommand::Root), + FROM_FILE_SUBCOMMAND => Ok(AppCommand::FromFile), + TO_ASCII_SUBCOMMAND => Ok(AppCommand::ToASCII), + _ => Err(format!("Non-registered subcommand '{}'", name)), + } + } +} + /// Create application using clap. It sets all options and command-line help. pub fn create_app<'a>() -> App<'a, 'a> { + // These commons args are shared by all commands. + let common_args = [ + Arg::with_name("dry-run") + .long("dry-run") + .short("n") + .help("Only show what would be done (default mode)") + .conflicts_with("force"), + Arg::with_name("force") + .long("force") + .short("f") + .help("Make actual changes to files"), + Arg::with_name("backup") + .long("backup") + .short("b") + .help("Generate file backups before renaming"), + Arg::with_name("silent") + .long("silent") + .short("s") + .help("Do not print any information"), + Arg::with_name("color") + .long("color") + .possible_values(&["always", "auto", "never"]) + .default_value("auto") + .help("Set color output mode"), + Arg::with_name("dump") + .long("dump") + .help("Force dumping operations into a file even in dry-run mode") + .conflicts_with("no-dump"), + Arg::with_name("no-dump") + .long("no-dump") + .help("Do not dump operations into a file") + .conflicts_with("dump"), + ]; + + // Path related arguments. + let path_args = [ + Arg::with_name("PATH(S)") + .help("Target paths") + .validator_os(is_valid_string) + .multiple(true) + .required(true), + Arg::with_name("include-dirs") + .long("include-dirs") + .short("D") + .group("TEST") + .help("Rename matching directories"), + Arg::with_name("recursive") + .long("recursive") + .short("r") + .help("Recursive mode"), + Arg::with_name("max-depth") + .requires("recursive") + .long("max-depth") + .short("d") + .takes_value(true) + .value_name("LEVEL") + .validator(is_integer) + .help("Set max depth in recursive mode"), + Arg::with_name("hidden") + .requires("recursive") + .long("hidden") + .short("x") + .help("Include hidden files and directories"), + ]; + App::new(crate_name!()) .setting(AppSettings::SubcommandsNegateReqs) .version(crate_version!()) @@ -22,93 +112,6 @@ pub fn create_app<'a>() -> App<'a, 'a> { .validator_os(is_valid_string) .index(2), ) - .arg( - Arg::with_name("PATH(S)") - .help("Target paths") - .validator_os(is_valid_string) - .multiple(true) - .required(true), - ) - .arg( - Arg::with_name("dry-run") - .long("dry-run") - .short("n") - .help("Only show what would be done (default mode)") - .global(true) - .conflicts_with("force"), - ) - .arg( - Arg::with_name("force") - .long("force") - .short("f") - .global(true) - .help("Make actual changes to files"), - ) - .arg( - Arg::with_name("backup") - .long("backup") - .short("b") - .global(true) - .help("Generate file backups before renaming"), - ) - .arg( - Arg::with_name("include-dirs") - .long("include-dirs") - .short("D") - .help("Rename matching directories"), - ) - .arg( - Arg::with_name("recursive") - .long("recursive") - .short("r") - .help("Recursive mode"), - ) - .arg( - Arg::with_name("max-depth") - .requires("recursive") - .long("max-depth") - .short("d") - .takes_value(true) - .value_name("LEVEL") - .validator(is_integer) - .help("Set max depth in recursive mode"), - ) - .arg( - Arg::with_name("hidden") - .requires("recursive") - .long("hidden") - .short("x") - .help("Include hidden files and directories"), - ) - .arg( - Arg::with_name("silent") - .long("silent") - .short("s") - .global(true) - .help("Do not print any information"), - ) - .arg( - Arg::with_name("color") - .long("color") - .possible_values(&["always", "auto", "never"]) - .default_value("auto") - .global(true) - .help("Set color output mode"), - ) - .arg( - Arg::with_name("dump") - .long("dump") - .help("Force dumping operations into a file even in dry-run mode") - .global(true) - .conflicts_with("no-dump"), - ) - .arg( - Arg::with_name("no-dump") - .long("no-dump") - .help("Do not dump operations into a file") - .global(true) - .conflicts_with("dump"), - ) .arg( Arg::with_name("replace-limit") .long("replace-limit") @@ -119,24 +122,31 @@ pub fn create_app<'a>() -> App<'a, 'a> { .validator(is_integer) .help("Limit of replacements, all matches if set to 0"), ) - .subcommand( - SubCommand::with_name("from-file") - .arg( - Arg::with_name("DUMPFILE") - .takes_value(true) - .required(true) - .value_name("DUMPFILE") - .validator_os(is_valid_string) - .index(1), - ) - .arg( - Arg::with_name("undo") - .long("undo") - .short("u") - .help("Undo the operations from the dump file"), - ) - .about("Read operations from a dump file"), - ) + .args(&common_args) + .args(&path_args) + .subcommand(SubCommand::with_name(FROM_FILE_SUBCOMMAND) + .args(&common_args) + .arg( + Arg::with_name("DUMPFILE") + .takes_value(true) + .required(true) + .value_name("DUMPFILE") + .validator_os(is_valid_string) + .index(1), + ) + .arg( + Arg::with_name("undo") + .long("undo") + .short("u") + .help("Undo the operations from the dump file"), + ) + .about("Read operations from a dump file"), + ) + .subcommand(SubCommand::with_name(TO_ASCII_SUBCOMMAND) + .args(&common_args) + .args(&path_args) + .about("Replace all file name chars with ASCII chars. This operation is extremely lossy.") + ) } #[allow(clippy::all)] /// Check if the input provided is valid unsigned integer @@ -155,4 +165,25 @@ fn is_valid_string(os_str: &OsStr) -> Result<(), OsString> { } #[cfg(test)] -mod test {} +mod test { + use super::*; + + #[test] + fn app_command_from_str() { + assert_eq!(AppCommand::from_str("").unwrap(), AppCommand::Root); + assert_eq!( + AppCommand::from_str(FROM_FILE_SUBCOMMAND).unwrap(), + AppCommand::FromFile + ); + assert_eq!( + AppCommand::from_str(TO_ASCII_SUBCOMMAND).unwrap(), + AppCommand::ToASCII + ); + } + + #[test] + #[should_panic] + fn app_command_from_str_unknown_error() { + AppCommand::from_str("this-command-does-not-exists").unwrap(); + } +} diff --git a/src/config.rs b/src/config.rs index a320665..ab1225d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ -use app::create_app; +use app::{create_app, AppCommand}; use atty; +use clap::ArgMatches; use output::Printer; use regex::Regex; use std::sync::Arc; @@ -8,15 +9,13 @@ use std::sync::Arc; /// from the parsed arguments from command-line input using `clap`. Only UTF-8 valid arguments are /// considered. pub struct Config { - pub expression: Regex, - pub replacement: String, pub force: bool, pub backup: bool, pub dirs: bool, pub dump: bool, - pub mode: RunMode, + pub run_mode: RunMode, + pub replace_mode: ReplaceMode, pub printer: Printer, - pub limit: usize, } impl Config { @@ -42,77 +41,101 @@ pub enum RunMode { }, } -/// Parse arguments and do some checking. -fn parse_arguments() -> Result { - let app = create_app(); - let matches = app.get_matches(); +pub enum ReplaceMode { + RegExp { + expression: Regex, + replacement: String, + limit: usize, + }, + ToASCII, +} - // Set output mode - let printer = if matches.is_present("silent") { - Printer::silent() - } else { - match matches.value_of("color").unwrap_or("auto") { - "always" => Printer::color(), - "never" => Printer::no_color(), - "auto" | _ => detect_output_color(), +struct ArgumentParser<'a> { + matches: &'a ArgMatches<'a>, + printer: &'a Printer, + command: &'a AppCommand, +} + +impl ArgumentParser<'_> { + fn parse_run_mode(&self) -> Result { + if let AppCommand::FromFile = self.command { + return Ok(RunMode::FromFile { + path: String::from(self.matches.value_of("DUMPFILE").unwrap_or_default()), + undo: self.matches.is_present("undo"), + }); } - }; - // Get and validate regex expression and replacement from arguments - let expression = match Regex::new(matches.value_of("EXPRESSION").unwrap_or_default()) { - Ok(expr) => expr, - Err(err) => { - return Err(format!( - "{}Bad expression provided\n\n{}", - printer.colors.error.paint("Error: "), - printer.colors.error.paint(err.to_string()) - )); + // Detect run mode and set parameters accordingly + let input_paths: Vec = self + .matches + .values_of("PATH(S)") + .unwrap_or_default() + .map(String::from) + .collect(); + + if self.matches.is_present("recursive") { + let max_depth = if self.matches.is_present("max-depth") { + Some( + self.matches + .value_of("max-depth") + .unwrap_or_default() + .parse::() + .unwrap_or_default(), + ) + } else { + None + }; + + Ok(RunMode::Recursive { + paths: input_paths, + max_depth, + hidden: self.matches.is_present("hidden"), + }) + } else { + Ok(RunMode::Simple(input_paths)) } - }; - let replacement = String::from(matches.value_of("REPLACEMENT").unwrap_or_default()); - - // Detect run mode and set parameters accordingly - let input_paths: Vec = matches - .values_of("PATH(S)") - .unwrap_or_default() - .map(String::from) - .collect(); - - let mode = if matches.is_present("from-file") { - let submatches = match matches.subcommand_matches("from-file") { - Some(matches) => matches, - None => { + } + + fn parse_replace_mode(&self) -> Result { + if let AppCommand::ToASCII = self.command { + return Ok(ReplaceMode::ToASCII); + } + + // Get and validate regex expression and replacement from arguments + let expression = match Regex::new(self.matches.value_of("EXPRESSION").unwrap_or_default()) { + Ok(expr) => expr, + Err(err) => { return Err(format!( - "{}Empty from-file subcommand provided\n\n", - printer.colors.error.paint("Error: ") - )) + "{}Bad expression provided\n\n{}", + self.printer.colors.error.paint("Error: "), + self.printer.colors.error.paint(err.to_string()) + )); } }; + let replacement = String::from(self.matches.value_of("REPLACEMENT").unwrap_or_default()); - RunMode::FromFile { - path: String::from(submatches.value_of("DUMPFILE").unwrap_or_default()), - undo: submatches.is_present("undo"), - } - } else if matches.is_present("recursive") { - let max_depth = if matches.is_present("max-depth") { - Some( - matches - .value_of("max-depth") - .unwrap_or_default() - .parse::() - .unwrap_or_default(), - ) - } else { - None - }; + let limit = self + .matches + .value_of("replace-limit") + .unwrap_or_default() + .parse::() + .unwrap_or_default(); - RunMode::Recursive { - paths: input_paths, - max_depth, - hidden: matches.is_present("hidden"), - } - } else { - RunMode::Simple(input_paths) + Ok(ReplaceMode::RegExp { + expression, + replacement, + limit, + }) + } +} + +/// Parse arguments and do some checking. +fn parse_arguments() -> Result { + let app = create_app(); + let matches = app.get_matches(); + let (command, matches) = match matches.subcommand() { + (name, Some(submatches)) => (AppCommand::from_str(name)?, submatches), + (_, None) => (AppCommand::Root, &matches), // Always defaults to root if no submatches found. }; // Set dump defaults: write in force mode and do not in dry-run unless it is explicitly asked @@ -122,22 +145,33 @@ fn parse_arguments() -> Result { matches.is_present("dump") }; - let limit = matches - .value_of("replace-limit") - .unwrap_or_default() - .parse::() - .unwrap_or_default(); + let printer = if matches.is_present("silent") { + Printer::silent() + } else { + match matches.value_of("color").unwrap_or("auto") { + "always" => Printer::color(), + "never" => Printer::no_color(), + "auto" | _ => detect_output_color(), + } + }; + + let argument_parser = ArgumentParser { + printer: &printer, + matches, + command: &command, + }; + + let run_mode = argument_parser.parse_run_mode()?; + let replace_mode = argument_parser.parse_replace_mode()?; Ok(Config { - expression, - replacement, force: matches.is_present("force"), backup: matches.is_present("backup"), dirs: matches.is_present("include-dirs"), dump, - mode, + run_mode, + replace_mode, printer, - limit, }) } diff --git a/src/main.rs b/src/main.rs index 7cc672f..075e5e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ //! expressions. //! extern crate ansi_term; +extern crate any_ascii; extern crate atty; extern crate chrono; extern crate difference; diff --git a/src/output.rs b/src/output.rs index 3848e13..3b0f6e7 100644 --- a/src/output.rs +++ b/src/output.rs @@ -121,7 +121,6 @@ impl Printer { let mut target_parent = target.parent().unwrap().to_string_lossy().to_string(); let mut target_name = target.file_name().unwrap().to_string_lossy().to_string(); - // Avoid diffing if not coloring output if self.mode == PrinterMode::Color { target_name = self.string_diff( @@ -135,18 +134,23 @@ impl Printer { source_name = self.colors.source.paint(&source_name).to_string(); if source_parent != "" { - source_parent = self.colors.source.paint(format!("{}/", source_parent)).to_string(); + source_parent = self + .colors + .source + .paint(format!("{}/", source_parent)) + .to_string(); } if target_parent != "" { - target_parent = self.colors.target.paint(format!("{}/", target_parent)).to_string(); + target_parent = self + .colors + .target + .paint(format!("{}/", target_parent)) + .to_string(); } self.print(&format!( "{}{} -> {}{}", - source_parent, - source_name, - target_parent, - target_name + source_parent, source_name, target_parent, target_name )); } diff --git a/src/renamer.rs b/src/renamer.rs index d9021a7..be408c5 100644 --- a/src/renamer.rs +++ b/src/renamer.rs @@ -1,4 +1,5 @@ -use config::{Config, RunMode}; +use any_ascii::any_ascii; +use config::{Config, ReplaceMode, RunMode}; use dumpfile; use error::*; use fileutils::{cleanup_paths, create_backup, get_paths}; @@ -21,10 +22,10 @@ impl Renamer { /// Process path batch pub fn process(&self) -> Result { - let operations = match self.config.mode { + let operations = match self.config.run_mode { RunMode::Simple(_) | RunMode::Recursive { .. } => { // Get paths - let mut input_paths = get_paths(&self.config.mode); + let mut input_paths = get_paths(&self.config.run_mode); // Remove directories and on existing paths from the list cleanup_paths(&mut input_paths, self.config.dirs); @@ -62,18 +63,25 @@ impl Renamer { Ok(()) } - /// Replace expression match in the given path using stored config. + /// Replace file name matches in the given path using stored config. fn replace_match(&self, path: &PathBuf) -> PathBuf { - let expression = &self.config.expression; - let replacement = &self.config.replacement; - let file_name = path.file_name().unwrap().to_str().unwrap(); let parent = path.parent(); - let target_name = expression.replacen(file_name, self.config.limit, &replacement[..]); + let target_name = match &self.config.replace_mode { + ReplaceMode::RegExp { + expression, + replacement, + limit, + } => expression + .replacen(file_name, *limit, &replacement[..]) + .to_string(), + ReplaceMode::ToASCII => any_ascii(file_name), + }; + match parent { - None => PathBuf::from(target_name.to_string()), - Some(path) => path.join(Path::new(&target_name.into_owned())), + None => PathBuf::from(target_name), + Some(path) => path.join(Path::new(&target_name)), } } @@ -211,15 +219,17 @@ mod test { // Create config let mock_config = Arc::new(Config { - expression: Regex::new("test").unwrap(), - replacement: "passed".to_string(), force: true, backup: true, dirs: false, dump: false, - mode: RunMode::Simple(mock_files), + run_mode: RunMode::Simple(mock_files), + replace_mode: ReplaceMode::RegExp { + expression: Regex::new("test").unwrap(), + replacement: "passed".to_string(), + limit: 1, + }, printer: Printer::color(), - limit: 1, }); // Run renamer @@ -267,15 +277,17 @@ mod test { } let mock_config = Arc::new(Config { - expression: Regex::new("a").unwrap(), - replacement: "b".to_string(), force: true, backup: false, dirs: false, dump: false, - mode: RunMode::Simple(mock_files), + run_mode: RunMode::Simple(mock_files), + replace_mode: ReplaceMode::RegExp { + expression: Regex::new("a").unwrap(), + replacement: "b".to_string(), + limit: 0, + }, printer: Printer::color(), - limit: 0, }); let renamer = match Renamer::new(&mock_config) { @@ -300,4 +312,52 @@ mod test { // Check renamed files assert!(Path::new(&format!("{}/replbce_bll_bbbbb.txt", temp_path)).exists()); } + + #[test] + fn to_ascii() { + let tempdir = tempfile::tempdir().expect("Error creating temp directory"); + println!("Running test in '{:?}'", tempdir); + let temp_path = tempdir.path().to_str().unwrap(); + + let mock_files: Vec = vec![ + format!("{}/ǹön-âścîı-lower.txt", temp_path), + format!("{}/ǸÖN-ÂŚCÎI-UPPER.txt", temp_path), + ]; + for file in &mock_files { + fs::File::create(&file).expect("Error creating mock file..."); + } + + let mock_config = Arc::new(Config { + force: true, + backup: false, + dirs: false, + dump: false, + run_mode: RunMode::Simple(mock_files), + replace_mode: ReplaceMode::ToASCII, + printer: Printer::color(), + }); + + let renamer = match Renamer::new(&mock_config) { + Ok(renamer) => renamer, + Err(err) => { + mock_config.printer.print_error(&err); + process::exit(1); + } + }; + let operations = match renamer.process() { + Ok(operations) => operations, + Err(err) => { + mock_config.printer.print_error(&err); + process::exit(1); + } + }; + if let Err(err) = renamer.batch_rename(operations) { + mock_config.printer.print_error(&err); + process::exit(1); + } + + // Check renamed files + assert!(Path::new(&format!("{}/non-ascii-lower.txt", temp_path)).exists()); + assert!(Path::new(&format!("{}/NON-ASCII-UPPER.txt", temp_path)).exists()); + } }