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 #[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 #[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
220pub 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
304pub 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 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}