Skip to main content

mvx/
lib.rs

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