1
Fork 0
mirror of https://github.com/RGBCube/uutils-coreutils synced 2025-07-28 03:27:44 +00:00

mv: gnu test case mv-n compatibility (#6599)

* uucore: add update control `none-fail`

* uucore: show suggestion when parse errors occurs because of an ambiguous value

* added tests for fail-none and ambiguous parse error

* uucore: ambiguous value code refractor

* cp: no-clobber fail silently and outputs skipped message in debug

* mv: add --debug support

* minor changes

---------

Co-authored-by: Sylvestre Ledru <sylvestre@debian.org>
This commit is contained in:
sreehari prasad 2024-09-14 12:41:17 +05:30 committed by GitHub
parent db402875f6
commit 8a9fb84a8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 147 additions and 63 deletions

View file

@ -39,7 +39,7 @@ use uucore::{backup_control, update_control};
pub use uucore::{backup_control::BackupMode, update_control::UpdateMode}; pub use uucore::{backup_control::BackupMode, update_control::UpdateMode};
use uucore::{ use uucore::{
format_usage, help_about, help_section, help_usage, prompt_yes, format_usage, help_about, help_section, help_usage, prompt_yes,
shortcut_value_parser::ShortcutValueParser, show_error, show_warning, util_name, shortcut_value_parser::ShortcutValueParser, show_error, show_warning,
}; };
use crate::copydir::copy_directory; use crate::copydir::copy_directory;
@ -79,8 +79,10 @@ quick_error! {
StripPrefixError(err: StripPrefixError) { from() } StripPrefixError(err: StripPrefixError) { from() }
/// Result of a skipped file /// Result of a skipped file
/// Currently happens when "no" is selected in interactive mode /// Currently happens when "no" is selected in interactive mode or when
Skipped { } /// `no-clobber` flag is set and destination is already present.
/// `exit with error` is used to determine which exit code should be returned.
Skipped(exit_with_error:bool) { }
/// Result of a skipped file /// Result of a skipped file
InvalidArgument(description: String) { display("{}", description) } InvalidArgument(description: String) { display("{}", description) }
@ -1210,7 +1212,7 @@ fn show_error_if_needed(error: &Error) {
Error::NotAllFilesCopied => { Error::NotAllFilesCopied => {
// Need to return an error code // Need to return an error code
} }
Error::Skipped => { Error::Skipped(_) => {
// touch a b && echo "n"|cp -i a b && echo $? // touch a b && echo "n"|cp -i a b && echo $?
// should return an error from GNU 9.2 // should return an error from GNU 9.2
} }
@ -1295,7 +1297,9 @@ pub fn copy(sources: &[PathBuf], target: &Path, options: &Options) -> CopyResult
&mut copied_files, &mut copied_files,
) { ) {
show_error_if_needed(&error); show_error_if_needed(&error);
non_fatal_errors = true; if !matches!(error, Error::Skipped(false)) {
non_fatal_errors = true;
}
} else { } else {
copied_destinations.insert(dest.clone()); copied_destinations.insert(dest.clone());
} }
@ -1397,17 +1401,19 @@ fn copy_source(
} }
impl OverwriteMode { impl OverwriteMode {
fn verify(&self, path: &Path) -> CopyResult<()> { fn verify(&self, path: &Path, debug: bool) -> CopyResult<()> {
match *self { match *self {
Self::NoClobber => { Self::NoClobber => {
eprintln!("{}: not replacing {}", util_name(), path.quote()); if debug {
Err(Error::NotAllFilesCopied) println!("skipped {}", path.quote());
}
Err(Error::Skipped(false))
} }
Self::Interactive(_) => { Self::Interactive(_) => {
if prompt_yes!("overwrite {}?", path.quote()) { if prompt_yes!("overwrite {}?", path.quote()) {
Ok(()) Ok(())
} else { } else {
Err(Error::Skipped) Err(Error::Skipped(true))
} }
} }
Self::Clobber(_) => Ok(()), Self::Clobber(_) => Ok(()),
@ -1654,7 +1660,7 @@ fn handle_existing_dest(
} }
if options.update != UpdateMode::ReplaceIfOlder { if options.update != UpdateMode::ReplaceIfOlder {
options.overwrite.verify(dest)?; options.overwrite.verify(dest, options.debug)?;
} }
let mut is_dest_removed = false; let mut is_dest_removed = false;
@ -1892,6 +1898,9 @@ fn handle_copy_mode(
return Ok(()); return Ok(());
} }
update_control::UpdateMode::ReplaceNoneFail => {
return Err(Error::Error(format!("not replacing '{}'", dest.display())));
}
update_control::UpdateMode::ReplaceIfOlder => { update_control::UpdateMode::ReplaceIfOlder => {
let dest_metadata = fs::symlink_metadata(dest)?; let dest_metadata = fs::symlink_metadata(dest)?;
@ -1900,7 +1909,7 @@ fn handle_copy_mode(
if src_time <= dest_time { if src_time <= dest_time {
return Ok(()); return Ok(());
} else { } else {
options.overwrite.verify(dest)?; options.overwrite.verify(dest, options.debug)?;
copy_helper( copy_helper(
source, source,
@ -2262,7 +2271,7 @@ fn copy_helper(
File::create(dest).context(dest.display().to_string())?; File::create(dest).context(dest.display().to_string())?;
} else if source_is_fifo && options.recursive && !options.copy_contents { } else if source_is_fifo && options.recursive && !options.copy_contents {
#[cfg(unix)] #[cfg(unix)]
copy_fifo(dest, options.overwrite)?; copy_fifo(dest, options.overwrite, options.debug)?;
} else if source_is_symlink { } else if source_is_symlink {
copy_link(source, dest, symlinked_files)?; copy_link(source, dest, symlinked_files)?;
} else { } else {
@ -2287,9 +2296,9 @@ fn copy_helper(
// "Copies" a FIFO by creating a new one. This workaround is because Rust's // "Copies" a FIFO by creating a new one. This workaround is because Rust's
// built-in fs::copy does not handle FIFOs (see rust-lang/rust/issues/79390). // built-in fs::copy does not handle FIFOs (see rust-lang/rust/issues/79390).
#[cfg(unix)] #[cfg(unix)]
fn copy_fifo(dest: &Path, overwrite: OverwriteMode) -> CopyResult<()> { fn copy_fifo(dest: &Path, overwrite: OverwriteMode, debug: bool) -> CopyResult<()> {
if dest.exists() { if dest.exists() {
overwrite.verify(dest)?; overwrite.verify(dest, debug)?;
fs::remove_file(dest)?; fs::remove_file(dest)?;
} }

View file

@ -83,6 +83,9 @@ pub struct Options {
/// '-g, --progress' /// '-g, --progress'
pub progress_bar: bool, pub progress_bar: bool,
/// `--debug`
pub debug: bool,
} }
/// specifies behavior of the overwrite flag /// specifies behavior of the overwrite flag
@ -109,6 +112,7 @@ static OPT_NO_TARGET_DIRECTORY: &str = "no-target-directory";
static OPT_VERBOSE: &str = "verbose"; static OPT_VERBOSE: &str = "verbose";
static OPT_PROGRESS: &str = "progress"; static OPT_PROGRESS: &str = "progress";
static ARG_FILES: &str = "files"; static ARG_FILES: &str = "files";
static OPT_DEBUG: &str = "debug";
#[uucore::main] #[uucore::main]
pub fn uumain(args: impl uucore::Args) -> UResult<()> { pub fn uumain(args: impl uucore::Args) -> UResult<()> {
@ -135,10 +139,14 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let backup_mode = backup_control::determine_backup_mode(&matches)?; let backup_mode = backup_control::determine_backup_mode(&matches)?;
let update_mode = update_control::determine_update_mode(&matches); let update_mode = update_control::determine_update_mode(&matches);
if overwrite_mode == OverwriteMode::NoClobber && backup_mode != BackupMode::NoBackup { if backup_mode != BackupMode::NoBackup
&& (overwrite_mode == OverwriteMode::NoClobber
|| update_mode == UpdateMode::ReplaceNone
|| update_mode == UpdateMode::ReplaceNoneFail)
{
return Err(UUsageError::new( return Err(UUsageError::new(
1, 1,
"options --backup and --no-clobber are mutually exclusive", "cannot combine --backup with -n/--no-clobber or --update=none-fail",
)); ));
} }
@ -161,9 +169,10 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
update: update_mode, update: update_mode,
target_dir, target_dir,
no_target_dir: matches.get_flag(OPT_NO_TARGET_DIRECTORY), no_target_dir: matches.get_flag(OPT_NO_TARGET_DIRECTORY),
verbose: matches.get_flag(OPT_VERBOSE), verbose: matches.get_flag(OPT_VERBOSE) || matches.get_flag(OPT_DEBUG),
strip_slashes: matches.get_flag(OPT_STRIP_TRAILING_SLASHES), strip_slashes: matches.get_flag(OPT_STRIP_TRAILING_SLASHES),
progress_bar: matches.get_flag(OPT_PROGRESS), progress_bar: matches.get_flag(OPT_PROGRESS),
debug: matches.get_flag(OPT_DEBUG),
}; };
mv(&files[..], &opts) mv(&files[..], &opts)
@ -256,6 +265,12 @@ pub fn uu_app() -> Command {
.value_parser(ValueParser::os_string()) .value_parser(ValueParser::os_string())
.value_hint(clap::ValueHint::AnyPath), .value_hint(clap::ValueHint::AnyPath),
) )
.arg(
Arg::new(OPT_DEBUG)
.long(OPT_DEBUG)
.help("explain how a file is copied. Implies -v")
.action(ArgAction::SetTrue),
)
} }
fn determine_overwrite_mode(matches: &ArgMatches) -> OverwriteMode { fn determine_overwrite_mode(matches: &ArgMatches) -> OverwriteMode {
@ -521,6 +536,9 @@ fn rename(
} }
if opts.update == UpdateMode::ReplaceNone { if opts.update == UpdateMode::ReplaceNone {
if opts.debug {
println!("skipped {}", to.quote());
}
return Ok(()); return Ok(());
} }
@ -530,10 +548,17 @@ fn rename(
return Ok(()); return Ok(());
} }
if opts.update == UpdateMode::ReplaceNoneFail {
let err_msg = format!("not replacing {}", to.quote());
return Err(io::Error::new(io::ErrorKind::Other, err_msg));
}
match opts.overwrite { match opts.overwrite {
OverwriteMode::NoClobber => { OverwriteMode::NoClobber => {
let err_msg = format!("not replacing {}", to.quote()); if opts.debug {
return Err(io::Error::new(io::ErrorKind::Other, err_msg)); println!("skipped {}", to.quote());
}
return Ok(());
} }
OverwriteMode::Interactive => { OverwriteMode::Interactive => {
if !prompt_yes!("overwrite {}?", to.quote()) { if !prompt_yes!("overwrite {}?", to.quote()) {

View file

@ -59,6 +59,7 @@ pub enum UpdateMode {
/// --update=`older` /// --update=`older`
/// -u /// -u
ReplaceIfOlder, ReplaceIfOlder,
ReplaceNoneFail,
} }
pub mod arguments { pub mod arguments {
@ -76,7 +77,7 @@ pub mod arguments {
clap::Arg::new(OPT_UPDATE) clap::Arg::new(OPT_UPDATE)
.long("update") .long("update")
.help("move only when the SOURCE file is newer than the destination file or when the destination file is missing") .help("move only when the SOURCE file is newer than the destination file or when the destination file is missing")
.value_parser(ShortcutValueParser::new(["none", "all", "older"])) .value_parser(ShortcutValueParser::new(["none", "all", "older","none-fail"]))
.num_args(0..=1) .num_args(0..=1)
.default_missing_value("older") .default_missing_value("older")
.require_equals(true) .require_equals(true)
@ -130,6 +131,7 @@ pub fn determine_update_mode(matches: &ArgMatches) -> UpdateMode {
"all" => UpdateMode::ReplaceAll, "all" => UpdateMode::ReplaceAll,
"none" => UpdateMode::ReplaceNone, "none" => UpdateMode::ReplaceNone,
"older" => UpdateMode::ReplaceIfOlder, "older" => UpdateMode::ReplaceIfOlder,
"none-fail" => UpdateMode::ReplaceNoneFail,
_ => unreachable!("other args restricted by clap"), _ => unreachable!("other args restricted by clap"),
} }
} else if matches.get_flag(arguments::OPT_UPDATE_NO_ARG) { } else if matches.get_flag(arguments::OPT_UPDATE_NO_ARG) {

View file

@ -2,7 +2,7 @@
// //
// For the full copyright and license information, please view the LICENSE // For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code. // file that was distributed with this source code.
// spell-checker:ignore abcdefgh abef // spell-checker:ignore abcdefgh abef Strs
//! A parser that accepts shortcuts for values. //! A parser that accepts shortcuts for values.
//! `ShortcutValueParser` is similar to clap's `PossibleValuesParser` //! `ShortcutValueParser` is similar to clap's `PossibleValuesParser`
@ -32,6 +32,7 @@ impl ShortcutValueParser {
cmd: &clap::Command, cmd: &clap::Command,
arg: Option<&clap::Arg>, arg: Option<&clap::Arg>,
value: &str, value: &str,
possible_values: &[&PossibleValue],
) -> clap::Error { ) -> clap::Error {
let mut err = clap::Error::new(ErrorKind::InvalidValue).with_cmd(cmd); let mut err = clap::Error::new(ErrorKind::InvalidValue).with_cmd(cmd);
@ -52,10 +53,39 @@ impl ShortcutValueParser {
ContextValue::Strings(self.0.iter().map(|x| x.get_name().to_string()).collect()), ContextValue::Strings(self.0.iter().map(|x| x.get_name().to_string()).collect()),
); );
// if `possible_values` is not empty then that means this error is because of an ambiguous value.
if !possible_values.is_empty() {
add_ambiguous_value_tip(possible_values, &mut err, value);
}
err err
} }
} }
/// Adds a suggestion when error is because of ambiguous values based on the provided possible values.
fn add_ambiguous_value_tip(
possible_values: &[&PossibleValue],
err: &mut clap::error::Error,
value: &str,
) {
let mut formatted_possible_values = String::new();
for (i, s) in possible_values.iter().enumerate() {
formatted_possible_values.push_str(&format!("'{}'", s.get_name()));
if i < possible_values.len() - 2 {
formatted_possible_values.push_str(", ");
} else if i < possible_values.len() - 1 {
formatted_possible_values.push_str(" or ");
}
}
err.insert(
ContextKind::Suggested,
ContextValue::StyledStrs(vec![format!(
"It looks like '{}' could match several values. Did you mean {}?",
value, formatted_possible_values
)
.into()]),
);
}
impl TypedValueParser for ShortcutValueParser { impl TypedValueParser for ShortcutValueParser {
type Value = String; type Value = String;
@ -76,13 +106,13 @@ impl TypedValueParser for ShortcutValueParser {
.collect(); .collect();
match matched_values.len() { match matched_values.len() {
0 => Err(self.generate_clap_error(cmd, arg, value)), 0 => Err(self.generate_clap_error(cmd, arg, value, &[])),
1 => Ok(matched_values[0].get_name().to_string()), 1 => Ok(matched_values[0].get_name().to_string()),
_ => { _ => {
if let Some(direct_match) = matched_values.iter().find(|x| x.get_name() == value) { if let Some(direct_match) = matched_values.iter().find(|x| x.get_name() == value) {
Ok(direct_match.get_name().to_string()) Ok(direct_match.get_name().to_string())
} else { } else {
Err(self.generate_clap_error(cmd, arg, value)) Err(self.generate_clap_error(cmd, arg, value, &matched_values))
} }
} }
} }
@ -143,7 +173,11 @@ mod tests {
for ambiguous_value in ambiguous_values { for ambiguous_value in ambiguous_values {
let result = parser.parse_ref(&cmd, None, OsStr::new(ambiguous_value)); let result = parser.parse_ref(&cmd, None, OsStr::new(ambiguous_value));
assert_eq!(ErrorKind::InvalidValue, result.unwrap_err().kind()); assert_eq!(ErrorKind::InvalidValue, result.as_ref().unwrap_err().kind());
assert!(result.unwrap_err().to_string().contains(&format!(
"It looks like '{}' could match several values. Did you mean 'abcd' or 'abef'?",
ambiguous_value
)));
} }
let result = parser.parse_ref(&cmd, None, OsStr::new("abc")); let result = parser.parse_ref(&cmd, None, OsStr::new("abc"));

View file

@ -324,18 +324,25 @@ fn test_cp_arg_update_interactive_error() {
#[test] #[test]
fn test_cp_arg_update_none() { fn test_cp_arg_update_none() {
for argument in ["--update=none", "--update=non", "--update=n"] { let (at, mut ucmd) = at_and_ucmd!();
let (at, mut ucmd) = at_and_ucmd!(); ucmd.arg(TEST_HELLO_WORLD_SOURCE)
.arg(TEST_HOW_ARE_YOU_SOURCE)
.arg("--update=none")
.succeeds()
.no_stderr()
.no_stdout();
assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "How are you?\n");
}
ucmd.arg(TEST_HELLO_WORLD_SOURCE) #[test]
.arg(TEST_HOW_ARE_YOU_SOURCE) fn test_cp_arg_update_none_fail() {
.arg(argument) let (at, mut ucmd) = at_and_ucmd!();
.succeeds() ucmd.arg(TEST_HELLO_WORLD_SOURCE)
.no_stderr() .arg(TEST_HOW_ARE_YOU_SOURCE)
.no_stdout(); .arg("--update=none-fail")
.fails()
assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "How are you?\n"); .stderr_contains(format!("not replacing '{}'", TEST_HOW_ARE_YOU_SOURCE));
} assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "How are you?\n");
} }
#[test] #[test]
@ -588,10 +595,9 @@ fn test_cp_arg_interactive_verbose_clobber() {
let (at, mut ucmd) = at_and_ucmd!(); let (at, mut ucmd) = at_and_ucmd!();
at.touch("a"); at.touch("a");
at.touch("b"); at.touch("b");
ucmd.args(&["-vin", "a", "b"]) ucmd.args(&["-vin", "--debug", "a", "b"])
.fails() .succeeds()
.stderr_is("cp: not replacing 'b'\n") .stdout_contains("skipped 'b'");
.no_stdout();
} }
#[test] #[test]
@ -690,8 +696,9 @@ fn test_cp_arg_no_clobber() {
ucmd.arg(TEST_HELLO_WORLD_SOURCE) ucmd.arg(TEST_HELLO_WORLD_SOURCE)
.arg(TEST_HOW_ARE_YOU_SOURCE) .arg(TEST_HOW_ARE_YOU_SOURCE)
.arg("--no-clobber") .arg("--no-clobber")
.fails() .arg("--debug")
.stderr_contains("not replacing"); .succeeds()
.stdout_contains("skipped 'how_are_you.txt'");
assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "How are you?\n"); assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "How are you?\n");
} }
@ -702,7 +709,9 @@ fn test_cp_arg_no_clobber_inferred_arg() {
ucmd.arg(TEST_HELLO_WORLD_SOURCE) ucmd.arg(TEST_HELLO_WORLD_SOURCE)
.arg(TEST_HOW_ARE_YOU_SOURCE) .arg(TEST_HOW_ARE_YOU_SOURCE)
.arg("--no-clob") .arg("--no-clob")
.fails(); .arg("--debug")
.succeeds()
.stdout_contains("skipped 'how_are_you.txt'");
assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "How are you?\n"); assert_eq!(at.read(TEST_HOW_ARE_YOU_SOURCE), "How are you?\n");
} }
@ -718,6 +727,7 @@ fn test_cp_arg_no_clobber_twice() {
.arg("--no-clobber") .arg("--no-clobber")
.arg("source.txt") .arg("source.txt")
.arg("dest.txt") .arg("dest.txt")
.arg("--debug")
.succeeds() .succeeds()
.no_stderr(); .no_stderr();
@ -729,7 +739,9 @@ fn test_cp_arg_no_clobber_twice() {
.arg("--no-clobber") .arg("--no-clobber")
.arg("source.txt") .arg("source.txt")
.arg("dest.txt") .arg("dest.txt")
.fails(); .arg("--debug")
.succeeds()
.stdout_contains("skipped 'dest.txt'");
assert_eq!(at.read("source.txt"), "some-content"); assert_eq!(at.read("source.txt"), "some-content");
// Should be empty as the "no-clobber" should keep // Should be empty as the "no-clobber" should keep
@ -1773,11 +1785,12 @@ fn test_cp_preserve_links_case_7() {
ucmd.arg("-n") ucmd.arg("-n")
.arg("--preserve=links") .arg("--preserve=links")
.arg("--debug")
.arg("src/f") .arg("src/f")
.arg("src/g") .arg("src/g")
.arg("dest") .arg("dest")
.fails() .succeeds()
.stderr_contains("not replacing"); .stdout_contains("skipped");
assert!(at.dir_exists("dest")); assert!(at.dir_exists("dest"));
assert!(at.plus("dest").join("f").exists()); assert!(at.plus("dest").join("f").exists());

View file

@ -299,9 +299,9 @@ fn test_mv_interactive_no_clobber_force_last_arg_wins() {
scene scene
.ucmd() .ucmd()
.args(&[file_a, file_b, "-f", "-i", "-n"]) .args(&[file_a, file_b, "-f", "-i", "-n", "--debug"])
.fails() .succeeds()
.stderr_is(format!("mv: not replacing '{file_b}'\n")); .stdout_contains("skipped 'b.txt'");
scene scene
.ucmd() .ucmd()
@ -352,9 +352,9 @@ fn test_mv_no_clobber() {
ucmd.arg("-n") ucmd.arg("-n")
.arg(file_a) .arg(file_a)
.arg(file_b) .arg(file_b)
.fails() .arg("--debug")
.code_is(1) .succeeds()
.stderr_only(format!("mv: not replacing '{file_b}'\n")); .stdout_contains("skipped 'test_mv_no_clobber_file_b");
assert!(at.file_exists(file_a)); assert!(at.file_exists(file_a));
assert!(at.file_exists(file_b)); assert!(at.file_exists(file_b));
@ -863,14 +863,16 @@ fn test_mv_backup_off() {
} }
#[test] #[test]
fn test_mv_backup_no_clobber_conflicting_options() { fn test_mv_backup_conflicting_options() {
new_ucmd!() for conflicting_opt in ["--no-clobber", "--update=none-fail", "--update=none"] {
.arg("--backup") new_ucmd!()
.arg("--no-clobber") .arg("--backup")
.arg("file1") .arg(conflicting_opt)
.arg("file2") .arg("file1")
.fails() .arg("file2")
.usage_error("options --backup and --no-clobber are mutually exclusive"); .fails()
.usage_error("cannot combine --backup with -n/--no-clobber or --update=none-fail");
}
} }
#[test] #[test]
@ -1400,10 +1402,9 @@ fn test_mv_arg_interactive_skipped_vin() {
let (at, mut ucmd) = at_and_ucmd!(); let (at, mut ucmd) = at_and_ucmd!();
at.touch("a"); at.touch("a");
at.touch("b"); at.touch("b");
ucmd.args(&["-vin", "a", "b"]) ucmd.args(&["-vin", "a", "b", "--debug"])
.fails() .succeeds()
.stderr_is("mv: not replacing 'b'\n") .stdout_contains("skipped 'b'");
.no_stdout();
} }
#[test] #[test]