#[cfg(not(unix))]
fn main() {
eprintln!("mv: only available on Unix");
std::process::exit(1);
}
#[cfg(unix)]
use std::path::Path;
#[cfg(unix)]
use std::process;
#[cfg(unix)]
use coreutils_rs::mv::{BackupMode, MvConfig, mv_file, parse_backup_mode, strip_trailing_slashes};
#[cfg(unix)]
const TOOL_NAME: &str = "mv";
#[cfg(unix)]
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[cfg(unix)]
fn main() {
coreutils_rs::common::reset_sigpipe();
let mut config = MvConfig::default();
let mut operands: Vec<String> = Vec::new();
let mut saw_dashdash = false;
let args: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if saw_dashdash {
operands.push(arg.clone());
i += 1;
continue;
}
match arg.as_str() {
"--help" => {
print_help();
return;
}
"--version" => {
println!("{} (fcoreutils) {}", TOOL_NAME, VERSION);
return;
}
"--" => saw_dashdash = true,
"-f" | "--force" => {
config.force = true;
config.interactive = false;
config.no_clobber = false;
}
"-i" | "--interactive" => {
config.interactive = true;
config.force = false;
config.no_clobber = false;
}
"-n" | "--no-clobber" => {
config.no_clobber = true;
config.force = false;
config.interactive = false;
}
"-v" | "--verbose" => config.verbose = true,
"-u" | "--update" => config.update = true,
"-b" => config.backup = Some(BackupMode::Simple),
"--strip-trailing-slashes" => config.strip_trailing_slashes = true,
"-T" | "--no-target-directory" => config.no_target_directory = true,
"-t" => {
i += 1;
if i >= args.len() {
eprintln!("{}: option requires an argument -- 't'", TOOL_NAME);
eprintln!("Try '{} --help' for more information.", TOOL_NAME);
process::exit(1);
}
config.target_directory = Some(args[i].clone());
}
"-S" => {
i += 1;
if i >= args.len() {
eprintln!("{}: option requires an argument -- 'S'", TOOL_NAME);
eprintln!("Try '{} --help' for more information.", TOOL_NAME);
process::exit(1);
}
config.suffix = args[i].clone();
}
_ if arg.starts_with("--backup=") => {
let val = &arg["--backup=".len()..];
match parse_backup_mode(val) {
Some(mode) => config.backup = Some(mode),
None => {
eprintln!("{}: invalid backup type '{}'", TOOL_NAME, val);
process::exit(1);
}
}
}
"--backup" => config.backup = Some(BackupMode::Existing),
_ if arg.starts_with("--target-directory=") => {
config.target_directory = Some(arg["--target-directory=".len()..].to_string());
}
_ if arg.starts_with("--suffix=") => {
config.suffix = arg["--suffix=".len()..].to_string();
}
_ if arg.starts_with("-S") && arg.len() > 2 => {
config.suffix = arg[2..].to_string();
}
_ if arg.starts_with("-t") && arg.len() > 2 => {
config.target_directory = Some(arg[2..].to_string());
}
_ if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") => {
let chars: Vec<char> = arg[1..].chars().collect();
let mut j = 0;
while j < chars.len() {
match chars[j] {
'f' => {
config.force = true;
config.interactive = false;
config.no_clobber = false;
}
'i' => {
config.interactive = true;
config.force = false;
config.no_clobber = false;
}
'n' => {
config.no_clobber = true;
config.force = false;
config.interactive = false;
}
'v' => config.verbose = true,
'u' => config.update = true,
'b' => config.backup = Some(BackupMode::Simple),
'T' => config.no_target_directory = true,
'S' => {
let rest: String = chars[j + 1..].iter().collect();
if rest.is_empty() {
i += 1;
if i >= args.len() {
eprintln!("{}: option requires an argument -- 'S'", TOOL_NAME);
eprintln!("Try '{} --help' for more information.", TOOL_NAME);
process::exit(1);
}
config.suffix = args[i].clone();
} else {
config.suffix = rest;
}
break;
}
't' => {
let rest: String = chars[j + 1..].iter().collect();
if rest.is_empty() {
i += 1;
if i >= args.len() {
eprintln!("{}: option requires an argument -- 't'", TOOL_NAME);
eprintln!("Try '{} --help' for more information.", TOOL_NAME);
process::exit(1);
}
config.target_directory = Some(args[i].clone());
} else {
config.target_directory = Some(rest);
}
break;
}
_ => {
eprintln!("{}: invalid option -- '{}'", TOOL_NAME, chars[j]);
eprintln!("Try '{} --help' for more information.", TOOL_NAME);
process::exit(1);
}
}
j += 1;
}
}
_ => operands.push(arg.clone()),
}
i += 1;
}
if operands.is_empty() {
eprintln!("{}: missing file operand", TOOL_NAME);
eprintln!("Try '{} --help' for more information.", TOOL_NAME);
process::exit(1);
}
if config.strip_trailing_slashes {
for op in &mut operands {
*op = strip_trailing_slashes(op).to_string();
}
}
let mut exit_code = 0;
if let Some(ref dir) = config.target_directory {
if !Path::new(dir).is_dir() {
eprintln!("{}: target '{}' is not a directory", TOOL_NAME, dir);
process::exit(1);
}
for source in &operands {
let src_path = Path::new(source);
if !src_path.exists() && src_path.symlink_metadata().is_err() {
eprintln!(
"{}: cannot stat '{}': No such file or directory",
TOOL_NAME, source
);
exit_code = 1;
continue;
}
let basename = src_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| source.clone());
let dst = Path::new(dir).join(&basename);
if let Err(e) = mv_file(src_path, &dst, &config) {
eprintln!(
"{}: cannot move '{}' to '{}': {}",
TOOL_NAME,
source,
dst.display(),
coreutils_rs::common::io_error_msg(&e)
);
exit_code = 1;
}
}
} else if config.no_target_directory {
if operands.len() < 2 {
eprintln!(
"{}: missing destination file operand after '{}'",
TOOL_NAME, operands[0]
);
eprintln!("Try '{} --help' for more information.", TOOL_NAME);
process::exit(1);
}
if operands.len() > 2 {
eprintln!("{}: extra operand '{}'", TOOL_NAME, operands[2]);
eprintln!("Try '{} --help' for more information.", TOOL_NAME);
process::exit(1);
}
let src = Path::new(&operands[0]);
let dst = Path::new(&operands[1]);
if !src.exists() && src.symlink_metadata().is_err() {
eprintln!(
"{}: cannot stat '{}': No such file or directory",
TOOL_NAME, operands[0]
);
process::exit(1);
}
if let Err(e) = mv_file(src, dst, &config) {
eprintln!(
"{}: cannot move '{}' to '{}': {}",
TOOL_NAME,
operands[0],
operands[1],
coreutils_rs::common::io_error_msg(&e)
);
process::exit(1);
}
} else if operands.len() == 1 {
eprintln!(
"{}: missing destination file operand after '{}'",
TOOL_NAME, operands[0]
);
eprintln!("Try '{} --help' for more information.", TOOL_NAME);
process::exit(1);
} else if operands.len() == 2 {
let src = Path::new(&operands[0]);
let dst_str = &operands[1];
let dst = Path::new(dst_str);
if !src.exists() && src.symlink_metadata().is_err() {
eprintln!(
"{}: cannot stat '{}': No such file or directory",
TOOL_NAME, operands[0]
);
process::exit(1);
}
if dst.is_dir() {
let basename = src
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| operands[0].clone());
let final_dst = dst.join(&basename);
if let Err(e) = mv_file(src, &final_dst, &config) {
eprintln!(
"{}: cannot move '{}' to '{}': {}",
TOOL_NAME,
operands[0],
final_dst.display(),
coreutils_rs::common::io_error_msg(&e)
);
exit_code = 1;
}
} else if let Err(e) = mv_file(src, dst, &config) {
eprintln!(
"{}: cannot move '{}' to '{}': {}",
TOOL_NAME,
operands[0],
operands[1],
coreutils_rs::common::io_error_msg(&e)
);
exit_code = 1;
}
} else {
let dir = &operands[operands.len() - 1];
if !Path::new(dir).is_dir() {
eprintln!("{}: target '{}' is not a directory", TOOL_NAME, dir);
process::exit(1);
}
for source in &operands[..operands.len() - 1] {
let src_path = Path::new(source);
if !src_path.exists() && src_path.symlink_metadata().is_err() {
eprintln!(
"{}: cannot stat '{}': No such file or directory",
TOOL_NAME, source
);
exit_code = 1;
continue;
}
let basename = src_path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| source.clone());
let final_dst = Path::new(dir).join(&basename);
if let Err(e) = mv_file(src_path, &final_dst, &config) {
eprintln!(
"{}: cannot move '{}' to '{}': {}",
TOOL_NAME,
source,
final_dst.display(),
coreutils_rs::common::io_error_msg(&e)
);
exit_code = 1;
}
}
}
if exit_code != 0 {
process::exit(exit_code);
}
}
#[cfg(unix)]
fn print_help() {
println!("Usage: {} [OPTION]... [-T] SOURCE DEST", TOOL_NAME);
println!(" or: {} [OPTION]... SOURCE... DIRECTORY", TOOL_NAME);
println!(" or: {} [OPTION]... -t DIRECTORY SOURCE...", TOOL_NAME);
println!("Rename SOURCE to DEST, or move SOURCE(s) to DIRECTORY.");
println!();
println!(" -b like --backup but does not accept an argument");
println!(" --backup[=CONTROL] make a backup of each existing destination file");
println!(" -f, --force do not prompt before overwriting");
println!(" -i, --interactive prompt before overwrite");
println!(" -n, --no-clobber do not overwrite an existing file");
println!(" --strip-trailing-slashes remove any trailing slashes from each SOURCE");
println!(" -S, --suffix=SUFFIX override the usual backup suffix");
println!(" -t, --target-directory=DIRECTORY move all SOURCE arguments into DIRECTORY");
println!(" -T, --no-target-directory treat DEST as a normal file");
println!(" -u, --update move only when the SOURCE file is newer");
println!(" than the destination file or when the");
println!(" destination file is missing");
println!(" -v, --verbose explain what is being done");
println!(" --help display this help and exit");
println!(" --version output version information and exit");
println!();
println!("The backup suffix is '~', unless set with --suffix or SIMPLE_BACKUP_SUFFIX.");
println!("The version control method may be selected via the --backup option or through");
println!("the VERSION_CONTROL environment variable. Here are the values:");
println!();
println!(" none, off never make backups (even if --backup is given)");
println!(" numbered, t make numbered backups");
println!(" existing, nil numbered if numbered backups exist, simple otherwise");
println!(" simple, never always make simple backups");
}
#[cfg(test)]
mod tests {
use std::fs;
use std::process::Command;
fn cmd() -> Command {
let mut path = std::env::current_exe().unwrap();
path.pop();
path.pop();
path.push("fmv");
Command::new(path)
}
#[cfg(unix)]
#[test]
fn test_mv_rename() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("source.txt");
let dst = dir.path().join("dest.txt");
fs::write(&src, "hello").unwrap();
let output = cmd()
.args([src.to_str().unwrap(), dst.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success(), "mv should succeed");
assert!(!src.exists(), "source should no longer exist");
assert!(dst.exists(), "destination should exist");
assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
}
#[cfg(unix)]
#[test]
fn test_mv_to_directory() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("file.txt");
let dest_dir = dir.path().join("dest");
fs::create_dir(&dest_dir).unwrap();
fs::write(&src, "content").unwrap();
let output = cmd()
.args([src.to_str().unwrap(), dest_dir.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success(), "mv to directory should succeed");
assert!(!src.exists(), "source should no longer exist");
assert!(
dest_dir.join("file.txt").exists(),
"file should be in dest dir"
);
assert_eq!(
fs::read_to_string(dest_dir.join("file.txt")).unwrap(),
"content"
);
}
#[cfg(unix)]
#[test]
fn test_mv_force() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.txt");
let dst = dir.path().join("existing.txt");
fs::write(&src, "new content").unwrap();
fs::write(&dst, "old content").unwrap();
let output = cmd()
.args(["-f", src.to_str().unwrap(), dst.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success(), "mv -f should succeed");
assert!(!src.exists(), "source should be removed");
assert_eq!(fs::read_to_string(&dst).unwrap(), "new content");
}
#[cfg(unix)]
#[test]
fn test_mv_no_clobber() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
let dst = dir.path().join("dst.txt");
fs::write(&src, "new").unwrap();
fs::write(&dst, "old").unwrap();
let output = cmd()
.args(["-n", src.to_str().unwrap(), dst.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success(), "mv -n should succeed (no error)");
assert!(src.exists(), "source should still exist with -n");
assert_eq!(
fs::read_to_string(&dst).unwrap(),
"old",
"destination should not be overwritten with -n"
);
}
#[cfg(unix)]
#[test]
fn test_mv_verbose() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("verbose_src.txt");
let dst = dir.path().join("verbose_dst.txt");
fs::write(&src, "data").unwrap();
let output = cmd()
.args(["-v", src.to_str().unwrap(), dst.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("->") || stderr.contains("renamed"),
"verbose output should contain rename info: {}",
stderr
);
}
#[cfg(unix)]
#[test]
fn test_mv_backup() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("new.txt");
let dst = dir.path().join("existing.txt");
fs::write(&src, "new content").unwrap();
fs::write(&dst, "old content").unwrap();
let output = cmd()
.args(["-b", src.to_str().unwrap(), dst.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success(), "mv -b should succeed");
let backup = dir.path().join("existing.txt~");
assert!(backup.exists(), "backup file should exist");
assert_eq!(fs::read_to_string(&backup).unwrap(), "old content");
assert_eq!(fs::read_to_string(&dst).unwrap(), "new content");
assert!(!src.exists(), "source should be removed");
}
#[cfg(unix)]
#[test]
fn test_mv_matches_gnu() {
let dir = tempfile::tempdir().unwrap();
let gnu_src = dir.path().join("gnu_src.txt");
let gnu_dst = dir.path().join("gnu_dst.txt");
let our_src = dir.path().join("our_src.txt");
let our_dst = dir.path().join("our_dst.txt");
fs::write(&gnu_src, "test").unwrap();
fs::write(&our_src, "test").unwrap();
let gnu = Command::new("mv")
.args([gnu_src.to_str().unwrap(), gnu_dst.to_str().unwrap()])
.output();
if let Ok(gnu_output) = gnu {
let our_output = cmd()
.args([our_src.to_str().unwrap(), our_dst.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(
our_output.status.code(),
gnu_output.status.code(),
"Exit codes should match"
);
if gnu_output.status.success() {
assert!(!gnu_src.exists(), "GNU mv should have moved source");
assert!(!our_src.exists(), "our mv should have moved source");
assert_eq!(
fs::read_to_string(&gnu_dst).unwrap(),
fs::read_to_string(&our_dst).unwrap(),
"File contents should match"
);
}
}
}
#[cfg(unix)]
#[test]
fn test_mv_missing_operand() {
let output = cmd().output().unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("missing file operand"));
}
#[cfg(unix)]
#[test]
fn test_mv_missing_dest() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("only.txt");
fs::write(&src, "data").unwrap();
let output = cmd().arg(src.to_str().unwrap()).output().unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("missing destination"));
}
#[cfg(unix)]
#[test]
fn test_mv_target_directory() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("td_file.txt");
let dest_dir = dir.path().join("td_dest");
fs::write(&src, "content").unwrap();
fs::create_dir(&dest_dir).unwrap();
let output = cmd()
.args(["-t", dest_dir.to_str().unwrap(), src.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success());
assert!(dest_dir.join("td_file.txt").exists());
assert!(!src.exists());
}
#[cfg(unix)]
#[test]
fn test_mv_no_target_directory() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("nt_src.txt");
let dst = dir.path().join("nt_dst.txt");
fs::write(&src, "data").unwrap();
let output = cmd()
.args(["-T", src.to_str().unwrap(), dst.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success());
assert!(!src.exists());
assert!(dst.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
}
#[cfg(unix)]
#[test]
fn test_mv_update_newer() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("newer.txt");
let dst = dir.path().join("older.txt");
fs::write(&dst, "old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
fs::write(&src, "new").unwrap();
let output = cmd()
.args(["-u", src.to_str().unwrap(), dst.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success());
assert!(!src.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "new");
}
#[cfg(unix)]
#[test]
fn test_mv_update_older() {
let dir = tempfile::tempdir().unwrap();
let dst = dir.path().join("newer_dst.txt");
let src = dir.path().join("older_src.txt");
fs::write(&src, "old").unwrap();
std::thread::sleep(std::time::Duration::from_millis(1100));
fs::write(&dst, "new").unwrap();
let output = cmd()
.args(["-u", src.to_str().unwrap(), dst.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success());
assert!(src.exists(), "source should still exist (older than dest)");
assert_eq!(fs::read_to_string(&dst).unwrap(), "new");
}
#[cfg(unix)]
#[test]
fn test_mv_multiple_to_directory() {
let dir = tempfile::tempdir().unwrap();
let f1 = dir.path().join("a.txt");
let f2 = dir.path().join("b.txt");
let dest = dir.path().join("dest");
fs::write(&f1, "aaa").unwrap();
fs::write(&f2, "bbb").unwrap();
fs::create_dir(&dest).unwrap();
let output = cmd()
.args([
f1.to_str().unwrap(),
f2.to_str().unwrap(),
dest.to_str().unwrap(),
])
.output()
.unwrap();
assert!(output.status.success());
assert!(!f1.exists());
assert!(!f2.exists());
assert!(dest.join("a.txt").exists());
assert!(dest.join("b.txt").exists());
}
#[cfg(unix)]
#[test]
fn test_mv_nonexistent_source() {
let dir = tempfile::tempdir().unwrap();
let dst = dir.path().join("dst.txt");
let output = cmd()
.args(["/nonexistent_mv_test_12345", dst.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("cannot stat") || stderr.contains("No such file"));
}
#[cfg(unix)]
#[test]
fn test_mv_backup_suffix() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src.txt");
let dst = dir.path().join("dst.txt");
fs::write(&src, "new").unwrap();
fs::write(&dst, "old").unwrap();
let output = cmd()
.args([
"--suffix=.bak",
"-b",
src.to_str().unwrap(),
dst.to_str().unwrap(),
])
.output()
.unwrap();
assert!(output.status.success());
let backup = dir.path().join("dst.txt.bak");
assert!(backup.exists(), "backup with custom suffix should exist");
assert_eq!(fs::read_to_string(&backup).unwrap(), "old");
}
#[cfg(unix)]
#[test]
fn test_mv_directory() {
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src_dir");
let dst_dir = dir.path().join("dst_dir");
fs::create_dir(&src_dir).unwrap();
fs::write(src_dir.join("inner.txt"), "inside").unwrap();
let output = cmd()
.args([src_dir.to_str().unwrap(), dst_dir.to_str().unwrap()])
.output()
.unwrap();
assert!(output.status.success());
assert!(!src_dir.exists());
assert!(dst_dir.join("inner.txt").exists());
assert_eq!(
fs::read_to_string(dst_dir.join("inner.txt")).unwrap(),
"inside"
);
}
}