Skip to main content

mvx/
lib.rs

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    /// Format fast-path counts as e.g. "1 directory", "3 files", "1 directory + 3 files".
63    /// Returns `None` when both counts are zero.
64    #[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    /// Dim the detail text when in a batch (batch summary is the primary output).
109    #[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    /// Format a completion message for a file or directory operation.
119    #[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
283/// # Errors
284///
285/// Will return `Err` if move/merge fails for any reason.
286pub 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
377/// # Errors
378///
379/// Will return `Err` if can not register Ctrl-C handler.
380pub 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            // Use _exit() to terminate immediately without running atexit handlers
388            // or destructors, which can deadlock (e.g. indicatif's render thread).
389            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}