From 5cd9140f647f1c56ad9c3d4a8f1c347b852cacf3 Mon Sep 17 00:00:00 2001 From: mmc Date: Wed, 19 Aug 2026 21:31:00 -0500 Subject: [PATCH 1/2] rename: implement the rename utility rename replaces a substring in filenames. The option surface follows rename(1): -v, -s, -n, -a, -l, -o and -i, with all three operands required. Behavior throughout is what util-linux 2.42.2 and 2.40.4 were both observed doing. The parts a reader should not have to reverse engineer: - The exit status is two counters rather than bit flags. 0 means something was renamed, 1 that everything failed, 2 a mix, and 4 that nothing matched. An operand that matched nothing, that -o skipped, or that was declined at an -i prompt counts as neither, so it can neither degrade a success nor promote a failure. - Only the final path component is rewritten, unless either operand holds a separator, which widens the scope to the whole path. Trailing separators are stripped from the operand, but the existence check still sees the operand exactly as it was typed. - An empty substring inserts at every code-unit boundary rather than matching nowhere, and a substring equal to its replacement exits 4 before any syscall runs. - The two overwrite guards ask different questions deliberately. The default path uses a check that follows symlinks, so a dangling destination is clobbered; -s lstats the new target name, so a dangling entry blocks it. - Filenames are carried as OsStr and the substitution engine is generic over the code unit: bytes on unix, UTF-16 units on Windows. A name that is not valid Unicode survives the report and the diagnostics without being replaced. Known divergences, each structural to clap and Rust rather than a defect in the port: the --help and --version layout, ANSI escapes on a terminal, the eager mutual-exclusion check, the rpmatch answer set, and SIGPIPE, which Rust ignores and the reference dies of. --- Cargo.lock | 10 + Cargo.toml | 2 + src/uu/rename/Cargo.toml | 19 ++ src/uu/rename/rename.md | 7 + src/uu/rename/src/argv.rs | 60 +++++ src/uu/rename/src/encoding.rs | 95 +++++++ src/uu/rename/src/errors.rs | 109 ++++++++ src/uu/rename/src/main.rs | 1 + src/uu/rename/src/output.rs | 69 +++++ src/uu/rename/src/prompt.rs | 136 ++++++++++ src/uu/rename/src/rename.rs | 458 ++++++++++++++++++++++++++++++++++ src/uu/rename/src/subst.rs | 410 ++++++++++++++++++++++++++++++ 12 files changed, 1376 insertions(+) create mode 100644 src/uu/rename/Cargo.toml create mode 100644 src/uu/rename/rename.md create mode 100644 src/uu/rename/src/argv.rs create mode 100644 src/uu/rename/src/encoding.rs create mode 100644 src/uu/rename/src/errors.rs create mode 100644 src/uu/rename/src/main.rs create mode 100644 src/uu/rename/src/output.rs create mode 100644 src/uu/rename/src/prompt.rs create mode 100644 src/uu/rename/src/rename.rs create mode 100644 src/uu/rename/src/subst.rs diff --git a/Cargo.lock b/Cargo.lock index 57ea0e95..022b6651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1561,6 +1561,7 @@ dependencies = [ "uu_mesg", "uu_mountpoint", "uu_nologin", + "uu_rename", "uu_renice", "uu_rev", "uu_setpgid", @@ -1744,6 +1745,15 @@ dependencies = [ "uucore 0.2.2", ] +[[package]] +name = "uu_rename" +version = "0.0.1" +dependencies = [ + "clap", + "libc", + "uucore 0.2.2", +] + [[package]] name = "uu_renice" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index 4b253cc5..bce324d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ feat_common_core = [ "mesg", "mountpoint", "nologin", + "rename", "renice", "rev", "setpgid", @@ -116,6 +117,7 @@ mcookie = { optional = true, version = "0.0.1", package = "uu_mcookie", path = " mesg = { optional = true, version = "0.0.1", package = "uu_mesg", path = "src/uu/mesg" } mountpoint = { optional = true, version = "0.0.1", package = "uu_mountpoint", path = "src/uu/mountpoint" } nologin = { optional = true, version = "0.0.1", package = "uu_nologin", path = "src/uu/nologin" } +rename = { optional = true, version = "0.0.1", package = "uu_rename", path = "src/uu/rename" } renice = { optional = true, version = "0.0.1", package = "uu_renice", path = "src/uu/renice" } rev = { optional = true, version = "0.0.1", package = "uu_rev", path = "src/uu/rev" } setpgid = { optional = true, version = "0.0.1", package = "uu_setpgid", path = "src/uu/setpgid" } diff --git a/src/uu/rename/Cargo.toml b/src/uu/rename/Cargo.toml new file mode 100644 index 00000000..938a99e9 --- /dev/null +++ b/src/uu/rename/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "uu_rename" +version = "0.0.1" +edition = "2021" +description = "rename ~ (uutils) Rename files by substring replacement" + +[lib] +path = "src/rename.rs" + +[[bin]] +name = "rename" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true } +uucore = { workspace = true } + +[target.'cfg(unix)'.dependencies] +libc = { workspace = true } diff --git a/src/uu/rename/rename.md b/src/uu/rename/rename.md new file mode 100644 index 00000000..0dac6d2c --- /dev/null +++ b/src/uu/rename/rename.md @@ -0,0 +1,7 @@ +# rename + +``` +rename [options] ... +``` + +Rename files. diff --git a/src/uu/rename/src/argv.rs b/src/uu/rename/src/argv.rs new file mode 100644 index 00000000..df83033d --- /dev/null +++ b/src/uu/rename/src/argv.rs @@ -0,0 +1,60 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Where getopt would have stopped reading options. +//! +//! rename(1) permutes options with operands, so a flag is recognized anywhere +//! before a `--` - after all three operands, or between the substring and the +//! replacement. `POSIXLY_CORRECT` turns that off, and its mere presence does +//! it whatever the value: scanning stops at the first argument that is not an +//! option, and everything from there on is a filename however much it looks +//! like a flag. +//! +//! clap has no such concept, so rather than teach it one the argv is +//! terminated where getopt would have stopped and clap is handed the result. + +use std::env; +use std::ffi::{OsStr, OsString}; + +const TERMINATOR: &str = "--"; + +pub(crate) fn collect_getopt_argv(args: impl uucore::Args) -> Vec { + terminate_at_first_operand(args.collect(), env::var_os("POSIXLY_CORRECT").is_some()) +} + +/// A bare `-` is an operand and not an option, so it stops the scan like any +/// other name. `--` is longer than one unit and so answers true here, which is +/// why the caller tests for it first. +fn is_option(arg: &OsStr) -> bool { + // ASCII survives this on every platform: the encoded form keeps ASCII bytes + // as themselves, which is all a leading `-` needs. + matches!(arg.as_encoded_bytes(), [b'-', _, ..]) +} + +fn terminate_at_first_operand(mut argv: Vec, posixly_correct: bool) -> Vec { + if !posixly_correct { + return argv; + } + + let mut operand_at = None; + // argv[0] is the utility's own name and is never scanned. + for (index, arg) in argv.iter().enumerate().skip(1) { + if arg == TERMINATOR { + // getopt consumes this one itself, so there is nothing to insert + // and nothing after it to protect. + return argv; + } + if !is_option(arg) { + operand_at = Some(index); + break; + } + } + + if let Some(index) = operand_at { + argv.insert(index, OsString::from(TERMINATOR)); + } + + argv +} diff --git a/src/uu/rename/src/encoding.rs b/src/uu/rename/src/encoding.rs new file mode 100644 index 00000000..2b6830aa --- /dev/null +++ b/src/uu/rename/src/encoding.rs @@ -0,0 +1,95 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! The bridge between OsStr and the code units the engine works on. +//! +//! Both directions are lossless: bytes on unix, UTF-16 units on Windows. The +//! path separator is 0x2F in either encoding, so the scope rule needs no +//! special casing. `\` is deliberately not a separator here: rename(1) +//! documents `/`, and Rust accepts `/` on Windows too. +//! +//! Only unix and windows are covered; a third target does not build. +//! +//! Writing a name is done here rather than through `uucore::display::OsWrite` +//! because that trait refuses a Windows name that is not valid Unicode, with +//! `io::ErrorKind::InvalidData`, where a report has to print whatever the name +//! actually is - and because it has no impl for stderr. + +#[cfg(unix)] +mod imp { + use std::ffi::{OsStr, OsString}; + use std::io::{self, Write}; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + use std::path::Path; + + pub(crate) type Unit = u8; + pub(crate) const SEP: Unit = b'/'; + + pub(crate) fn units(s: &OsStr) -> Vec { + s.as_bytes().to_vec() + } + + pub(crate) fn os_string(units: Vec) -> OsString { + OsString::from_vec(units) + } + + /// Filenames are written raw. A name holding an invalid byte is emitted as + /// that byte, which is what C util-linux does and what makes its -v output + /// round-trip through a shell. + pub(crate) fn write_os(w: &mut impl Write, s: &OsStr) -> io::Result<()> { + w.write_all(s.as_bytes()) + } + + pub(crate) fn symlink(target: &OsStr, link: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(target, link) + } +} + +#[cfg(windows)] +mod imp { + use std::ffi::{OsStr, OsString}; + use std::io::{self, Write}; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use std::path::Path; + + pub(crate) type Unit = u16; + pub(crate) const SEP: Unit = b'/' as u16; + + pub(crate) fn units(s: &OsStr) -> Vec { + s.encode_wide().collect() + } + + pub(crate) fn os_string(units: Vec) -> OsString { + OsString::from_wide(&units) + } + + /// There is no raw byte form of a Windows filename, so a lone surrogate is + /// replaced here. Unlike the unix arm this is lossy, and only for output. + pub(crate) fn write_os(w: &mut impl Write, s: &OsStr) -> io::Result<()> { + w.write_all(s.to_string_lossy().as_bytes()) + } + + /// Windows splits the call in two and needs the privilege or Developer + /// Mode to make either. Which one is right depends on what the target + /// names, and a relative target is stored relative to the LINK, so it is + /// resolved from the link's own directory - not from the process working + /// directory, which would answer about a different object and leave a link + /// Windows reports as broken. The -o guard resolves relative to the + /// process instead, because that side has C util-linux to conform to and + /// this side has none. + /// + /// A target that does not resolve is treated as a file, and whatever the + /// OS then says is passed back to the caller rather than pre-empted here. + pub(crate) fn symlink(target: &OsStr, link: &Path) -> io::Result<()> { + let base = link.parent().unwrap_or_else(|| Path::new("")); + if base.join(target).is_dir() { + std::os::windows::fs::symlink_dir(target, link) + } else { + std::os::windows::fs::symlink_file(target, link) + } + } +} + +pub(crate) use imp::{os_string, symlink, units, write_os, Unit, SEP}; diff --git a/src/uu/rename/src/errors.rs b/src/uu/rename/src/errors.rs new file mode 100644 index 00000000..7cce09da --- /dev/null +++ b/src/uu/rename/src/errors.rs @@ -0,0 +1,109 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! Per-operand diagnostics. +//! +//! Each variant keeps the path it complains about as an `OsString` and writes +//! it out as the bytes it is made of. A name that is not valid UTF-8 is exactly +//! the kind of name people reach for rename to fix, and C util-linux prints it +//! raw on stderr just as it does on stdout; rendering it through a `String` +//! would replace it with U+FFFD. +//! +//! That is also why these do not implement `Display`. `Formatter` writes +//! `&str`, so the diagnostic and a lossless name are mutually exclusive. +//! +//! The trailing " (os error NN)" that Rust appends to an io::Error is stripped +//! back off: C util-linux prints the bare strerror text and nothing else. + +use std::ffi::OsString; +use std::io::{self, Write}; + +use uucore::error::strip_errno; + +use crate::output::Output; + +#[derive(Debug)] +pub(crate) enum RenameError { + NotAccessible { + path: OsString, + source: io::Error, + }, + + NotASymlink { + path: OsString, + }, + + RenameFailed { + old: OsString, + new: OsString, + source: io::Error, + }, + + /// The two halves of a symlink rewrite report separately, as they do for + /// C util-linux: a link in a directory that denies writes fails at the + /// unlink, and says so. + UnlinkFailed { + path: OsString, + source: io::Error, + }, + + /// This one names the target it tried to create, so an empty target prints + /// two spaces between "to" and "failed". + SymlinkFailed { + path: OsString, + new: OsString, + source: io::Error, + }, + + WriteFailed { + source: io::Error, + }, +} + +impl RenameError { + /// The `rename: ` prefix is written here rather than by `show_error!`, + /// which renders through `format!` and cannot carry a name losslessly. + pub(crate) fn report(&self, out: &mut Output) { + out.write_bytes(uucore::util_name().as_bytes()); + out.write_bytes(b": "); + + match self { + Self::NotAccessible { path, source } => { + out.write_os(path); + out.write_bytes(b": not accessible: "); + out.write_bytes(strip_errno(source).as_bytes()); + } + Self::NotASymlink { path } => { + out.write_os(path); + out.write_bytes(b": not a symbolic link"); + } + Self::RenameFailed { old, new, source } => { + out.write_os(old); + out.write_bytes(b": rename to "); + out.write_os(new); + out.write_bytes(b" failed: "); + out.write_bytes(strip_errno(source).as_bytes()); + } + Self::UnlinkFailed { path, source } => { + out.write_os(path); + out.write_bytes(b": unlink failed: "); + out.write_bytes(strip_errno(source).as_bytes()); + } + Self::SymlinkFailed { path, new, source } => { + out.write_os(path); + out.write_bytes(b": symlinking to "); + out.write_os(new); + out.write_bytes(b" failed: "); + out.write_bytes(strip_errno(source).as_bytes()); + } + Self::WriteFailed { source } => { + out.write_bytes(b"write error: "); + out.write_bytes(strip_errno(source).as_bytes()); + } + } + + out.write_bytes(b"\n"); + } +} diff --git a/src/uu/rename/src/main.rs b/src/uu/rename/src/main.rs new file mode 100644 index 00000000..661fe143 --- /dev/null +++ b/src/uu/rename/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_rename); diff --git a/src/uu/rename/src/output.rs b/src/uu/rename/src/output.rs new file mode 100644 index 00000000..bb126bf9 --- /dev/null +++ b/src/uu/rename/src/output.rs @@ -0,0 +1,69 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! A stream that remembers its first write failure instead of propagating it. +//! +//! Both of rename's streams need this, and for the same reason: C util-linux +//! finishes the whole run and then reports a failed write once, exiting 1 +//! whatever the tally says. The renames still happen; only the report is lost. + +use std::ffi::OsStr; +use std::io::{self, Write}; + +use crate::encoding; + +pub(crate) struct Output { + inner: W, + error: Option, +} + +impl Output { + pub(crate) fn new(inner: W) -> Self { + Self { inner, error: None } + } + + fn record(&mut self, result: io::Result<()>) { + if let Err(error) = result { + self.error.get_or_insert(error); + } + } + + pub(crate) fn write_bytes(&mut self, bytes: &[u8]) { + let result = self.inner.write_all(bytes); + self.record(result); + } + + /// Names go out raw, so one holding a newline really does split the line in + /// two - as it does for C util-linux, on either stream. + pub(crate) fn write_os(&mut self, value: &OsStr) { + let result = encoding::write_os(&mut self.inner, value); + self.record(result); + } + + pub(crate) fn write_quoted(&mut self, value: &OsStr) { + self.write_bytes(b"`"); + self.write_os(value); + self.write_bytes(b"'"); + } + + pub(crate) fn flush(&mut self) { + let result = self.inner.flush(); + self.record(result); + } + + pub(crate) fn into_error(self) -> Option { + self.error + } + + /// On the REPORT stream C util-linux is selective about which failed write + /// it complains of at exit: a full disk yes, a reader that has gone away + /// no, so a run piped into something short-lived still reports what it did. + /// It is not selective on the diagnostic stream, which is why this is a + /// method here and not a rule in `into_error`. + pub(crate) fn into_reported_error(self) -> Option { + self.into_error() + .filter(|error| error.kind() != io::ErrorKind::BrokenPipe) + } +} diff --git a/src/uu/rename/src/prompt.rs b/src/uu/rename/src/prompt.rs new file mode 100644 index 00000000..f1dfbc28 --- /dev/null +++ b/src/uu/rename/src/prompt.rs @@ -0,0 +1,136 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +//! The -i prompt, and how much of stdin it takes. +//! +//! C util-linux decides this once, from the terminal settings on fd 0, before +//! the first operand is looked at, and the answer has two shapes. Off a +//! terminal, and on one whose line discipline assembles lines, a whole line is +//! consumed per prompt and a read holding no newline is read again. On a +//! terminal in non-canonical mode nothing will ever supply that newline, so one +//! read is the answer whatever it holds, the rest of it is discarded, and the +//! newline the terminal never sent is written to stdout instead. +//! +//! Reading a line rather than the whole answer is not an optimization. It is +//! what keeps a run bounded: a stdin that never ends would otherwise be +//! accumulated until the allocator gives up. + +use std::ffi::OsStr; +use std::io::{self, BufRead, Write}; + +use crate::output::Output; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Answering { + Line, + Keystroke, +} + +impl Answering { + pub(crate) fn from_stdin() -> Self { + if imp::stdin_assembles_lines() { + Self::Line + } else { + Self::Keystroke + } + } + + /// Ask, then take one answer. Accepted when it begins with `y` or `Y`, + /// which is what rpmatch accepts under LC_ALL=C; a locale whose YESEXPR + /// differs is a known divergence. + pub(crate) fn accepts(self, out: &mut Output, new: &OsStr) -> bool { + out.write_bytes(uucore::util_name().as_bytes()); + out.write_bytes(b": overwrite "); + out.write_quoted(new); + out.write_bytes(b"? "); + out.flush(); + + let answer = self.read(&mut io::stdin().lock(), out); + match answer { + Some(answer) => matches!(answer, b'y' | b'Y'), + None => { + // A prompt that reaches end of input declines, and says so: + // C util-linux echoes a literal `n` and terminates the line + // the prompt left open. The echo is not the locale's nostr, + // and a typed decline never produces it. + out.write_bytes(b"n\n"); + false + } + } + } + + /// The first byte of the answer, or `None` at end of input - which includes + /// a stdin that cannot be read at all, because C util-linux cannot tell + /// those two apart either and assumes the same answer for both. + /// + /// Only the first byte is ever kept, so the buffer never grows with the + /// input. + fn read(self, input: &mut R, out: &mut Output) -> Option { + let mut first = None; + + loop { + let (leading, available, newline) = match input.fill_buf() { + Ok([]) | Err(_) => break, + Ok(chunk) => ( + chunk.first().copied(), + chunk.len(), + chunk.iter().position(|byte| *byte == b'\n'), + ), + }; + + first = first.or(leading); + + match self { + Self::Keystroke => { + input.consume(available); + if first != Some(b'\n') { + out.write_bytes(b"\n"); + } + return first; + } + Self::Line => match newline { + Some(end) => { + input.consume(end + 1); + return first; + } + None => input.consume(available), + }, + } + } + + // An answer the input ended without terminating is still an answer. + first + } +} + +#[cfg(unix)] +mod imp { + /// Anything that is not a terminal is read through a buffer that stops at a + /// newline, and a terminal assembles lines exactly when ICANON is set. + pub(super) fn stdin_assembles_lines() -> bool { + let mut termios = std::mem::MaybeUninit::::uninit(); + + // SAFETY: tcgetattr writes the struct only on success, and this is the + // only reference to it. + let described = unsafe { libc::tcgetattr(libc::STDIN_FILENO, termios.as_mut_ptr()) } == 0; + if !described { + return true; + } + + // SAFETY: tcgetattr returned success, so it initialized c_lflag. + let c_lflag = unsafe { (*termios.as_ptr()).c_lflag }; + c_lflag & libc::ICANON != 0 + } +} + +#[cfg(windows)] +mod imp { + /// Windows has no termios. A console reads a line at a time unless a + /// program clears ENABLE_LINE_INPUT, which nothing in this utility does. + /// C util-linux does not run here, so there is nothing to conform to. + pub(super) fn stdin_assembles_lines() -> bool { + true + } +} diff --git a/src/uu/rename/src/rename.rs b/src/uu/rename/src/rename.rs new file mode 100644 index 00000000..a171c8e3 --- /dev/null +++ b/src/uu/rename/src/rename.rs @@ -0,0 +1,458 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::io::{self, BufWriter, IsTerminal, Write}; +use std::path::Path; + +use clap::builder::ValueParser; +use clap::{crate_version, Arg, ArgAction, ArgMatches, Command}; +use uucore::error::{set_exit_code, UResult}; +use uucore::{format_usage, help_about, help_usage}; + +mod argv; +mod encoding; +mod errors; +mod output; +mod prompt; +mod subst; + +use argv::collect_getopt_argv; +use encoding::{os_string, units, Unit, SEP}; +use errors::RenameError; +use output::Output; +use prompt::Answering; +use subst::{rewrite, Mode}; + +const ABOUT: &str = help_about!("rename.md"); +const USAGE: &str = help_usage!("rename.md"); + +mod options { + pub const VERBOSE: &str = "verbose"; + pub const SYMLINK: &str = "symlink"; + pub const NO_ACT: &str = "no-act"; + pub const ALL: &str = "all"; + pub const LAST: &str = "last"; + pub const NO_OVERWRITE: &str = "no-overwrite"; + pub const INTERACTIVE: &str = "interactive"; + pub const SUBSTRING: &str = "substring"; + pub const REPLACEMENT: &str = "replacement"; + pub const FILES: &str = "files"; +} + +/// What the run did, counted per operand. +/// +/// The status is selected from two counters rather than or'd together, so a +/// no-match, a name that did not change, an -o skip and an -i decline - which +/// touch neither counter - can neither degrade a success nor promote a +/// failure. The documented 64, for an unanticipated error, is never produced. +#[derive(Debug, Default)] +struct Tally { + /// Operands whose computed name differed from the current one and whose + /// operation succeeded. Note that this is not the same as "the tree + /// changed": two links to one inode rename successfully and change nothing. + renamed: usize, + failed: usize, +} + +impl Tally { + fn code(&self) -> i32 { + match (self.renamed > 0, self.failed > 0) { + (true, true) => 2, + (true, false) => 0, + (false, true) => 1, + (false, false) => 4, + } + } +} + +/// What one operand did. A skip, a no-match and an unchanged name are all +/// `Neither`; C util-linux cannot tell them apart either. +enum Outcome { + Renamed, + Neither, +} + +struct Options { + verbose: bool, + symlink: bool, + no_act: bool, + no_overwrite: bool, + interactive: bool, + mode: Mode, + needle: Vec, + replacement: Vec, +} + +impl Options { + fn from_matches(matches: &ArgMatches) -> Self { + let mode = if matches.get_flag(options::ALL) { + Mode::All + } else if matches.get_flag(options::LAST) { + Mode::Last + } else { + Mode::First + }; + + Self { + verbose: matches.get_flag(options::VERBOSE), + symlink: matches.get_flag(options::SYMLINK), + no_act: matches.get_flag(options::NO_ACT), + no_overwrite: matches.get_flag(options::NO_OVERWRITE), + interactive: matches.get_flag(options::INTERACTIVE), + mode, + needle: argument(matches, options::SUBSTRING), + replacement: argument(matches, options::REPLACEMENT), + } + } +} + +/// Both are `required(true)`, so clap has already refused an invocation that +/// omits them; the default stands in for an `unwrap` the review guidelines +/// forbid. +fn argument(matches: &ArgMatches, id: &str) -> Vec { + matches + .get_one::(id) + .map(|value| units(value.as_os_str())) + .unwrap_or_default() +} + +#[uucore::main] +pub fn uumain(args: impl uucore::Args) -> UResult<()> { + let matches = match uu_app().try_get_matches_from(collect_getopt_argv(args)) { + Ok(matches) => matches, + Err(error) => { + report_parse_failure(&error); + return Ok(()); + } + }; + let options = Options::from_matches(&matches); + + // An identical substring and replacement short-circuit the whole run, not + // one operand: C util-linux issues no filesystem syscall at all, even for + // operands that do not exist. + if options.needle == options.replacement { + set_exit_code(4); + return Ok(()); + } + + let mut tally = Tally::default(); + let stdout = io::stdout(); + // C util-linux's stdout is stdio's: line buffered on a terminal and fully + // buffered anywhere else. Rust's is line buffered everywhere, and the + // difference is not only a matter of syscall counts - under a write limit + // it stops our loop part way through a run C util-linux finishes. + let sink: Box = if stdout.is_terminal() { + Box::new(stdout.lock()) + } else { + Box::new(BufWriter::new(stdout.lock())) + }; + let mut out = Output::new(sink); + let mut err = Output::new(io::stderr().lock()); + + // Asked once for the whole run, before any operand is looked at, which is + // where C util-linux asks it too. + let answering = Answering::from_stdin(); + + for operand in matches + .get_many::(options::FILES) + .unwrap_or_default() + { + match rename_one(&options, answering, operand, &mut out) { + Ok(Outcome::Renamed) => tally.renamed += 1, + Ok(Outcome::Neither) => {} + Err(error) => { + error.report(&mut err); + tally.failed += 1; + } + } + } + + out.flush(); + // A failed write discards the tally on either stream, and C util-linux + // finishes the run first either way. Only the report stream's failure has + // anywhere left to report itself, and only it forgives a reader that left. + let report_failed = if let Some(source) = out.into_reported_error() { + RenameError::WriteFailed { source }.report(&mut err); + true + } else { + false + }; + err.flush(); + let diagnostics_failed = err.into_error().is_some(); + + set_exit_code(if report_failed || diagnostics_failed { + 1 + } else { + tally.code() + }); + + Ok(()) +} + +/// clap has already decided what to say and which stream to say it on; all +/// that is left is to say it without unwrapping the write. +/// +/// The `?` this replaces hands the error to uucore, which prints it through a +/// `Display` impl that cannot report a failed write and panics on one. Calling +/// `print` here returns that result instead. Note the status comes from +/// `use_stderr` and not from clap's own `exit_code`, which is 2 for a usage +/// error where both C util-linux and this tree use 1. +fn report_parse_failure(error: &clap::Error) { + let written = error.print(); + let mut err = Output::new(io::stderr().lock()); + + match written { + // A reader that has gone away is not reported, here or at exit. + Err(source) if source.kind() != io::ErrorKind::BrokenPipe => { + RenameError::WriteFailed { source }.report(&mut err); + err.flush(); + set_exit_code(1); + } + _ => set_exit_code(i32::from(error.use_stderr())), + } +} + +/// One operand, start to finish. The only place that touches the filesystem. +fn rename_one( + options: &Options, + answering: Answering, + operand: &OsStr, + out: &mut Output, +) -> Result { + // The existence check keeps the operand exactly as it was typed, trailing + // separators and all, which is why `d1/s1/` reports ENOTDIR instead of + // renaming `d1/s1`. Everything after this point uses the stripped form. + let metadata = fs::symlink_metadata(operand).map_err(|source| RenameError::NotAccessible { + path: operand.to_os_string(), + source, + })?; + + // This precedes the match test, so a non-symlink fails even when the + // substring appears nowhere in its name. + if options.symlink && !metadata.file_type().is_symlink() { + return Err(RenameError::NotASymlink { + path: operand.to_os_string(), + }); + } + + // Symlink mode rewrites the link's target text and never the link's own + // name; the chain is not resolved, so a link to a link sees only the next + // hop's text. + let source = if options.symlink { + fs::read_link(operand) + .map_err(|source| RenameError::NotAccessible { + path: operand.to_os_string(), + source, + })? + .into_os_string() + } else { + operand.to_os_string() + }; + + let source_units = units(&source); + let change = rewrite( + &source_units, + &options.needle, + &options.replacement, + options.mode, + SEP, + ); + if change.is_unchanged() { + return Ok(Outcome::Neither); + } + + let old = os_string(change.old.to_vec()); + let new = os_string(change.new); + + // Only the two safeguards ask, and so C util-linux only asks for them: + // without -o or -i it never looks at the destination at all, and neither + // does this. + let taken = (options.no_overwrite || options.interactive) + && if options.symlink { + link_target_entry_exists(&new) + } else { + destination_exists_following_links(&new) + }; + + let skipped = if !taken { + false + } else if options.interactive && !options.no_act { + !answering.accepts(out, &new) + } else { + // -o, or -i under -n: -n never prompts and never reads stdin. `taken` + // is only ever set under one of the two safeguards, so reaching here + // means the other one is in force. + true + }; + if skipped { + report_skip(out, options, operand, &old, &new); + } + + if options.no_act { + // The verbose line prints even for an operand the guard skipped, so a + // skipped operand under -n prints two lines and counts as neither. + report(out, options, operand, &old, &new); + return Ok(if skipped { + Outcome::Neither + } else { + Outcome::Renamed + }); + } + + if skipped { + return Ok(Outcome::Neither); + } + + if options.symlink { + // Not atomic, and deliberately so: C util-linux unlinks then creates + // too, and a failure to create the replacement leaves the original + // link gone. + fs::remove_file(operand).map_err(|source| RenameError::UnlinkFailed { + path: operand.to_os_string(), + source, + })?; + encoding::symlink(&new, Path::new(operand)).map_err(|source| { + RenameError::SymlinkFailed { + path: operand.to_os_string(), + new: new.clone(), + source, + } + })?; + } else { + fs::rename(&old, &new).map_err(|source| RenameError::RenameFailed { + old: old.clone(), + new: new.clone(), + source, + })?; + } + + report(out, options, operand, &old, &new); + Ok(Outcome::Renamed) +} + +/// The default path asks whether anything is reachable at the destination, so +/// it follows symlinks: a dangling symlink sitting there does not count as +/// existing, and -o lets the rename clobber it. A probe that cannot be +/// performed at all - a parent directory denying search - reads as absent, +/// which is measured behavior and not a defensive default. +/// +/// Both guards ask whether the NAME is taken, never whether it names the same +/// file as the source, as C util-linux does. That shows on a filesystem which +/// folds case, where a rename differing only in case finds its own source at +/// the destination: -o skips it and -i prompts for it. +fn destination_exists_following_links(path: &OsStr) -> bool { + Path::new(path).try_exists().unwrap_or(false) +} + +/// Symlink mode asks a different question: is there an ENTRY at the new target +/// path? This is an lstat where the one above is a stat, so a dangling entry +/// counts here and blocks the rewrite. Do not merge the two. +fn link_target_entry_exists(path: &OsStr) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn report( + out: &mut Output, + options: &Options, + operand: &OsStr, + old: &OsStr, + new: &OsStr, +) { + if !options.verbose { + return; + } + + if options.symlink { + out.write_os(operand); + out.write_bytes(b": "); + } + out.write_quoted(old); + out.write_bytes(b" -> "); + out.write_quoted(new); + out.write_bytes(b"\n"); +} + +/// The skip report, which unlike the prompt is printed only under -v. +fn report_skip( + out: &mut Output, + options: &Options, + operand: &OsStr, + old: &OsStr, + new: &OsStr, +) { + if !options.verbose { + return; + } + + if options.symlink { + // Symlink mode names the link and the target it still has. + out.write_bytes(b"Skipping existing link: "); + out.write_quoted(operand); + out.write_bytes(b" -> "); + out.write_quoted(old); + } else { + out.write_bytes(b"Skipping existing file: "); + out.write_quoted(new); + } + out.write_bytes(b"\n"); +} + +fn flag(id: &'static str, short: char, help: &'static str) -> Arg { + Arg::new(id) + .short(short) + .long(id) + .help(help) + .action(ArgAction::SetTrue) +} + +pub fn uu_app() -> Command { + Command::new(uucore::util_name()) + .version(crate_version!()) + .about(ABOUT) + .override_usage(format_usage(USAGE)) + .infer_long_args(true) + // getopt lets a flag repeat, and so does C util-linux: `-v -v` renames. + // A SetTrue argument rejects its second occurrence without this, and + // it does not weaken `conflicts_with`, which clap validates separately. + .args_override_self(true) + .arg(flag(options::VERBOSE, 'v', "explain what is being done")) + .arg(flag(options::SYMLINK, 's', "act on the target of symlinks")) + .arg(flag(options::NO_ACT, 'n', "do not make any changes")) + .arg(flag(options::ALL, 'a', "replace all occurrences").conflicts_with(options::LAST)) + .arg(flag(options::LAST, 'l', "replace only the last occurrence")) + .arg( + flag(options::NO_OVERWRITE, 'o', "don't overwrite existing files") + .conflicts_with(options::INTERACTIVE), + ) + .arg(flag(options::INTERACTIVE, 'i', "prompt before overwrite")) + // The usage line already names all three, and C util-linux's help has + // no operand section at all, so listing them again would print three + // names with nothing beside them. + .arg( + Arg::new(options::SUBSTRING) + .value_name("substring") + .required(true) + .hide(true) + .value_parser(ValueParser::os_string()), + ) + .arg( + Arg::new(options::REPLACEMENT) + .value_name("replacement") + .required(true) + .hide(true) + .value_parser(ValueParser::os_string()), + ) + .arg( + Arg::new(options::FILES) + .value_name("file") + .required(true) + .num_args(1..) + .hide(true) + .action(ArgAction::Append) + .value_parser(ValueParser::os_string()), + ) +} diff --git a/src/uu/rename/src/subst.rs b/src/uu/rename/src/subst.rs new file mode 100644 index 00000000..00f85b20 --- /dev/null +++ b/src/uu/rename/src/subst.rs @@ -0,0 +1,410 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +// spell-checker:ignore (words) axbxcx abxcx aaaaaaaa abab abcZ axbxc axbxcZ +// spell-checker:ignore (words) aZbxcx aZbZcZ Zabc ZaZbZcZ ZxZ ZYaZYbZYcZY + +//! The substitution engine. Pure: no filesystem, no syscalls, no encoding +//! assumptions. Generic over the filename code unit so the same rules apply to +//! bytes on unix and to UTF-16 units on Windows. + +/// Which occurrence of the substring a run replaces. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Mode { + First, + All, + Last, +} + +/// Replace occurrences of `needle` in `name` with `replacement`. +/// +/// Returns the new name, which equals `name` when nothing matched. +pub(crate) fn substitute( + name: &[T], + needle: &[T], + replacement: &[T], + mode: Mode, +) -> Vec { + if needle.is_empty() { + return interleave(name, replacement, mode); + } + + match mode { + Mode::First => match find(name, needle) { + Some(at) => splice(name, at, needle.len(), replacement), + None => name.to_vec(), + }, + Mode::Last => match rfind(name, needle) { + Some(at) => splice(name, at, needle.len(), replacement), + None => name.to_vec(), + }, + Mode::All => { + let mut out = Vec::with_capacity(name.len()); + let mut rest = name; + while let Some(at) = find(rest, needle) { + out.extend_from_slice(&rest[..at]); + out.extend_from_slice(replacement); + rest = &rest[at + needle.len()..]; + } + out.extend_from_slice(rest); + out + } + } +} + +/// One name before and after the substitution. +/// +/// `old` is not always the string that was passed in: outside whole-path mode +/// the trailing separators are stripped off, and both the messages and the +/// rename itself use the stripped form. +pub(crate) struct Rewrite<'a, T> { + pub(crate) old: &'a [T], + pub(crate) new: Vec, +} + +impl Rewrite<'_, T> { + /// Nothing to do. C util-linux cannot tell this apart from a name that + /// never matched, and counts both as neither renamed nor failed. + pub(crate) fn is_unchanged(&self) -> bool { + self.old == self.new + } +} + +/// Apply the substitution at the right scope. +/// +/// Normally only the final path component changes. If either argv string +/// contains a separator the whole path is in scope, which is what lets a rename +/// move a file between directories. The rule is decided on the two strings +/// themselves, so it holds even when the needle cannot match anything. +/// +/// Symlink mode feeds the link's target text through here unchanged: the target +/// is scoped the same way, so `-s sub SUB` leaves a target of `sub/t2` alone. +pub(crate) fn rewrite<'a, T: Copy + PartialEq>( + name: &'a [T], + needle: &[T], + replacement: &[T], + mode: Mode, + sep: T, +) -> Rewrite<'a, T> { + if needle.contains(&sep) || replacement.contains(&sep) { + return Rewrite { + old: name, + new: substitute(name, needle, replacement, mode), + }; + } + + let (prefix, component) = split_component(name, sep); + + let mut new = Vec::with_capacity(name.len()); + new.extend_from_slice(prefix); + new.extend_from_slice(&substitute(component, needle, replacement, mode)); + + Rewrite { + old: &name[..prefix.len() + component.len()], + new, + } +} + +/// Split a name into everything before its final component and the component +/// itself. Trailing separators belong to neither and are dropped, which is what +/// the -v line shows: `d1/` is reported as `d1`. +/// +/// A name that is nothing but separators is the exception, and it is why the +/// second search stops one unit short: there is nothing to strip without +/// emptying the name, so the component becomes the final separator alone and +/// everything before it is the prefix. For every other name the unit at +/// `end - 1` is a non-separator by construction, so the shorter search finds +/// the same separator the full one would. +fn split_component(name: &[T], sep: T) -> (&[T], &[T]) { + let end = name + .iter() + .rposition(|unit| *unit != sep) + .map_or(name.len(), |last| last + 1); + let start = name[..end.saturating_sub(1)] + .iter() + .rposition(|unit| *unit == sep) + .map_or(0, |at| at + 1); + (&name[..start], &name[start..end]) +} + +/// An empty needle matches between every two code units. `First` prepends, +/// `Last` appends, and `All` inserts at every boundary including both ends - +/// n + 1 times for a name of n code units. +fn interleave(name: &[T], replacement: &[T], mode: Mode) -> Vec { + match mode { + Mode::First => [replacement, name].concat(), + Mode::Last => [name, replacement].concat(), + Mode::All => { + let mut out = Vec::with_capacity(replacement.len() * (name.len() + 1) + name.len()); + for unit in name { + out.extend_from_slice(replacement); + out.push(*unit); + } + out.extend_from_slice(replacement); + out + } + } +} + +/// The index of the first occurrence of `needle`, which must not be empty. +fn find(name: &[T], needle: &[T]) -> Option { + name.windows(needle.len()).position(|w| w == needle) +} + +/// The index of the last occurrence of `needle`, which must not be empty. +fn rfind(name: &[T], needle: &[T]) -> Option { + name.windows(needle.len()).rposition(|w| w == needle) +} + +fn splice(name: &[T], at: usize, len: usize, replacement: &[T]) -> Vec { + let mut out = Vec::with_capacity(name.len() + replacement.len()); + out.extend_from_slice(&name[..at]); + out.extend_from_slice(replacement); + out.extend_from_slice(&name[at + len..]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The rewritten name, for the many cases where the old side is not the + /// point of the test. + fn rewritten(name: &[u8], needle: &[u8], replacement: &[u8], mode: Mode) -> Vec { + rewrite(name, needle, replacement, mode, b'/').new + } + + #[test] + fn test_first_replaces_only_the_leading_occurrence() { + assert_eq!(substitute(b"axbxcx", b"x", b"Z", Mode::First), b"aZbxcx"); + assert_eq!(substitute(b"xx", b"x", b"Z", Mode::First), b"Zx"); + } + + #[test] + fn test_all_replaces_every_occurrence() { + assert_eq!(substitute(b"axbxcx", b"x", b"Z", Mode::All), b"aZbZcZ"); + assert_eq!(substitute(b"xx", b"x", b"Z", Mode::All), b"ZZ"); + } + + #[test] + fn test_last_replaces_only_the_trailing_occurrence() { + assert_eq!(substitute(b"axbxcx", b"x", b"Z", Mode::Last), b"axbxcZ"); + assert_eq!(substitute(b"xx", b"x", b"Z", Mode::Last), b"xZ"); + } + + /// Matches do not overlap: the scan resumes after the match it just took. + #[test] + fn test_matches_do_not_overlap() { + assert_eq!(substitute(b"aaaa", b"aa", b"Z", Mode::First), b"Zaa"); + assert_eq!(substitute(b"aaaa", b"aa", b"Z", Mode::All), b"ZZ"); + assert_eq!(substitute(b"aaaa", b"aa", b"Z", Mode::Last), b"aaZ"); + } + + /// Last scans backward, which an odd-length overlap distinguishes from + /// "the final match a forward scan happens to reach". + #[test] + fn test_last_scans_backward() { + assert_eq!(substitute(b"aaaa", b"aaa", b"Z", Mode::Last), b"aZ"); + assert_eq!(substitute(b"aaaa", b"aaa", b"Z", Mode::All), b"Za"); + } + + /// The replacement is never rescanned, so a replacement containing the + /// needle terminates instead of looping. + #[test] + fn test_the_replacement_is_not_rescanned() { + assert_eq!(substitute(b"aaaa", b"a", b"aa", Mode::All), b"aaaaaaaa"); + assert_eq!(substitute(b"x", b"x", b"xx", Mode::All), b"xx"); + assert_eq!(substitute(b"ab", b"ab", b"abab", Mode::All), b"abab"); + } + + #[test] + fn test_no_match_returns_the_name_unchanged() { + assert_eq!(substitute(b"n1", b"q", b"z", Mode::First), b"n1"); + assert_eq!(substitute(b"a", b"aaa", b"Z", Mode::All), b"a"); + } + + #[test] + fn test_an_empty_replacement_deletes() { + assert_eq!(substitute(b"axbxcx", b"x", b"", Mode::First), b"abxcx"); + assert_eq!(substitute(b"axbxcx", b"x", b"", Mode::All), b"abc"); + assert_eq!(substitute(b"axbxcx", b"x", b"", Mode::Last), b"axbxc"); + } + + #[test] + fn test_an_empty_needle_prepends_by_default() { + assert_eq!(substitute(b"abc", b"", b"Z", Mode::First), b"Zabc"); + assert_eq!(substitute(b"x", b"", b"Z", Mode::First), b"Zx"); + } + + #[test] + fn test_an_empty_needle_appends_under_last() { + assert_eq!(substitute(b"abc", b"", b"Z", Mode::Last), b"abcZ"); + assert_eq!(substitute(b"x", b"", b"Z", Mode::Last), b"xZ"); + } + + /// n + 1 insertions for a name of n code units, both ends included. + #[test] + fn test_an_empty_needle_interleaves_under_all() { + assert_eq!(substitute(b"abc", b"", b"Z", Mode::All), b"ZaZbZcZ"); + assert_eq!(substitute(b"x", b"", b"Z", Mode::All), b"ZxZ"); + assert_eq!(substitute(b"abc", b"", b"ZY", Mode::All), b"ZYaZYbZYcZY"); + } + + /// The count is in bytes, not characters. A name holding one two-byte + /// character is five bytes and takes six insertions; a char-based engine + /// would give five and would be wrong on exactly the mojibake filenames + /// people reach for rename to fix. + #[test] + fn test_the_interleave_counts_code_units_not_characters() { + assert_eq!( + substitute(b"caf\xc3\xa9", b"", b"_", Mode::All), + b"_c_a_f_\xc3_\xa9_" + ); + } + + /// A needle may split a multibyte sequence; C util-linux does it without + /// complaint and the result is not valid UTF-8. + #[test] + fn test_matching_is_over_bytes_with_no_character_awareness() { + assert_eq!( + substitute(b"caf\xc3\xa9", b"\xc3", b"X", Mode::First), + b"cafX\xa9" + ); + assert_eq!(substitute(b"lat\xe9n", b"n", b"N", Mode::Last), b"lat\xe9N"); + } + + /// Unreachable through the CLI - an empty operand never survives the + /// existence check. Pinned so the code cannot panic on it. + #[test] + fn test_an_empty_needle_and_an_empty_name_still_insert_once() { + assert_eq!(substitute(b"", b"", b"Z", Mode::All), b"Z"); + assert_eq!(substitute(b"", b"", b"Z", Mode::First), b"Z"); + assert_eq!(substitute(b"", b"", b"Z", Mode::Last), b"Z"); + } + + /// Either argv string containing a separator switches the whole path into + /// scope; the check is on the strings, not on the result. + #[test] + fn test_a_separator_in_either_argument_widens_the_scope() { + assert_eq!(rewritten(b"d1/s1", b"s", b"z", Mode::First), b"d1/z1"); + assert_eq!(rewritten(b"d1/s1", b"d1", b"d3", Mode::First), b"d1/s1"); + assert_eq!(rewritten(b"d1/s1", b"d1/", b"d3/", Mode::First), b"d3/s1"); + // A separator in the replacement alone widens the scope just as well, + // and the substitution stays literal: the separator already in the + // name is not absorbed, giving the doubled one below. + assert_eq!(rewritten(b"d1/s1", b"d1", b"d3/", Mode::First), b"d3//s1"); + assert_eq!(rewritten(b"d1/s1", b"d1/s", b"X", Mode::First), b"X1"); + } + + /// Whole-path mode is entered even when the needle cannot match, which + /// settles "before or after substitution". + #[test] + fn test_the_scope_is_decided_before_matching() { + assert_eq!(rewritten(b"d1/s1", b"z/q", b"Q", Mode::First), b"d1/s1"); + } + + #[test] + fn test_only_the_final_component_is_rewritten_by_default() { + assert_eq!(rewritten(b"d1//s1", b"s", b"z", Mode::First), b"d1//z1"); + assert_eq!(rewritten(b"d1/s1", b"", b"X", Mode::First), b"d1/Xs1"); + assert_eq!(rewritten(b"d1/s1", b"", b"X", Mode::All), b"d1/XsX1X"); + assert_eq!(rewritten(b"./top1", b"top", b"TOP", Mode::First), b"./TOP1"); + assert_eq!(rewritten(b"./top1", b".", b"X", Mode::First), b"./top1"); + } + + /// Trailing separators are not part of the component, and they are gone + /// from the old side too: `d1/` is reported as `d1`. + #[test] + fn test_trailing_separators_are_stripped_from_both_sides() { + let one = rewrite(b"d1/", b"1", b"2", Mode::First, b'/'); + assert_eq!(one.old, b"d1"); + assert_eq!(one.new, b"d2"); + + let many = rewrite(b"d1///", b"1", b"2", Mode::First, b'/'); + assert_eq!(many.old, b"d1"); + assert_eq!(many.new, b"d2"); + + let nested = rewrite(b"d1/sub/", b"sub", b"SUB", Mode::First, b'/'); + assert_eq!(nested.old, b"d1/sub"); + assert_eq!(nested.new, b"d1/SUB"); + + assert_eq!(rewritten(b"d1/", b"", b"Y", Mode::First), b"Yd1"); + } + + /// Stripping happens before the comparison, so an operand whose component + /// does not change is a no-op even though the raw operand and the new name + /// differ by a separator. C util-linux exits 4 in silence here. + #[test] + fn test_a_component_that_does_not_change_is_unchanged_despite_the_stripping() { + let quiet = rewrite(b"d1/", b"QQ", b"ZZ", Mode::First, b'/'); + assert_eq!(quiet.old, b"d1"); + assert_eq!(quiet.new, b"d1"); + assert!(quiet.is_unchanged()); + + let moved = rewrite(b"d1/", b"1", b"2", Mode::First, b'/'); + assert!(!moved.is_unchanged()); + } + + /// Whole-path mode does no stripping at all - there is no component to + /// split out - so a trailing separator survives on both sides. + #[test] + fn test_whole_path_mode_does_not_strip() { + let kept = rewrite(b"d1/", b"d1/", b"d9/", Mode::First, b'/'); + assert_eq!(kept.old, b"d1/"); + assert_eq!(kept.new, b"d9/"); + } + + /// A name that is nothing but separators keeps them all: it is not + /// stripped, and its component is the final separator by itself. + #[test] + fn test_an_all_separator_name_keeps_its_separators() { + let root = rewrite(b"/", b"", b"Y", Mode::First, b'/'); + assert_eq!(root.old, b"/"); + assert_eq!(root.new, b"Y/"); + + assert_eq!(rewritten(b"/", b"", b"Y", Mode::All), b"Y/Y"); + assert_eq!(rewritten(b"//", b"", b"Y", Mode::First), b"/Y/"); + assert_eq!(rewritten(b"//", b"", b"Y", Mode::All), b"/Y/Y"); + assert_eq!(rewritten(b"///", b"", b"Y", Mode::First), b"//Y/"); + assert_eq!(rewritten(b"/", b"x", b"y", Mode::First), b"/"); + assert_eq!(rewritten(b"", b"x", b"y", Mode::First), b""); + } + + /// In whole-path mode the modes apply to the separators themselves. + #[test] + fn test_whole_path_mode_treats_separators_as_ordinary_units() { + assert_eq!(rewritten(b"d1//s1", b"/", b"_", Mode::First), b"d1_/s1"); + assert_eq!(rewritten(b"d1//s1", b"/", b"_", Mode::Last), b"d1/_s1"); + assert_eq!(rewritten(b"d1//s1", b"/", b"_", Mode::All), b"d1__s1"); + assert_eq!(rewritten(b"d1/s1", b"/", b"//", Mode::First), b"d1//s1"); + } + + #[test] + fn test_the_scope_rule_is_code_unit_generic() { + let sep = u16::from(b'/'); + let name: Vec = "d1/s1".encode_utf16().collect(); + let needle: Vec = "s".encode_utf16().collect(); + let replacement: Vec = "z".encode_utf16().collect(); + let expected: Vec = "d1/z1".encode_utf16().collect(); + assert_eq!( + rewrite(&name, &needle, &replacement, Mode::First, sep).new, + expected + ); + } + + /// The engine is instantiated over u16 on Windows, which a unix build + /// never compiles. Same table, same expectations. + #[test] + fn test_the_engine_is_code_unit_generic() { + let name: Vec = "axbxcx".encode_utf16().collect(); + let needle: Vec = "x".encode_utf16().collect(); + let replacement: Vec = "Z".encode_utf16().collect(); + let expected: Vec = "aZbZcZ".encode_utf16().collect(); + assert_eq!( + substitute(&name, &needle, &replacement, Mode::All), + expected + ); + } +} From d4627bd0e3c3d3122b915277487e7e71d3164c9d Mon Sep 17 00:00:00 2001 From: mmc Date: Wed, 19 Aug 2026 21:31:09 -0500 Subject: [PATCH 2/2] rename: add integration tests Sixty-four tests: the exit-status tally, the three substitution modes, the path-scope rule, both overwrite guards, symlink mode, byte-oriented names, and the test_invalid_arg smoke test every other util carries. They defend the decisions a refactor would plausibly undo rather than restating what clap already guarantees. The two overwrite predicates and their opposite treatment of a dangling destination, the short circuit that runs before any syscall, and the order of stripping against the existence check each have a test whose only job is to fail if someone tidies them away. Diagnostics are asserted only as far as we write them. The errno text after our half belongs to libc, so the suite does not pin strerror strings on platforms it cannot run. The substitution engine keeps its unit tests beside it in subst.rs. `cargo test` from the workspace root does not run them, because the root package is the only default member; `cargo test -p uu_rename` does. --- tests/by-util/test_rename.rs | 1289 ++++++++++++++++++++++++++++++++++ tests/tests.rs | 4 + 2 files changed, 1293 insertions(+) create mode 100644 tests/by-util/test_rename.rs diff --git a/tests/by-util/test_rename.rs b/tests/by-util/test_rename.rs new file mode 100644 index 00000000..21d93d36 --- /dev/null +++ b/tests/by-util/test_rename.rs @@ -0,0 +1,1289 @@ +// This file is part of the uutils util-linux package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. +// spell-checker:ignore (words) axbxcx aZbxcx aZbZcZ axbxcZ aXbXcX +// spell-checker:ignore (words) rpmatch lnk dang + +use uutests::{at_and_ucmd, new_ucmd}; + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails().code_is(1); +} + +// -- the exit-status tally ----------------------------------------------- + +#[test] +fn test_a_plain_rename_reports_nothing_and_exits_zero() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["s", "z", "s1"]).succeeds().no_output(); + assert!(at.file_exists("z1")); + assert!(!at.file_exists("s1")); +} + +/// A no-match is not a failure and not a success: it is invisible to the tally. +#[test] +fn test_a_no_match_exits_four() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("n1"); + ucmd.args(&["q", "z", "n1"]).fails().code_is(4).no_output(); + assert!(at.file_exists("n1")); +} + +// -- option permutation and POSIXLY_CORRECT ------------------------------- + +/// POSIXLY_CORRECT turns permutation off: scanning stops at the first operand +/// and everything after it is a filename, however much it looks like a flag. +#[test] +fn test_posixly_correct_stops_the_scan_at_the_first_operand() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.env("POSIXLY_CORRECT", "") + .args(&["s", "z", "s1", "-v"]) + .fails() + .code_is(2) + .no_stdout(); + assert!(at.file_exists("z1")); +} + +#[test] +fn test_posixly_correct_is_read_for_presence_and_not_for_value() { + for value in ["", "0"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.env("POSIXLY_CORRECT", value) + .args(&["s", "z", "s1", "-v"]) + .fails() + .code_is(2) + .no_stdout(); + assert!(at.file_exists("z1"), "{value:?}"); + } +} + +/// Scanning stops at the first NON-OPTION, not at the first operand slot, so +/// flags written before it are still flags - and this invocation is short of +/// operands either way. +#[test] +fn test_posixly_correct_still_reads_the_flags_that_come_first() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.env("POSIXLY_CORRECT", "") + .args(&["-v", "s", "z", "s1"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n"); + assert!(at.file_exists("z1")); +} + +/// The existence check runs before the substitution result matters. +#[test] +fn test_a_missing_operand_fails_even_when_the_needle_cannot_match() { + new_ucmd!() + .args(&["q", "z", "s9"]) + .fails() + .code_is(1) + .no_stdout(); +} + +/// The truth table. No boundary between the statuses is documented, so this +/// walks every mix of the three per-operand outcomes: s1 s2 rename, s8 s9 are +/// absent, n1 n2 exist and hold no `s`. Two rows carry the rules a tally of +/// flags rather than counters would get wrong - S+N is 0 rather than 2 or 4, +/// and F+N is 1 rather than 2. The last three reverse a mix already listed, +/// because two counters cannot encode an order. +#[test] +fn test_the_tally_selects_one_status_for_every_mix_of_outcomes() { + for (operands, code) in [ + (&["s1"][..], 0), + (&["s9"], 1), + (&["n1"], 4), + (&["s1", "s2"], 0), + (&["s1", "s9"], 2), + (&["s1", "n1"], 0), + (&["s8", "s9"], 1), + (&["s9", "n1"], 1), + (&["n1", "n2"], 4), + (&["s1", "s9", "n1"], 2), + (&["s9", "n1", "n2"], 1), + (&["s9", "s1"], 2), + (&["n1", "s1"], 0), + (&["n1", "s9"], 1), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + for name in ["s1", "s2", "n1", "n2"] { + at.touch(name); + } + let actual = ucmd.args(&["s", "z"]).args(operands).run().code(); + assert_eq!(actual, code, "operands {operands:?}"); + } +} + +/// The other half of this claim, that each failure is reported exactly once +/// and in argv order, is pinned by +/// unix::test_every_failing_operand_is_reported_once_and_in_order, because the +/// text it has to assert is the platform's strerror. +#[test] +fn test_a_failure_in_the_middle_does_not_stop_the_operands_after_it() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s2"); + ucmd.args(&["-v", "s", "z", "s8", "s2", "s9"]) + .fails() + .code_is(2) + .stdout_is("`s2' -> `z2'\n"); + assert!(at.file_exists("z2")); +} + +// -- the substring-equals-replacement short circuit ---------------------- + +/// The short circuit is for the whole run, before any operand is touched, so a +/// missing operand is not even noticed. +#[test] +fn test_an_identical_substring_and_replacement_touch_nothing() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["s", "s", "s1", "s9"]) + .fails() + .code_is(4) + .no_output(); + assert!(at.file_exists("s1")); +} + +// -- reporting ----------------------------------------------------------- + +#[test] +fn test_verbose_names_both_paths() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["-v", "s", "z", "s1"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n"); +} + +/// The report delimits both names with a backtick and a quote and escapes +/// nothing, so a name containing either one goes out as it is. +#[test] +fn test_the_report_does_not_escape_its_own_delimiters() { + for (name, needle, replacement, expected) in [ + ("q'te", "q", "Q", "`Q'te'"), + ("sp ace", "sp", "SP", "`SP ace'"), + ("t`ck", "t", "T", "`T`ck'"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch(name); + ucmd.args(&["-v", needle, replacement, name]) + .succeeds() + .stdout_is(format!("`{name}' -> {expected}\n")); + } +} + +/// -n counts as though the rename had happened and prints the same line. +#[test] +fn test_no_act_changes_nothing_but_still_reports() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["-nv", "s", "z", "s1"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n"); + assert!(at.file_exists("s1")); + assert!(!at.file_exists("z1")); +} + +// -- substitution modes -------------------------------------------------- + +/// Which occurrence each mode takes, and how each scan behaves where the +/// matches overlap. The overlap rows are the ones that separate the three +/// modes: -l scans backward rather than taking the last match a forward scan +/// would find, which is why `aaa` in `aaaa` gives `Za` one way and `aZ` the +/// other. The replacement is never rescanned, so replacing `a` by `aa` +/// terminates. +#[test] +fn test_the_modes_choose_which_occurrence_is_replaced() { + for (flags, needle, replacement, name, new) in [ + ("-v", "x", "Z", "axbxcx", "aZbxcx"), + ("-va", "x", "Z", "axbxcx", "aZbZcZ"), + ("-vl", "x", "Z", "axbxcx", "axbxcZ"), + ("-v", "aaa", "Z", "aaaa", "Za"), + ("-va", "aaa", "Z", "aaaa", "Za"), + ("-vl", "aaa", "Z", "aaaa", "aZ"), + ("-va", "a", "aa", "aaaa", "aaaaaaaa"), + ("-v", "x", "", "axbxcx", "abxcx"), + ("-va", "x", "", "axbxcx", "abc"), + ("-vl", "x", "", "axbxcx", "axbxc"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch(name); + ucmd.args(&[flags, needle, replacement, name]) + .succeeds() + .stdout_is(format!("`{name}' -> `{new}'\n")); + assert!(at.file_exists(new), "{flags} {needle} {replacement} {name}"); + } +} + +/// The count is one more than the number of code units in the name, which is +/// what makes the -a row a statement about the engine rather than about this +/// fixture. +#[test] +fn test_an_empty_needle_inserts_at_every_boundary() { + for (flags, replacement, name, new) in [ + ("-v", "Z", "abc", "Zabc"), + ("-vl", "Z", "abc", "abcZ"), + ("-va", "Z", "abc", "ZaZbZcZ"), + ("-va", "ZY", "abc", "ZYaZYbZYcZY"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch(name); + ucmd.args(&[flags, "", replacement, name]) + .succeeds() + .stdout_is(format!("`{name}' -> `{new}'\n")); + assert!(at.file_exists(new), "{flags} '' {replacement} {name}"); + } +} + +// -- path scope ---------------------------------------------------------- + +#[test] +fn test_only_the_final_component_is_rewritten() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.touch("d1/s1"); + ucmd.args(&["-v", "d1", "d9", "d1/s1"]) + .fails() + .code_is(4) + .no_output(); + assert!(at.file_exists("d1/s1")); +} + +/// A separator in either argument widens the scope to the whole path, which is +/// how a rename moves a file between directories. +#[test] +fn test_a_separator_in_an_argument_moves_the_file() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.mkdir("d9"); + at.touch("d1/s1"); + ucmd.args(&["-v", "d1/", "d9/", "d1/s1"]) + .succeeds() + .stdout_is("`d1/s1' -> `d9/s1'\n"); + assert!(at.file_exists("d9/s1")); +} + +/// The scope is decided on the two argument strings, before any matching, so a +/// separator widens it even when the needle cannot possibly match. +#[test] +fn test_a_separator_in_either_argument_widens_the_scope() { + // Only the substring holds a separator. + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.touch("d1/s1"); + ucmd.args(&["-v", "d1/s", "X", "d1/s1"]) + .succeeds() + .stdout_is("`d1/s1' -> `X1'\n"); + assert!(at.file_exists("X1")); + + // Only the replacement does. + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.mkdir("d3"); + at.touch("d1/s1"); + ucmd.args(&["d1", "d3/", "d1/s1"]).succeeds(); + assert!(at.file_exists("d3/s1")); +} + +/// The report shows the stripped form on both sides, however many separators +/// there were. +#[test] +fn test_trailing_separators_are_stripped_from_the_operand() { + for (flags, needle, replacement, operand, expected) in [ + ("-v", "1", "9", "d1/", "`d1' -> `d9'\n"), + ("-v", "1", "9", "d1///", "`d1' -> `d9'\n"), + ("-v", "", "Y", "d1/", "`d1' -> `Yd1'\n"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + ucmd.args(&[flags, needle, replacement, operand]) + .succeeds() + .stdout_is(expected); + } +} + +/// Whole-path mode never splits a component out, so it has nothing to strip +/// and the trailing separator survives into the new name. +#[test] +fn test_whole_path_mode_keeps_a_trailing_separator() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + ucmd.args(&["-v", "d1/", "d9/", "d1/"]) + .succeeds() + .stdout_is("`d1/' -> `d9/'\n"); + assert!(at.dir_exists("d9")); +} + +/// The comparison that decides whether anything changed is between the +/// stripped name and the new one. Comparing against the raw operand instead +/// would find a difference here and report a rename of `d1` onto itself. +#[test] +fn test_the_change_comparison_happens_after_stripping() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + ucmd.args(&["-v", "QQ", "ZZ", "d1/"]) + .fails() + .code_is(4) + .no_output(); + assert!(at.dir_exists("d1")); +} + +/// A leading `./` is prefix, not component, so it is neither matched against +/// nor lost. +#[test] +fn test_a_dot_relative_operand_keeps_its_prefix() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("top1"); + ucmd.args(&["-v", "top", "TOP", "./top1"]) + .succeeds() + .stdout_is("`./top1' -> `./TOP1'\n"); + assert!(at.file_exists("TOP1")); + + // The dot lives in the prefix, so a needle of "." matches nothing. + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("top1"); + ucmd.args(&[".", "X", "./top1"]).fails().code_is(4); + assert!(at.file_exists("top1")); +} + +/// The stripping happens after the existence check, which still sees the +/// operand exactly as it was typed - so a trailing separator on a regular file +/// is an error rather than a rename. What the platform calls ENOTDIR is its +/// business, so only our half of the line is asserted. +#[test] +fn test_the_existence_check_sees_the_untouched_operand() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.touch("d1/s1"); + ucmd.args(&["s", "z", "d1/s1/"]) + .fails() + .code_is(1) + .stderr_contains("rename: d1/s1/: not accessible: "); + assert!(at.file_exists("d1/s1")); +} + +// -- the overwrite safeguards -------------------------------------------- + +/// A quiet -o run says nothing at all where a quiet -i run still asks. With +/// -v the prompt and whatever follows share a line, because the prompt carries +/// no newline of its own. +#[test] +fn test_the_skip_report_is_verbose_gated_and_the_prompt_is_not() { + for (flags, answer, code, expected) in [ + ("-o", "", 4, ""), + ("-vo", "", 4, "Skipping existing file: `n1'\n"), + ("-i", "n\n", 4, "rename: overwrite `n1'? "), + ( + "-vi", + "n\n", + 4, + "rename: overwrite `n1'? Skipping existing file: `n1'\n", + ), + ("-i", "y\n", 0, "rename: overwrite `n1'? "), + ("-vi", "y\n", 0, "rename: overwrite `n1'? `s1' -> `n1'\n"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("n1"); + let result = ucmd.args(&[flags, "s", "n", "s1"]).pipe_in(answer).run(); + assert_eq!(result.code(), code, "{flags} answered {answer:?}"); + result.no_stderr().stdout_is(expected); + } +} + +/// rpmatch under LC_ALL=C resolves to a test on the first character, and +/// uutests pins LC_ALL=C for every run. An implementation comparing the whole +/// answer against "y" would reject "yes", which is accepted; one that skipped +/// leading whitespace would accept " y", which is not. +#[test] +fn test_the_answer_is_matched_on_its_first_character() { + for answer in ["y\n", "yes\n", "Y extra\n"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("z1"); + ucmd.args(&["-i", "s", "z", "s1"]) + .pipe_in(answer) + .succeeds() + .stdout_is("rename: overwrite `z1'? "); + assert!( + !at.file_exists("s1"), + "{answer:?} should have been accepted" + ); + } + + for answer in ["n\n", "maybe\n", " y\n", "\n", "1\n"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("z1"); + ucmd.args(&["-i", "s", "z", "s1"]) + .pipe_in(answer) + .fails() + .code_is(4) + .stdout_is("rename: overwrite `z1'? "); + assert!(at.file_exists("s1"), "{answer:?} should have been declined"); + } +} + +/// Neither guard fires unless something is actually in the way, and -i does +/// not read stdin when it has nothing to ask about. +#[test] +fn test_an_absent_destination_is_never_guarded() { + for flags in ["-vo", "-vi"] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + // The answer is never read, so the write end may break first. That it + // breaks at all is the evidence, not an error. + ucmd.args(&[flags, "s", "z", "s1"]) + .pipe_in("n\n") + .ignore_stdin_write_error() + .succeeds() + .stdout_is("`s1' -> `z1'\n"); + assert!(at.file_exists("z1")); + } +} + +#[test] +fn test_no_overwrite_refuses_a_destination_of_any_kind() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.mkdir("z1"); + ucmd.args(&["-vo", "s", "z", "s1"]) + .fails() + .code_is(4) + .stdout_is("Skipping existing file: `z1'\n"); + assert!(at.file_exists("s1")); +} + +/// An accepted prompt is a decision to try, not a promise that it works: the +/// rename can still fail, and then it is a failure like any other. +#[test] +fn test_an_accepted_prompt_that_cannot_be_carried_out_fails() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.mkdir("z1"); + at.touch("z1/keep"); + ucmd.args(&["-i", "s", "z", "s1"]) + .pipe_in("y\n") + .fails() + .code_is(1) + .stdout_is("rename: overwrite `z1'? "); + assert!(at.file_exists("s1")); +} + +/// Under -n both guards still run and still report, and the verbose line +/// prints on top of the skip - two lines for an operand that counts as +/// neither, so the run reports 4 rather than 0. -i degrades to exactly what -o +/// does and never reads stdin, so no answer changes any of it. +#[test] +fn test_no_act_degrades_both_safeguards_to_the_same_skip() { + for (flags, answer) in [("-nvo", ""), ("-nvi", "y\n"), ("-nvi", "n\n")] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("n1"); + // -n never reads stdin, so the write end may break before the answer + // lands. That is the behavior under test, not a failure of it. + ucmd.args(&[flags, "s", "n", "s1"]) + .pipe_in(answer) + .ignore_stdin_write_error() + .fails() + .code_is(4) + .stdout_is("Skipping existing file: `n1'\n`s1' -> `n1'\n"); + assert!(at.file_exists("s1"), "{flags} answered {answer:?}"); + } +} + +/// A prompt that reaches end of input answers itself with a literal `n` and +/// terminates the line, so unlike every other decline this one does leave the +/// prompt's line closed. The echo is not verbose-gated, because the prompt it +/// closes is not either. +#[test] +fn test_interactive_at_end_of_input_declines() { + for (flags, expected) in [ + ( + "-vi", + "rename: overwrite `z1'? n\nSkipping existing file: `z1'\n", + ), + ("-i", "rename: overwrite `z1'? n\n"), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("z1"); + ucmd.args(&[flags, "s", "z", "s1"]) + .fails() + .code_is(4) + .stdout_is(expected); + assert!(at.file_exists("s1"), "{flags}"); + } +} + +/// A run that outlives its input reaches end of input on the second prompt +/// rather than re-reading the first answer, and a multi-character yes does not +/// spill into the answer the next prompt reads. +#[test] +fn test_each_prompt_consumes_a_whole_line() { + for (answers, expected, second_renamed) in [ + ( + "y\n", + "rename: overwrite `n1'? rename: overwrite `n2'? n\n", + false, + ), + ( + "yn\n", + "rename: overwrite `n1'? rename: overwrite `n2'? n\n", + false, + ), + ( + "yes\ny\n", + "rename: overwrite `n1'? rename: overwrite `n2'? ", + true, + ), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + for name in ["s1", "s2", "n1", "n2"] { + at.touch(name); + } + ucmd.args(&["-i", "s", "n", "s1", "s2"]) + .pipe_in(answers) + .succeeds() + .stdout_is(expected); + assert!(!at.file_exists("s1"), "{answers:?}"); + assert_eq!(!at.file_exists("s2"), second_renamed, "{answers:?}"); + } +} + +// -- the option surface -------------------------------------------------- + +/// Three operands are required and a usage error is exit 1, which is the same +/// status as "everything failed". The wording is clap's and is not asserted. +#[test] +fn test_too_few_operands_exit_one() { + let cases: [&[&str]; 3] = [&[], &["s", "z"], &["-v", "s", "z"]]; + for args in cases { + new_ucmd!().args(args).fails().code_is(1); + } +} + +/// The only two mutually exclusive pairs, in every spelling that reaches them. +/// Overriding an argument with itself does not weaken this: `-l -a -l` still +/// conflicts. +#[test] +fn test_the_exclusive_pairs_are_refused_however_they_are_spelled() { + let cases: [&[&str]; 3] = [&["-a", "-l"], &["-o", "-i"], &["-l", "-a", "-l"]]; + for args in cases { + new_ucmd!() + .args(args) + .args(&["x", "Z", "axbxcx"]) + .fails() + .code_is(1); + } +} + +/// Every option has a long form and clap is configured to accept any +/// unambiguous prefix of one. A prefix shared by two options is an error +/// rather than a choice: `--n` reaches both of the no- options. +#[test] +fn test_a_long_option_may_be_abbreviated_to_an_unambiguous_prefix() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["--verb", "s", "z", "s1"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n"); + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["--n", "s", "z", "s1"]).fails().code_is(1); + assert!(at.file_exists("s1")); +} + +/// Options are permuted: a flag is recognized anywhere, including between the +/// two arguments and between two file operands. +#[test] +fn test_a_flag_is_recognized_anywhere_among_the_operands() { + for args in [ + &["-v", "s", "z", "s1"][..], + &["s", "-v", "z", "s1"], + &["s", "z", "-v", "s1"], + &["s", "z", "s1", "-v"], + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(args).succeeds().stdout_is("`s1' -> `z1'\n"); + } + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("s2"); + ucmd.args(&["s", "z", "s1", "-v", "s2"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n`s2' -> `z2'\n"); +} + +#[test] +fn test_a_terminator_turns_everything_after_it_into_an_operand() { + for args in [&["--", "s", "z", "s1"][..], &["s", "z", "--", "s1"]] { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(args).succeeds(); + assert!(at.file_exists("z1"), "{args:?}"); + } + + // The flag is an operand now, and a missing one at that. + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["--", "s", "z", "s1", "-v"]) + .fails() + .code_is(2) + .no_stdout(); + assert!(at.file_exists("z1")); +} + +/// getopt lets a flag repeat and C util-linux renames regardless, so this is +/// behavior rather than wording: a clap SetTrue argument rejects its second +/// occurrence unless the command overrides an argument with itself. +#[test] +fn test_a_repeated_option_is_accepted() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + ucmd.args(&["-v", "-v", "s", "z", "s1"]) + .succeeds() + .stdout_is("`s1' -> `z1'\n"); + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("axbxcx"); + ucmd.args(&["-a", "-a", "x", "Z", "axbxcx"]).succeeds(); + assert!(at.file_exists("aZbZcZ")); +} + +/// Symlink mode, byte-oriented names, mode bits and POSIX rename(2) semantics +/// all need helpers that do not exist or do not behave the same on Windows, so +/// they are gathered here rather than gated one attribute at a time. Everything +/// above this line runs on all three platforms. +#[cfg(unix)] +mod unix { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + use uutests::util::AtPath; + use uutests::{at_and_ucmd, new_ucmd}; + + /// Tests that arrange for a mode bit to deny something expect that denial + /// to happen. Root is not denied by any mode bit, so they are skipped + /// there rather than made to pass by accident. CI never runs as root; this + /// is for the developer who does. + fn skipped_as_root() -> bool { + if uucore::process::geteuid() == 0 { + println!("test skipped: root is not denied by any mode bit"); + return true; + } + false + } + + // -- error classes ---------------------------------------------------- + + /// The diagnostic carries the platform's strerror text, which is why this + /// half lives here and its other half, + /// test_a_failure_in_the_middle_does_not_stop_the_operands_after_it, + /// stays ungated. + #[test] + fn test_every_failing_operand_is_reported_once_and_in_order() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s2"); + ucmd.args(&["-v", "s", "z", "s8", "s2", "s9"]) + .fails() + .code_is(2) + .stdout_is("`s2' -> `z2'\n") + .stderr_is( + "rename: s8: not accessible: No such file or directory\n\ + rename: s9: not accessible: No such file or directory\n", + ); + assert!(at.file_exists("z2")); + } + + /// A fixture builder, the argv that fails against it, and the diagnostic + /// the failure produces. + type ErrorCase<'a> = (fn(&AtPath), Vec<&'a str>, String); + + /// The tally counts failures, not kinds of failure. Each of these is a + /// different errno reached through a different call, and every one of them + /// is exactly one failure and nothing else. Only the part of the + /// diagnostic we write is asserted - the errno text belongs to libc. + #[test] + fn test_every_error_class_counts_as_one_failure() { + let cases: [ErrorCase; 2] = [ + ( + |at| { + at.touch("top1"); + at.mkdir("d3"); + at.touch("d3/keep"); + }, + vec!["top1", "d3", "top1"], + "rename: top1: rename to d3 failed: ".into(), + ), + ( + |at| { + at.mkdir("d1"); + at.mkdir("d3"); + at.touch("d3/keep"); + }, + vec!["d1", "d3", "d1"], + "rename: d1: rename to d3 failed: ".into(), + ), + ]; + + for (setup, args, expected) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + setup(&at); + ucmd.args(&args) + .fails() + .code_is(1) + .no_stdout() + .stderr_contains(&expected); + } + } + + /// A directory that denies writes fails the rename; one that denies search + /// fails the existence check instead, and the two say different things. + /// Which they are is the claim; the errno text after them is the + /// platform's and is left to it. + #[test] + fn test_a_denied_directory_fails_at_whichever_step_needs_it() { + if skipped_as_root() { + return; + } + + for (mode, expected) in [ + (0o500, "rename: p/s1: rename to p/z1 failed: "), + (0o000, "rename: p/s1: not accessible: "), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("p"); + at.touch("p/s1"); + at.set_mode("p", mode); + ucmd.args(&["s", "z", "p/s1"]) + .fails() + .code_is(1) + .stderr_contains(expected); + at.set_mode("p", 0o700); + assert!(at.file_exists("p/s1")); + } + } + + /// A replacement that deletes the whole name is not caught before the + /// syscall: the empty string is handed to rename(2) and the kernel refuses + /// it. + #[test] + fn test_a_replacement_that_empties_the_name_fails_in_the_kernel() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("abc"); + ucmd.args(&["-v", "abc", "", "abc"]) + .fails() + .code_is(1) + .no_stdout() + .stderr_is("rename: abc: rename to failed: No such file or directory\n"); + assert!(at.file_exists("abc")); + } + + // -- byte-oriented names ---------------------------------------------- + + /// Nothing quotes or escapes a name, in the report or in a diagnostic. + #[test] + fn test_a_name_holding_a_newline_splits_the_line_it_is_printed_on() { + let (at, mut ucmd) = at_and_ucmd!(); + let name = OsStr::from_bytes(b"nl\nx"); + at.touch(name); + ucmd.args(&[OsStr::new("-v"), OsStr::new("nl"), OsStr::new("NL"), name]) + .succeeds() + .stdout_is("`nl\nx' -> `NL\nx'\n"); + assert!(at.file_exists(OsStr::from_bytes(b"NL\nx"))); + + new_ucmd!() + .args(&[ + OsStr::new("-v"), + OsStr::new("no"), + OsStr::new("yes"), + OsStr::from_bytes(b"no\nsuch"), + ]) + .fails() + .code_is(1) + .stderr_is("rename: no\nsuch: not accessible: No such file or directory\n"); + } + + // -- POSIX rename(2) semantics ---------------------------------------- + + /// A substitution that only rewrites a separator names the very same file, + /// and that still counts as a rename. + #[test] + fn test_a_rename_that_only_changes_the_string_still_counts() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.touch("d1/s1"); + ucmd.args(&["-v", "/", "//", "d1/s1"]) + .succeeds() + .stdout_is("`d1/s1' -> `d1//s1'\n"); + assert!(at.file_exists("d1/s1")); + } + + // -- the overwrite safeguards, where they need a link ----------------- + + /// -o asks whether anything is reachable at the destination, which follows + /// the link: a dangling symlink is not something, so it gets clobbered. + #[test] + fn test_no_overwrite_does_not_protect_a_dangling_destination() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.relative_symlink_file("t_missing", "n1"); + ucmd.args(&["-vo", "s", "n", "s1"]) + .succeeds() + .stdout_is("`s1' -> `n1'\n"); + assert!(!at.symlink_exists("n1")); + assert!(at.file_exists("n1")); + } + + /// A probe that cannot be performed at all reads as "nothing there", so -o + /// lets the rename go ahead and it fails on its own terms. Both runs below + /// report the same thing, which is the whole point: -o changes nothing + /// when it cannot see the destination. + #[test] + fn test_no_overwrite_treats_a_denied_probe_as_absent() { + if skipped_as_root() { + return; + } + + for flags in [&["-v", "-o"][..], &["-v"]] { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("d1"); + at.touch("d1/s1"); + at.mkdir("e"); + at.touch("e/s1"); + at.set_mode("e", 0o000); + ucmd.args(flags) + .args(&["d1/", "e/", "d1/s1"]) + .fails() + .code_is(1) + .no_stdout() + .stderr_contains("rename: d1/s1: rename to e/s1 failed: "); + at.set_mode("e", 0o700); + assert!(at.file_exists("d1/s1")); + } + } + + // -- symlink mode ----------------------------------------------------- + + #[test] + fn test_symlink_mode_rewrites_the_target_not_the_name() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("t1"); + at.relative_symlink_file("t1", "l_ok"); + ucmd.args(&["-vs", "t", "z", "l_ok"]) + .succeeds() + .stdout_is("l_ok: `t1' -> `z1'\n"); + assert_eq!(at.resolve_link("l_ok"), "z1"); + } + + /// The symlink type check precedes the match test, so a non-symlink fails + /// even when the substring appears nowhere. + #[test] + fn test_symlink_mode_on_a_regular_file_fails_without_matching() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("t1"); + ucmd.args(&["-s", "q", "Q", "t1"]) + .fails() + .code_is(1) + .stderr_is("rename: t1: not a symbolic link\n"); + } + + /// -s never resolves a chain: the target text of l_c1 is "l_c2", not "t1". + #[test] + fn test_symlink_mode_does_not_follow_a_chain() { + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("t1"); + at.relative_symlink_file("t1", "l_c2"); + at.relative_symlink_file("l_c2", "l_c1"); + ucmd.args(&["-s", "t", "z", "l_c1"]).fails().code_is(4); + assert_eq!(at.resolve_link("l_c1"), "l_c2"); + } + + /// One tree of links covering every target shape -s has to handle. + fn create_links(at: &AtPath) { + at.touch("t1"); + at.mkdir("sub"); + at.touch("sub/t2"); + at.relative_symlink_file("t1", "l_ok"); + at.relative_symlink_file("t_missing", "l_dangle"); + at.relative_symlink_file("sub", "l_dir"); + at.relative_symlink_file("sub/t2", "l_deep"); + at.relative_symlink_file("/nonexistent/t1", "l_abs"); + at.relative_symlink_file("axbxcx", "l_rep"); + at.relative_symlink_file("t1", "l_c2"); + at.relative_symlink_file("l_c2", "l_c1"); + at.relative_symlink_file("l_self", "l_self"); + at.relative_symlink_file("t2", "sub/l_x"); + } + + /// -s rewrites the target text and nothing else, whatever that text is: a + /// target that leads nowhere, an absolute one, an empty needle inserting + /// into one, and a mode flag choosing among several matches in one. + #[test] + fn test_symlink_mode_rewrites_a_target_of_any_shape() { + let cases: [(&[&str], &str, &str); 4] = [ + (&["-s", "t", "z", "l_dangle"], "l_dangle", "z_missing"), + (&["-s", "t", "z", "l_abs"], "l_abs", "/nonexistent/z1"), + (&["-s", "", "P", "l_deep"], "l_deep", "sub/Pt2"), + (&["-s", "-a", "x", "X", "l_rep"], "l_rep", "aXbXcX"), + ]; + + for (args, link, target) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(args).succeeds(); + assert_eq!(at.resolve_link(link), target, "{args:?}"); + } + } + + /// The target is scoped exactly the way a filename is: the final component + /// only, unless an argument holds a separator. A needle that matches only + /// the directory part matches nothing, and so does one that matches the + /// link's own name rather than its target. + #[test] + fn test_symlink_mode_scopes_the_target_like_a_filename() { + let cases: [(&[&str], &str, &str); 2] = [ + (&["-s", "sub", "SUB", "l_deep"], "l_deep", "sub/t2"), + (&["-s", "ok", "OK", "l_ok"], "l_ok", "t1"), + ]; + + for (args, link, target) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(args).fails().code_is(4).no_output(); + assert_eq!(at.resolve_link(link), target, "{args:?}"); + } + } + + /// The type error is an ordinary per-operand failure, so it mixes with a + /// success the way any other failure does. + #[test] + fn test_symlink_mode_mixes_a_type_error_into_the_tally() { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(&["-s", "-v", "t", "z", "t1", "l_ok"]) + .fails() + .code_is(2) + .stdout_is("l_ok: `t1' -> `z1'\n") + .stderr_is("rename: t1: not a symbolic link\n"); + assert_eq!(at.resolve_link("l_ok"), "z1"); + } + + /// Without -s a symlink is an ordinary directory entry: its name is + /// rewritten and its target is left alone. + /// + /// This rename differs only in case, so neither a lookup of the old name + /// nor one of the new name can tell whether anything happened on a + /// filesystem that folds case - both would answer about the same entry. + /// The stored spelling has to come from the directory itself, which is + /// also the stronger assertion here: it rejects a rename to any name other + /// than exactly `l_OK`. + #[test] + fn test_a_link_without_symlink_mode_is_renamed_by_name() { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(&["_ok", "_OK", "l_ok"]).succeeds(); + assert_eq!(at.resolve_link("l_OK"), "t1"); + + let names: Vec<_> = std::fs::read_dir(at.plus(".")) + .into_iter() + .flatten() + .filter_map(Result::ok) + .map(|entry| entry.file_name()) + .collect(); + // The positive assertion guards the negative one: a listing that could + // not be read is empty, and would otherwise report the old name gone. + assert!(names.iter().any(|name| name == "l_OK"), "{names:?}"); + assert!(!names.iter().any(|name| name == "l_ok"), "{names:?}"); + } + + /// Under -s the guard asks whether the new target NAME is a directory + /// entry, not whether the link would resolve. `sub` is taken and `zzz` is + /// not. + #[test] + fn test_symlink_mode_no_overwrite_probes_the_new_target_name() { + let cases: [(&[&str], &str, &str, i32); 2] = [ + (&["-s", "-o", "t1", "sub", "l_ok"], "l_ok", "t1", 4), + (&["-s", "-o", "t1", "zzz", "l_ok"], "l_ok", "zzz", 0), + ]; + + for (args, link, target, code) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + let actual = ucmd.args(args).run().code(); + assert_eq!(actual, code, "{args:?}"); + assert_eq!(at.resolve_link(link), target, "{args:?}"); + } + + // The skip line names the link and the target it still has, not the + // one it was refused. + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(&["-s", "-o", "-v", "t1", "sub", "l_ok"]) + .fails() + .code_is(4) + .stdout_is("Skipping existing link: `l_ok' -> `t1'\n"); + } + + /// Under -s the prompt names the new target rather than the link, which is + /// the opposite way round from the skip line just above. + #[test] + fn test_symlink_mode_prompts_about_the_new_target() { + let cases: [(&[&str], &str, i32, &str, &str); 2] = [ + ( + &["-s", "-i", "t1", "sub", "l_ok"], + "y\n", + 0, + "rename: overwrite `sub'? ", + "sub", + ), + ( + &["-s", "-v", "-i", "t1", "sub", "l_ok"], + "n\n", + 4, + "rename: overwrite `sub'? Skipping existing link: `l_ok' -> `t1'\n", + "t1", + ), + ]; + + for (args, answer, code, expected, target) in cases { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + let result = ucmd.args(args).pipe_in(answer).run(); + assert_eq!(result.code(), code, "{args:?}"); + result.stdout_is(expected); + assert_eq!(at.resolve_link("l_ok"), target, "{args:?}"); + } + } + + /// The rewrite is not atomic and deliberately so: the unlink has already + /// happened when the symlink fails, and the original link is gone. The + /// message names the target it tried to create, so an empty target prints + /// two spaces. + /// + /// Only Linux can be made to fail this way: its symlink(2) returns ENOENT + /// when the target is an empty string, where the BSD call that macOS + /// inherits reserves that error for an empty link name and creates the + /// empty target happily. + #[cfg(target_os = "linux")] + #[test] + fn test_a_symlink_that_cannot_be_created_leaves_no_link_behind() { + let (at, mut ucmd) = at_and_ucmd!(); + create_links(&at); + ucmd.args(&["-s", "t1", "", "l_ok"]) + .fails() + .code_is(1) + .no_stdout() + .stderr_is("rename: l_ok: symlinking to failed: No such file or directory\n"); + assert!(!at.symlink_exists("l_ok")); + } + + /// Under -s the -o guard is an lstat on the new target, so a dangling entry + /// there blocks the rewrite, where the default path stats instead, finds + /// nothing, and lets the rename clobber it. The two modes need different + /// predicates and must not share one helper. + #[test] + fn test_symlink_mode_no_overwrite_is_blocked_by_a_dangling_new_target() { + let (at, mut ucmd) = at_and_ucmd!(); + at.relative_symlink_file("gone", "old_t"); + at.relative_symlink_file("also_gone", "new_t"); + at.relative_symlink_file("old_t", "lnk"); + ucmd.args(&["-vos", "old_t", "new_t", "lnk"]) + .fails() + .code_is(4) + .stdout_is("Skipping existing link: `lnk' -> `old_t'\n"); + assert_eq!(at.resolve_link("lnk"), "old_t"); + } + + /// The rewrite is an unlink followed by a symlink, and the two halves + /// report separately: a link whose parent directory denies writes fails at + /// the unlink, keeps the link, and says which step it was. + #[test] + fn test_symlink_mode_reports_a_failed_unlink() { + if skipped_as_root() { + return; + } + + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("p"); + at.touch("t1"); + at.relative_symlink_file("t1", "p/l_ro"); + at.set_mode("p", 0o500); + ucmd.args(&["-s", "t", "z", "p/l_ro"]) + .fails() + .code_is(1) + .stderr_contains("rename: p/l_ro: unlink failed: "); + at.set_mode("p", 0o700); + assert_eq!(at.resolve_link("p/l_ro"), "t1"); + } + + /// The -s guard resolves a relative new target against the process working + /// directory rather than the link's own directory: `sub/b1` exists, but the + /// probe is for `b1`, which does not, so -o does not block the rewrite. + #[test] + fn test_symlink_mode_no_overwrite_probes_relative_to_the_working_directory() { + let (at, mut ucmd) = at_and_ucmd!(); + at.mkdir("sub"); + at.touch("sub/a1"); + at.touch("sub/b1"); + at.relative_symlink_file("a1", "sub/lnk"); + ucmd.args(&["-vos", "a", "b", "sub/lnk"]) + .succeeds() + .stdout_is("sub/lnk: `a1' -> `b1'\n"); + assert_eq!(at.resolve_link("sub/lnk"), "b1"); + } +} + +/// Two things need more than a unix: a stdout that refuses everything written +/// to it needs /dev/full, which is a Linux device, and a filename that is not +/// valid UTF-8 needs a filesystem that stores names as bytes. APFS, the +/// default on macOS, accepts only valid UTF-8 for creation, so making one +/// there fails with EILSEQ before the utility is ever invoked. +#[cfg(target_os = "linux")] +mod linux { + use std::ffi::OsStr; + use std::fs::{File, OpenOptions}; + use std::os::unix::ffi::OsStrExt; + use uutests::{at_and_ucmd, new_ucmd}; + + fn dev_full() -> Option { + match OpenOptions::new().write(true).open("/dev/full") { + Ok(file) => Some(file), + Err(_) => { + println!("test skipped: /dev/full is not available"); + None + } + } + } + + // -- byte-oriented names ---------------------------------------------- + + /// A filename is a byte string, and one that is not valid UTF-8 goes + /// through unchanged - matched as bytes, written to stdout as bytes, and + /// created as bytes. + #[test] + fn test_a_name_that_is_not_valid_utf8_survives_the_round_trip() { + let (at, mut ucmd) = at_and_ucmd!(); + let old = OsStr::from_bytes(b"lat\xe9n"); + at.touch(old); + ucmd.args(&[OsStr::new("-v"), OsStr::new("lat"), OsStr::new("LAT"), old]) + .succeeds() + .stdout_is_bytes(b"`lat\xe9n' -> `LAT\xe9n'\n"); + assert!(at.file_exists(OsStr::from_bytes(b"LAT\xe9n"))); + } + + /// The result is not valid UTF-8 and that is not an error. An engine + /// working over characters could not express any of these. + #[test] + fn test_a_needle_may_split_a_multibyte_character() { + for (needle, replacement, new) in [ + (&b"\xc3"[..], &b"X"[..], &b"cafX\xa9"[..]), + (&b"f\xc3"[..], &b"F"[..], &b"caF\xa9"[..]), + ] { + let (at, mut ucmd) = at_and_ucmd!(); + let name = OsStr::from_bytes(b"caf\xc3\xa9"); + at.touch(name); + ucmd.args(&[ + OsStr::from_bytes(needle), + OsStr::from_bytes(replacement), + name, + ]) + .succeeds(); + assert!(at.file_exists(OsStr::from_bytes(new)), "{needle:?}"); + } + } + + /// The unit is a byte: `caf` is five bytes and takes six + /// insertions, where an engine seeing four characters would insert five + /// times - which is the whole reason the engine is generic over the code + /// unit. + #[test] + fn test_an_empty_needle_counts_code_units_not_characters() { + let (at, mut ucmd) = at_and_ucmd!(); + let name = OsStr::from_bytes(b"caf\xc3\xa9"); + at.touch(name); + ucmd.args(&[OsStr::new("-a"), OsStr::new(""), OsStr::new("_"), name]) + .succeeds(); + assert!(at.file_exists(OsStr::from_bytes(b"_c_a_f_\xc3_\xa9_"))); + } + + // -- an unwritable stdout --------------------------------------------- + + /// Reporting is not best-effort. A run whose output cannot be written says + /// so once, at the end, and reports 1 - discarding a tally that had + /// already decided otherwise, even though every rename really happened. + /// A run that writes nothing never notices. + #[test] + fn test_a_stdout_that_cannot_be_written_overrides_the_tally() { + let cases: [(&[&str], i32, &str); 2] = [ + ( + &["-v", "s", "z", "s1", "s2"], + 1, + "rename: write error: No space left on device\n", + ), + (&["s", "z", "s1", "s2"], 0, ""), + ]; + + for (args, code, expected) in cases { + let Some(sink) = dev_full() else { return }; + + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("s2"); + let result = ucmd.args(args).set_stdout(sink).run(); + assert_eq!(result.code(), code, "{args:?}"); + result.stderr_is(expected); + } + + // The renames the failing run reported are still renames. + let Some(sink) = dev_full() else { return }; + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + at.touch("s2"); + ucmd.args(&["-v", "s", "z", "s1", "s2"]) + .set_stdout(sink) + .fails() + .code_is(1); + assert!(at.file_exists("z1")); + assert!(at.file_exists("z2")); + } + + /// The override is not a property of stdout. A diagnostic that cannot be + /// written discards the tally the same way - and, more importantly, does + /// not stop the run: the operand after the failing one is still renamed + /// and its report still reaches a stdout that works. + #[test] + fn test_a_stderr_that_cannot_be_written_overrides_the_tally_and_does_not_stop_the_run() { + let Some(sink) = dev_full() else { return }; + let (at, mut ucmd) = at_and_ucmd!(); + at.touch("s1"); + let result = ucmd + .args(&["-v", "s", "z", "s9", "s1"]) + .set_stderr(sink) + .run(); + assert_eq!(result.code(), 1); + result.stdout_is("`s1' -> `z1'\n"); + assert!(at.file_exists("z1")); + } + + /// Help is written by clap rather than by us, but a failed write is still a + /// failed write: it is reported and it is worth 1, not a panic. + #[test] + fn test_a_clap_stream_that_cannot_be_written_is_reported() { + for args in [&["--help"], &["--version"]] { + let Some(sink) = dev_full() else { return }; + let mut ucmd = new_ucmd!(); + let result = ucmd.args(args).set_stdout(sink).run(); + assert_eq!(result.code(), 1, "{args:?}"); + result.stderr_is("rename: write error: No space left on device\n"); + } + } +} diff --git a/tests/tests.rs b/tests/tests.rs index 95682dd3..8ee1a2ba 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -98,3 +98,7 @@ mod test_uuidgen; #[cfg(feature = "chcpu")] #[path = "by-util/test_chcpu.rs"] mod test_chcpu; + +#[cfg(feature = "rename")] +#[path = "by-util/test_rename.rs"] +mod test_rename;