use crate::{
Ctx, FAIL_MARK, MoveOrCopy, SourceKind, TransferStats, item_progress_bar, message_with_arrow,
};
use anyhow::{Context, ensure};
use colored::Colorize;
use std::{fs, path::Path, sync::atomic::Ordering};
pub(crate) fn same_device(src: &Path, dest: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let Ok(src_dev) = fs::metadata(src).map(|m| m.dev()) else {
return false;
};
std::iter::successors(Some(dest), |p| p.parent())
.find_map(|p| fs::metadata(p).ok())
.is_some_and(|m| m.dev() == src_dev)
}
pub(crate) fn merge_or_copy<Src: AsRef<Path>, Dest: AsRef<Path>, F: Fn(u64)>(
src: Src,
dest: Dest,
batch_cb: F,
ctx: &Ctx,
) -> anyhow::Result<(String, TransferStats)> {
let src = src.as_ref();
let dest = dest.as_ref();
log::trace!(
"merge_or_copy('{}', '{}', {:?})",
src.display(),
dest.display(),
ctx.moc,
);
ensure!(src.exists(), "Source '{}' does not exist", src.display());
ensure!(
src.is_dir(),
"Source '{}' exists but is not a directory",
src.display()
);
if dest.exists() {
ensure!(
dest.is_dir(),
"Destination '{}' already exists and is not a directory",
dest.display()
);
}
let timer = std::time::Instant::now();
let skip_sizing = matches!(ctx.moc, MoveOrCopy::Move) && same_device(src, dest);
let pb = if skip_sizing {
indicatif::ProgressBar::hidden()
} else {
let total_size = collect_total_size(src);
ctx.mp
.add(item_progress_bar(total_size, src, dest, ctx.moc))
};
let stats = merge_or_copy_recursive(src, dest, ctx, &pb, &batch_cb)?;
if matches!(ctx.moc, MoveOrCopy::Move) {
let _ = fs::remove_dir(src);
}
pb.finish_and_clear();
Ok((
ctx.done_message(SourceKind::Dir, stats, timer.elapsed(), src, dest),
stats,
))
}
fn merge_or_copy_recursive<F: Fn(u64)>(
src: &Path,
dest: &Path,
ctx: &Ctx,
pb: &indicatif::ProgressBar,
batch_cb: &F,
) -> anyhow::Result<TransferStats> {
if matches!(ctx.moc, MoveOrCopy::Move) && !dest.exists() {
if let Some(parent) = dest.parent().filter(|p| !p.exists()) {
fs::create_dir_all(parent)
.with_context(|| format!("creating parent directory '{}'", parent.display()))?;
}
match fs::rename(src, dest) {
Ok(()) => {
if !pb.is_hidden() {
pb.inc(collect_total_size(dest));
batch_cb(pb.position());
}
return Ok(TransferStats {
fast_path_dir_count: 1,
..Default::default()
});
}
Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
log::debug!(
"'{}' and '{}' are on different devices, falling back to recursive merge.",
src.display(),
dest.display(),
);
}
Err(e) => {
return Err(e).with_context(|| {
format!("renaming '{}' to '{}'", src.display(), dest.display())
});
}
}
}
if !dest.exists() {
fs::create_dir_all(dest)
.with_context(|| format!("creating directory '{}'", dest.display()))?;
}
let mut entries: Vec<_> = fs::read_dir(src)
.with_context(|| format!("reading directory '{}'", src.display()))?
.filter_map(Result::ok)
.map(|e| e.path())
.collect();
entries.sort();
let mut stats = TransferStats::default();
let mut msgs: Vec<String> = Vec::new();
for entry in entries {
let name = entry.file_name().unwrap();
let dest_entry = dest.join(name);
if ctx.ctrlc.load(Ordering::Relaxed) {
for msg in &msgs {
log::info!("{msg}");
}
log::error!(
"{FAIL_MARK} Cancelled: {}",
message_with_arrow(&entry, &dest_entry, ctx.moc)
);
pb.abandon_with_message(
format!("{FAIL_MARK} {}", pb.message())
.red()
.bold()
.to_string(),
);
std::process::exit(130);
}
if entry.is_dir() {
stats += merge_or_copy_recursive(&entry, &dest_entry, ctx, pb, batch_cb)?;
if matches!(ctx.moc, MoveOrCopy::Move) {
let _ = fs::remove_dir(&entry);
}
} else {
let file_size = fs::metadata(&entry).map(|m| m.len()).unwrap_or(0);
let init_pos = pb.position();
let (msg, file_stats) = crate::file::move_or_copy(
&entry,
&dest_entry,
|copied_bytes: u64| {
pb.set_position(init_pos + copied_bytes);
batch_cb(init_pos + copied_bytes);
},
ctx,
)
.with_context(|| message_with_arrow(&entry, &dest_entry, ctx.moc))?;
let final_pos = init_pos + file_size;
pb.set_position(final_pos);
batch_cb(final_pos);
stats += file_stats;
msgs.push(msg);
}
}
Ok(stats)
}
pub(crate) fn collect_total_size(dir: &Path) -> u64 {
fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(Result::ok)
.map(|e| e.path())
.map(|path| {
if path.is_dir() {
collect_total_size(&path)
} else {
fs::metadata(&path).map(|m| m.len()).unwrap_or(0)
}
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tests::{
assert_error_with_msg, assert_file_copied, assert_file_moved, create_temp_file,
hidden_multi_progress, noop_ctrlc,
};
use tempfile::tempdir;
fn _merge_or_copy<Src: AsRef<Path>, Dest: AsRef<Path>>(
src: Src,
dest: Dest,
moc: MoveOrCopy,
force: bool,
) -> anyhow::Result<String> {
let mp = hidden_multi_progress();
let ctrlc = noop_ctrlc();
let ctx = Ctx {
moc,
force,
dry_run: false,
batch_size: 1,
mp: &mp,
ctrlc: &ctrlc,
};
merge_or_copy(src, dest, |_| {}, &ctx).map(|(msg, _)| msg)
}
#[test]
fn fails_when_source_does_not_exist() {
let src_dir = tempdir().unwrap();
let nonexistent = src_dir.path().join("nonexistent");
let dest_dir = tempdir().unwrap();
assert_error_with_msg(
_merge_or_copy(&nonexistent, &dest_dir, MoveOrCopy::Move, false),
"does not exist",
);
}
#[test]
fn fails_when_source_is_not_a_directory() {
let work_dir = tempdir().unwrap();
let src_file = create_temp_file(work_dir.path(), "file", "content");
let dest_dir = tempdir().unwrap();
assert_error_with_msg(
_merge_or_copy(&src_file, &dest_dir, MoveOrCopy::Move, false),
"not a directory",
);
}
#[test]
fn fails_when_dest_exists_but_is_not_a_directory() {
let src_dir = tempdir().unwrap();
create_temp_file(src_dir.path(), "file", "content");
let work_dir = tempdir().unwrap();
let dest_file = create_temp_file(work_dir.path(), "dest", "existing");
assert_error_with_msg(
_merge_or_copy(&src_dir, &dest_file, MoveOrCopy::Move, false),
"not a directory",
);
}
#[test]
fn creates_dest_when_it_does_not_exist() {
let src_dir = tempdir().unwrap();
create_temp_file(src_dir.path(), "file1", "content");
let work_dir = tempdir().unwrap();
let dest_dir = work_dir.path().join("new_dest");
assert!(!dest_dir.exists());
_merge_or_copy(&src_dir, &dest_dir, MoveOrCopy::Move, false).unwrap();
assert!(dest_dir.join("file1").exists());
}
#[test]
fn copy_directory_fails_without_force_when_files_overlap() {
let src_dir = tempdir().unwrap();
create_temp_file(src_dir.path(), "file1", "From source");
let dest_dir = tempdir().unwrap();
create_temp_file(dest_dir.path(), "file1", "From dest");
assert_error_with_msg(
_merge_or_copy(&src_dir, &dest_dir, MoveOrCopy::Copy, false),
"already exists",
);
}
#[test]
fn move_removes_source_directory() {
let src_dir = tempdir().unwrap();
create_temp_file(src_dir.path(), "file1", "content");
create_temp_file(src_dir.path(), "subdir/file2", "content2");
let src_path = src_dir.path().to_path_buf();
let dest_dir = tempdir().unwrap();
_merge_or_copy(&src_path, &dest_dir, MoveOrCopy::Move, false).unwrap();
assert!(
!src_path.exists(),
"Source directory should be removed after move"
);
assert!(dest_dir.path().join("file1").exists());
assert!(dest_dir.path().join("subdir/file2").exists());
}
#[test]
fn collect_total_size_empty() {
let temp_dir = tempdir().unwrap();
assert_eq!(collect_total_size(temp_dir.path()), 0);
}
#[test]
fn collect_total_size_with_files() {
let temp_dir = tempdir().unwrap();
create_temp_file(temp_dir.path(), "file1", "abc");
create_temp_file(temp_dir.path(), "subdir/file2", "defgh");
assert_eq!(collect_total_size(temp_dir.path()), 8);
}
#[test]
fn merge_directory_overwrites_existing_files_with_force() {
let src_dir = tempdir().unwrap();
let src_rel_paths = [
"file1",
"file2",
"subdir/subfile1",
"subdir/subfile2",
"subdir/nested/nested_file",
];
for path in src_rel_paths {
create_temp_file(src_dir.path(), path, &format!("From source: {path}"));
}
let dest_dir = tempdir().unwrap();
let dest_rel_paths = [
"file1",
"file3",
"subdir/subfile1",
"subdir/subfile3",
"subdir/nested/nested_file",
];
for path in dest_rel_paths {
create_temp_file(dest_dir.path(), path, &format!("From dest: {path}"));
}
_merge_or_copy(&src_dir, &dest_dir, MoveOrCopy::Move, true).unwrap();
for path in src_rel_paths {
let src_path = src_dir.path().join(path);
let dest_path = dest_dir.path().join(path);
assert_file_moved(&src_path, &dest_path, &format!("From source: {path}"));
}
for path in dest_rel_paths {
let dest_path = dest_dir.path().join(path);
assert!(
dest_path.exists(),
"File '{}' should exist",
dest_path.display()
);
}
}
#[test]
fn copy_directory_overwrites_existing_files_with_force() {
let src_dir = tempdir().unwrap();
let src_rel_paths = [
"file1",
"file2",
"subdir/subfile1",
"subdir/subfile2",
"subdir/nested/nested_file",
];
for path in src_rel_paths {
create_temp_file(src_dir.path(), path, &format!("From source: {path}"));
}
let dest_dir = tempdir().unwrap();
let dest_rel_paths = [
"file1",
"file3",
"subdir/subfile1",
"subdir/subfile3",
"subdir/nested/nested_file",
];
for path in dest_rel_paths {
create_temp_file(dest_dir.path(), path, &format!("From dest: {path}"));
}
_merge_or_copy(&src_dir, &dest_dir, MoveOrCopy::Copy, true).unwrap();
for path in src_rel_paths {
let src_path = src_dir.path().join(path);
let dest_path = dest_dir.path().join(path);
assert_file_copied(&src_path, &dest_path);
}
for path in dest_rel_paths {
let dest_path = dest_dir.path().join(path);
assert!(
dest_path.exists(),
"File '{}' should exist",
dest_path.display()
);
}
}
#[test]
fn merge_directory_succeeds_without_force_when_no_files_overlap() {
let src_dir = tempdir().unwrap();
let src_rel_paths = ["file1", "subdir/subfile1"];
for path in src_rel_paths {
create_temp_file(src_dir.path(), path, &format!("From source: {path}"));
}
let dest_dir = tempdir().unwrap();
let dest_rel_paths = ["file2", "subdir/subfile2"];
for path in dest_rel_paths {
create_temp_file(dest_dir.path(), path, &format!("From dest: {path}"));
}
_merge_or_copy(&src_dir, &dest_dir, MoveOrCopy::Move, false).unwrap();
for path in src_rel_paths {
let src_path = src_dir.path().join(path);
let dest_path = dest_dir.path().join(path);
assert_file_moved(&src_path, &dest_path, &format!("From source: {path}"));
}
for path in dest_rel_paths {
let dest_path = dest_dir.path().join(path);
assert!(
dest_path.exists(),
"File '{}' should exist",
dest_path.display()
);
}
}
#[test]
fn merge_directory_fails_without_force_when_files_overlap() {
let src_dir = tempdir().unwrap();
create_temp_file(src_dir.path(), "file1", "From source");
let dest_dir = tempdir().unwrap();
create_temp_file(dest_dir.path(), "file1", "From dest");
let result = _merge_or_copy(&src_dir, &dest_dir, MoveOrCopy::Move, false);
assert!(result.is_err());
assert!(format!("{:#}", result.unwrap_err()).contains("already exists"));
}
#[test]
fn merge_preserves_empty_directories() {
let src_dir = tempdir().unwrap();
create_temp_file(src_dir.path(), "file1", "content");
fs::create_dir_all(src_dir.path().join("empty_dir")).unwrap();
fs::create_dir_all(src_dir.path().join("subdir/empty_nested")).unwrap();
let dest_dir = tempdir().unwrap();
_merge_or_copy(&src_dir, &dest_dir, MoveOrCopy::Move, false).unwrap();
assert!(dest_dir.path().join("empty_dir").is_dir());
assert!(dest_dir.path().join("subdir/empty_nested").is_dir());
assert!(!src_dir.path().exists());
}
#[test]
fn move_to_nonexistent_dest_uses_fast_path() {
let src_dir = tempdir().unwrap();
create_temp_file(src_dir.path(), "file1", "content1");
create_temp_file(src_dir.path(), "subdir/file2", "content2");
let work_dir = tempdir().unwrap();
let dest_dir = work_dir.path().join("new_dest");
let mp = hidden_multi_progress();
let ctrlc = noop_ctrlc();
let ctx = Ctx {
moc: MoveOrCopy::Move,
force: false,
dry_run: false,
batch_size: 1,
mp: &mp,
ctrlc: &ctrlc,
};
let (msg, stats) = merge_or_copy(&src_dir, &dest_dir, |_| {}, &ctx).unwrap();
assert!(msg.contains("Renamed"));
assert!(msg.contains("directory"));
assert_eq!(stats.fast_path_dir_count, 1);
assert_eq!(stats.fast_path_file_count, 0);
assert_eq!(stats.io_bytes, 0);
assert!(!src_dir.path().exists());
assert!(dest_dir.join("file1").exists());
assert!(dest_dir.join("subdir/file2").exists());
}
#[test]
fn merge_renames_non_overlapping_subdirs() {
let src_dir = tempdir().unwrap();
create_temp_file(src_dir.path(), "overlap/src_file", "from src");
create_temp_file(src_dir.path(), "unique_src/file1", "content1");
create_temp_file(src_dir.path(), "unique_src/nested/file2", "content2");
let dest_dir = tempdir().unwrap();
create_temp_file(dest_dir.path(), "overlap/dest_file", "from dest");
let mp = hidden_multi_progress();
let ctrlc = noop_ctrlc();
let ctx = Ctx {
moc: MoveOrCopy::Move,
force: false,
dry_run: false,
batch_size: 1,
mp: &mp,
ctrlc: &ctrlc,
};
let (_, stats) = merge_or_copy(&src_dir, &dest_dir, |_| {}, &ctx).unwrap();
assert_eq!(stats.fast_path_dir_count, 1);
assert!(dest_dir.path().join("unique_src/file1").exists());
assert!(dest_dir.path().join("unique_src/nested/file2").exists());
assert!(dest_dir.path().join("overlap/src_file").exists());
assert!(dest_dir.path().join("overlap/dest_file").exists());
assert!(!src_dir.path().exists());
}
#[test]
fn copy_preserves_empty_directories() {
let src_dir = tempdir().unwrap();
create_temp_file(src_dir.path(), "file1", "content");
fs::create_dir_all(src_dir.path().join("empty_dir")).unwrap();
let dest_dir = tempdir().unwrap();
_merge_or_copy(&src_dir, &dest_dir, MoveOrCopy::Copy, false).unwrap();
assert!(dest_dir.path().join("empty_dir").is_dir());
assert!(src_dir.path().join("empty_dir").is_dir());
}
}