diff --git a/README.md b/README.md index f7075f00a1..a46f84851d 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,7 @@ Nu adheres closely to a set of goals that make up its design philosophy. As feat | cp source path | Copy files | | ls (path) | View the contents of the current or given path | | mkdir path | Make directories, creates intermediary directories as required. | +| mv source target | Move files or directories. | | date (--utc) | Get the current datetime | | ps | View current processes | | sys | View information about the current system | diff --git a/src/cli.rs b/src/cli.rs index 93fccbf185..fe8836a6ae 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -189,9 +189,10 @@ pub async fn cli() -> Result<(), Box> { static_command(Exit), static_command(Clip), static_command(Autoview), - static_command(Copycp), + static_command(Cpy), static_command(Date), static_command(Mkdir), + static_command(Move), static_command(Save), static_command(Table), static_command(VTable), diff --git a/src/commands.rs b/src/commands.rs index e2c3aebd22..b04dd3df6a 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -25,6 +25,7 @@ crate mod get; crate mod lines; crate mod ls; crate mod mkdir; +crate mod mv; crate mod next; crate mod nth; crate mod open; @@ -61,12 +62,13 @@ crate use command::{ RawCommandArgs, StaticCommand, UnevaluatedCallInfo, }; crate use config::Config; -crate use cp::Copycp; +crate use cp::Cpy; crate use date::Date; crate use enter::Enter; crate use exit::Exit; crate use get::Get; crate use mkdir::Mkdir; +crate use mv::Move; crate use open::Open; crate use rm::Remove; crate use save::Save; diff --git a/src/commands/mv.rs b/src/commands/mv.rs new file mode 100644 index 0000000000..c5b6e7095a --- /dev/null +++ b/src/commands/mv.rs @@ -0,0 +1,279 @@ +use crate::errors::ShellError; +use crate::parser::hir::SyntaxType; +use crate::parser::registry::{CommandRegistry, Signature}; +use crate::prelude::*; +use std::path::PathBuf; + +#[cfg(windows)] +use crate::utils::FileStructure; + +pub struct Move; + +impl StaticCommand for Move { + fn run( + &self, + args: CommandArgs, + registry: &CommandRegistry, + ) -> Result { + mv(args, registry) + } + + fn name(&self) -> &str { + "mv" + } + + fn signature(&self) -> Signature { + Signature::build("mv").named("file", SyntaxType::Any) + } +} + +pub fn mv(args: CommandArgs, registry: &CommandRegistry) -> Result { + let mut source = PathBuf::from(args.shell_manager.path()); + let mut destination = PathBuf::from(args.shell_manager.path()); + let span = args.name_span(); + let args = args.evaluate_once(registry)?; + + match args + .nth(0) + .ok_or_else(|| ShellError::string(&format!("No file or directory specified")))? + .as_string()? + .as_str() + { + file => { + source.push(file); + } + } + + match args + .nth(1) + .ok_or_else(|| ShellError::string(&format!("No file or directory specified")))? + .as_string()? + .as_str() + { + file => { + destination.push(file); + } + } + + let sources = glob::glob(&source.to_string_lossy()); + + if sources.is_err() { + return Err(ShellError::labeled_error( + "Invalid pattern.", + "Invalid pattern.", + args.nth(0).unwrap().span(), + )); + } + + let sources: Vec<_> = sources.unwrap().collect(); + + if sources.len() == 1 { + if let Ok(entry) = &sources[0] { + if destination.exists() && destination.is_dir() { + destination = dunce::canonicalize(&destination).unwrap(); + destination.push(source.file_name().unwrap()); + } + + if entry.is_file() { + match std::fs::rename(&entry, &destination) { + Err(e) => { + return Err(ShellError::labeled_error( + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + span, + )); + } + Ok(o) => o, + }; + } + + if entry.is_dir() { + match std::fs::create_dir_all(&destination) { + Err(e) => { + return Err(ShellError::labeled_error( + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + span, + )); + } + Ok(o) => o, + }; + #[cfg(not(windows))] + { + match std::fs::rename(&entry, &destination) { + Err(e) => { + return Err(ShellError::labeled_error( + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + span, + )); + } + Ok(o) => o, + }; + } + #[cfg(windows)] + { + let mut sources: FileStructure = FileStructure::new(); + + sources.walk_decorate(&entry); + + let strategy = |(source_file, depth_level)| { + let mut new_dst = destination.clone(); + let path = dunce::canonicalize(&source_file).unwrap(); + + let mut comps: Vec<_> = path + .components() + .map(|fragment| fragment.as_os_str()) + .rev() + .take(1 + depth_level) + .collect(); + + comps.reverse(); + + for fragment in comps.iter() { + new_dst.push(fragment); + } + + (PathBuf::from(&source_file), PathBuf::from(new_dst)) + }; + + for (ref src, ref dst) in sources.paths_applying_with(strategy) { + if src.is_dir() { + if !dst.exists() { + match std::fs::create_dir_all(dst) { + Err(e) => { + return Err(ShellError::labeled_error( + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + span, + )); + } + Ok(o) => o, + }; + } + } + + if src.is_file() { + match std::fs::rename(src, dst) { + Err(e) => { + return Err(ShellError::labeled_error( + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + span, + )); + } + Ok(o) => o, + }; + } + } + + std::fs::remove_dir_all(entry).expect("can not remove directory"); + } + } + } + } else { + if destination.exists() { + if !sources.iter().all(|x| (x.as_ref().unwrap()).is_file()) { + return Err(ShellError::labeled_error( + "Rename aborted (directories found).", + "Rename aborted (directories found).", + args.nth(0).unwrap().span(), + )); + } + + for entry in sources { + if let Ok(entry) = entry { + let mut to = PathBuf::from(&destination); + to.push(&entry.file_name().unwrap()); + + if entry.is_file() { + match std::fs::rename(&entry, &to) { + Err(e) => { + return Err(ShellError::labeled_error( + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + format!( + "Rename {:?} to {:?} aborted. {:}", + entry.file_name().unwrap(), + destination.file_name().unwrap(), + e.to_string(), + ), + span, + )); + } + Ok(o) => o, + }; + } + } + } + } else { + return Err(ShellError::labeled_error( + format!( + "Rename aborted. (Does {:?} exist?)", + &destination.file_name().unwrap() + ), + format!( + "Rename aborted. (Does {:?} exist?)", + &destination.file_name().unwrap() + ), + args.nth(1).unwrap().span(), + )); + } + } + + Ok(OutputStream::empty()) +} diff --git a/tests/command_mv_tests.rs b/tests/command_mv_tests.rs new file mode 100644 index 0000000000..62132c7f2a --- /dev/null +++ b/tests/command_mv_tests.rs @@ -0,0 +1,222 @@ +mod helpers; + +use h::{in_directory as cwd, Playground, Stub::*}; +use helpers as h; + +use std::path::{Path, PathBuf}; + +#[test] +fn moves_a_file() { + let sandbox = Playground::setup_for("mv_test_1") + .with_files(vec![ + EmptyFile("andres.txt"), + ]) + .mkdir("expected") + .test_dir_name(); + + let full_path = format!("{}/{}", Playground::root(), sandbox); + let original = format!("{}/{}", full_path, "andres.txt"); + let expected = format!("{}/{}", full_path, "expected/yehuda.txt"); + + nu!( + _output, + cwd(&full_path), + "mv andres.txt expected/yehuda.txt" + ); + + assert!(!h::file_exists_at(PathBuf::from(original))); + assert!(h::file_exists_at(PathBuf::from(expected))); +} + +#[test] +fn overwrites_if_moving_to_existing_file() { + let sandbox = Playground::setup_for("mv_test_2") + .with_files(vec![ + EmptyFile("andres.txt"), + EmptyFile("jonathan.txt"), + ]) + .test_dir_name(); + + let full_path = format!("{}/{}", Playground::root(), sandbox); + let original = format!("{}/{}", full_path, "andres.txt"); + let expected = format!("{}/{}", full_path, "jonathan.txt"); + + nu!( + _output, + cwd(&full_path), + "mv andres.txt jonathan.txt" + ); + + assert!(!h::file_exists_at(PathBuf::from(original))); + assert!(h::file_exists_at(PathBuf::from(expected))); +} + +#[test] +fn moves_a_directory() { + let sandbox = Playground::setup_for("mv_test_3") + .mkdir("empty_dir") + .test_dir_name(); + + let full_path = format!("{}/{}", Playground::root(), sandbox); + let original_dir = format!("{}/{}", full_path, "empty_dir"); + let expected = format!("{}/{}", full_path, "renamed_dir"); + + nu!( + _output, + cwd(&full_path), + "mv empty_dir renamed_dir" + ); + + assert!(!h::dir_exists_at(PathBuf::from(original_dir))); + assert!(h::dir_exists_at(PathBuf::from(expected))); +} + +#[test] +fn moves_the_file_inside_directory_if_path_to_move_is_existing_directory() { + let sandbox = Playground::setup_for("mv_test_4") + .with_files(vec![ + EmptyFile("jonathan.txt"), + ]) + .mkdir("expected") + .test_dir_name(); + + let full_path = format!("{}/{}", Playground::root(), sandbox); + let original_dir = format!("{}/{}", full_path, "jonathan.txt"); + let expected = format!("{}/{}", full_path, "expected/jonathan.txt"); + + nu!( + _output, + cwd(&full_path), + "mv jonathan.txt expected" + ); + + + assert!(!h::file_exists_at(PathBuf::from(original_dir))); + assert!(h::file_exists_at(PathBuf::from(expected))); +} + +#[test] +fn moves_the_directory_inside_directory_if_path_to_move_is_existing_directory() { + let sandbox = Playground::setup_for("mv_test_5") + .within("contributors") + .with_files(vec![ + EmptyFile("jonathan.txt"), + ]) + .mkdir("expected") + .test_dir_name(); + + let full_path = format!("{}/{}", Playground::root(), sandbox); + let original_dir = format!("{}/{}", full_path, "contributors"); + let expected = format!("{}/{}", full_path, "expected/contributors"); + + nu!( + _output, + cwd(&full_path), + "mv contributors expected" + ); + + + assert!(!h::dir_exists_at(PathBuf::from(original_dir))); + assert!(h::file_exists_at(PathBuf::from(expected))); +} + +#[test] +fn moves_the_directory_inside_directory_if_path_to_move_is_nonexistent_directory() { + let sandbox = Playground::setup_for("mv_test_6") + .within("contributors") + .with_files(vec![ + EmptyFile("jonathan.txt"), + ]) + .mkdir("expected") + .test_dir_name(); + + let full_path = format!("{}/{}", Playground::root(), sandbox); + let original_dir = format!("{}/{}", full_path, "contributors"); + + nu!( + _output, + cwd(&full_path), + "mv contributors expected/this_dir_exists_now/los_tres_amigos" + ); + + let expected = format!("{}/{}", full_path, "expected/this_dir_exists_now/los_tres_amigos"); + + assert!(!h::dir_exists_at(PathBuf::from(original_dir))); + assert!(h::file_exists_at(PathBuf::from(expected))); +} + +#[test] +fn moves_using_path_with_wildcard() { + let sandbox = Playground::setup_for("mv_test_7") + .within("originals") + .with_files(vec![ + EmptyFile("andres.ini"), + EmptyFile("caco3_plastics.csv"), + EmptyFile("cargo_sample.toml"), + EmptyFile("jonathan.ini"), + EmptyFile("jonathan.xml"), + EmptyFile("sgml_description.json"), + EmptyFile("sample.ini"), + EmptyFile("utf16.ini"), + EmptyFile("yehuda.ini"), + ]) + .mkdir("work_dir") + .mkdir("expected") + .test_dir_name(); + + let full_path = format!("{}/{}", Playground::root(), sandbox); + let work_dir = format!("{}/{}", full_path, "work_dir"); + let expected_copies_path = format!("{}/{}", full_path, "expected"); + + nu!( + _output, + cwd(&work_dir), + "mv ../originals/*.ini ../expected" + ); + + assert!(h::files_exist_at( + vec![ + Path::new("yehuda.ini"), + Path::new("jonathan.ini"), + Path::new("sample.ini"), + Path::new("andres.ini"), + ], + PathBuf::from(&expected_copies_path) + )); +} + + +#[test] +fn moves_using_a_glob() { + let sandbox = Playground::setup_for("mv_test_8") + .within("meals") + .with_files(vec![ + EmptyFile("arepa.txt"), + EmptyFile("empanada.txt"), + EmptyFile("taquiza.txt"), + ]) + .mkdir("work_dir") + .mkdir("expected") + .test_dir_name(); + + let full_path = format!("{}/{}", Playground::root(), sandbox); + let meal_dir = format!("{}/{}", full_path, "meals"); + let work_dir = format!("{}/{}", full_path, "work_dir"); + let expected_copies_path = format!("{}/{}", full_path, "expected"); + + nu!( + _output, + cwd(&work_dir), + "mv ../meals/* ../expected" + ); + + assert!(h::dir_exists_at(PathBuf::from(meal_dir))); + assert!(h::files_exist_at( + vec![ + Path::new("arepa.txt"), + Path::new("empanada.txt"), + Path::new("taquiza.txt"), + ], + PathBuf::from(&expected_copies_path) + )); +} \ No newline at end of file