1use anyhow::{Context, bail, ensure};
2use colored::Colorize;
3use log::LevelFilter;
4use std::io::Write;
5use std::path::Path;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicBool, Ordering};
8
9mod dir;
10mod file;
11
12#[derive(Debug, Clone, Copy)]
13pub enum SourceKind {
14 File,
15 Dir,
16}
17
18impl SourceKind {
19 #[must_use]
20 pub(crate) fn done_arrow(self) -> colored::ColoredString {
21 match self {
22 Self::File => "→",
23 Self::Dir => "↣",
24 }
25 .green()
26 .bold()
27 }
28}
29
30#[derive(Debug, Clone, Copy)]
31pub enum MoveOrCopy {
32 Move,
33 Copy,
34}
35
36impl MoveOrCopy {
37 #[must_use]
38 pub const fn arrow(&self) -> &'static str {
39 match self {
40 Self::Move => "->",
41 Self::Copy => "=>",
42 }
43 }
44
45 #[must_use]
46 pub const fn progress_chars(&self) -> &'static str {
47 match self {
48 Self::Move => "->-",
49 Self::Copy => "=>=",
50 }
51 }
52}
53
54#[derive(Debug, Clone, Copy, Default)]
55pub(crate) struct TransferStats {
56 pub io_bytes: u64,
57 pub fast_path_file_count: u64,
58 pub fast_path_dir_count: u64,
59}
60
61impl TransferStats {
62 #[must_use]
65 fn fast_path_summary(self) -> Option<String> {
66 if self.fast_path_file_count == 0 && self.fast_path_dir_count == 0 {
67 return None;
68 }
69 let mut parts = Vec::new();
70 if self.fast_path_dir_count > 0 {
71 let noun = if self.fast_path_dir_count == 1 {
72 "directory"
73 } else {
74 "directories"
75 };
76 parts.push(format!("{} {noun}", self.fast_path_dir_count));
77 }
78 if self.fast_path_file_count > 0 {
79 let noun = if self.fast_path_file_count == 1 {
80 "file"
81 } else {
82 "files"
83 };
84 parts.push(format!("{} {noun}", self.fast_path_file_count));
85 }
86 Some(parts.join(" + "))
87 }
88}
89
90impl std::ops::AddAssign for TransferStats {
91 fn add_assign(&mut self, rhs: Self) {
92 self.io_bytes += rhs.io_bytes;
93 self.fast_path_file_count += rhs.fast_path_file_count;
94 self.fast_path_dir_count += rhs.fast_path_dir_count;
95 }
96}
97
98pub struct Ctx<'a> {
99 pub moc: MoveOrCopy,
100 pub force: bool,
101 pub dry_run: bool,
102 pub batch_size: usize,
103 pub mp: &'a indicatif::MultiProgress,
104 pub ctrlc: &'a AtomicBool,
105}
106
107impl Ctx<'_> {
108 #[must_use]
110 pub fn maybe_dim(&self, s: String) -> String {
111 if self.batch_size > 1 {
112 s.dimmed().to_string()
113 } else {
114 s
115 }
116 }
117
118 #[must_use]
120 pub(crate) fn done_message<Src: AsRef<Path>, Dest: AsRef<Path>>(
121 &self,
122 kind: SourceKind,
123 stats: TransferStats,
124 elapsed: std::time::Duration,
125 src: Src,
126 dest: Dest,
127 ) -> String {
128 let detail = format!(
129 "{}: {}",
130 self.done_stats(kind, stats, elapsed),
131 message_with_arrow(src, dest, self.moc),
132 );
133 format!("{} {}", kind.done_arrow(), self.maybe_dim(detail))
134 }
135
136 #[must_use]
137 fn done_stats(
138 &self,
139 kind: SourceKind,
140 stats: TransferStats,
141 elapsed: std::time::Duration,
142 ) -> String {
143 let verb = match (self.moc, kind) {
144 (MoveOrCopy::Move, SourceKind::File) => "Moved",
145 (MoveOrCopy::Move, SourceKind::Dir) => "Merged",
146 (MoveOrCopy::Copy, _) => "Copied",
147 };
148
149 let fast_parts = stats.fast_path_summary();
150
151 if stats.io_bytes > 0 {
152 let fast_suffix = if let Some(summary) = &fast_parts {
153 let label = match self.moc {
154 MoveOrCopy::Move => "renamed",
155 MoveOrCopy::Copy => "reflinked",
156 };
157 format!(", {summary} {label}")
158 } else {
159 String::new()
160 };
161 format!(
162 "{verb} {} in {}{}{fast_suffix}",
163 indicatif::HumanBytes(stats.io_bytes),
164 indicatif::HumanDuration(elapsed),
165 human_speed(stats.io_bytes, elapsed),
166 )
167 } else if let Some(summary) = &fast_parts {
168 let label = match self.moc {
169 MoveOrCopy::Move => "Renamed",
170 MoveOrCopy::Copy => "Reflinked",
171 };
172 format!("{label} {summary} in {}", indicatif::HumanDuration(elapsed),)
173 } else {
174 format!("{verb} in {}", indicatif::HumanDuration(elapsed))
175 }
176 }
177}
178
179#[must_use]
180pub fn init_logging(level_filter: LevelFilter) -> indicatif::MultiProgress {
181 let mp = indicatif::MultiProgress::new();
182 if level_filter < LevelFilter::Info {
183 mp.set_draw_target(indicatif::ProgressDrawTarget::hidden());
184 }
185 let mp_clone = mp.clone();
186
187 env_logger::Builder::new()
188 .filter_level(level_filter)
189 .format(move |buf, record| {
190 let ts = chrono::Local::now().to_rfc3339().bold();
191
192 let file_and_line = format!(
193 "[{}:{}]",
194 record
195 .file()
196 .map(Path::new)
197 .and_then(Path::file_name)
198 .unwrap_or_default()
199 .display(),
200 record.line().unwrap_or(0),
201 )
202 .italic();
203 let level = match record.level() {
204 log::Level::Error => "ERROR".red(),
205 log::Level::Warn => "WARN ".yellow(),
206 log::Level::Info => "INFO ".green(),
207 log::Level::Debug => "DEBUG".blue(),
208 log::Level::Trace => "TRACE".magenta(),
209 }
210 .bold();
211
212 let msg = format!("{ts} {file_and_line:12} {level} {}", record.args());
213 if mp_clone.is_hidden() {
214 writeln!(buf, "{msg}")
215 } else {
216 mp_clone.println(msg)
217 }
218 })
219 .init();
220
221 mp
222}
223
224fn validate_sources(srcs: &[&Path], dest: &Path) -> anyhow::Result<SourceKind> {
225 let mut all_files = true;
226 let mut all_dirs = true;
227 for src in srcs {
228 if src.is_file() {
229 all_dirs = false;
230 } else if src.is_dir() {
231 all_files = false;
232 } else {
233 bail!(
234 "Source path '{}' is neither a file nor directory.",
235 src.display()
236 );
237 }
238 }
239
240 if srcs.len() > 1 {
241 ensure!(
242 all_files || all_dirs,
243 "When there are multiple sources, they must be all files or all directories.",
244 );
245 if !dest.is_dir() {
246 if all_dirs || dest.to_string_lossy().ends_with('/') {
247 std::fs::create_dir_all(dest)?;
248 } else {
249 bail!(
250 "When there are multiple file sources, the destination must be a directory or end with '/'."
251 );
252 }
253 }
254 }
255
256 Ok(if all_files {
257 SourceKind::File
258 } else {
259 SourceKind::Dir
260 })
261}
262
263fn process_source(
264 src: &Path,
265 dest: &Path,
266 batch_pb: &indicatif::ProgressBar,
267 base: u64,
268 sized: bool,
269 ctx: &Ctx,
270) -> anyhow::Result<(String, TransferStats)> {
271 let progress = move |bytes: u64| {
272 if sized {
273 batch_pb.set_position(base + bytes);
274 }
275 };
276 if src.is_file() {
277 file::move_or_copy(src, dest, progress, ctx)
278 } else {
279 dir::merge_or_copy(src, dest, progress, ctx)
280 }
281}
282
283pub fn run_batch<Src: AsRef<Path>, Srcs: AsRef<[Src]>, Dest: AsRef<Path>>(
287 srcs: Srcs,
288 dest: Dest,
289 ctx: &Ctx,
290) -> anyhow::Result<String> {
291 let srcs = srcs
292 .as_ref()
293 .iter()
294 .map(std::convert::AsRef::as_ref)
295 .collect::<Vec<_>>();
296 let dest = dest.as_ref();
297 log::trace!(
298 "run_batch('{:?}', '{}', {:?})",
299 srcs.iter().map(|s| s.display()).collect::<Vec<_>>(),
300 dest.display(),
301 ctx.moc,
302 );
303
304 let kind = validate_sources(&srcs, dest)?;
305
306 if ctx.dry_run {
307 for src in srcs {
308 let action = match (ctx.moc, src.is_dir()) {
309 (MoveOrCopy::Move, true) => "merge",
310 (MoveOrCopy::Move, false) => "move",
311 (MoveOrCopy::Copy, _) => "copy",
312 };
313 println!("Would {action} '{}' to '{}'", src.display(), dest.display());
314 }
315 return Ok(String::new());
316 }
317
318 let n = srcs.len();
319 let sizes: Vec<u64> = srcs
320 .iter()
321 .map(|s| {
322 let skip =
323 matches!(ctx.moc, MoveOrCopy::Move) && s.is_dir() && dir::same_device(s, dest);
324 if skip { 0 } else { source_size(s) }
325 })
326 .collect();
327 let batch_pb = if n > 1 {
328 ctx.mp
329 .add(bytes_progress_bar(sizes.iter().sum(), "blue", ctx.moc))
330 } else {
331 indicatif::ProgressBar::hidden()
332 };
333
334 let batch_timer = std::time::Instant::now();
335 let mut cumulative: u64 = 0;
336 let mut batch_stats = TransferStats::default();
337 for (i, src) in srcs.iter().enumerate() {
338 if ctx.ctrlc.load(Ordering::Relaxed) {
339 log::error!(
340 "{FAIL_MARK} Cancelled: {}",
341 message_with_arrow(src, dest, ctx.moc)
342 );
343 std::process::exit(130);
344 }
345
346 let up_next = srcs
347 .get(i + 1)
348 .map(|s| {
349 format!(
350 " Up Next: {}",
351 s.file_name().unwrap_or(s.as_os_str()).to_string_lossy()
352 )
353 .dimmed()
354 })
355 .unwrap_or_default();
356 batch_pb.set_message(format!("[{}/{}]{up_next}", i + 1, n));
357
358 let (msg, stats) = process_source(src, dest, &batch_pb, cumulative, sizes[i] > 0, ctx)
359 .with_context(|| message_with_arrow(src, dest, ctx.moc))?;
360 batch_stats += stats;
361
362 cumulative += sizes[i];
363 batch_pb.set_position(cumulative);
364 ctx.mp.println(msg)?;
365 }
366 batch_pb.finish_and_clear();
367
368 batch_pb.println(format!(
369 "{} {}",
370 kind.done_arrow(),
371 ctx.done_stats(kind, batch_stats, batch_timer.elapsed()),
372 ));
373
374 Ok(String::new())
375}
376
377pub fn ctrlc_flag() -> anyhow::Result<Arc<AtomicBool>> {
381 let flag = Arc::new(AtomicBool::new(false));
382 let flag_clone = Arc::clone(&flag);
383 let already_pressed = AtomicBool::new(false);
384 ctrlc::set_handler(move || {
385 if already_pressed.swap(true, Ordering::Relaxed) {
386 log::warn!("{FAIL_MARK} Ctrl-C again, force exiting...");
387 unsafe { libc::_exit(130) };
390 }
391 log::warn!(
392 "{FAIL_MARK} Ctrl-C detected, finishing current file... (press again to force exit)"
393 );
394 flag_clone.store(true, Ordering::Relaxed);
395 })?;
396
397 Ok(flag)
398}
399
400fn bytes_progress_bar(size: u64, color: &str, moc: MoveOrCopy) -> indicatif::ProgressBar {
401 let template = format!(
402 "{{total_bytes:>11}} [{{bar:40.{color}/white}}] {{bytes:<11}} ({{bytes_per_sec:>13}}, ETA: {{eta_precise}} ) {{prefix}} {{msg}}"
403 );
404 let style = indicatif::ProgressStyle::with_template(&template)
405 .unwrap()
406 .progress_chars(moc.progress_chars());
407 indicatif::ProgressBar::new(size).with_style(style)
408}
409
410fn item_progress_bar<Src: AsRef<Path>, Dest: AsRef<Path>>(
411 size: u64,
412 src: Src,
413 dest: Dest,
414 moc: MoveOrCopy,
415) -> indicatif::ProgressBar {
416 let color = if src.as_ref().is_dir() {
417 "cyan"
418 } else {
419 "green"
420 };
421 bytes_progress_bar(size, color, moc).with_message(message_with_arrow(src, dest, moc))
422}
423
424fn source_size(src: &Path) -> u64 {
425 if src.is_file() {
426 std::fs::metadata(src).map(|m| m.len()).unwrap_or(0)
427 } else {
428 dir::collect_total_size(src)
429 }
430}
431
432pub const FAIL_MARK: &str = "✗";
433
434pub(crate) fn human_speed(bytes: u64, elapsed: std::time::Duration) -> String {
435 let millis = elapsed.as_millis();
436 if millis == 0 {
437 return String::new();
438 }
439 let bps = u64::try_from(u128::from(bytes) * 1000 / millis).unwrap_or(u64::MAX);
440 format!(" ({}/s)", indicatif::HumanBytes(bps))
441}
442
443fn message_with_arrow<Src: AsRef<Path>, Dest: AsRef<Path>>(
444 src: Src,
445 dest: Dest,
446 moc: MoveOrCopy,
447) -> String {
448 format!(
449 "{} {} {}",
450 src.as_ref().display(),
451 moc.arrow(),
452 dest.as_ref().display()
453 )
454}
455
456#[cfg(test)]
457pub(crate) mod tests {
458 use super::*;
459 use std::fs;
460 use std::path::{Path, PathBuf};
461 use tempfile::tempdir;
462
463 pub(crate) fn noop_ctrlc() -> Arc<AtomicBool> {
464 Arc::new(AtomicBool::new(false))
465 }
466
467 pub(crate) fn hidden_multi_progress() -> indicatif::MultiProgress {
468 indicatif::MultiProgress::with_draw_target(indicatif::ProgressDrawTarget::hidden())
469 }
470
471 pub(crate) fn create_temp_file<P: AsRef<Path>>(dir: P, name: &str, content: &str) -> PathBuf {
472 let path = dir.as_ref().join(name);
473 if let Some(parent) = path.parent() {
474 fs::create_dir_all(parent).unwrap();
475 }
476 std::fs::write(&path, content).unwrap();
477 path
478 }
479
480 pub(crate) fn assert_file_moved<Src: AsRef<Path>, Dest: AsRef<Path>>(
481 src_path: Src,
482 dest_path: Dest,
483 expected_content: &str,
484 ) {
485 let src = src_path.as_ref();
486 let dest = dest_path.as_ref();
487 assert!(
488 !src.exists(),
489 "Source file still exists at {}",
490 src.display()
491 );
492 assert!(
493 dest.exists(),
494 "Destination file does not exist at {}",
495 dest.display()
496 );
497 let moved_content = fs::read_to_string(dest_path).unwrap();
498 assert_eq!(
499 moved_content, expected_content,
500 "File content doesn't match after move"
501 );
502 }
503
504 pub(crate) fn assert_file_not_moved<Src: AsRef<Path>, Dest: AsRef<Path>>(
505 src_path: Src,
506 dest_path: Dest,
507 ) {
508 let src = src_path.as_ref();
509 let dest = dest_path.as_ref();
510 assert!(
511 src.exists(),
512 "Source file does not exist at {}",
513 src.display()
514 );
515 assert!(
516 !dest.exists(),
517 "Destination file should not exist at {}",
518 dest.display()
519 );
520 }
521
522 pub(crate) fn assert_file_copied<Src: AsRef<Path>, Dest: AsRef<Path>>(
523 src_path: Src,
524 dest_path: Dest,
525 ) {
526 let src = src_path.as_ref();
527 let dest = dest_path.as_ref();
528 assert!(
529 src.exists(),
530 "Source file does not exists at {}",
531 src.display()
532 );
533 assert!(
534 dest.exists(),
535 "Destination file does not exist at {}",
536 dest.display()
537 );
538 assert_eq!(
539 fs::read_to_string(src).unwrap(),
540 fs::read_to_string(dest_path).unwrap(),
541 "File content doesn't match after copy"
542 );
543 }
544
545 pub(crate) fn assert_error_with_msg(result: anyhow::Result<String>, msg: &str) {
546 assert!(result.is_err(), "Expected an error, but got success");
547 let err_msg = format!("{:#}", result.unwrap_err());
548 assert!(
549 err_msg.contains(msg),
550 "Error message doesn't contain '{msg}': {err_msg}",
551 );
552 }
553
554 fn _run_batch<Src: AsRef<Path>, Srcs: AsRef<[Src]>, Dest: AsRef<Path>>(
555 srcs: Srcs,
556 dest: Dest,
557 moc: MoveOrCopy,
558 force: bool,
559 ) -> anyhow::Result<String> {
560 let mp = hidden_multi_progress();
561 let ctrlc = noop_ctrlc();
562 let ctx = Ctx {
563 moc,
564 force,
565 dry_run: false,
566 batch_size: srcs.as_ref().len(),
567 mp: &mp,
568 ctrlc: &ctrlc,
569 };
570 run_batch(srcs, dest, &ctx)
571 }
572
573 #[test]
574 fn move_file_to_new_dest() {
575 let work_dir = tempdir().unwrap();
576 let src_content = "This is a test file";
577 let src_path = create_temp_file(work_dir.path(), "a", src_content);
578 let dest_path = work_dir.path().join("b");
579
580 _run_batch([&src_path], &dest_path, MoveOrCopy::Move, false).unwrap();
581 assert_file_moved(&src_path, &dest_path, src_content);
582 }
583
584 #[test]
585 fn move_multiple_files_to_directory() {
586 let work_dir = tempdir().unwrap();
587 let src_content = "This is a test file";
588 let src_paths = vec![
589 create_temp_file(work_dir.path(), "a", src_content),
590 create_temp_file(work_dir.path(), "b", src_content),
591 ];
592 let dest_dir = work_dir.path().join("dest");
593 fs::create_dir_all(&dest_dir).unwrap();
594
595 _run_batch(&src_paths, &dest_dir, MoveOrCopy::Move, false).unwrap();
596 for src_path in src_paths {
597 let dest_path = dest_dir.join(src_path.file_name().unwrap());
598 assert_file_moved(&src_path, &dest_path, src_content);
599 }
600 }
601
602 #[test]
603 fn move_multiple_files_fails_when_dest_does_not_exist_without_trailing_slash() {
604 let work_dir = tempdir().unwrap();
605 let src_content = "This is a test file";
606 let src_paths = vec![
607 create_temp_file(work_dir.path(), "a", src_content),
608 create_temp_file(work_dir.path(), "b", src_content),
609 ];
610 let dest_dir = work_dir.path().join("dest");
611
612 assert_error_with_msg(
613 _run_batch(&src_paths, &dest_dir, MoveOrCopy::Move, false),
614 "destination must be a directory or end with '/'",
615 );
616 for src_path in src_paths {
617 let dest_path = dest_dir.join(src_path.file_name().unwrap());
618 assert_file_not_moved(&src_path, &dest_path);
619 }
620 }
621
622 #[test]
623 fn move_multiple_files_creates_dest_with_trailing_slash() {
624 let work_dir = tempdir().unwrap();
625 let src_content = "This is a test file";
626 let src_paths = vec![
627 create_temp_file(work_dir.path(), "a", src_content),
628 create_temp_file(work_dir.path(), "b", src_content),
629 ];
630 let dest_dir = work_dir.path().join("dest/");
631
632 _run_batch(&src_paths, &dest_dir, MoveOrCopy::Move, false).unwrap();
633 for src_path in &src_paths {
634 let dest_path = dest_dir.join(src_path.file_name().unwrap());
635 assert_file_moved(src_path, &dest_path, src_content);
636 }
637 }
638
639 #[test]
640 fn move_multiple_dirs_creates_dest_when_it_does_not_exist() {
641 let work_dir = tempdir().unwrap();
642 let src_dirs: Vec<_> = (0..3)
643 .map(|i| {
644 let d = tempdir().unwrap();
645 create_temp_file(d.path(), &format!("file{i}"), &format!("content{i}"));
646 d
647 })
648 .collect();
649 let dest_dir = work_dir.path().join("dest");
650
651 _run_batch(&src_dirs, &dest_dir, MoveOrCopy::Move, false).unwrap();
652 for (i, src_dir) in src_dirs.iter().enumerate() {
653 let src_path = src_dir.path().join(format!("file{i}"));
654 let dest_path = dest_dir.join(format!("file{i}"));
655 assert_file_moved(&src_path, &dest_path, &format!("content{i}"));
656 }
657 }
658
659 #[test]
660 fn move_mix_of_files_and_directories_fails() {
661 let work_dir = tempdir().unwrap();
662 let src_dir = tempdir().unwrap();
663 let src_paths = vec![
664 create_temp_file(work_dir.path(), "a", "This is a test file"),
665 src_dir.path().to_path_buf(),
666 ];
667 let dest_dir = work_dir.path().join("dest");
668 fs::create_dir_all(&dest_dir).unwrap();
669
670 assert_error_with_msg(
671 _run_batch(&src_paths, &dest_dir, MoveOrCopy::Move, false),
672 "When there are multiple sources, they must be all files or all directories.",
673 );
674 }
675
676 #[test]
677 fn copy_file_basic() {
678 let work_dir = tempdir().unwrap();
679 let src_content = "This is a test file";
680 let src_path = create_temp_file(work_dir.path(), "a", src_content);
681 let dest_path = work_dir.path().join("b");
682
683 _run_batch([&src_path], &dest_path, MoveOrCopy::Copy, false).unwrap();
684 assert_file_copied(&src_path, &dest_path);
685 }
686
687 #[test]
688 fn move_file_into_directory_with_trailing_slash() {
689 let work_dir = tempdir().unwrap();
690 let src_content = "This is a test file";
691 let src_name = "a";
692 let src_path = create_temp_file(&work_dir, src_name, src_content);
693 let dest_dir = work_dir.path().join("b/c/");
694
695 _run_batch([&src_path], &dest_dir, MoveOrCopy::Move, false).unwrap();
696 assert_file_moved(src_path, dest_dir.join(src_name), src_content);
697 }
698
699 #[test]
700 fn copy_file_into_directory_with_trailing_slash() {
701 let work_dir = tempdir().unwrap();
702 let src_content = "This is a test file";
703 let src_name = "a";
704 let src_path = create_temp_file(&work_dir, src_name, src_content);
705 let dest_dir = work_dir.path().join("b/c/");
706
707 _run_batch([&src_path], &dest_dir, MoveOrCopy::Copy, false).unwrap();
708 assert_file_copied(src_path, dest_dir.join(src_name));
709 }
710
711 #[test]
712 fn merge_directory_into_empty_dest() {
713 let src_dir = tempdir().unwrap();
714 let src_rel_paths = [
715 "file1",
716 "file2",
717 "subdir/subfile1",
718 "subdir/subfile2",
719 "subdir/nested/nested_file",
720 ];
721 for path in src_rel_paths {
722 create_temp_file(src_dir.path(), path, &format!("From source: {path}"));
723 }
724
725 let dest_dir = tempdir().unwrap();
726 _run_batch([&src_dir], &dest_dir, MoveOrCopy::Move, false).unwrap();
727 for path in src_rel_paths {
728 let src_path = src_dir.path().join(path);
729 let dest_path = dest_dir.path().join(path);
730 assert_file_moved(&src_path, &dest_path, &format!("From source: {path}"));
731 }
732 }
733
734 #[test]
735 fn merge_multiple_directories_into_dest() {
736 let src_num = 5;
737 let src_dirs = (0..src_num)
738 .filter_map(|_| tempdir().ok())
739 .collect::<Vec<tempfile::TempDir>>();
740 let src_rel_paths = (0..src_num)
741 .map(|i| format! {"nested{i}/file{i}"})
742 .collect::<Vec<String>>();
743 (0..src_num).for_each(|i| {
744 create_temp_file(&src_dirs[i], &src_rel_paths[i], &format!("content{i}"));
745 });
746
747 let dest_dir = tempdir().unwrap();
748 _run_batch(&src_dirs, &dest_dir, MoveOrCopy::Move, false).unwrap();
749 (0..src_num).for_each(|i| {
750 let src_path = src_dirs[i].path().join(&src_rel_paths[i]);
751 let dest_path = dest_dir.path().join(&src_rel_paths[i]);
752 assert_file_moved(&src_path, &dest_path, &format!("content{i}"));
753 });
754 }
755
756 #[test]
757 fn dry_run_does_not_modify_files() {
758 let work_dir = tempdir().unwrap();
759 let src_content = "This is a test file";
760 let src_path = create_temp_file(work_dir.path(), "a", src_content);
761 let dest_path = work_dir.path().join("b");
762
763 let mp = hidden_multi_progress();
764 let ctrlc = noop_ctrlc();
765 let ctx = Ctx {
766 moc: MoveOrCopy::Move,
767 force: false,
768 dry_run: true,
769 batch_size: 1,
770 mp: &mp,
771 ctrlc: &ctrlc,
772 };
773 run_batch([&src_path], &dest_path, &ctx).unwrap();
774
775 assert!(
776 src_path.exists(),
777 "Source should still exist in dry-run mode"
778 );
779 assert!(
780 !dest_path.exists(),
781 "Dest should not be created in dry-run mode"
782 );
783 }
784
785 #[test]
786 fn fails_with_nonexistent_source() {
787 let work_dir = tempdir().unwrap();
788 let src_path = work_dir.path().join("nonexistent");
789 let dest_path = work_dir.path().join("dest");
790
791 assert_error_with_msg(
792 _run_batch([&src_path], &dest_path, MoveOrCopy::Move, false),
793 "neither a file nor directory",
794 );
795 }
796
797 #[test]
798 fn copy_multiple_files_to_directory() {
799 let work_dir = tempdir().unwrap();
800 let src_paths = vec![
801 create_temp_file(work_dir.path(), "a", "content_a"),
802 create_temp_file(work_dir.path(), "b", "content_b"),
803 ];
804 let dest_dir = work_dir.path().join("dest");
805 fs::create_dir_all(&dest_dir).unwrap();
806
807 _run_batch(&src_paths, &dest_dir, MoveOrCopy::Copy, false).unwrap();
808 for src_path in &src_paths {
809 let dest_path = dest_dir.join(src_path.file_name().unwrap());
810 assert_file_copied(src_path, &dest_path);
811 }
812 }
813
814 #[test]
815 fn copy_directory_into_empty_dest() {
816 let src_dir = tempdir().unwrap();
817 let src_rel_paths = [
818 "file1",
819 "file2",
820 "subdir/subfile1",
821 "subdir/subfile2",
822 "subdir/nested/nested_file",
823 ];
824 for path in src_rel_paths {
825 create_temp_file(src_dir.path(), path, &format!("From source: {path}"));
826 }
827
828 let dest_dir = tempdir().unwrap();
829 _run_batch([&src_dir], &dest_dir, MoveOrCopy::Copy, false).unwrap();
830 for path in src_rel_paths {
831 let src_path = src_dir.path().join(path);
832 let dest_path = dest_dir.path().join(path);
833 assert_file_copied(&src_path, &dest_path);
834 }
835 }
836}