diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 46639151d..8418b2a38 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -35,6 +35,7 @@ pub fn create_default_context() -> Rc> { working_set.add_decl(Box::new(Lines)); working_set.add_decl(Box::new(Ls)); working_set.add_decl(Box::new(Module)); + working_set.add_decl(Box::new(Mv)); working_set.add_decl(Box::new(Ps)); working_set.add_decl(Box::new(Select)); working_set.add_decl(Box::new(Sys)); diff --git a/crates/nu-command/src/filesystem/mod.rs b/crates/nu-command/src/filesystem/mod.rs index 13148b535..90b697fcd 100644 --- a/crates/nu-command/src/filesystem/mod.rs +++ b/crates/nu-command/src/filesystem/mod.rs @@ -1,5 +1,7 @@ mod cd; mod ls; +mod mv; pub use cd::Cd; pub use ls::Ls; +pub use mv::Mv; diff --git a/crates/nu-command/src/filesystem/mv.rs b/crates/nu-command/src/filesystem/mv.rs new file mode 100644 index 000000000..19cdf0d89 --- /dev/null +++ b/crates/nu-command/src/filesystem/mv.rs @@ -0,0 +1,139 @@ +use std::env::current_dir; +use std::path::{Path, PathBuf}; + +use nu_engine::CallExt; +use nu_protocol::ast::Call; +use nu_protocol::engine::{Command, EvaluationContext}; +use nu_protocol::{ShellError, Signature, SyntaxShape, Value}; + +pub struct Mv; + +impl Command for Mv { + fn name(&self) -> &str { + "mv" + } + + fn usage(&self) -> &str { + "Move files or directories." + } + + fn signature(&self) -> nu_protocol::Signature { + Signature::build("mv") + .required( + "source", + SyntaxShape::GlobPattern, + "the location to move files/directories from", + ) + .required( + "destination", + SyntaxShape::Filepath, + "the location to move files/directories to", + ) + } + + fn run( + &self, + context: &EvaluationContext, + call: &Call, + _input: Value, + ) -> Result { + // TODO: handle invalid directory or insufficient permissions + let source: String = call.req(context, 0)?; + let destination: String = call.req(context, 1)?; + + let path: PathBuf = current_dir().unwrap(); + let source = path.join(source.as_str()); + let destination = path.join(destination.as_str()); + + let mut sources = + glob::glob(&source.to_string_lossy()).map_or_else(|_| Vec::new(), Iterator::collect); + + if sources.is_empty() { + return Err(ShellError::FileNotFound( + call.positional.first().unwrap().span, + )); + } + + if (destination.exists() && !destination.is_dir() && sources.len() > 1) + || (!destination.exists() && sources.len() > 1) + { + return Err(ShellError::MoveNotPossible { + source_message: "Can't move many files".to_string(), + source_span: call.positional[0].span, + destination_message: "into single file".to_string(), + destination_span: call.positional[1].span, + }); + } + + let some_if_source_is_destination = sources + .iter() + .find(|f| matches!(f, Ok(f) if destination.starts_with(f))); + if destination.exists() && destination.is_dir() && sources.len() == 1 { + if let Some(Ok(_filename)) = some_if_source_is_destination { + return Err(ShellError::MoveNotPossible { + source_message: "Can't move directory".to_string(), + source_span: call.positional[0].span, + destination_message: "into itself".to_string(), + destination_span: call.positional[1].span, + }); + } + } + + if let Some(Ok(_filename)) = some_if_source_is_destination { + sources = sources + .into_iter() + .filter(|f| matches!(f, Ok(f) if !destination.starts_with(f))) + .collect(); + } + + for entry in sources.into_iter().flatten() { + move_file(call, &entry, &destination)? + } + + Ok(Value::Nothing { span: call.head }) + } +} + +fn move_file(call: &Call, from: &Path, to: &Path) -> Result<(), ShellError> { + if to.exists() && from.is_dir() && to.is_file() { + return Err(ShellError::MoveNotPossible { + source_message: "Can't move a directory".to_string(), + source_span: call.positional[0].span, + destination_message: "to a file".to_string(), + destination_span: call.positional[1].span, + }); + } + + let destination_dir_exists = if to.is_dir() { + true + } else { + to.parent().map(Path::exists).unwrap_or(true) + }; + + if !destination_dir_exists { + return Err(ShellError::DirectoryNotFound(call.positional[1].span)); + } + + let mut to = to.to_path_buf(); + if to.is_dir() { + let from_file_name = match from.file_name() { + Some(name) => name, + None => return Err(ShellError::DirectoryNotFound(call.positional[1].span)), + }; + + to.push(from_file_name); + } + + move_item(call, from, &to) +} + +fn move_item(call: &Call, from: &Path, to: &Path) -> Result<(), ShellError> { + // We first try a rename, which is a quick operation. If that doesn't work, we'll try a copy + // and remove the old file/folder. This is necessary if we're moving across filesystems or devices. + std::fs::rename(&from, &to).map_err(|_| ShellError::MoveNotPossible { + source_message: "failed to move".to_string(), + source_span: call.positional[0].span, + destination_message: "into".to_string(), + destination_span: call.positional[1].span, + }) +} diff --git a/crates/nu-protocol/src/shell_error.rs b/crates/nu-protocol/src/shell_error.rs index 8307e9961..0bb59291e 100644 --- a/crates/nu-protocol/src/shell_error.rs +++ b/crates/nu-protocol/src/shell_error.rs @@ -78,4 +78,23 @@ pub enum ShellError { #[error("Flag not found")] #[diagnostic(code(nu::shell::flag_not_found), url(docsrs))] FlagNotFound(String, #[label("{0} not found")] Span), + + #[error("File not found")] + #[diagnostic(code(nu::shell::file_not_found), url(docsrs))] + FileNotFound(#[label("file not found")] Span), + + #[error("Directory not found")] + #[diagnostic(code(nu::shell::directory_not_found), url(docsrs))] + DirectoryNotFound(#[label("directory not found")] Span), + + #[error("Move not possible")] + #[diagnostic(code(nu::shell::move_not_possible), url(docsrs))] + MoveNotPossible { + source_message: String, + #[label("{source_message}")] + source_span: Span, + destination_message: String, + #[label("{destination_message}")] + destination_span: Span, + }, }