diff --git a/Cargo.lock b/Cargo.lock index 728c4d10fd..fc0ab56ab4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2871,6 +2871,7 @@ dependencies = [ "uu_cp", "uu_mkdir", "uu_mktemp", + "uu_mv", "uu_whoami", "uuid", "wax", @@ -5807,6 +5808,18 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_mv" +version = "0.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e307e61d34d2e1dba0659ef443ada8340ec3b788bd6c8fc7fdfe0e02c6b4cfc" +dependencies = [ + "clap", + "fs_extra", + "indicatif", + "uucore", +] + [[package]] name = "uu_whoami" version = "0.0.23" diff --git a/crates/nu-command/Cargo.toml b/crates/nu-command/Cargo.toml index cb097b1bdc..6d441ec49e 100644 --- a/crates/nu-command/Cargo.toml +++ b/crates/nu-command/Cargo.toml @@ -88,6 +88,7 @@ toml = "0.8" unicode-segmentation = "1.10" ureq = { version = "2.9", default-features = false, features = ["charset", "gzip", "json", "native-tls"] } url = "2.2" +uu_mv = "0.0.23" uu_cp = "0.0.23" uu_whoami = "0.0.23" uu_mkdir = "0.0.23" diff --git a/crates/nu-command/src/default_context.rs b/crates/nu-command/src/default_context.rs index 0dd85a51b3..8ef8918bed 100644 --- a/crates/nu-command/src/default_context.rs +++ b/crates/nu-command/src/default_context.rs @@ -205,6 +205,7 @@ pub fn add_shell_command_context(mut engine_state: EngineState) -> EngineState { UMkdir, Mktemp, Mv, + UMv, Cp, UCp, Open, diff --git a/crates/nu-command/src/filesystem/mod.rs b/crates/nu-command/src/filesystem/mod.rs index 152005e793..17de7860e0 100644 --- a/crates/nu-command/src/filesystem/mod.rs +++ b/crates/nu-command/src/filesystem/mod.rs @@ -12,6 +12,7 @@ mod start; mod touch; mod ucp; mod umkdir; +mod umv; mod util; mod watch; @@ -29,4 +30,5 @@ pub use start::Start; pub use touch::Touch; pub use ucp::UCp; pub use umkdir::UMkdir; +pub use umv::UMv; pub use watch::Watch; diff --git a/crates/nu-command/src/filesystem/umv.rs b/crates/nu-command/src/filesystem/umv.rs new file mode 100644 index 0000000000..10716298b3 --- /dev/null +++ b/crates/nu-command/src/filesystem/umv.rs @@ -0,0 +1,174 @@ +use nu_cmd_base::arg_glob; +use nu_engine::current_dir; +use nu_engine::CallExt; +use nu_glob::GlobResult; +use nu_path::{expand_path_with, expand_to_real_path}; +use nu_protocol::ast::Call; +use nu_protocol::engine::{Command, EngineState, Stack}; +use nu_protocol::{ + Category, Example, PipelineData, ShellError, Signature, Spanned, SyntaxShape, Type, +}; +use std::ffi::OsString; +use std::path::PathBuf; +use uu_mv::{BackupMode, UpdateMode}; + +#[derive(Clone)] +pub struct UMv; + +impl Command for UMv { + fn name(&self) -> &str { + "umv" + } + + fn usage(&self) -> &str { + "Move files or directories." + } + + fn examples(&self) -> Vec { + vec![ + Example { + description: "Rename a file", + example: "umv before.txt after.txt", + result: None, + }, + Example { + description: "Move a file into a directory", + example: "umv test.txt my/subdirectory", + result: None, + }, + Example { + description: "Move many files into a directory", + example: "umv *.txt my/subdirectory", + result: None, + }, + ] + } + + fn search_terms(&self) -> Vec<&str> { + vec!["move"] + } + + fn signature(&self) -> nu_protocol::Signature { + Signature::build("umv") + .input_output_types(vec![(Type::Nothing, Type::Nothing)]) + .switch("force", "do not prompt before overwriting", Some('f')) + .switch("verbose", "explain what is being done.", Some('v')) + .switch("progress", "display a progress bar", Some('p')) + .switch("interactive", "prompt before overwriting", Some('i')) + .switch("no-clobber", "do not overwrite an existing file", Some('n')) + .rest( + "paths", + SyntaxShape::Filepath, + "Rename SRC to DST, or move SRC to DIR.", + ) + .allow_variants_without_examples(true) + .category(Category::FileSystem) + } + + fn run( + &self, + engine_state: &EngineState, + stack: &mut Stack, + call: &Call, + _input: PipelineData, + ) -> Result { + let interactive = call.has_flag(engine_state, stack, "interactive")?; + let no_clobber = call.has_flag(engine_state, stack, "no-clobber")?; + let progress = call.has_flag(engine_state, stack, "progress")?; + let verbose = call.has_flag(engine_state, stack, "verbose")?; + let overwrite = if no_clobber { + uu_mv::OverwriteMode::NoClobber + } else if interactive { + uu_mv::OverwriteMode::Interactive + } else { + uu_mv::OverwriteMode::Force + }; + + let paths: Vec> = call.rest(engine_state, stack, 0)?; + let paths: Vec> = paths + .into_iter() + .map(|p| Spanned { + item: nu_utils::strip_ansi_string_unlikely(p.item), + span: p.span, + }) + .collect(); + if paths.is_empty() { + return Err(ShellError::GenericError { + error: "Missing file operand".into(), + msg: "Missing file operand".into(), + span: Some(call.head), + help: Some("Please provide source and destination paths".into()), + inner: Vec::new(), + }); + } + if paths.len() == 1 { + return Err(ShellError::GenericError { + error: "Missing destination path".into(), + msg: format!("Missing destination path operand after {}", paths[0].item), + span: Some(paths[0].span), + help: None, + inner: Vec::new(), + }); + } + + // Do not glob target + let sources = &paths[..paths.len() - 1]; + let cwd = current_dir(engine_state, stack)?; + let mut files: Vec = Vec::new(); + for p in sources { + let exp_files = arg_glob(p, &cwd)?.collect::>(); + if exp_files.is_empty() { + return Err(ShellError::FileNotFound { span: p.span }); + }; + let mut app_vals: Vec = Vec::new(); + for v in exp_files { + match v { + Ok(path) => { + app_vals.push(path); + } + Err(e) => { + return Err(ShellError::ErrorExpandingGlob { + msg: format!("error {} in path {}", e.error(), e.path().display()), + span: p.span, + }); + } + } + } + files.append(&mut app_vals); + } + // Add back the target after globbing + let spanned_target = paths.last().ok_or(ShellError::NushellFailedSpanned { + msg: "Missing file operand".into(), + label: "Missing file operand".into(), + span: call.head, + })?; + let expanded_target = expand_to_real_path(spanned_target.item.clone()); + let abs_target_path = expand_path_with(expanded_target, &cwd); + files.push(abs_target_path.clone()); + let files = files + .into_iter() + .map(|p| p.into_os_string()) + .collect::>(); + let options = uu_mv::Options { + overwrite, + progress_bar: progress, + verbose, + suffix: String::from("~"), + backup: BackupMode::NoBackup, + update: UpdateMode::ReplaceAll, + target_dir: None, + no_target_dir: false, + strip_slashes: false, + }; + if let Err(error) = uu_mv::mv(&files, &options) { + return Err(ShellError::GenericError { + error: format!("{}", error), + msg: format!("{}", error), + span: None, + help: None, + inner: Vec::new(), + }); + } + Ok(PipelineData::empty()) + } +} diff --git a/crates/nu-command/tests/commands/move_/mod.rs b/crates/nu-command/tests/commands/move_/mod.rs index 58d0a7f6cd..cfaef6e60f 100644 --- a/crates/nu-command/tests/commands/move_/mod.rs +++ b/crates/nu-command/tests/commands/move_/mod.rs @@ -1,2 +1,3 @@ mod column; mod mv; +mod umv; diff --git a/crates/nu-command/tests/commands/move_/umv.rs b/crates/nu-command/tests/commands/move_/umv.rs new file mode 100644 index 0000000000..7af7bfc853 --- /dev/null +++ b/crates/nu-command/tests/commands/move_/umv.rs @@ -0,0 +1,594 @@ +use nu_test_support::fs::{files_exist_at, Stub::EmptyFile, Stub::FileWithContent}; +use nu_test_support::nu; +use nu_test_support::playground::Playground; +use rstest::rstest; + +#[test] +fn moves_a_file() { + Playground::setup("umv_test_1", |dirs, sandbox| { + sandbox + .with_files(vec![EmptyFile("andres.txt")]) + .mkdir("expected"); + + let original = dirs.test().join("andres.txt"); + let expected = dirs.test().join("expected/yehuda.txt"); + + nu!( + cwd: dirs.test(), + "umv andres.txt expected/yehuda.txt" + ); + + assert!(!original.exists()); + assert!(expected.exists()); + }) +} + +#[test] +fn overwrites_if_moving_to_existing_file_and_force_provided() { + Playground::setup("umv_test_2", |dirs, sandbox| { + sandbox.with_files(vec![EmptyFile("andres.txt"), EmptyFile("jttxt")]); + + let original = dirs.test().join("andres.txt"); + let expected = dirs.test().join("jttxt"); + + nu!( + cwd: dirs.test(), + "umv andres.txt -f jttxt" + ); + + assert!(!original.exists()); + assert!(expected.exists()); + }) +} + +#[test] +fn moves_a_directory() { + Playground::setup("umv_test_3", |dirs, sandbox| { + sandbox.mkdir("empty_dir"); + + let original_dir = dirs.test().join("empty_dir"); + let expected = dirs.test().join("renamed_dir"); + + nu!( + cwd: dirs.test(), + "umv empty_dir renamed_dir" + ); + + assert!(!original_dir.exists()); + assert!(expected.exists()); + }) +} + +#[test] +fn moves_the_file_inside_directory_if_path_to_move_is_existing_directory() { + Playground::setup("umv_test_4", |dirs, sandbox| { + sandbox + .with_files(vec![EmptyFile("jttxt")]) + .mkdir("expected"); + + let original_dir = dirs.test().join("jttxt"); + let expected = dirs.test().join("expected/jttxt"); + + nu!( + cwd: dirs.test(), + "umv jttxt expected" + ); + + assert!(!original_dir.exists()); + assert!(expected.exists()); + }) +} + +#[test] +fn moves_the_directory_inside_directory_if_path_to_move_is_existing_directory() { + Playground::setup("umv_test_5", |dirs, sandbox| { + sandbox + .within("contributors") + .with_files(vec![EmptyFile("jttxt")]) + .mkdir("expected"); + + let original_dir = dirs.test().join("contributors"); + let expected = dirs.test().join("expected/contributors"); + + nu!( + cwd: dirs.test(), + "umv contributors expected" + ); + + assert!(!original_dir.exists()); + assert!(expected.exists()); + assert!(files_exist_at(vec!["jttxt"], expected)) + }) +} + +#[test] +fn moves_using_path_with_wildcard() { + Playground::setup("umv_test_7", |dirs, sandbox| { + sandbox + .within("originals") + .with_files(vec![ + EmptyFile("andres.ini"), + EmptyFile("caco3_plastics.csv"), + EmptyFile("cargo_sample.toml"), + EmptyFile("jt.ini"), + EmptyFile("jt.xml"), + EmptyFile("sgml_description.json"), + EmptyFile("sample.ini"), + EmptyFile("utf16.ini"), + EmptyFile("yehuda.ini"), + ]) + .mkdir("work_dir") + .mkdir("expected"); + + let work_dir = dirs.test().join("work_dir"); + let expected = dirs.test().join("expected"); + + nu!(cwd: work_dir, "umv ../originals/*.ini ../expected"); + + assert!(files_exist_at( + vec!["yehuda.ini", "jt.ini", "sample.ini", "andres.ini",], + expected + )); + }) +} + +#[test] +fn moves_using_a_glob() { + Playground::setup("umv_test_8", |dirs, sandbox| { + sandbox + .within("meals") + .with_files(vec![ + EmptyFile("arepa.txt"), + EmptyFile("empanada.txt"), + EmptyFile("taquiza.txt"), + ]) + .mkdir("work_dir") + .mkdir("expected"); + + let meal_dir = dirs.test().join("meals"); + let work_dir = dirs.test().join("work_dir"); + let expected = dirs.test().join("expected"); + + nu!(cwd: work_dir, "umv ../meals/* ../expected"); + + assert!(meal_dir.exists()); + assert!(files_exist_at( + vec!["arepa.txt", "empanada.txt", "taquiza.txt",], + expected + )); + }) +} + +#[test] +fn moves_a_directory_with_files() { + Playground::setup("umv_test_9", |dirs, sandbox| { + sandbox + .mkdir("vehicles/car") + .mkdir("vehicles/bicycle") + .with_files(vec![ + EmptyFile("vehicles/car/car1.txt"), + EmptyFile("vehicles/car/car2.txt"), + ]) + .with_files(vec![ + EmptyFile("vehicles/bicycle/bicycle1.txt"), + EmptyFile("vehicles/bicycle/bicycle2.txt"), + ]); + + let original_dir = dirs.test().join("vehicles"); + let expected_dir = dirs.test().join("expected"); + + nu!( + cwd: dirs.test(), + "umv vehicles expected" + ); + + assert!(!original_dir.exists()); + assert!(expected_dir.exists()); + assert!(files_exist_at( + vec![ + "car/car1.txt", + "car/car2.txt", + "bicycle/bicycle1.txt", + "bicycle/bicycle2.txt" + ], + expected_dir + )); + }) +} + +#[test] +fn errors_if_source_doesnt_exist() { + Playground::setup("umv_test_10", |dirs, sandbox| { + sandbox.mkdir("test_folder"); + let actual = nu!( + cwd: dirs.test(), + "umv non-existing-file test_folder/" + ); + assert!(actual.err.contains("file not found")); + }) +} + +#[test] +#[ignore = "GNU/uutils overwrites rather than error out"] +fn error_if_moving_to_existing_file_without_force() { + Playground::setup("umv_test_10_0", |dirs, sandbox| { + sandbox.with_files(vec![EmptyFile("andres.txt"), EmptyFile("jttxt")]); + + let actual = nu!( + cwd: dirs.test(), + "umv andres.txt jttxt" + ); + assert!(actual.err.contains("file already exists")) + }) +} + +#[test] +fn errors_if_destination_doesnt_exist() { + Playground::setup("umv_test_10_1", |dirs, sandbox| { + sandbox.with_files(vec![EmptyFile("empty.txt")]); + + let actual = nu!( + cwd: dirs.test(), + "umv empty.txt does/not/exist/" + ); + + assert!(actual.err.contains("failed to access")); + }) +} + +#[test] +#[ignore = "GNU/uutils doesnt expand, rather cannot stat 'file?.txt'"] +fn errors_if_multiple_sources_but_destination_not_a_directory() { + Playground::setup("umv_test_10_2", |dirs, sandbox| { + sandbox.with_files(vec![ + EmptyFile("file1.txt"), + EmptyFile("file2.txt"), + EmptyFile("file3.txt"), + ]); + + let actual = nu!( + cwd: dirs.test(), + "umv file?.txt not_a_dir" + ); + + assert!(actual + .err + .contains("Can only move multiple sources if destination is a directory")); + }) +} + +#[test] +fn errors_if_renaming_directory_to_an_existing_file() { + Playground::setup("umv_test_10_3", |dirs, sandbox| { + sandbox + .mkdir("mydir") + .with_files(vec![EmptyFile("empty.txt")]); + + let actual = nu!( + cwd: dirs.test(), + "umv mydir empty.txt" + ); + assert!(actual.err.contains("cannot overwrite non-directory"),); + assert!(actual.err.contains("with directory"),); + }) +} + +#[test] +fn errors_if_moving_to_itself() { + Playground::setup("umv_test_10_4", |dirs, sandbox| { + sandbox.mkdir("mydir").mkdir("mydir/mydir_2"); + + let actual = nu!( + cwd: dirs.test(), + "umv mydir mydir/mydir_2/" + ); + assert!(actual.err.contains("cannot move")); + assert!(actual.err.contains("to a subdirectory of")); + }); +} + +#[test] +fn does_not_error_on_relative_parent_path() { + Playground::setup("umv_test_11", |dirs, sandbox| { + sandbox + .mkdir("first") + .with_files(vec![EmptyFile("first/william_hartnell.txt")]); + + let original = dirs.test().join("first/william_hartnell.txt"); + let expected = dirs.test().join("william_hartnell.txt"); + + nu!( + cwd: dirs.test().join("first"), + "umv william_hartnell.txt ./.." + ); + + assert!(!original.exists()); + assert!(expected.exists()); + }) +} + +#[test] +fn move_files_using_glob_two_parents_up_using_multiple_dots() { + Playground::setup("umv_test_12", |dirs, sandbox| { + sandbox.within("foo").within("bar").with_files(vec![ + EmptyFile("jtjson"), + EmptyFile("andres.xml"), + EmptyFile("yehuda.yaml"), + EmptyFile("kevin.txt"), + EmptyFile("many_more.ppl"), + ]); + + nu!( + cwd: dirs.test().join("foo/bar"), + r#" + umv * ... + "# + ); + + let files = vec![ + "yehuda.yaml", + "jtjson", + "andres.xml", + "kevin.txt", + "many_more.ppl", + ]; + + let original_dir = dirs.test().join("foo/bar"); + let destination_dir = dirs.test(); + + assert!(files_exist_at(files.clone(), destination_dir)); + assert!(!files_exist_at(files, original_dir)) + }) +} + +#[test] +fn move_file_from_two_parents_up_using_multiple_dots_to_current_dir() { + Playground::setup("cp_test_10", |dirs, sandbox| { + sandbox.with_files(vec![EmptyFile("hello_there")]); + sandbox.within("foo").mkdir("bar"); + + nu!( + cwd: dirs.test().join("foo/bar"), + r#" + umv .../hello_there . + "# + ); + + let expected = dirs.test().join("foo/bar/hello_there"); + let original = dirs.test().join("hello_there"); + + assert!(expected.exists()); + assert!(!original.exists()); + }) +} + +#[test] +fn does_not_error_when_some_file_is_moving_into_itself() { + Playground::setup("umv_test_13", |dirs, sandbox| { + sandbox.mkdir("11").mkdir("12"); + + let original_dir = dirs.test().join("11"); + let expected = dirs.test().join("12/11"); + nu!(cwd: dirs.test(), "umv 1* 12"); + + assert!(!original_dir.exists()); + assert!(expected.exists()); + }) +} + +#[test] +fn mv_ignores_ansi() { + Playground::setup("umv_test_ansi", |_dirs, sandbox| { + sandbox.with_files(vec![EmptyFile("test.txt")]); + let actual = nu!( + cwd: sandbox.cwd(), + r#" + ls | find test | umv $in.0.name success.txt; ls | $in.0.name + "# + ); + + assert_eq!(actual.out, "success.txt"); + }) +} + +#[test] +fn mv_directory_with_same_name() { + Playground::setup("umv_test_directory_with_same_name", |_dirs, sandbox| { + sandbox.mkdir("testdir"); + sandbox.mkdir("testdir/testdir"); + + let cwd = sandbox.cwd().join("testdir"); + let actual = nu!( + cwd: cwd, + r#" + umv testdir .. + "# + ); + assert!(actual.err.contains("Directory not empty")); + }) +} + +#[test] +// Test that changing the case of a file/directory name works; +// this is an important edge case on Windows (and any other case-insensitive file systems). +// We were bitten badly by this once: https://github.com/nushell/nushell/issues/6583 + +// Currently as we are using `uutils` and have no say in the behavior, this should succeed on Linux, +// but fail on both macOS and Windows. +fn mv_change_case_of_directory() { + Playground::setup("mv_change_case_of_directory", |dirs, sandbox| { + sandbox + .mkdir("somedir") + .with_files(vec![EmptyFile("somedir/somefile.txt")]); + + let original_dir = String::from("somedir"); + let new_dir = String::from("SomeDir"); + + let _actual = nu!( + cwd: dirs.test(), + format!("umv {original_dir} {new_dir}") + ); + + // Doing this instead of `Path::exists()` because we need to check file existence in + // a case-sensitive way. `Path::exists()` is understandably case-insensitive on NTFS + let _files_in_test_directory: Vec = std::fs::read_dir(dirs.test()) + .unwrap() + .map(|de| de.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + + #[cfg(target_os = "linux")] + assert!( + !_files_in_test_directory.contains(&original_dir) + && _files_in_test_directory.contains(&new_dir) + ); + + #[cfg(target_os = "linux")] + assert!(files_exist_at( + vec!["somefile.txt",], + dirs.test().join(new_dir) + )); + + #[cfg(not(target_os = "linux"))] + _actual.err.contains("to a subdirectory of itself"); + }) +} + +#[test] +// Currently as we are using `uutils` and have no say in the behavior, this should succeed on Linux, +// but fail on both macOS and Windows. +fn mv_change_case_of_file() { + Playground::setup("mv_change_case_of_file", |dirs, sandbox| { + sandbox.with_files(vec![EmptyFile("somefile.txt")]); + + let original_file_name = String::from("somefile.txt"); + let new_file_name = String::from("SomeFile.txt"); + + let _actual = nu!( + cwd: dirs.test(), + format!("umv {original_file_name} -f {new_file_name}") + ); + + // Doing this instead of `Path::exists()` because we need to check file existence in + // a case-sensitive way. `Path::exists()` is understandably case-insensitive on NTFS + let _files_in_test_directory: Vec = std::fs::read_dir(dirs.test()) + .unwrap() + .map(|de| de.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + #[cfg(target_os = "linux")] + assert!( + !_files_in_test_directory.contains(&original_file_name) + && _files_in_test_directory.contains(&new_file_name) + ); + #[cfg(not(target_os = "linux"))] + _actual.err.contains("are the same file"); + }) +} + +#[test] +#[ignore = "Update not supported..remove later"] +fn mv_with_update_flag() { + Playground::setup("mv_with_update_flag", |_dirs, sandbox| { + sandbox.with_files(vec![ + EmptyFile("valid.txt"), + FileWithContent("newer_valid.txt", "body"), + ]); + + let actual = nu!( + cwd: sandbox.cwd(), + "umv -uf valid.txt newer_valid.txt; open newer_valid.txt", + ); + assert_eq!(actual.out, "body"); + + // create a file after assert to make sure that newest_valid.txt is newest + std::thread::sleep(std::time::Duration::from_secs(1)); + sandbox.with_files(vec![FileWithContent("newest_valid.txt", "newest_body")]); + let actual = nu!(cwd: sandbox.cwd(), "umv -uf newest_valid.txt valid.txt; open valid.txt"); + assert_eq!(actual.out, "newest_body"); + + // when destination doesn't exist + sandbox.with_files(vec![FileWithContent("newest_valid.txt", "newest_body")]); + let actual = nu!(cwd: sandbox.cwd(), "umv -uf newest_valid.txt des_missing.txt; open des_missing.txt"); + assert_eq!(actual.out, "newest_body"); + }); +} + +#[test] +fn test_mv_no_clobber() { + Playground::setup("umv_test_13", |dirs, sandbox| { + let file_a = "test_mv_no_clobber_file_a"; + let file_b = "test_mv_no_clobber_file_b"; + sandbox.with_files(vec![EmptyFile(file_a)]); + sandbox.with_files(vec![EmptyFile(file_b)]); + + let actual = nu!( + cwd: dirs.test(), + "umv -n {} {}", + file_a, + file_b, + ); + assert!(actual.err.contains("not replacing")); + }) +} + +#[test] +fn mv_with_no_arguments() { + Playground::setup("umv_test_14", |dirs, _| { + let actual = nu!( + cwd: dirs.test(), + "umv", + ); + assert!(actual.err.contains("Missing file operand")); + }) +} + +#[test] +fn mv_with_no_target() { + Playground::setup("umv_test_15", |dirs, _| { + let actual = nu!( + cwd: dirs.test(), + "umv a", + ); + assert!(actual.err.contains( + format!( + "Missing destination path operand after {}", + dirs.test().join("a").display() + ) + .as_str() + )); + }) +} + +#[rstest] +#[case(r#"'a]c'"#)] +#[case(r#"'a[c'"#)] +#[case(r#"'a[bc]d'"#)] +#[case(r#"'a][c'"#)] +fn mv_files_with_glob_metachars(#[case] src_name: &str) { + Playground::setup("umv_test_16", |dirs, sandbox| { + sandbox.with_files(vec![FileWithContent( + src_name, + "What is the sound of one hand clapping?", + )]); + + let src = dirs.test().join(src_name); + + let actual = nu!( + cwd: dirs.test(), + "umv {} {}", + src.display(), + "hello_world_dest" + ); + + assert!(actual.err.is_empty()); + assert!(dirs.test().join("hello_world_dest").exists()); + }); +} + +#[cfg(not(windows))] +#[rstest] +#[case(r#"'a]?c'"#)] +#[case(r#"'a*.?c'"#)] +// windows doesn't allow filename with `*`. +fn mv_files_with_glob_metachars_nw(#[case] src_name: &str) { + mv_files_with_glob_metachars(src_name); +}