Skip to main content

common/
copy.rs

1use std::os::unix::fs::MetadataExt;
2
3use anyhow::{anyhow, Context};
4use async_recursion::async_recursion;
5use throttle::get_file_iops_tokens;
6use tracing::instrument;
7
8use crate::config::DryRunMode;
9use crate::filecmp;
10use crate::filter::{FilterResult, FilterSettings};
11use crate::preserve;
12use crate::progress;
13use crate::rm;
14use crate::rm::{Settings as RmSettings, Summary as RmSummary};
15
16/// Error type for copy operations that preserves operation summary even on failure.
17///
18/// # Logging Convention
19/// When logging this error, use `{:#}` or `{:?}` format to preserve the error chain:
20/// ```ignore
21/// tracing::error!("operation failed: {:#}", &error); // ✅ Shows full chain
22/// tracing::error!("operation failed: {:?}", &error); // ✅ Shows full chain
23/// ```
24/// The Display implementation also shows the full chain, but workspace linting enforces `{:#}`
25/// for consistency.
26#[derive(Debug, thiserror::Error)]
27#[error("{source:#}")]
28pub struct Error {
29    #[source]
30    pub source: anyhow::Error,
31    pub summary: Summary,
32}
33
34impl Error {
35    #[must_use]
36    pub fn new(source: anyhow::Error, summary: Summary) -> Self {
37        Error { source, summary }
38    }
39}
40
41/// Filter condition for overwrite operations.
42///
43/// Used with `--overwrite-filter` to skip overwriting files that match
44/// a directional condition (e.g., destination is newer than source).
45#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
46pub enum OverwriteFilter {
47    /// Skip overwriting if the destination file is strictly newer (by mtime).
48    #[value(name = "newer")]
49    Newer,
50}
51
52impl std::fmt::Display for OverwriteFilter {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            OverwriteFilter::Newer => write!(f, "newer"),
56        }
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct Settings {
62    pub dereference: bool,
63    pub fail_early: bool,
64    pub overwrite: bool,
65    pub overwrite_compare: filecmp::MetadataCmpSettings,
66    pub overwrite_filter: Option<OverwriteFilter>,
67    pub ignore_existing: bool,
68    pub chunk_size: u64,
69    /// Buffer size for remote copy file transfer operations in bytes.
70    ///
71    /// This is only used for remote copy operations and controls the buffer size
72    /// when copying data between files and network streams. The actual buffer is
73    /// capped to the file size to avoid over-allocation for small files.
74    pub remote_copy_buffer_size: usize,
75    /// filter settings for include/exclude patterns
76    pub filter: Option<crate::filter::FilterSettings>,
77    /// dry-run mode for previewing operations
78    pub dry_run: Option<crate::config::DryRunMode>,
79}
80
81/// Reports a dry-run action for copy operations
82fn report_dry_run_copy(src: &std::path::Path, dst: &std::path::Path, entry_type: &str) {
83    println!("would copy {} {:?} -> {:?}", entry_type, src, dst);
84}
85
86/// Reports a skipped entry during dry-run
87fn report_dry_run_skip(
88    path: &std::path::Path,
89    result: &FilterResult,
90    mode: DryRunMode,
91    entry_type: &str,
92) {
93    match mode {
94        DryRunMode::Brief => { /* brief mode doesn't show skipped files */ }
95        DryRunMode::All => {
96            println!("skip {} {:?}", entry_type, path);
97        }
98        DryRunMode::Explain => match result {
99            FilterResult::ExcludedByDefault => {
100                println!(
101                    "skip {} {:?} (no include pattern matched)",
102                    entry_type, path
103                );
104            }
105            FilterResult::ExcludedByPattern(pattern) => {
106                println!("skip {} {:?} (excluded by '{}')", entry_type, path, pattern);
107            }
108            FilterResult::Included => { /* shouldn't happen */ }
109        },
110    }
111}
112
113/// Check if a path should be filtered out
114fn should_skip_entry(
115    filter: &Option<FilterSettings>,
116    relative_path: &std::path::Path,
117    is_dir: bool,
118) -> Option<FilterResult> {
119    if let Some(ref f) = filter {
120        let result = f.should_include(relative_path, is_dir);
121        match result {
122            FilterResult::Included => None,
123            _ => Some(result),
124        }
125    } else {
126        None
127    }
128}
129
130/// Result of checking if an empty directory should be cleaned up.
131/// Used when filtering is active and a directory we created ended up empty.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum EmptyDirAction {
134    /// keep the directory (directly matched or no filter active)
135    Keep,
136    /// directory was only traversed, remove it
137    Remove,
138    /// dry-run mode, don't count this directory in summary
139    DryRunSkip,
140}
141
142/// Determine what to do with an empty directory when filtering is active.
143///
144/// This is called when we created a directory but nothing was copied into it
145/// (no files, symlinks, or child directories). The decision depends on whether
146/// the directory itself was directly matched by an include pattern, or if we
147/// only entered it to look for potential matches inside.
148///
149/// # Arguments
150/// * `filter` - the active filter settings (None means no filtering)
151/// * `we_created_dir` - whether we created this directory (vs it already existed)
152/// * `anything_copied` - whether any content was copied into this directory
153/// * `relative_path` - path relative to the source root (for pattern matching)
154/// * `is_root` - whether this is the root (user-specified) source directory
155/// * `is_dry_run` - whether we're in dry-run mode
156pub fn check_empty_dir_cleanup(
157    filter: Option<&FilterSettings>,
158    we_created_dir: bool,
159    anything_copied: bool,
160    relative_path: &std::path::Path,
161    is_root: bool,
162    is_dry_run: bool,
163) -> EmptyDirAction {
164    // if no filter active or something was copied, keep the directory
165    if filter.is_none() || anything_copied {
166        return EmptyDirAction::Keep;
167    }
168    // if we didn't create this directory, don't remove it
169    if !we_created_dir {
170        return EmptyDirAction::Keep;
171    }
172    // never remove the root directory — it's the user-specified source
173    if is_root {
174        return EmptyDirAction::Keep;
175    }
176    // filter is guaranteed to be Some here (checked above)
177    let f = filter.unwrap();
178    // check if directory directly matches include pattern
179    if f.directly_matches_include(relative_path, true) {
180        return EmptyDirAction::Keep;
181    }
182    // directory was only traversed for potential matches
183    if is_dry_run {
184        EmptyDirAction::DryRunSkip
185    } else {
186        EmptyDirAction::Remove
187    }
188}
189
190#[instrument]
191pub fn is_file_type_same(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
192    let ft1 = md1.file_type();
193    let ft2 = md2.file_type();
194    ft1.is_dir() == ft2.is_dir()
195        && ft1.is_file() == ft2.is_file()
196        && ft1.is_symlink() == ft2.is_symlink()
197}
198
199#[instrument(skip(prog_track, src_metadata, settings, preserve))]
200pub async fn copy_file(
201    prog_track: &'static progress::Progress,
202    src: &std::path::Path,
203    dst: &std::path::Path,
204    src_metadata: &std::fs::Metadata,
205    settings: &Settings,
206    preserve: &preserve::Settings,
207    is_fresh: bool,
208) -> Result<Summary, Error> {
209    // check --ignore-existing before dry-run so dry-run output reflects actual behavior.
210    // use symlink_metadata to detect dangling symlinks too (Path::exists follows symlinks)
211    if !is_fresh && settings.ignore_existing && tokio::fs::symlink_metadata(dst).await.is_ok() {
212        if let Some(mode) = settings.dry_run {
213            match mode {
214                DryRunMode::Brief => {}
215                DryRunMode::All => println!("skip file {:?}", dst),
216                DryRunMode::Explain => println!("skip file {:?} (destination exists)", dst),
217            }
218        }
219        tracing::debug!("destination exists, skipping (--ignore-existing)");
220        prog_track.files_unchanged.inc();
221        return Ok(Summary {
222            files_unchanged: 1,
223            ..Default::default()
224        });
225    }
226    // handle dry-run mode for files
227    if settings.dry_run.is_some() {
228        report_dry_run_copy(src, dst, "file");
229        return Ok(Summary {
230            files_copied: 1,
231            bytes_copied: src_metadata.len(),
232            ..Default::default()
233        });
234    }
235    tracing::debug!("opening 'src' for reading and 'dst' for writing");
236    get_file_iops_tokens(settings.chunk_size, src_metadata.size()).await;
237    let mut rm_summary = RmSummary::default();
238    if !is_fresh && dst.exists() {
239        if settings.overwrite {
240            tracing::debug!("file exists, check if it's identical");
241            let dst_metadata = tokio::fs::symlink_metadata(dst)
242                .await
243                .with_context(|| format!("failed reading metadata from {:?}", &dst))
244                .map_err(|err| Error::new(err, Default::default()))?;
245            if is_file_type_same(src_metadata, &dst_metadata) {
246                if filecmp::metadata_equal(&settings.overwrite_compare, src_metadata, &dst_metadata)
247                {
248                    tracing::debug!("file is identical, skipping");
249                    prog_track.files_unchanged.inc();
250                    return Ok(Summary {
251                        files_unchanged: 1,
252                        ..Default::default()
253                    });
254                }
255                if let Some(OverwriteFilter::Newer) = settings.overwrite_filter {
256                    if filecmp::dest_is_newer(src_metadata, &dst_metadata) {
257                        tracing::debug!("dest is newer than source, skipping");
258                        prog_track.files_unchanged.inc();
259                        return Ok(Summary {
260                            files_unchanged: 1,
261                            ..Default::default()
262                        });
263                    }
264                }
265            }
266            tracing::info!("file is different, removing existing file");
267            // note tokio::fs::overwrite cannot handle this path being e.g. a directory
268            rm_summary = rm::rm(
269                prog_track,
270                dst,
271                &RmSettings {
272                    fail_early: settings.fail_early,
273                    filter: None,
274                    dry_run: None,
275                },
276            )
277            .await
278            .map_err(|err| {
279                let rm_summary = err.summary;
280                let copy_summary = Summary {
281                    rm_summary,
282                    ..Default::default()
283                };
284                Error::new(err.source, copy_summary)
285            })?;
286        } else {
287            return Err(Error::new(
288                anyhow!(
289                    "destination {:?} already exists, did you intend to specify --overwrite?",
290                    dst
291                ),
292                Default::default(),
293            ));
294        }
295    }
296    tracing::debug!("copying data");
297    let mut copy_summary = Summary {
298        rm_summary,
299        ..Default::default()
300    };
301    tokio::fs::copy(src, dst)
302        .await
303        .with_context(|| format!("failed copying {:?} to {:?}", &src, &dst))
304        .map_err(|err| Error::new(err, copy_summary))?;
305    prog_track.files_copied.inc();
306    prog_track.bytes_copied.add(src_metadata.len());
307    tracing::debug!("setting permissions");
308    preserve::set_file_metadata(preserve, src_metadata, dst)
309        .await
310        .map_err(|err| Error::new(err, copy_summary))?;
311    // we mark files as "copied" only after all metadata is set as well
312    copy_summary.bytes_copied += src_metadata.len();
313    copy_summary.files_copied += 1;
314    Ok(copy_summary)
315}
316
317#[derive(Copy, Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
318pub struct Summary {
319    pub bytes_copied: u64,
320    pub files_copied: usize,
321    pub symlinks_created: usize,
322    pub directories_created: usize,
323    pub files_unchanged: usize,
324    pub symlinks_unchanged: usize,
325    pub directories_unchanged: usize,
326    pub files_skipped: usize,
327    pub symlinks_skipped: usize,
328    pub directories_skipped: usize,
329    pub rm_summary: RmSummary,
330}
331
332impl std::ops::Add for Summary {
333    type Output = Self;
334    fn add(self, other: Self) -> Self {
335        Self {
336            bytes_copied: self.bytes_copied + other.bytes_copied,
337            files_copied: self.files_copied + other.files_copied,
338            symlinks_created: self.symlinks_created + other.symlinks_created,
339            directories_created: self.directories_created + other.directories_created,
340            files_unchanged: self.files_unchanged + other.files_unchanged,
341            symlinks_unchanged: self.symlinks_unchanged + other.symlinks_unchanged,
342            directories_unchanged: self.directories_unchanged + other.directories_unchanged,
343            files_skipped: self.files_skipped + other.files_skipped,
344            symlinks_skipped: self.symlinks_skipped + other.symlinks_skipped,
345            directories_skipped: self.directories_skipped + other.directories_skipped,
346            rm_summary: self.rm_summary + other.rm_summary,
347        }
348    }
349}
350
351impl std::fmt::Display for Summary {
352    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
353        write!(
354            f,
355            "copy:\n\
356            -----\n\
357            bytes copied: {}\n\
358            files copied: {}\n\
359            symlinks created: {}\n\
360            directories created: {}\n\
361            files unchanged: {}\n\
362            symlinks unchanged: {}\n\
363            directories unchanged: {}\n\
364            files skipped: {}\n\
365            symlinks skipped: {}\n\
366            directories skipped: {}\n\
367            \n\
368            delete:\n\
369            -------\n\
370            {}",
371            bytesize::ByteSize(self.bytes_copied),
372            self.files_copied,
373            self.symlinks_created,
374            self.directories_created,
375            self.files_unchanged,
376            self.symlinks_unchanged,
377            self.directories_unchanged,
378            self.files_skipped,
379            self.symlinks_skipped,
380            self.directories_skipped,
381            &self.rm_summary,
382        )
383    }
384}
385
386/// Public entry point for copy operations.
387/// Internally delegates to copy_internal with source_root tracking for proper filter matching.
388#[instrument(skip(prog_track, settings, preserve))]
389pub async fn copy(
390    prog_track: &'static progress::Progress,
391    src: &std::path::Path,
392    dst: &std::path::Path,
393    settings: &Settings,
394    preserve: &preserve::Settings,
395    is_fresh: bool,
396) -> Result<Summary, Error> {
397    // check filter for top-level source (files, directories, and symlinks)
398    if let Some(ref filter) = settings.filter {
399        let src_name = src.file_name().map(std::path::Path::new);
400        if let Some(name) = src_name {
401            let src_metadata = tokio::fs::symlink_metadata(src)
402                .await
403                .with_context(|| format!("failed reading metadata from src: {:?}", &src))
404                .map_err(|err| Error::new(err, Default::default()))?;
405            let is_dir = src_metadata.is_dir();
406            let result = filter.should_include_root_item(name, is_dir);
407            match result {
408                crate::filter::FilterResult::Included => {}
409                result => {
410                    if let Some(mode) = settings.dry_run {
411                        let entry_type = if src_metadata.is_dir() {
412                            "directory"
413                        } else if src_metadata.is_symlink() {
414                            "symlink"
415                        } else {
416                            "file"
417                        };
418                        report_dry_run_skip(src, &result, mode, entry_type);
419                    }
420                    // return summary with skipped count
421                    let skipped_summary = if src_metadata.is_dir() {
422                        prog_track.directories_skipped.inc();
423                        Summary {
424                            directories_skipped: 1,
425                            ..Default::default()
426                        }
427                    } else if src_metadata.is_symlink() {
428                        prog_track.symlinks_skipped.inc();
429                        Summary {
430                            symlinks_skipped: 1,
431                            ..Default::default()
432                        }
433                    } else {
434                        prog_track.files_skipped.inc();
435                        Summary {
436                            files_skipped: 1,
437                            ..Default::default()
438                        }
439                    };
440                    return Ok(skipped_summary);
441                }
442            }
443        }
444    }
445    copy_internal(
446        prog_track, src, dst, src, settings, preserve, is_fresh, None,
447    )
448    .await
449}
450
451#[instrument(skip(prog_track, settings, preserve, open_file_guard))]
452#[async_recursion]
453#[allow(clippy::too_many_arguments)]
454async fn copy_internal(
455    prog_track: &'static progress::Progress,
456    src: &std::path::Path,
457    dst: &std::path::Path,
458    source_root: &std::path::Path,
459    settings: &Settings,
460    preserve: &preserve::Settings,
461    mut is_fresh: bool,
462    open_file_guard: Option<throttle::OpenFileGuard>,
463) -> Result<Summary, Error> {
464    let _ops_guard = prog_track.ops.guard();
465    tracing::debug!("reading source metadata");
466    let src_metadata = tokio::fs::symlink_metadata(src)
467        .await
468        .with_context(|| format!("failed reading metadata from src: {:?}", &src))
469        .map_err(|err| Error::new(err, Default::default()))?;
470    if settings.dereference && src_metadata.is_symlink() {
471        debug_assert!(
472            open_file_guard.is_none(),
473            "open file guard should not be pre-acquired for symlinks"
474        );
475        let link = tokio::fs::canonicalize(&src)
476            .await
477            .with_context(|| format!("failed reading src symlink {:?}", &src))
478            .map_err(|err| Error::new(err, Default::default()))?;
479        return copy(prog_track, &link, dst, settings, preserve, is_fresh).await;
480    }
481    if src_metadata.is_file() {
482        // acquire permit if not pre-acquired by the caller
483        let _guard = match open_file_guard {
484            Some(g) => g,
485            None => throttle::open_file_permit().await,
486        };
487        return copy_file(
488            prog_track,
489            src,
490            dst,
491            &src_metadata,
492            settings,
493            preserve,
494            is_fresh,
495        )
496        .await;
497    }
498    debug_assert!(
499        open_file_guard.is_none(),
500        "open file guard should not be pre-acquired for directories or symlinks"
501    );
502    if src_metadata.is_symlink() {
503        // check --ignore-existing before dry-run so dry-run output reflects actual behavior.
504        // use symlink_metadata to detect dangling symlinks too (Path::exists follows symlinks)
505        if !is_fresh && settings.ignore_existing && tokio::fs::symlink_metadata(dst).await.is_ok() {
506            if let Some(mode) = settings.dry_run {
507                match mode {
508                    DryRunMode::Brief => {}
509                    DryRunMode::All => println!("skip symlink {:?}", dst),
510                    DryRunMode::Explain => println!("skip symlink {:?} (destination exists)", dst),
511                }
512            }
513            tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
514            prog_track.symlinks_unchanged.inc();
515            return Ok(Summary {
516                symlinks_unchanged: 1,
517                ..Default::default()
518            });
519        }
520        // handle dry-run mode for symlinks
521        if settings.dry_run.is_some() {
522            report_dry_run_copy(src, dst, "symlink");
523            return Ok(Summary {
524                symlinks_created: 1,
525                ..Default::default()
526            });
527        }
528        let mut rm_summary = RmSummary::default();
529        let link = tokio::fs::read_link(src)
530            .await
531            .with_context(|| format!("failed reading symlink {:?}", &src))
532            .map_err(|err| Error::new(err, Default::default()))?;
533        // try creating a symlink, if dst path exists and overwrite is set - remove and try again
534        if let Err(error) = tokio::fs::symlink(&link, dst).await {
535            if settings.ignore_existing && error.kind() == std::io::ErrorKind::AlreadyExists {
536                tracing::debug!("destination exists, skipping symlink (--ignore-existing)");
537                prog_track.symlinks_unchanged.inc();
538                return Ok(Summary {
539                    symlinks_unchanged: 1,
540                    ..Default::default()
541                });
542            }
543            if settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
544                let dst_metadata = tokio::fs::symlink_metadata(dst)
545                    .await
546                    .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
547                    .map_err(|err| Error::new(err, Default::default()))?;
548                if is_file_type_same(&src_metadata, &dst_metadata) {
549                    let dst_link = tokio::fs::read_link(dst)
550                        .await
551                        .with_context(|| format!("failed reading dst symlink: {:?}", &dst))
552                        .map_err(|err| Error::new(err, Default::default()))?;
553                    if link == dst_link {
554                        tracing::debug!(
555                            "'dst' is a symlink and points to the same location as 'src'"
556                        );
557                        if preserve.symlink.any() {
558                            // do we need to update the metadata for this symlink?
559                            let dst_metadata = tokio::fs::symlink_metadata(dst)
560                                .await
561                                .with_context(|| {
562                                    format!("failed reading metadata from dst: {:?}", &dst)
563                                })
564                                .map_err(|err| Error::new(err, Default::default()))?;
565                            if !filecmp::metadata_equal(
566                                &settings.overwrite_compare,
567                                &src_metadata,
568                                &dst_metadata,
569                            ) {
570                                tracing::debug!("'dst' metadata is different, updating");
571                                preserve::set_symlink_metadata(preserve, &src_metadata, dst)
572                                    .await
573                                    .map_err(|err| Error::new(err, Default::default()))?;
574                                prog_track.symlinks_removed.inc();
575                                prog_track.symlinks_created.inc();
576                                return Ok(Summary {
577                                    rm_summary: RmSummary {
578                                        symlinks_removed: 1,
579                                        ..Default::default()
580                                    },
581                                    symlinks_created: 1,
582                                    ..Default::default()
583                                });
584                            }
585                        }
586                        tracing::debug!("symlink already exists, skipping");
587                        prog_track.symlinks_unchanged.inc();
588                        return Ok(Summary {
589                            symlinks_unchanged: 1,
590                            ..Default::default()
591                        });
592                    }
593                    tracing::debug!("'dst' is a symlink but points to a different path, updating");
594                } else {
595                    tracing::info!("'dst' is not a symlink, updating");
596                }
597                rm_summary = rm::rm(
598                    prog_track,
599                    dst,
600                    &RmSettings {
601                        fail_early: settings.fail_early,
602                        filter: None,
603                        dry_run: None,
604                    },
605                )
606                .await
607                .map_err(|err| {
608                    let rm_summary = err.summary;
609                    let copy_summary = Summary {
610                        rm_summary,
611                        ..Default::default()
612                    };
613                    Error::new(err.source, copy_summary)
614                })?;
615                tokio::fs::symlink(&link, dst)
616                    .await
617                    .with_context(|| format!("failed creating symlink {:?}", &dst))
618                    .map_err(|err| {
619                        let copy_summary = Summary {
620                            rm_summary,
621                            ..Default::default()
622                        };
623                        Error::new(err, copy_summary)
624                    })?;
625            } else {
626                return Err(Error::new(
627                    anyhow!("failed creating symlink {:?}", &dst),
628                    Default::default(),
629                ));
630            }
631        }
632        preserve::set_symlink_metadata(preserve, &src_metadata, dst)
633            .await
634            .map_err(|err| {
635                let copy_summary = Summary {
636                    rm_summary,
637                    ..Default::default()
638                };
639                Error::new(err, copy_summary)
640            })?;
641        prog_track.symlinks_created.inc();
642        return Ok(Summary {
643            rm_summary,
644            symlinks_created: 1,
645            ..Default::default()
646        });
647    }
648    if !src_metadata.is_dir() {
649        return Err(Error::new(
650            anyhow!(
651                "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
652                src,
653                dst,
654                src_metadata.file_type()
655            ),
656            Default::default(),
657        ));
658    }
659    // handle dry-run mode for directories at the top level
660    if settings.dry_run.is_some() {
661        if settings.ignore_existing
662            && !is_fresh
663            && tokio::fs::symlink_metadata(dst).await.is_ok()
664            && !dst.is_dir()
665        {
666            // destination is not a directory - would skip entire subtree
667            if let Some(mode) = settings.dry_run {
668                match mode {
669                    DryRunMode::Brief => {}
670                    DryRunMode::All => println!("skip dir {:?}", dst),
671                    DryRunMode::Explain => {
672                        println!("skip dir {:?} (destination exists, not a directory)", dst);
673                    }
674                }
675            }
676            return Ok(Summary {
677                directories_unchanged: 1,
678                ..Default::default()
679            });
680        }
681        report_dry_run_copy(src, dst, "dir");
682        // still need to recurse to show contents
683    }
684    tracing::debug!("process contents of 'src' directory");
685    let mut entries = tokio::fs::read_dir(src)
686        .await
687        .with_context(|| format!("cannot open directory {src:?} for reading"))
688        .map_err(|err| Error::new(err, Default::default()))?;
689    // in dry-run mode, skip directory creation but still traverse contents
690    let mut copy_summary = if settings.dry_run.is_some() {
691        Summary {
692            directories_created: 1, // report as would be created
693            ..Default::default()
694        }
695    } else if let Err(error) = tokio::fs::create_dir(dst).await {
696        assert!(
697            !is_fresh,
698            "unexpected error creating directory: {dst:?}: {error}"
699        );
700        if (settings.overwrite || settings.ignore_existing)
701            && error.kind() == std::io::ErrorKind::AlreadyExists
702        {
703            // check if the destination is a directory - if so, leave it
704            //
705            // N.B. the permissions may prevent us from writing to it but the alternative is to open up the directory
706            // while we're writing to it which isn't safe
707            let dst_metadata = tokio::fs::symlink_metadata(dst)
708                .await
709                .with_context(|| format!("failed reading metadata from dst: {:?}", &dst))
710                .map_err(|err| Error::new(err, Default::default()))?;
711            if dst_metadata.is_dir() {
712                tracing::debug!("'dst' is a directory, leaving it as is");
713                prog_track.directories_unchanged.inc();
714                Summary {
715                    directories_unchanged: 1,
716                    ..Default::default()
717                }
718            } else if settings.ignore_existing {
719                // destination is not a directory but something exists at this path;
720                // with --ignore-existing we skip the entire subtree
721                tracing::debug!("destination exists but is not a directory, skipping subtree (--ignore-existing)");
722                prog_track.directories_unchanged.inc();
723                return Ok(Summary {
724                    directories_unchanged: 1,
725                    ..Default::default()
726                });
727            } else {
728                tracing::info!("'dst' is not a directory, removing and creating a new one");
729                let rm_summary = rm::rm(
730                    prog_track,
731                    dst,
732                    &RmSettings {
733                        fail_early: settings.fail_early,
734                        filter: None,
735                        dry_run: None,
736                    },
737                )
738                .await
739                .map_err(|err| {
740                    let rm_summary = err.summary;
741                    let copy_summary = Summary {
742                        rm_summary,
743                        ..Default::default()
744                    };
745                    Error::new(err.source, copy_summary)
746                })?;
747                tokio::fs::create_dir(dst)
748                    .await
749                    .with_context(|| format!("cannot create directory {dst:?}"))
750                    .map_err(|err| {
751                        let copy_summary = Summary {
752                            rm_summary,
753                            ..Default::default()
754                        };
755                        Error::new(err, copy_summary)
756                    })?;
757                // anything copied into dst may assume they don't need to check for conflicts
758                is_fresh = true;
759                prog_track.directories_created.inc();
760                Summary {
761                    rm_summary,
762                    directories_created: 1,
763                    ..Default::default()
764                }
765            }
766        } else {
767            let error = Err::<(), std::io::Error>(error)
768                .with_context(|| format!("cannot create directory {:?}", dst))
769                .unwrap_err();
770            tracing::error!("{:#}", &error);
771            return Err(Error::new(error, Default::default()));
772        }
773    } else {
774        // new directory created, anything copied into dst may assume they don't need to check for conflicts
775        is_fresh = true;
776        prog_track.directories_created.inc();
777        Summary {
778            directories_created: 1,
779            ..Default::default()
780        }
781    };
782    // track whether we created this directory (vs it already existing)
783    // this is used later to decide if we should clean up an empty directory
784    let we_created_this_dir = copy_summary.directories_created == 1;
785    let mut join_set = tokio::task::JoinSet::new();
786    let errors = crate::error_collector::ErrorCollector::default();
787    while let Some(entry) = entries
788        .next_entry()
789        .await
790        .with_context(|| format!("failed traversing src directory {:?}", &src))
791        .map_err(|err| Error::new(err, copy_summary))?
792    {
793        // it's better to await the token here so that we throttle the syscalls generated by the
794        // DirEntry call. the ops-throttle will never cause a deadlock (unlike max-open-files limit)
795        // so it's safe to do here.
796        throttle::get_ops_token().await;
797        let entry_path = entry.path();
798        let entry_name = entry_path.file_name().unwrap();
799        let dst_path = dst.join(entry_name);
800        // check entry type for filter matching and skip counting
801        let entry_file_type = entry.file_type().await.ok();
802        let entry_is_dir = entry_file_type.map(|ft| ft.is_dir()).unwrap_or(false);
803        let entry_is_symlink = entry_file_type.map(|ft| ft.is_symlink()).unwrap_or(false);
804        // compute relative path from source_root for filter matching
805        let relative_path = entry_path.strip_prefix(source_root).unwrap_or(&entry_path);
806        // apply filter if configured
807        if let Some(skip_result) = should_skip_entry(&settings.filter, relative_path, entry_is_dir)
808        {
809            if let Some(mode) = settings.dry_run {
810                let entry_type = if entry_is_dir {
811                    "dir"
812                } else if entry_is_symlink {
813                    "symlink"
814                } else {
815                    "file"
816                };
817                report_dry_run_skip(&entry_path, &skip_result, mode, entry_type);
818            }
819            tracing::debug!("skipping {:?} due to filter", &entry_path);
820            // increment skipped counters
821            if entry_is_dir {
822                copy_summary.directories_skipped += 1;
823                prog_track.directories_skipped.inc();
824            } else if entry_is_symlink {
825                copy_summary.symlinks_skipped += 1;
826                prog_track.symlinks_skipped.inc();
827            } else {
828                copy_summary.files_skipped += 1;
829                prog_track.files_skipped.inc();
830            }
831            continue;
832        }
833        // for regular files (not dirs, not symlinks), acquire the open file permit before
834        // spawning the task. this provides backpressure so we don't create unbounded tasks.
835        // it's safe because files are leaf nodes that never recurse, so no deadlock is possible.
836        // we don't acquire for symlinks because with --dereference they could resolve to
837        // directories, which would risk deadlock. when file_type() fails we also skip
838        // pre-acquisition since we can't be sure it's a file — copy_internal will acquire
839        // the permit if needed after reading the actual metadata.
840        let entry_is_regular_file = entry_file_type.as_ref().is_some_and(|ft| ft.is_file());
841        let open_file_guard = if entry_is_regular_file {
842            Some(throttle::open_file_permit().await)
843        } else {
844            None
845        };
846        // spawn recursive call - dry-run reporting is handled by copy_internal
847        // (copy_file, symlink handling, and directory handling all have their own dry-run reporting)
848        let settings = settings.clone();
849        let preserve = *preserve;
850        let source_root = source_root.to_owned();
851        let do_copy = || async move {
852            copy_internal(
853                prog_track,
854                &entry_path,
855                &dst_path,
856                &source_root,
857                &settings,
858                &preserve,
859                is_fresh,
860                open_file_guard,
861            )
862            .await
863        };
864        join_set.spawn(do_copy());
865    }
866    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
867    // one thing we CAN do however is to drop it as soon as we're done with it
868    drop(entries);
869    while let Some(res) = join_set.join_next().await {
870        match res {
871            Ok(result) => match result {
872                Ok(summary) => copy_summary = copy_summary + summary,
873                Err(error) => {
874                    tracing::error!("copy: {:?} -> {:?} failed with: {:#}", src, dst, &error);
875                    copy_summary = copy_summary + error.summary;
876                    if settings.fail_early {
877                        return Err(Error::new(error.source, copy_summary));
878                    }
879                    errors.push(error.source);
880                }
881            },
882            Err(error) => {
883                if settings.fail_early {
884                    return Err(Error::new(error.into(), copy_summary));
885                }
886                errors.push(error.into());
887            }
888        }
889    }
890    // when filtering is active and we created this directory, check if anything was actually
891    // copied into it. if nothing was copied, we may need to clean up the empty directory.
892    let this_dir_count = usize::from(we_created_this_dir);
893    let child_dirs_created = copy_summary
894        .directories_created
895        .saturating_sub(this_dir_count);
896    let anything_copied = copy_summary.files_copied > 0
897        || copy_summary.symlinks_created > 0
898        || child_dirs_created > 0;
899    let relative_path = src.strip_prefix(source_root).unwrap_or(src);
900    let is_root = src == source_root;
901    match check_empty_dir_cleanup(
902        settings.filter.as_ref(),
903        we_created_this_dir,
904        anything_copied,
905        relative_path,
906        is_root,
907        settings.dry_run.is_some(),
908    ) {
909        EmptyDirAction::Keep => { /* proceed with metadata application */ }
910        EmptyDirAction::DryRunSkip => {
911            tracing::debug!(
912                "dry-run: directory {:?} would not be created (nothing to copy inside)",
913                &dst
914            );
915            copy_summary.directories_created = 0;
916            return Ok(copy_summary);
917        }
918        EmptyDirAction::Remove => {
919            tracing::debug!(
920                "directory {:?} has nothing to copy inside, removing empty directory",
921                &dst
922            );
923            match tokio::fs::remove_dir(dst).await {
924                Ok(()) => {
925                    copy_summary.directories_created = 0;
926                    return Ok(copy_summary);
927                }
928                Err(err) => {
929                    // removal failed (not empty, permission error, etc.) — keep directory
930                    tracing::debug!(
931                        "failed to remove empty directory {:?}: {:#}, keeping",
932                        &dst,
933                        &err
934                    );
935                    // fall through to apply metadata
936                }
937            }
938        }
939    }
940    // apply directory metadata regardless of whether all children copied successfully.
941    // the directory itself was created earlier in this function (we would have returned
942    // early if create_dir failed), so we should preserve the source metadata.
943    // skip metadata setting in dry-run mode since directory wasn't actually created
944    tracing::debug!("set 'dst' directory metadata");
945    let metadata_result = if settings.dry_run.is_some() {
946        Ok(()) // skip metadata setting in dry-run mode
947    } else {
948        preserve::set_dir_metadata(preserve, &src_metadata, dst).await
949    };
950    if errors.has_errors() {
951        // child failures take precedence - log metadata error if it also failed
952        if let Err(metadata_err) = metadata_result {
953            tracing::error!(
954                "copy: {:?} -> {:?} failed to set directory metadata: {:#}",
955                src,
956                dst,
957                &metadata_err
958            );
959        }
960        // unwrap is safe: has_errors() guarantees into_error() returns Some
961        return Err(Error::new(errors.into_error().unwrap(), copy_summary));
962    }
963    // no child failures, so metadata error is the primary error
964    metadata_result.map_err(|err| Error::new(err, copy_summary))?;
965    Ok(copy_summary)
966}
967
968#[cfg(test)]
969mod copy_tests {
970    use crate::testutils;
971    use anyhow::Context;
972    use std::os::unix::fs::MetadataExt;
973    use std::os::unix::fs::PermissionsExt;
974    use tracing_test::traced_test;
975
976    use super::*;
977
978    static PROGRESS: std::sync::LazyLock<progress::Progress> =
979        std::sync::LazyLock::new(progress::Progress::new);
980    static NO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
981        std::sync::LazyLock::new(preserve::preserve_none);
982    static DO_PRESERVE_SETTINGS: std::sync::LazyLock<preserve::Settings> =
983        std::sync::LazyLock::new(preserve::preserve_all);
984
985    #[tokio::test]
986    #[traced_test]
987    async fn check_basic_copy() -> Result<(), anyhow::Error> {
988        let tmp_dir = testutils::setup_test_dir().await?;
989        let test_path = tmp_dir.as_path();
990        let summary = copy(
991            &PROGRESS,
992            &test_path.join("foo"),
993            &test_path.join("bar"),
994            &Settings {
995                dereference: false,
996                fail_early: false,
997                overwrite: false,
998                overwrite_compare: filecmp::MetadataCmpSettings {
999                    size: true,
1000                    mtime: true,
1001                    ..Default::default()
1002                },
1003                overwrite_filter: None,
1004                ignore_existing: false,
1005                chunk_size: 0,
1006                remote_copy_buffer_size: 0,
1007                filter: None,
1008                dry_run: None,
1009            },
1010            &NO_PRESERVE_SETTINGS,
1011            false,
1012        )
1013        .await?;
1014        assert_eq!(summary.files_copied, 5);
1015        assert_eq!(summary.symlinks_created, 2);
1016        assert_eq!(summary.directories_created, 3);
1017        testutils::check_dirs_identical(
1018            &test_path.join("foo"),
1019            &test_path.join("bar"),
1020            testutils::FileEqualityCheck::Basic,
1021        )
1022        .await?;
1023        Ok(())
1024    }
1025
1026    #[tokio::test]
1027    #[traced_test]
1028    async fn no_read_permission() -> Result<(), anyhow::Error> {
1029        let tmp_dir = testutils::setup_test_dir().await?;
1030        let test_path = tmp_dir.as_path();
1031        let filepaths = vec![
1032            test_path.join("foo").join("0.txt"),
1033            test_path.join("foo").join("baz"),
1034        ];
1035        for fpath in &filepaths {
1036            // change file permissions to not readable
1037            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o000)).await?;
1038        }
1039        match copy(
1040            &PROGRESS,
1041            &test_path.join("foo"),
1042            &test_path.join("bar"),
1043            &Settings {
1044                dereference: false,
1045                fail_early: false,
1046                overwrite: false,
1047                overwrite_compare: filecmp::MetadataCmpSettings {
1048                    size: true,
1049                    mtime: true,
1050                    ..Default::default()
1051                },
1052                overwrite_filter: None,
1053                ignore_existing: false,
1054                chunk_size: 0,
1055                remote_copy_buffer_size: 0,
1056                filter: None,
1057                dry_run: None,
1058            },
1059            &NO_PRESERVE_SETTINGS,
1060            false,
1061        )
1062        .await
1063        {
1064            Ok(_) => panic!("Expected the copy to error!"),
1065            Err(error) => {
1066                tracing::info!("{}", &error);
1067                // foo
1068                // |- 0.txt  // <- no read permission
1069                // |- bar
1070                //    |- 1.txt
1071                //    |- 2.txt
1072                //    |- 3.txt
1073                // |- baz   // <- no read permission
1074                //    |- 4.txt
1075                //    |- 5.txt -> ../bar/2.txt
1076                //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1077                assert_eq!(error.summary.files_copied, 3);
1078                assert_eq!(error.summary.symlinks_created, 0);
1079                assert_eq!(error.summary.directories_created, 2);
1080            }
1081        }
1082        // make source directory same as what we expect destination to be
1083        for fpath in &filepaths {
1084            tokio::fs::set_permissions(&fpath, std::fs::Permissions::from_mode(0o700)).await?;
1085            if tokio::fs::symlink_metadata(fpath).await?.is_file() {
1086                tokio::fs::remove_file(fpath).await?;
1087            } else {
1088                tokio::fs::remove_dir_all(fpath).await?;
1089            }
1090        }
1091        testutils::check_dirs_identical(
1092            &test_path.join("foo"),
1093            &test_path.join("bar"),
1094            testutils::FileEqualityCheck::Basic,
1095        )
1096        .await?;
1097        Ok(())
1098    }
1099
1100    #[tokio::test]
1101    #[traced_test]
1102    async fn check_default_mode() -> Result<(), anyhow::Error> {
1103        let tmp_dir = testutils::setup_test_dir().await?;
1104        // set file to executable
1105        tokio::fs::set_permissions(
1106            tmp_dir.join("foo").join("0.txt"),
1107            std::fs::Permissions::from_mode(0o700),
1108        )
1109        .await?;
1110        // set file executable AND also set sticky bit, setuid and setgid
1111        let exec_sticky_file = tmp_dir.join("foo").join("bar").join("1.txt");
1112        tokio::fs::set_permissions(&exec_sticky_file, std::fs::Permissions::from_mode(0o3770))
1113            .await?;
1114        let test_path = tmp_dir.as_path();
1115        let summary = copy(
1116            &PROGRESS,
1117            &test_path.join("foo"),
1118            &test_path.join("bar"),
1119            &Settings {
1120                dereference: false,
1121                fail_early: false,
1122                overwrite: false,
1123                overwrite_compare: filecmp::MetadataCmpSettings {
1124                    size: true,
1125                    mtime: true,
1126                    ..Default::default()
1127                },
1128                overwrite_filter: None,
1129                ignore_existing: false,
1130                chunk_size: 0,
1131                remote_copy_buffer_size: 0,
1132                filter: None,
1133                dry_run: None,
1134            },
1135            &NO_PRESERVE_SETTINGS,
1136            false,
1137        )
1138        .await?;
1139        assert_eq!(summary.files_copied, 5);
1140        assert_eq!(summary.symlinks_created, 2);
1141        assert_eq!(summary.directories_created, 3);
1142        // clear the setuid, setgid and sticky bit for comparison
1143        tokio::fs::set_permissions(
1144            &exec_sticky_file,
1145            std::fs::Permissions::from_mode(
1146                std::fs::symlink_metadata(&exec_sticky_file)?
1147                    .permissions()
1148                    .mode()
1149                    & 0o0777,
1150            ),
1151        )
1152        .await?;
1153        testutils::check_dirs_identical(
1154            &test_path.join("foo"),
1155            &test_path.join("bar"),
1156            testutils::FileEqualityCheck::Basic,
1157        )
1158        .await?;
1159        Ok(())
1160    }
1161
1162    #[tokio::test]
1163    #[traced_test]
1164    async fn no_write_permission() -> Result<(), anyhow::Error> {
1165        let tmp_dir = testutils::setup_test_dir().await?;
1166        let test_path = tmp_dir.as_path();
1167        // directory - readable and non-executable
1168        let non_exec_dir = test_path.join("foo").join("bogey");
1169        tokio::fs::create_dir(&non_exec_dir).await?;
1170        tokio::fs::set_permissions(&non_exec_dir, std::fs::Permissions::from_mode(0o400)).await?;
1171        // directory - readable and executable
1172        tokio::fs::set_permissions(
1173            &test_path.join("foo").join("baz"),
1174            std::fs::Permissions::from_mode(0o500),
1175        )
1176        .await?;
1177        // file
1178        tokio::fs::set_permissions(
1179            &test_path.join("foo").join("baz").join("4.txt"),
1180            std::fs::Permissions::from_mode(0o440),
1181        )
1182        .await?;
1183        let summary = copy(
1184            &PROGRESS,
1185            &test_path.join("foo"),
1186            &test_path.join("bar"),
1187            &Settings {
1188                dereference: false,
1189                fail_early: false,
1190                overwrite: false,
1191                overwrite_compare: filecmp::MetadataCmpSettings {
1192                    size: true,
1193                    mtime: true,
1194                    ..Default::default()
1195                },
1196                overwrite_filter: None,
1197                ignore_existing: false,
1198                chunk_size: 0,
1199                remote_copy_buffer_size: 0,
1200                filter: None,
1201                dry_run: None,
1202            },
1203            &NO_PRESERVE_SETTINGS,
1204            false,
1205        )
1206        .await?;
1207        assert_eq!(summary.files_copied, 5);
1208        assert_eq!(summary.symlinks_created, 2);
1209        assert_eq!(summary.directories_created, 4);
1210        testutils::check_dirs_identical(
1211            &test_path.join("foo"),
1212            &test_path.join("bar"),
1213            testutils::FileEqualityCheck::Basic,
1214        )
1215        .await?;
1216        Ok(())
1217    }
1218
1219    #[tokio::test]
1220    #[traced_test]
1221    async fn dereference() -> Result<(), anyhow::Error> {
1222        let tmp_dir = testutils::setup_test_dir().await?;
1223        let test_path = tmp_dir.as_path();
1224        // make files pointed to by symlinks have different permissions than the symlink itself
1225        let src1 = &test_path.join("foo").join("bar").join("2.txt");
1226        let src2 = &test_path.join("foo").join("bar").join("3.txt");
1227        let test_mode = 0o440;
1228        for f in [src1, src2] {
1229            tokio::fs::set_permissions(f, std::fs::Permissions::from_mode(test_mode)).await?;
1230        }
1231        let summary = copy(
1232            &PROGRESS,
1233            &test_path.join("foo"),
1234            &test_path.join("bar"),
1235            &Settings {
1236                dereference: true, // <- important!
1237                fail_early: false,
1238                overwrite: false,
1239                overwrite_compare: filecmp::MetadataCmpSettings {
1240                    size: true,
1241                    mtime: true,
1242                    ..Default::default()
1243                },
1244                overwrite_filter: None,
1245                ignore_existing: false,
1246                chunk_size: 0,
1247                remote_copy_buffer_size: 0,
1248                filter: None,
1249                dry_run: None,
1250            },
1251            &NO_PRESERVE_SETTINGS,
1252            false,
1253        )
1254        .await?;
1255        assert_eq!(summary.files_copied, 7);
1256        assert_eq!(summary.symlinks_created, 0);
1257        assert_eq!(summary.directories_created, 3);
1258        // ...
1259        // |- baz
1260        //    |- 4.txt
1261        //    |- 5.txt -> ../bar/2.txt
1262        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1263        let dst1 = &test_path.join("bar").join("baz").join("5.txt");
1264        let dst2 = &test_path.join("bar").join("baz").join("6.txt");
1265        for f in [dst1, dst2] {
1266            let metadata = tokio::fs::symlink_metadata(f)
1267                .await
1268                .with_context(|| format!("failed reading metadata from {:?}", &f))?;
1269            assert!(metadata.is_file());
1270            // check that the permissions are the same as the source file modulo no sticky bit, setuid and setgid
1271            assert_eq!(metadata.permissions().mode() & 0o777, test_mode);
1272        }
1273        Ok(())
1274    }
1275
1276    async fn cp_compare(
1277        cp_args: &[&str],
1278        rcp_settings: &Settings,
1279        preserve: bool,
1280    ) -> Result<(), anyhow::Error> {
1281        let tmp_dir = testutils::setup_test_dir().await?;
1282        let test_path = tmp_dir.as_path();
1283        // run a cp command to copy the files
1284        let cp_output = tokio::process::Command::new("cp")
1285            .args(cp_args)
1286            .arg(test_path.join("foo"))
1287            .arg(test_path.join("bar"))
1288            .output()
1289            .await?;
1290        assert!(cp_output.status.success());
1291        // now run rcp
1292        let summary = copy(
1293            &PROGRESS,
1294            &test_path.join("foo"),
1295            &test_path.join("baz"),
1296            rcp_settings,
1297            if preserve {
1298                &DO_PRESERVE_SETTINGS
1299            } else {
1300                &NO_PRESERVE_SETTINGS
1301            },
1302            false,
1303        )
1304        .await?;
1305        if rcp_settings.dereference {
1306            assert_eq!(summary.files_copied, 7);
1307            assert_eq!(summary.symlinks_created, 0);
1308        } else {
1309            assert_eq!(summary.files_copied, 5);
1310            assert_eq!(summary.symlinks_created, 2);
1311        }
1312        assert_eq!(summary.directories_created, 3);
1313        testutils::check_dirs_identical(
1314            &test_path.join("bar"),
1315            &test_path.join("baz"),
1316            if preserve {
1317                testutils::FileEqualityCheck::Timestamp
1318            } else {
1319                testutils::FileEqualityCheck::Basic
1320            },
1321        )
1322        .await?;
1323        Ok(())
1324    }
1325
1326    #[tokio::test]
1327    #[traced_test]
1328    async fn test_cp_compat() -> Result<(), anyhow::Error> {
1329        cp_compare(
1330            &["-r"],
1331            &Settings {
1332                dereference: false,
1333                fail_early: false,
1334                overwrite: false,
1335                overwrite_compare: filecmp::MetadataCmpSettings {
1336                    size: true,
1337                    mtime: true,
1338                    ..Default::default()
1339                },
1340                overwrite_filter: None,
1341                ignore_existing: false,
1342                chunk_size: 0,
1343                remote_copy_buffer_size: 0,
1344                filter: None,
1345                dry_run: None,
1346            },
1347            false,
1348        )
1349        .await?;
1350        Ok(())
1351    }
1352
1353    #[tokio::test]
1354    #[traced_test]
1355    async fn test_cp_compat_preserve() -> Result<(), anyhow::Error> {
1356        cp_compare(
1357            &["-r", "-p"],
1358            &Settings {
1359                dereference: false,
1360                fail_early: false,
1361                overwrite: false,
1362                overwrite_compare: filecmp::MetadataCmpSettings {
1363                    size: true,
1364                    mtime: true,
1365                    ..Default::default()
1366                },
1367                overwrite_filter: None,
1368                ignore_existing: false,
1369                chunk_size: 0,
1370                remote_copy_buffer_size: 0,
1371                filter: None,
1372                dry_run: None,
1373            },
1374            true,
1375        )
1376        .await?;
1377        Ok(())
1378    }
1379
1380    #[tokio::test]
1381    #[traced_test]
1382    async fn test_cp_compat_dereference() -> Result<(), anyhow::Error> {
1383        cp_compare(
1384            &["-r", "-L"],
1385            &Settings {
1386                dereference: true,
1387                fail_early: false,
1388                overwrite: false,
1389                overwrite_compare: filecmp::MetadataCmpSettings {
1390                    size: true,
1391                    mtime: true,
1392                    ..Default::default()
1393                },
1394                overwrite_filter: None,
1395                ignore_existing: false,
1396                chunk_size: 0,
1397                remote_copy_buffer_size: 0,
1398                filter: None,
1399                dry_run: None,
1400            },
1401            false,
1402        )
1403        .await?;
1404        Ok(())
1405    }
1406
1407    #[tokio::test]
1408    #[traced_test]
1409    async fn test_cp_compat_preserve_and_dereference() -> Result<(), anyhow::Error> {
1410        cp_compare(
1411            &["-r", "-p", "-L"],
1412            &Settings {
1413                dereference: true,
1414                fail_early: false,
1415                overwrite: false,
1416                overwrite_compare: filecmp::MetadataCmpSettings {
1417                    size: true,
1418                    mtime: true,
1419                    ..Default::default()
1420                },
1421                overwrite_filter: None,
1422                ignore_existing: false,
1423                chunk_size: 0,
1424                remote_copy_buffer_size: 0,
1425                filter: None,
1426                dry_run: None,
1427            },
1428            true,
1429        )
1430        .await?;
1431        Ok(())
1432    }
1433
1434    async fn setup_test_dir_and_copy() -> Result<std::path::PathBuf, anyhow::Error> {
1435        let tmp_dir = testutils::setup_test_dir().await?;
1436        let test_path = tmp_dir.as_path();
1437        let summary = copy(
1438            &PROGRESS,
1439            &test_path.join("foo"),
1440            &test_path.join("bar"),
1441            &Settings {
1442                dereference: false,
1443                fail_early: false,
1444                overwrite: false,
1445                overwrite_compare: filecmp::MetadataCmpSettings {
1446                    size: true,
1447                    mtime: true,
1448                    ..Default::default()
1449                },
1450                overwrite_filter: None,
1451                ignore_existing: false,
1452                chunk_size: 0,
1453                remote_copy_buffer_size: 0,
1454                filter: None,
1455                dry_run: None,
1456            },
1457            &DO_PRESERVE_SETTINGS,
1458            false,
1459        )
1460        .await?;
1461        assert_eq!(summary.files_copied, 5);
1462        assert_eq!(summary.symlinks_created, 2);
1463        assert_eq!(summary.directories_created, 3);
1464        Ok(tmp_dir)
1465    }
1466
1467    #[tokio::test]
1468    #[traced_test]
1469    async fn test_cp_overwrite_basic() -> Result<(), anyhow::Error> {
1470        let tmp_dir = setup_test_dir_and_copy().await?;
1471        let output_path = &tmp_dir.join("bar");
1472        {
1473            // bar
1474            // |- 0.txt
1475            // |- bar  <---------------------------------------- REMOVE
1476            //    |- 1.txt  <----------------------------------- REMOVE
1477            //    |- 2.txt  <----------------------------------- REMOVE
1478            //    |- 3.txt  <----------------------------------- REMOVE
1479            // |- baz
1480            //    |- 4.txt
1481            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
1482            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1483            let summary = rm::rm(
1484                &PROGRESS,
1485                &output_path.join("bar"),
1486                &RmSettings {
1487                    fail_early: false,
1488                    filter: None,
1489                    dry_run: None,
1490                },
1491            )
1492            .await?
1493                + rm::rm(
1494                    &PROGRESS,
1495                    &output_path.join("baz").join("5.txt"),
1496                    &RmSettings {
1497                        fail_early: false,
1498                        filter: None,
1499                        dry_run: None,
1500                    },
1501                )
1502                .await?;
1503            assert_eq!(summary.files_removed, 3);
1504            assert_eq!(summary.symlinks_removed, 1);
1505            assert_eq!(summary.directories_removed, 1);
1506        }
1507        let summary = copy(
1508            &PROGRESS,
1509            &tmp_dir.join("foo"),
1510            output_path,
1511            &Settings {
1512                dereference: false,
1513                fail_early: false,
1514                overwrite: true, // <- important!
1515                overwrite_compare: filecmp::MetadataCmpSettings {
1516                    size: true,
1517                    mtime: true,
1518                    ..Default::default()
1519                },
1520                overwrite_filter: None,
1521                ignore_existing: false,
1522                chunk_size: 0,
1523                remote_copy_buffer_size: 0,
1524                filter: None,
1525                dry_run: None,
1526            },
1527            &DO_PRESERVE_SETTINGS,
1528            false,
1529        )
1530        .await?;
1531        assert_eq!(summary.files_copied, 3);
1532        assert_eq!(summary.symlinks_created, 1);
1533        assert_eq!(summary.directories_created, 1);
1534        testutils::check_dirs_identical(
1535            &tmp_dir.join("foo"),
1536            output_path,
1537            testutils::FileEqualityCheck::Timestamp,
1538        )
1539        .await?;
1540        Ok(())
1541    }
1542
1543    #[tokio::test]
1544    #[traced_test]
1545    async fn test_cp_overwrite_dir_file() -> Result<(), anyhow::Error> {
1546        let tmp_dir = setup_test_dir_and_copy().await?;
1547        let output_path = &tmp_dir.join("bar");
1548        {
1549            // bar
1550            // |- 0.txt
1551            // |- bar
1552            //    |- 1.txt  <------------------------------------- REMOVE
1553            //    |- 2.txt
1554            //    |- 3.txt
1555            // |- baz  <------------------------------------------ REMOVE
1556            //    |- 4.txt  <------------------------------------- REMOVE
1557            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
1558            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt <- REMOVE
1559            let summary = rm::rm(
1560                &PROGRESS,
1561                &output_path.join("bar").join("1.txt"),
1562                &RmSettings {
1563                    fail_early: false,
1564                    filter: None,
1565                    dry_run: None,
1566                },
1567            )
1568            .await?
1569                + rm::rm(
1570                    &PROGRESS,
1571                    &output_path.join("baz"),
1572                    &RmSettings {
1573                        fail_early: false,
1574                        filter: None,
1575                        dry_run: None,
1576                    },
1577                )
1578                .await?;
1579            assert_eq!(summary.files_removed, 2);
1580            assert_eq!(summary.symlinks_removed, 2);
1581            assert_eq!(summary.directories_removed, 1);
1582        }
1583        {
1584            // replace bar/1.txt file with a directory
1585            tokio::fs::create_dir(&output_path.join("bar").join("1.txt")).await?;
1586            // replace baz directory with a file
1587            tokio::fs::write(&output_path.join("baz"), "baz").await?;
1588        }
1589        let summary = copy(
1590            &PROGRESS,
1591            &tmp_dir.join("foo"),
1592            output_path,
1593            &Settings {
1594                dereference: false,
1595                fail_early: false,
1596                overwrite: true, // <- important!
1597                overwrite_compare: filecmp::MetadataCmpSettings {
1598                    size: true,
1599                    mtime: true,
1600                    ..Default::default()
1601                },
1602                overwrite_filter: None,
1603                ignore_existing: false,
1604                chunk_size: 0,
1605                remote_copy_buffer_size: 0,
1606                filter: None,
1607                dry_run: None,
1608            },
1609            &DO_PRESERVE_SETTINGS,
1610            false,
1611        )
1612        .await?;
1613        assert_eq!(summary.rm_summary.files_removed, 1);
1614        assert_eq!(summary.rm_summary.symlinks_removed, 0);
1615        assert_eq!(summary.rm_summary.directories_removed, 1);
1616        assert_eq!(summary.files_copied, 2);
1617        assert_eq!(summary.symlinks_created, 2);
1618        assert_eq!(summary.directories_created, 1);
1619        testutils::check_dirs_identical(
1620            &tmp_dir.join("foo"),
1621            output_path,
1622            testutils::FileEqualityCheck::Timestamp,
1623        )
1624        .await?;
1625        Ok(())
1626    }
1627
1628    #[tokio::test]
1629    #[traced_test]
1630    async fn test_cp_overwrite_symlink_file() -> Result<(), anyhow::Error> {
1631        let tmp_dir = setup_test_dir_and_copy().await?;
1632        let output_path = &tmp_dir.join("bar");
1633        {
1634            // bar
1635            // |- 0.txt
1636            // |- baz
1637            //    |- 4.txt  <------------------------------------- REMOVE
1638            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
1639            // ...
1640            let summary = rm::rm(
1641                &PROGRESS,
1642                &output_path.join("baz").join("4.txt"),
1643                &RmSettings {
1644                    fail_early: false,
1645                    filter: None,
1646                    dry_run: None,
1647                },
1648            )
1649            .await?
1650                + rm::rm(
1651                    &PROGRESS,
1652                    &output_path.join("baz").join("5.txt"),
1653                    &RmSettings {
1654                        fail_early: false,
1655                        filter: None,
1656                        dry_run: None,
1657                    },
1658                )
1659                .await?;
1660            assert_eq!(summary.files_removed, 1);
1661            assert_eq!(summary.symlinks_removed, 1);
1662            assert_eq!(summary.directories_removed, 0);
1663        }
1664        {
1665            // replace baz/4.txt file with a symlink
1666            tokio::fs::symlink("../0.txt", &output_path.join("baz").join("4.txt")).await?;
1667            // replace baz/5.txt symlink with a file
1668            tokio::fs::write(&output_path.join("baz").join("5.txt"), "baz").await?;
1669        }
1670        let summary = copy(
1671            &PROGRESS,
1672            &tmp_dir.join("foo"),
1673            output_path,
1674            &Settings {
1675                dereference: false,
1676                fail_early: false,
1677                overwrite: true, // <- important!
1678                overwrite_compare: filecmp::MetadataCmpSettings {
1679                    size: true,
1680                    mtime: true,
1681                    ..Default::default()
1682                },
1683                overwrite_filter: None,
1684                ignore_existing: false,
1685                chunk_size: 0,
1686                remote_copy_buffer_size: 0,
1687                filter: None,
1688                dry_run: None,
1689            },
1690            &DO_PRESERVE_SETTINGS,
1691            false,
1692        )
1693        .await?;
1694        assert_eq!(summary.rm_summary.files_removed, 1);
1695        assert_eq!(summary.rm_summary.symlinks_removed, 1);
1696        assert_eq!(summary.rm_summary.directories_removed, 0);
1697        assert_eq!(summary.files_copied, 1);
1698        assert_eq!(summary.symlinks_created, 1);
1699        assert_eq!(summary.directories_created, 0);
1700        testutils::check_dirs_identical(
1701            &tmp_dir.join("foo"),
1702            output_path,
1703            testutils::FileEqualityCheck::Timestamp,
1704        )
1705        .await?;
1706        Ok(())
1707    }
1708
1709    #[tokio::test]
1710    #[traced_test]
1711    async fn test_cp_overwrite_symlink_dir() -> Result<(), anyhow::Error> {
1712        let tmp_dir = setup_test_dir_and_copy().await?;
1713        let output_path = &tmp_dir.join("bar");
1714        {
1715            // bar
1716            // |- 0.txt
1717            // |- bar  <------------------------------------------ REMOVE
1718            //    |- 1.txt  <------------------------------------- REMOVE
1719            //    |- 2.txt  <------------------------------------- REMOVE
1720            //    |- 3.txt  <------------------------------------- REMOVE
1721            // |- baz
1722            //    |- 5.txt -> ../bar/2.txt <---------------------- REMOVE
1723            // ...
1724            let summary = rm::rm(
1725                &PROGRESS,
1726                &output_path.join("bar"),
1727                &RmSettings {
1728                    fail_early: false,
1729                    filter: None,
1730                    dry_run: None,
1731                },
1732            )
1733            .await?
1734                + rm::rm(
1735                    &PROGRESS,
1736                    &output_path.join("baz").join("5.txt"),
1737                    &RmSettings {
1738                        fail_early: false,
1739                        filter: None,
1740                        dry_run: None,
1741                    },
1742                )
1743                .await?;
1744            assert_eq!(summary.files_removed, 3);
1745            assert_eq!(summary.symlinks_removed, 1);
1746            assert_eq!(summary.directories_removed, 1);
1747        }
1748        {
1749            // replace bar directory with a symlink
1750            tokio::fs::symlink("0.txt", &output_path.join("bar")).await?;
1751            // replace baz/5.txt symlink with a directory
1752            tokio::fs::create_dir(&output_path.join("baz").join("5.txt")).await?;
1753        }
1754        let summary = copy(
1755            &PROGRESS,
1756            &tmp_dir.join("foo"),
1757            output_path,
1758            &Settings {
1759                dereference: false,
1760                fail_early: false,
1761                overwrite: true, // <- important!
1762                overwrite_compare: filecmp::MetadataCmpSettings {
1763                    size: true,
1764                    mtime: true,
1765                    ..Default::default()
1766                },
1767                overwrite_filter: None,
1768                ignore_existing: false,
1769                chunk_size: 0,
1770                remote_copy_buffer_size: 0,
1771                filter: None,
1772                dry_run: None,
1773            },
1774            &DO_PRESERVE_SETTINGS,
1775            false,
1776        )
1777        .await?;
1778        assert_eq!(summary.rm_summary.files_removed, 0);
1779        assert_eq!(summary.rm_summary.symlinks_removed, 1);
1780        assert_eq!(summary.rm_summary.directories_removed, 1);
1781        assert_eq!(summary.files_copied, 3);
1782        assert_eq!(summary.symlinks_created, 1);
1783        assert_eq!(summary.directories_created, 1);
1784        assert_eq!(summary.files_unchanged, 2);
1785        assert_eq!(summary.symlinks_unchanged, 1);
1786        assert_eq!(summary.directories_unchanged, 2);
1787        testutils::check_dirs_identical(
1788            &tmp_dir.join("foo"),
1789            output_path,
1790            testutils::FileEqualityCheck::Timestamp,
1791        )
1792        .await?;
1793        Ok(())
1794    }
1795
1796    #[tokio::test]
1797    #[traced_test]
1798    async fn test_cp_overwrite_error() -> Result<(), anyhow::Error> {
1799        let tmp_dir = testutils::setup_test_dir().await?;
1800        let test_path = tmp_dir.as_path();
1801        let summary = copy(
1802            &PROGRESS,
1803            &test_path.join("foo"),
1804            &test_path.join("bar"),
1805            &Settings {
1806                dereference: false,
1807                fail_early: false,
1808                overwrite: false,
1809                overwrite_compare: filecmp::MetadataCmpSettings {
1810                    size: true,
1811                    mtime: true,
1812                    ..Default::default()
1813                },
1814                overwrite_filter: None,
1815                ignore_existing: false,
1816                chunk_size: 0,
1817                remote_copy_buffer_size: 0,
1818                filter: None,
1819                dry_run: None,
1820            },
1821            &NO_PRESERVE_SETTINGS, // we want timestamps to differ!
1822            false,
1823        )
1824        .await?;
1825        assert_eq!(summary.files_copied, 5);
1826        assert_eq!(summary.symlinks_created, 2);
1827        assert_eq!(summary.directories_created, 3);
1828        let source_path = &test_path.join("foo");
1829        let output_path = &tmp_dir.join("bar");
1830        // unreadable
1831        tokio::fs::set_permissions(
1832            &source_path.join("bar"),
1833            std::fs::Permissions::from_mode(0o000),
1834        )
1835        .await?;
1836        tokio::fs::set_permissions(
1837            &source_path.join("baz").join("4.txt"),
1838            std::fs::Permissions::from_mode(0o000),
1839        )
1840        .await?;
1841        // bar
1842        // |- 0.txt
1843        // |- bar  <---------------------------------------- NON READABLE
1844        // |- baz
1845        //    |- 4.txt  <----------------------------------- NON READABLE
1846        //    |- 5.txt -> ../bar/2.txt
1847        //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1848        match copy(
1849            &PROGRESS,
1850            &tmp_dir.join("foo"),
1851            output_path,
1852            &Settings {
1853                dereference: false,
1854                fail_early: false,
1855                overwrite: true, // <- important!
1856                overwrite_compare: filecmp::MetadataCmpSettings {
1857                    size: true,
1858                    mtime: true,
1859                    ..Default::default()
1860                },
1861                overwrite_filter: None,
1862                ignore_existing: false,
1863                chunk_size: 0,
1864                remote_copy_buffer_size: 0,
1865                filter: None,
1866                dry_run: None,
1867            },
1868            &DO_PRESERVE_SETTINGS,
1869            false,
1870        )
1871        .await
1872        {
1873            Ok(_) => panic!("Expected the copy to error!"),
1874            Err(error) => {
1875                tracing::info!("{}", &error);
1876                assert_eq!(error.summary.files_copied, 1);
1877                assert_eq!(error.summary.symlinks_created, 2);
1878                assert_eq!(error.summary.directories_created, 0);
1879                assert_eq!(error.summary.rm_summary.files_removed, 2);
1880                assert_eq!(error.summary.rm_summary.symlinks_removed, 2);
1881                assert_eq!(error.summary.rm_summary.directories_removed, 0);
1882            }
1883        }
1884        Ok(())
1885    }
1886
1887    #[tokio::test]
1888    #[traced_test]
1889    async fn overwrite_filter_newer_skips_when_dest_is_newer() -> Result<(), anyhow::Error> {
1890        let tmp_dir = testutils::create_temp_dir().await?;
1891        let test_path = tmp_dir.as_path();
1892        let src_file = test_path.join("src.txt");
1893        let dst_file = test_path.join("dst.txt");
1894        // create dest first with older content, then source
1895        tokio::fs::write(&dst_file, "newer content").await?;
1896        // set dest mtime to the future so it's strictly newer than source
1897        let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
1898        filetime::set_file_mtime(&dst_file, future_time)?;
1899        tokio::fs::write(&src_file, "older content").await?;
1900        let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
1901        filetime::set_file_mtime(&src_file, past_time)?;
1902        let summary = copy_file(
1903            &PROGRESS,
1904            &src_file,
1905            &dst_file,
1906            &tokio::fs::metadata(&src_file).await?,
1907            &Settings {
1908                dereference: false,
1909                fail_early: false,
1910                overwrite: true,
1911                overwrite_compare: filecmp::MetadataCmpSettings {
1912                    size: true,
1913                    mtime: true,
1914                    ..Default::default()
1915                },
1916                overwrite_filter: Some(OverwriteFilter::Newer),
1917                ignore_existing: false,
1918                chunk_size: 0,
1919                remote_copy_buffer_size: 0,
1920                filter: None,
1921                dry_run: None,
1922            },
1923            &NO_PRESERVE_SETTINGS,
1924            false,
1925        )
1926        .await?;
1927        assert_eq!(summary.files_unchanged, 1);
1928        assert_eq!(summary.files_copied, 0);
1929        // dest should still have original content
1930        let content = tokio::fs::read_to_string(&dst_file).await?;
1931        assert_eq!(content, "newer content");
1932        Ok(())
1933    }
1934
1935    #[tokio::test]
1936    #[traced_test]
1937    async fn overwrite_filter_newer_copies_when_dest_is_older() -> Result<(), anyhow::Error> {
1938        let tmp_dir = testutils::create_temp_dir().await?;
1939        let test_path = tmp_dir.as_path();
1940        let src_file = test_path.join("src.txt");
1941        let dst_file = test_path.join("dst.txt");
1942        // create dest with old mtime, source with newer mtime
1943        tokio::fs::write(&dst_file, "old content").await?;
1944        let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
1945        filetime::set_file_mtime(&dst_file, past_time)?;
1946        tokio::fs::write(&src_file, "new content").await?;
1947        let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
1948        filetime::set_file_mtime(&src_file, future_time)?;
1949        let summary = copy_file(
1950            &PROGRESS,
1951            &src_file,
1952            &dst_file,
1953            &tokio::fs::metadata(&src_file).await?,
1954            &Settings {
1955                dereference: false,
1956                fail_early: false,
1957                overwrite: true,
1958                overwrite_compare: filecmp::MetadataCmpSettings {
1959                    size: true,
1960                    mtime: true,
1961                    ..Default::default()
1962                },
1963                overwrite_filter: Some(OverwriteFilter::Newer),
1964                ignore_existing: false,
1965                chunk_size: 0,
1966                remote_copy_buffer_size: 0,
1967                filter: None,
1968                dry_run: None,
1969            },
1970            &NO_PRESERVE_SETTINGS,
1971            false,
1972        )
1973        .await?;
1974        assert_eq!(summary.files_copied, 1);
1975        assert_eq!(summary.files_unchanged, 0);
1976        // dest should now have source content
1977        let content = tokio::fs::read_to_string(&dst_file).await?;
1978        assert_eq!(content, "new content");
1979        Ok(())
1980    }
1981
1982    #[tokio::test]
1983    #[traced_test]
1984    async fn overwrite_filter_newer_copies_when_same_mtime() -> Result<(), anyhow::Error> {
1985        let tmp_dir = testutils::create_temp_dir().await?;
1986        let test_path = tmp_dir.as_path();
1987        let src_file = test_path.join("src.txt");
1988        let dst_file = test_path.join("dst.txt");
1989        // create both files with the same mtime but different size
1990        tokio::fs::write(&dst_file, "old").await?;
1991        tokio::fs::write(&src_file, "new content").await?;
1992        let same_time = filetime::FileTime::from_unix_time(1_500_000_000, 0);
1993        filetime::set_file_mtime(&dst_file, same_time)?;
1994        filetime::set_file_mtime(&src_file, same_time)?;
1995        let summary = copy_file(
1996            &PROGRESS,
1997            &src_file,
1998            &dst_file,
1999            &tokio::fs::metadata(&src_file).await?,
2000            &Settings {
2001                dereference: false,
2002                fail_early: false,
2003                overwrite: true,
2004                overwrite_compare: filecmp::MetadataCmpSettings {
2005                    size: true,
2006                    mtime: true,
2007                    ..Default::default()
2008                },
2009                overwrite_filter: Some(OverwriteFilter::Newer),
2010                ignore_existing: false,
2011                chunk_size: 0,
2012                remote_copy_buffer_size: 0,
2013                filter: None,
2014                dry_run: None,
2015            },
2016            &NO_PRESERVE_SETTINGS,
2017            false,
2018        )
2019        .await?;
2020        // same mtime means NOT newer, so the file should be overwritten
2021        assert_eq!(summary.files_copied, 1);
2022        assert_eq!(summary.files_unchanged, 0);
2023        let content = tokio::fs::read_to_string(&dst_file).await?;
2024        assert_eq!(content, "new content");
2025        Ok(())
2026    }
2027
2028    #[tokio::test]
2029    #[traced_test]
2030    async fn overwrite_without_filter_copies_when_dest_is_newer() -> Result<(), anyhow::Error> {
2031        let tmp_dir = testutils::create_temp_dir().await?;
2032        let test_path = tmp_dir.as_path();
2033        let src_file = test_path.join("src.txt");
2034        let dst_file = test_path.join("dst.txt");
2035        // dest is newer, but no filter set so it should still overwrite
2036        tokio::fs::write(&dst_file, "newer content").await?;
2037        let future_time = filetime::FileTime::from_unix_time(2_000_000_000, 0);
2038        filetime::set_file_mtime(&dst_file, future_time)?;
2039        tokio::fs::write(&src_file, "older content").await?;
2040        let past_time = filetime::FileTime::from_unix_time(1_000_000_000, 0);
2041        filetime::set_file_mtime(&src_file, past_time)?;
2042        let summary = copy_file(
2043            &PROGRESS,
2044            &src_file,
2045            &dst_file,
2046            &tokio::fs::metadata(&src_file).await?,
2047            &Settings {
2048                dereference: false,
2049                fail_early: false,
2050                overwrite: true,
2051                overwrite_compare: filecmp::MetadataCmpSettings {
2052                    size: true,
2053                    mtime: true,
2054                    ..Default::default()
2055                },
2056                overwrite_filter: None,
2057                ignore_existing: false,
2058                chunk_size: 0,
2059                remote_copy_buffer_size: 0,
2060                filter: None,
2061                dry_run: None,
2062            },
2063            &NO_PRESERVE_SETTINGS,
2064            false,
2065        )
2066        .await?;
2067        // without filter, file should be overwritten regardless
2068        assert_eq!(summary.files_copied, 1);
2069        let content = tokio::fs::read_to_string(&dst_file).await?;
2070        assert_eq!(content, "older content");
2071        Ok(())
2072    }
2073
2074    #[tokio::test]
2075    #[traced_test]
2076    async fn ignore_existing_skips_when_dest_exists() -> Result<(), anyhow::Error> {
2077        let tmp_dir = testutils::create_temp_dir().await?;
2078        let test_path = tmp_dir.as_path();
2079        let src_file = test_path.join("src.txt");
2080        let dst_file = test_path.join("dst.txt");
2081        tokio::fs::write(&src_file, "source content").await?;
2082        tokio::fs::write(&dst_file, "dest content").await?;
2083        let summary = copy_file(
2084            &PROGRESS,
2085            &src_file,
2086            &dst_file,
2087            &tokio::fs::metadata(&src_file).await?,
2088            &Settings {
2089                dereference: false,
2090                fail_early: false,
2091                overwrite: false,
2092                overwrite_compare: Default::default(),
2093                overwrite_filter: None,
2094                ignore_existing: true,
2095                chunk_size: 0,
2096                remote_copy_buffer_size: 0,
2097                filter: None,
2098                dry_run: None,
2099            },
2100            &NO_PRESERVE_SETTINGS,
2101            false,
2102        )
2103        .await?;
2104        assert_eq!(summary.files_unchanged, 1);
2105        assert_eq!(summary.files_copied, 0);
2106        // dest should still have original content
2107        let content = tokio::fs::read_to_string(&dst_file).await?;
2108        assert_eq!(content, "dest content");
2109        Ok(())
2110    }
2111
2112    #[tokio::test]
2113    #[traced_test]
2114    async fn ignore_existing_skips_when_dest_is_different_type() -> Result<(), anyhow::Error> {
2115        let tmp_dir = testutils::create_temp_dir().await?;
2116        let test_path = tmp_dir.as_path();
2117        let src_file = test_path.join("src.txt");
2118        let dst_dir = test_path.join("dst.txt");
2119        tokio::fs::write(&src_file, "source content").await?;
2120        // destination is a directory, not a file
2121        tokio::fs::create_dir(&dst_dir).await?;
2122        let summary = copy_file(
2123            &PROGRESS,
2124            &src_file,
2125            &dst_dir,
2126            &tokio::fs::metadata(&src_file).await?,
2127            &Settings {
2128                dereference: false,
2129                fail_early: false,
2130                overwrite: false,
2131                overwrite_compare: Default::default(),
2132                overwrite_filter: None,
2133                ignore_existing: true,
2134                chunk_size: 0,
2135                remote_copy_buffer_size: 0,
2136                filter: None,
2137                dry_run: None,
2138            },
2139            &NO_PRESERVE_SETTINGS,
2140            false,
2141        )
2142        .await?;
2143        assert_eq!(summary.files_unchanged, 1);
2144        assert_eq!(summary.files_copied, 0);
2145        // dest directory should still exist
2146        assert!(dst_dir.is_dir());
2147        Ok(())
2148    }
2149
2150    #[tokio::test]
2151    #[traced_test]
2152    async fn ignore_existing_copies_when_dest_missing() -> Result<(), anyhow::Error> {
2153        let tmp_dir = testutils::create_temp_dir().await?;
2154        let test_path = tmp_dir.as_path();
2155        let src_file = test_path.join("src.txt");
2156        let dst_file = test_path.join("dst.txt");
2157        tokio::fs::write(&src_file, "source content").await?;
2158        let summary = copy_file(
2159            &PROGRESS,
2160            &src_file,
2161            &dst_file,
2162            &tokio::fs::metadata(&src_file).await?,
2163            &Settings {
2164                dereference: false,
2165                fail_early: false,
2166                overwrite: false,
2167                overwrite_compare: Default::default(),
2168                overwrite_filter: None,
2169                ignore_existing: true,
2170                chunk_size: 0,
2171                remote_copy_buffer_size: 0,
2172                filter: None,
2173                dry_run: None,
2174            },
2175            &NO_PRESERVE_SETTINGS,
2176            false,
2177        )
2178        .await?;
2179        assert_eq!(summary.files_copied, 1);
2180        let content = tokio::fs::read_to_string(&dst_file).await?;
2181        assert_eq!(content, "source content");
2182        Ok(())
2183    }
2184
2185    #[tokio::test]
2186    #[traced_test]
2187    async fn test_cp_dereference_symlink_chain() -> Result<(), anyhow::Error> {
2188        // Create a fresh temporary directory to avoid conflicts
2189        let tmp_dir = testutils::create_temp_dir().await?;
2190        let test_path = tmp_dir.as_path();
2191        // Create a chain of symlinks: foo -> bar -> baz (actual file)
2192        let baz_file = test_path.join("baz_file.txt");
2193        tokio::fs::write(&baz_file, "final content").await?;
2194        let bar_link = test_path.join("bar_link");
2195        let foo_link = test_path.join("foo_link");
2196        // Create chain: foo_link -> bar_link -> baz_file.txt
2197        tokio::fs::symlink(&baz_file, &bar_link).await?;
2198        tokio::fs::symlink(&bar_link, &foo_link).await?;
2199        // Create source directory with the symlink chain
2200        let src_dir = test_path.join("src_chain");
2201        tokio::fs::create_dir(&src_dir).await?;
2202        // Copy the chain into the source directory
2203        tokio::fs::symlink("../foo_link", &src_dir.join("foo")).await?;
2204        tokio::fs::symlink("../bar_link", &src_dir.join("bar")).await?;
2205        tokio::fs::symlink("../baz_file.txt", &src_dir.join("baz")).await?;
2206        // Test with dereference - should copy 3 files with same content
2207        let summary = copy(
2208            &PROGRESS,
2209            &src_dir,
2210            &test_path.join("dst_with_deref"),
2211            &Settings {
2212                dereference: true, // <- important!
2213                fail_early: false,
2214                overwrite: false,
2215                overwrite_compare: filecmp::MetadataCmpSettings {
2216                    size: true,
2217                    mtime: true,
2218                    ..Default::default()
2219                },
2220                overwrite_filter: None,
2221                ignore_existing: false,
2222                chunk_size: 0,
2223                remote_copy_buffer_size: 0,
2224                filter: None,
2225                dry_run: None,
2226            },
2227            &NO_PRESERVE_SETTINGS,
2228            false,
2229        )
2230        .await?;
2231        assert_eq!(summary.files_copied, 3); // foo, bar, baz all copied as files
2232        assert_eq!(summary.symlinks_created, 0); // dereference is set
2233        assert_eq!(summary.directories_created, 1);
2234        let dst_dir = test_path.join("dst_with_deref");
2235        // Verify all three are now regular files with the same content
2236        let foo_content = tokio::fs::read_to_string(dst_dir.join("foo")).await?;
2237        let bar_content = tokio::fs::read_to_string(dst_dir.join("bar")).await?;
2238        let baz_content = tokio::fs::read_to_string(dst_dir.join("baz")).await?;
2239        assert_eq!(foo_content, "final content");
2240        assert_eq!(bar_content, "final content");
2241        assert_eq!(baz_content, "final content");
2242        // Verify they are all regular files, not symlinks
2243        assert!(dst_dir.join("foo").is_file());
2244        assert!(dst_dir.join("bar").is_file());
2245        assert!(dst_dir.join("baz").is_file());
2246        assert!(!dst_dir.join("foo").is_symlink());
2247        assert!(!dst_dir.join("bar").is_symlink());
2248        assert!(!dst_dir.join("baz").is_symlink());
2249        Ok(())
2250    }
2251
2252    #[tokio::test]
2253    #[traced_test]
2254    async fn test_cp_dereference_symlink_to_directory() -> Result<(), anyhow::Error> {
2255        let tmp_dir = testutils::create_temp_dir().await?;
2256        let test_path = tmp_dir.as_path();
2257        // Create a directory with specific permissions and content
2258        let target_dir = test_path.join("target_dir");
2259        tokio::fs::create_dir(&target_dir).await?;
2260        tokio::fs::set_permissions(&target_dir, std::fs::Permissions::from_mode(0o755)).await?;
2261        // Add some files to the directory
2262        tokio::fs::write(target_dir.join("file1.txt"), "content1").await?;
2263        tokio::fs::write(target_dir.join("file2.txt"), "content2").await?;
2264        tokio::fs::set_permissions(
2265            &target_dir.join("file1.txt"),
2266            std::fs::Permissions::from_mode(0o644),
2267        )
2268        .await?;
2269        tokio::fs::set_permissions(
2270            &target_dir.join("file2.txt"),
2271            std::fs::Permissions::from_mode(0o600),
2272        )
2273        .await?;
2274        // Create a symlink pointing to the directory
2275        let dir_symlink = test_path.join("dir_symlink");
2276        tokio::fs::symlink(&target_dir, &dir_symlink).await?;
2277        // Test copying the symlink with dereference - should copy as a directory
2278        let summary = copy(
2279            &PROGRESS,
2280            &dir_symlink,
2281            &test_path.join("copied_dir"),
2282            &Settings {
2283                dereference: true, // <- important!
2284                fail_early: false,
2285                overwrite: false,
2286                overwrite_compare: filecmp::MetadataCmpSettings {
2287                    size: true,
2288                    mtime: true,
2289                    ..Default::default()
2290                },
2291                overwrite_filter: None,
2292                ignore_existing: false,
2293                chunk_size: 0,
2294                remote_copy_buffer_size: 0,
2295                filter: None,
2296                dry_run: None,
2297            },
2298            &DO_PRESERVE_SETTINGS,
2299            false,
2300        )
2301        .await?;
2302        assert_eq!(summary.files_copied, 2); // file1.txt, file2.txt
2303        assert_eq!(summary.symlinks_created, 0); // dereference is set
2304        assert_eq!(summary.directories_created, 1); // copied_dir
2305        let copied_dir = test_path.join("copied_dir");
2306        // Verify the directory and its contents were copied
2307        assert!(copied_dir.is_dir());
2308        assert!(!copied_dir.is_symlink()); // Should be a real directory, not a symlink
2309                                           // Verify files were copied with correct content
2310        let file1_content = tokio::fs::read_to_string(copied_dir.join("file1.txt")).await?;
2311        let file2_content = tokio::fs::read_to_string(copied_dir.join("file2.txt")).await?;
2312        assert_eq!(file1_content, "content1");
2313        assert_eq!(file2_content, "content2");
2314        // Verify permissions were preserved
2315        let copied_dir_metadata = tokio::fs::metadata(&copied_dir).await?;
2316        let file1_metadata = tokio::fs::metadata(copied_dir.join("file1.txt")).await?;
2317        let file2_metadata = tokio::fs::metadata(copied_dir.join("file2.txt")).await?;
2318        assert_eq!(copied_dir_metadata.permissions().mode() & 0o777, 0o755);
2319        assert_eq!(file1_metadata.permissions().mode() & 0o777, 0o644);
2320        assert_eq!(file2_metadata.permissions().mode() & 0o777, 0o600);
2321        Ok(())
2322    }
2323
2324    #[tokio::test]
2325    #[traced_test]
2326    async fn test_cp_dereference_permissions_preserved() -> Result<(), anyhow::Error> {
2327        let tmp_dir = testutils::create_temp_dir().await?;
2328        let test_path = tmp_dir.as_path();
2329        // Create files with specific permissions
2330        let file1 = test_path.join("file1.txt");
2331        let file2 = test_path.join("file2.txt");
2332        tokio::fs::write(&file1, "content1").await?;
2333        tokio::fs::write(&file2, "content2").await?;
2334        tokio::fs::set_permissions(&file1, std::fs::Permissions::from_mode(0o755)).await?;
2335        tokio::fs::set_permissions(&file2, std::fs::Permissions::from_mode(0o640)).await?;
2336        // Create symlinks pointing to these files
2337        let symlink1 = test_path.join("symlink1");
2338        let symlink2 = test_path.join("symlink2");
2339        tokio::fs::symlink(&file1, &symlink1).await?;
2340        tokio::fs::symlink(&file2, &symlink2).await?;
2341        // Test copying symlinks with dereference and preserve
2342        let summary1 = copy(
2343            &PROGRESS,
2344            &symlink1,
2345            &test_path.join("copied_file1.txt"),
2346            &Settings {
2347                dereference: true, // <- important!
2348                fail_early: false,
2349                overwrite: false,
2350                overwrite_compare: filecmp::MetadataCmpSettings::default(),
2351                overwrite_filter: None,
2352                ignore_existing: false,
2353                chunk_size: 0,
2354                remote_copy_buffer_size: 0,
2355                filter: None,
2356                dry_run: None,
2357            },
2358            &DO_PRESERVE_SETTINGS, // <- important!
2359            false,
2360        )
2361        .await?;
2362        let summary2 = copy(
2363            &PROGRESS,
2364            &symlink2,
2365            &test_path.join("copied_file2.txt"),
2366            &Settings {
2367                dereference: true,
2368                fail_early: false,
2369                overwrite: false,
2370                overwrite_compare: filecmp::MetadataCmpSettings::default(),
2371                overwrite_filter: None,
2372                ignore_existing: false,
2373                chunk_size: 0,
2374                remote_copy_buffer_size: 0,
2375                filter: None,
2376                dry_run: None,
2377            },
2378            &DO_PRESERVE_SETTINGS,
2379            false,
2380        )
2381        .await?;
2382        assert_eq!(summary1.files_copied, 1);
2383        assert_eq!(summary1.symlinks_created, 0);
2384        assert_eq!(summary2.files_copied, 1);
2385        assert_eq!(summary2.symlinks_created, 0);
2386        let copied1 = test_path.join("copied_file1.txt");
2387        let copied2 = test_path.join("copied_file2.txt");
2388        // Verify files are regular files, not symlinks
2389        assert!(copied1.is_file());
2390        assert!(!copied1.is_symlink());
2391        assert!(copied2.is_file());
2392        assert!(!copied2.is_symlink());
2393        // Verify content was copied correctly
2394        let content1 = tokio::fs::read_to_string(&copied1).await?;
2395        let content2 = tokio::fs::read_to_string(&copied2).await?;
2396        assert_eq!(content1, "content1");
2397        assert_eq!(content2, "content2");
2398        // Verify permissions from the target files were preserved (not symlink permissions)
2399        let copied1_metadata = tokio::fs::metadata(&copied1).await?;
2400        let copied2_metadata = tokio::fs::metadata(&copied2).await?;
2401        assert_eq!(copied1_metadata.permissions().mode() & 0o777, 0o755);
2402        assert_eq!(copied2_metadata.permissions().mode() & 0o777, 0o640);
2403        Ok(())
2404    }
2405
2406    #[tokio::test]
2407    #[traced_test]
2408    async fn test_cp_dereference_dir() -> Result<(), anyhow::Error> {
2409        let tmp_dir = testutils::setup_test_dir().await?;
2410        // symlink bar to bar-link
2411        tokio::fs::symlink("bar", &tmp_dir.join("foo").join("bar-link")).await?;
2412        // symlink bar-link to bar-link-link
2413        tokio::fs::symlink("bar-link", &tmp_dir.join("foo").join("bar-link-link")).await?;
2414        let summary = copy(
2415            &PROGRESS,
2416            &tmp_dir.join("foo"),
2417            &tmp_dir.join("bar"),
2418            &Settings {
2419                dereference: true, // <- important!
2420                fail_early: false,
2421                overwrite: false,
2422                overwrite_compare: filecmp::MetadataCmpSettings {
2423                    size: true,
2424                    mtime: true,
2425                    ..Default::default()
2426                },
2427                overwrite_filter: None,
2428                ignore_existing: false,
2429                chunk_size: 0,
2430                remote_copy_buffer_size: 0,
2431                filter: None,
2432                dry_run: None,
2433            },
2434            &DO_PRESERVE_SETTINGS,
2435            false,
2436        )
2437        .await?;
2438        assert_eq!(summary.files_copied, 13); // 0.txt, 3x bar/(1.txt, 2.txt, 3.txt), baz/(4.txt, 5.txt, 6.txt)
2439        assert_eq!(summary.symlinks_created, 0); // dereference is set
2440        assert_eq!(summary.directories_created, 5);
2441        // check_dirs_identical doesn't handle dereference so let's do it manually
2442        tokio::process::Command::new("cp")
2443            .args(["-r", "-L"])
2444            .arg(tmp_dir.join("foo"))
2445            .arg(tmp_dir.join("bar-cp"))
2446            .output()
2447            .await?;
2448        testutils::check_dirs_identical(
2449            &tmp_dir.join("bar"),
2450            &tmp_dir.join("bar-cp"),
2451            testutils::FileEqualityCheck::Basic,
2452        )
2453        .await?;
2454        Ok(())
2455    }
2456
2457    /// Tests to verify error messages include root causes for debugging
2458    mod error_message_tests {
2459        use super::*;
2460
2461        /// Helper to extract full error message with chain
2462        fn get_full_error_message(error: &Error) -> String {
2463            format!("{:#}", error.source)
2464        }
2465
2466        #[tokio::test]
2467        #[traced_test]
2468        async fn test_permission_error_includes_root_cause() -> Result<(), anyhow::Error> {
2469            let tmp_dir = testutils::create_temp_dir().await?;
2470            let unreadable = tmp_dir.join("unreadable.txt");
2471            tokio::fs::write(&unreadable, "test").await?;
2472            tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2473
2474            // symlink_metadata succeeds even without read permission
2475            let src_metadata = tokio::fs::symlink_metadata(&unreadable).await?;
2476            let result = copy_file(
2477                &PROGRESS,
2478                &unreadable,
2479                &tmp_dir.join("dest.txt"),
2480                &src_metadata,
2481                &Settings {
2482                    dereference: false,
2483                    fail_early: false,
2484                    overwrite: false,
2485                    overwrite_compare: Default::default(),
2486                    overwrite_filter: None,
2487                    ignore_existing: false,
2488                    chunk_size: 0,
2489                    remote_copy_buffer_size: 0,
2490                    filter: None,
2491                    dry_run: None,
2492                },
2493                &NO_PRESERVE_SETTINGS,
2494                false,
2495            )
2496            .await;
2497
2498            assert!(result.is_err(), "Should fail with permission error");
2499            let err_msg = get_full_error_message(&result.unwrap_err());
2500
2501            // The error message MUST include the root cause
2502            assert!(
2503                err_msg.to_lowercase().contains("permission")
2504                    || err_msg.contains("EACCES")
2505                    || err_msg.contains("denied"),
2506                "Error message must include permission-related text. Got: {}",
2507                err_msg
2508            );
2509            Ok(())
2510        }
2511
2512        #[tokio::test]
2513        #[traced_test]
2514        async fn test_nonexistent_source_includes_root_cause() -> Result<(), anyhow::Error> {
2515            let tmp_dir = testutils::create_temp_dir().await?;
2516
2517            let result = copy(
2518                &PROGRESS,
2519                &tmp_dir.join("does_not_exist.txt"),
2520                &tmp_dir.join("dest.txt"),
2521                &Settings {
2522                    dereference: false,
2523                    fail_early: false,
2524                    overwrite: false,
2525                    overwrite_compare: Default::default(),
2526                    overwrite_filter: None,
2527                    ignore_existing: false,
2528                    chunk_size: 0,
2529                    remote_copy_buffer_size: 0,
2530                    filter: None,
2531                    dry_run: None,
2532                },
2533                &NO_PRESERVE_SETTINGS,
2534                false,
2535            )
2536            .await;
2537
2538            assert!(result.is_err());
2539            let err_msg = get_full_error_message(&result.unwrap_err());
2540
2541            assert!(
2542                err_msg.to_lowercase().contains("no such file")
2543                    || err_msg.to_lowercase().contains("not found")
2544                    || err_msg.contains("ENOENT"),
2545                "Error message must include file not found text. Got: {}",
2546                err_msg
2547            );
2548            Ok(())
2549        }
2550
2551        #[tokio::test]
2552        #[traced_test]
2553        async fn test_unreadable_directory_includes_root_cause() -> Result<(), anyhow::Error> {
2554            let tmp_dir = testutils::create_temp_dir().await?;
2555            let unreadable_dir = tmp_dir.join("unreadable_dir");
2556            tokio::fs::create_dir(&unreadable_dir).await?;
2557            tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o000))
2558                .await?;
2559
2560            let result = copy(
2561                &PROGRESS,
2562                &unreadable_dir,
2563                &tmp_dir.join("dest"),
2564                &Settings {
2565                    dereference: false,
2566                    fail_early: true,
2567                    overwrite: false,
2568                    overwrite_compare: Default::default(),
2569                    overwrite_filter: None,
2570                    ignore_existing: false,
2571                    chunk_size: 0,
2572                    remote_copy_buffer_size: 0,
2573                    filter: None,
2574                    dry_run: None,
2575                },
2576                &NO_PRESERVE_SETTINGS,
2577                false,
2578            )
2579            .await;
2580
2581            assert!(result.is_err());
2582            let err_msg = get_full_error_message(&result.unwrap_err());
2583
2584            assert!(
2585                err_msg.to_lowercase().contains("permission")
2586                    || err_msg.contains("EACCES")
2587                    || err_msg.contains("denied"),
2588                "Error message must include permission-related text. Got: {}",
2589                err_msg
2590            );
2591
2592            // Clean up - restore permissions so cleanup can remove it
2593            tokio::fs::set_permissions(&unreadable_dir, std::fs::Permissions::from_mode(0o700))
2594                .await?;
2595            Ok(())
2596        }
2597
2598        #[tokio::test]
2599        #[traced_test]
2600        async fn test_destination_permission_error_includes_root_cause() -> Result<(), anyhow::Error>
2601        {
2602            let tmp_dir = testutils::setup_test_dir().await?;
2603            let test_path = tmp_dir.as_path();
2604            let readonly_parent = test_path.join("readonly_dest");
2605            tokio::fs::create_dir(&readonly_parent).await?;
2606            tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
2607                .await?;
2608
2609            let result = copy(
2610                &PROGRESS,
2611                &test_path.join("foo"),
2612                &readonly_parent.join("copy"),
2613                &Settings {
2614                    dereference: false,
2615                    fail_early: true,
2616                    overwrite: false,
2617                    overwrite_compare: Default::default(),
2618                    overwrite_filter: None,
2619                    ignore_existing: false,
2620                    chunk_size: 0,
2621                    remote_copy_buffer_size: 0,
2622                    filter: None,
2623                    dry_run: None,
2624                },
2625                &NO_PRESERVE_SETTINGS,
2626                false,
2627            )
2628            .await;
2629
2630            // restore permissions so cleanup succeeds even when copy fails
2631            tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
2632                .await?;
2633
2634            assert!(result.is_err(), "copy into read-only parent should fail");
2635            let err_msg = get_full_error_message(&result.unwrap_err());
2636
2637            assert!(
2638                err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
2639                "Error message must include permission denied text. Got: {}",
2640                err_msg
2641            );
2642            Ok(())
2643        }
2644    }
2645
2646    mod empty_dir_cleanup_tests {
2647        use super::*;
2648        use std::path::Path;
2649        #[test]
2650        fn test_check_empty_dir_cleanup_no_filter() {
2651            // when no filter, always keep
2652            assert_eq!(
2653                check_empty_dir_cleanup(None, true, false, Path::new("any"), false, false),
2654                EmptyDirAction::Keep
2655            );
2656        }
2657        #[test]
2658        fn test_check_empty_dir_cleanup_something_copied() {
2659            // when content was copied, keep
2660            let mut filter = FilterSettings::new();
2661            filter.add_include("*.txt").unwrap();
2662            assert_eq!(
2663                check_empty_dir_cleanup(Some(&filter), true, true, Path::new("any"), false, false),
2664                EmptyDirAction::Keep
2665            );
2666        }
2667        #[test]
2668        fn test_check_empty_dir_cleanup_not_created() {
2669            // when we didn't create the directory, keep
2670            let mut filter = FilterSettings::new();
2671            filter.add_include("*.txt").unwrap();
2672            assert_eq!(
2673                check_empty_dir_cleanup(
2674                    Some(&filter),
2675                    false,
2676                    false,
2677                    Path::new("any"),
2678                    false,
2679                    false
2680                ),
2681                EmptyDirAction::Keep
2682            );
2683        }
2684        #[test]
2685        fn test_check_empty_dir_cleanup_directly_matched() {
2686            // when directory directly matches include pattern, keep
2687            let mut filter = FilterSettings::new();
2688            filter.add_include("target/").unwrap();
2689            assert_eq!(
2690                check_empty_dir_cleanup(
2691                    Some(&filter),
2692                    true,
2693                    false,
2694                    Path::new("target"),
2695                    false,
2696                    false
2697                ),
2698                EmptyDirAction::Keep
2699            );
2700        }
2701        #[test]
2702        fn test_check_empty_dir_cleanup_traversed_only() {
2703            // when directory was only traversed, remove
2704            let mut filter = FilterSettings::new();
2705            filter.add_include("*.txt").unwrap();
2706            assert_eq!(
2707                check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, false),
2708                EmptyDirAction::Remove
2709            );
2710        }
2711        #[test]
2712        fn test_check_empty_dir_cleanup_dry_run() {
2713            // in dry-run mode, skip instead of remove
2714            let mut filter = FilterSettings::new();
2715            filter.add_include("*.txt").unwrap();
2716            assert_eq!(
2717                check_empty_dir_cleanup(Some(&filter), true, false, Path::new("src"), false, true),
2718                EmptyDirAction::DryRunSkip
2719            );
2720        }
2721        #[test]
2722        fn test_check_empty_dir_cleanup_root_always_kept() {
2723            // root directory is never removed, even with filter and nothing copied
2724            let mut filter = FilterSettings::new();
2725            filter.add_include("*.txt").unwrap();
2726            assert_eq!(
2727                check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, false),
2728                EmptyDirAction::Keep
2729            );
2730        }
2731        #[test]
2732        fn test_check_empty_dir_cleanup_root_kept_in_dry_run() {
2733            // root directory is kept even in dry-run mode
2734            let mut filter = FilterSettings::new();
2735            filter.add_include("*.txt").unwrap();
2736            assert_eq!(
2737                check_empty_dir_cleanup(Some(&filter), true, false, Path::new(""), true, true),
2738                EmptyDirAction::Keep
2739            );
2740        }
2741    }
2742
2743    /// Verify that directory metadata is applied even when child operations fail.
2744    /// This is a regression test for a bug where directory permissions were not preserved
2745    /// when copying with fail_early=false and some children failed to copy.
2746    #[tokio::test]
2747    #[traced_test]
2748    async fn test_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
2749        let tmp_dir = testutils::create_temp_dir().await?;
2750        let test_path = tmp_dir.as_path();
2751        // create source directory with specific permissions
2752        let src_dir = test_path.join("src");
2753        tokio::fs::create_dir(&src_dir).await?;
2754        tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
2755        // create a readable file and an unreadable file inside
2756        let readable_file = src_dir.join("readable.txt");
2757        tokio::fs::write(&readable_file, "content").await?;
2758        let unreadable_file = src_dir.join("unreadable.txt");
2759        tokio::fs::write(&unreadable_file, "secret").await?;
2760        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
2761            .await?;
2762        let dst_dir = test_path.join("dst");
2763        // copy with fail_early=false and preserve=all
2764        let result = copy(
2765            &PROGRESS,
2766            &src_dir,
2767            &dst_dir,
2768            &Settings {
2769                dereference: false,
2770                fail_early: false,
2771                overwrite: false,
2772                overwrite_compare: Default::default(),
2773                overwrite_filter: None,
2774                ignore_existing: false,
2775                chunk_size: 0,
2776                remote_copy_buffer_size: 0,
2777                filter: None,
2778                dry_run: None,
2779            },
2780            &DO_PRESERVE_SETTINGS,
2781            false,
2782        )
2783        .await;
2784        // restore permissions so cleanup can succeed
2785        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
2786            .await?;
2787        // verify the operation returned an error (unreadable file should fail)
2788        assert!(result.is_err(), "copy should fail due to unreadable file");
2789        let error = result.unwrap_err();
2790        // verify some files were copied (the readable one)
2791        assert_eq!(error.summary.files_copied, 1);
2792        assert_eq!(error.summary.directories_created, 1);
2793        // verify the destination directory exists and has the correct permissions
2794        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
2795        assert!(dst_metadata.is_dir());
2796        let actual_mode = dst_metadata.permissions().mode() & 0o7777;
2797        assert_eq!(
2798            actual_mode, 0o750,
2799            "directory should have preserved source permissions (0o750), got {:o}",
2800            actual_mode
2801        );
2802        Ok(())
2803    }
2804
2805    /// Verify that fail-early does not apply parent directory metadata after a child fails.
2806    #[tokio::test]
2807    #[traced_test]
2808    async fn test_fail_early_does_not_apply_parent_directory_metadata_after_child_error(
2809    ) -> Result<(), anyhow::Error> {
2810        let tmp_dir = testutils::create_temp_dir().await?;
2811        let test_path = tmp_dir.as_path();
2812        let src_dir = test_path.join("src");
2813        tokio::fs::create_dir(&src_dir).await?;
2814        tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
2815        let unreadable_file = src_dir.join("unreadable.txt");
2816        tokio::fs::write(&unreadable_file, "secret").await?;
2817        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o000))
2818            .await?;
2819        let fixed_secs = 946684800;
2820        let fixed_nsec = 123_456_789;
2821        let fixed_time = nix::sys::time::TimeSpec::new(fixed_secs, fixed_nsec);
2822        nix::sys::stat::utimensat(
2823            nix::fcntl::AT_FDCWD,
2824            &src_dir,
2825            &fixed_time,
2826            &fixed_time,
2827            nix::sys::stat::UtimensatFlags::NoFollowSymlink,
2828        )?;
2829        let src_metadata = tokio::fs::metadata(&src_dir).await?;
2830        let dst_dir = test_path.join("dst");
2831        let result = copy(
2832            &PROGRESS,
2833            &src_dir,
2834            &dst_dir,
2835            &Settings {
2836                dereference: false,
2837                fail_early: true,
2838                overwrite: false,
2839                overwrite_compare: Default::default(),
2840                overwrite_filter: None,
2841                ignore_existing: false,
2842                chunk_size: 0,
2843                remote_copy_buffer_size: 0,
2844                filter: None,
2845                dry_run: None,
2846            },
2847            &DO_PRESERVE_SETTINGS,
2848            false,
2849        )
2850        .await;
2851        tokio::fs::set_permissions(&unreadable_file, std::fs::Permissions::from_mode(0o644))
2852            .await?;
2853        assert!(result.is_err(), "copy should fail due to unreadable file");
2854        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
2855        assert!(dst_metadata.is_dir());
2856        assert_ne!(
2857            (dst_metadata.mtime(), dst_metadata.mtime_nsec()),
2858            (src_metadata.mtime(), src_metadata.mtime_nsec()),
2859            "fail-early should return before applying preserved directory timestamps"
2860        );
2861        Ok(())
2862    }
2863    mod filter_tests {
2864        use super::*;
2865        use crate::filter::FilterSettings;
2866        /// Test that path-based patterns (with /) work correctly with nested paths.
2867        /// This test exposes the bug where only entry_name is passed to the filter
2868        /// instead of the relative path.
2869        #[tokio::test]
2870        #[traced_test]
2871        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
2872            let tmp_dir = testutils::setup_test_dir().await?;
2873            let test_path = tmp_dir.as_path();
2874            // test directory structure from setup_test_dir:
2875            // foo/
2876            //   0.txt
2877            //   bar/
2878            //     1.txt
2879            //     2.txt
2880            //   baz/
2881            //     3.txt -> ../0.txt (symlink)
2882            //     4.txt
2883            //     5 -> ../bar (symlink)
2884            // create filter that should match bar/*.txt (files in bar directory)
2885            let mut filter = FilterSettings::new();
2886            filter.add_include("bar/*.txt").unwrap();
2887            let summary = copy(
2888                &PROGRESS,
2889                &test_path.join("foo"),
2890                &test_path.join("dst"),
2891                &Settings {
2892                    dereference: false,
2893                    fail_early: false,
2894                    overwrite: false,
2895                    overwrite_compare: Default::default(),
2896                    overwrite_filter: None,
2897                    ignore_existing: false,
2898                    chunk_size: 0,
2899                    remote_copy_buffer_size: 0,
2900                    filter: Some(filter),
2901                    dry_run: None,
2902                },
2903                &NO_PRESERVE_SETTINGS,
2904                false,
2905            )
2906            .await?;
2907            // should only copy files matching bar/*.txt pattern
2908            // bar/1.txt, bar/2.txt, and bar/3.txt should be copied
2909            assert_eq!(
2910                summary.files_copied, 3,
2911                "should copy 3 files matching bar/*.txt"
2912            );
2913            // verify the right files exist
2914            assert!(
2915                test_path.join("dst/bar/1.txt").exists(),
2916                "bar/1.txt should be copied"
2917            );
2918            assert!(
2919                test_path.join("dst/bar/2.txt").exists(),
2920                "bar/2.txt should be copied"
2921            );
2922            assert!(
2923                test_path.join("dst/bar/3.txt").exists(),
2924                "bar/3.txt should be copied"
2925            );
2926            // verify files outside the pattern don't exist
2927            assert!(
2928                !test_path.join("dst/0.txt").exists(),
2929                "0.txt should not be copied"
2930            );
2931            Ok(())
2932        }
2933        /// Test that anchored patterns (starting with /) match only at root.
2934        #[tokio::test]
2935        #[traced_test]
2936        async fn test_anchored_pattern_matches_only_at_root() -> Result<(), anyhow::Error> {
2937            let tmp_dir = testutils::setup_test_dir().await?;
2938            let test_path = tmp_dir.as_path();
2939            // create filter that should match /bar/** (bar directory and all its contents)
2940            let mut filter = FilterSettings::new();
2941            filter.add_include("/bar/**").unwrap();
2942            let summary = copy(
2943                &PROGRESS,
2944                &test_path.join("foo"),
2945                &test_path.join("dst"),
2946                &Settings {
2947                    dereference: false,
2948                    fail_early: false,
2949                    overwrite: false,
2950                    overwrite_compare: Default::default(),
2951                    overwrite_filter: None,
2952                    ignore_existing: false,
2953                    chunk_size: 0,
2954                    remote_copy_buffer_size: 0,
2955                    filter: Some(filter),
2956                    dry_run: None,
2957                },
2958                &NO_PRESERVE_SETTINGS,
2959                false,
2960            )
2961            .await?;
2962            // should only copy bar directory and its contents
2963            assert!(
2964                test_path.join("dst/bar").exists(),
2965                "bar directory should be copied"
2966            );
2967            assert!(
2968                !test_path.join("dst/baz").exists(),
2969                "baz directory should not be copied"
2970            );
2971            assert!(
2972                !test_path.join("dst/0.txt").exists(),
2973                "0.txt should not be copied"
2974            );
2975            // verify summary counts
2976            assert_eq!(
2977                summary.files_copied, 3,
2978                "should copy 3 files in bar (1.txt, 2.txt, 3.txt)"
2979            );
2980            assert_eq!(
2981                summary.directories_created, 2,
2982                "should create 2 directories (root dst + bar)"
2983            );
2984            // skipped: 0.txt (file) and baz (directory) - baz contents not counted since dir is skipped
2985            assert_eq!(summary.files_skipped, 1, "should skip 1 file (0.txt)");
2986            assert_eq!(
2987                summary.directories_skipped, 1,
2988                "should skip 1 directory (baz)"
2989            );
2990            Ok(())
2991        }
2992        /// Test that double-star patterns (**) match across directories.
2993        #[tokio::test]
2994        #[traced_test]
2995        async fn test_double_star_pattern_matches_nested() -> Result<(), anyhow::Error> {
2996            let tmp_dir = testutils::setup_test_dir().await?;
2997            let test_path = tmp_dir.as_path();
2998            // create filter that should match all .txt files at any depth
2999            let mut filter = FilterSettings::new();
3000            filter.add_include("**/*.txt").unwrap();
3001            let summary = copy(
3002                &PROGRESS,
3003                &test_path.join("foo"),
3004                &test_path.join("dst"),
3005                &Settings {
3006                    dereference: false,
3007                    fail_early: false,
3008                    overwrite: false,
3009                    overwrite_compare: Default::default(),
3010                    overwrite_filter: None,
3011                    ignore_existing: false,
3012                    chunk_size: 0,
3013                    remote_copy_buffer_size: 0,
3014                    filter: Some(filter),
3015                    dry_run: None,
3016                },
3017                &NO_PRESERVE_SETTINGS,
3018                false,
3019            )
3020            .await?;
3021            // should copy all .txt files: 0.txt, bar/1.txt, bar/2.txt, bar/3.txt, baz/4.txt
3022            assert_eq!(
3023                summary.files_copied, 5,
3024                "should copy all 5 .txt files with **/*.txt pattern"
3025            );
3026            Ok(())
3027        }
3028        /// Test that filters are applied to top-level file arguments.
3029        #[tokio::test]
3030        #[traced_test]
3031        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
3032            let tmp_dir = testutils::setup_test_dir().await?;
3033            let test_path = tmp_dir.as_path();
3034            // create filter that excludes .txt files
3035            let mut filter = FilterSettings::new();
3036            filter.add_exclude("*.txt").unwrap();
3037            let result = copy(
3038                &PROGRESS,
3039                &test_path.join("foo/0.txt"), // single file source
3040                &test_path.join("dst.txt"),
3041                &Settings {
3042                    dereference: false,
3043                    fail_early: false,
3044                    overwrite: false,
3045                    overwrite_compare: Default::default(),
3046                    overwrite_filter: None,
3047                    ignore_existing: false,
3048                    chunk_size: 0,
3049                    remote_copy_buffer_size: 0,
3050                    filter: Some(filter),
3051                    dry_run: None,
3052                },
3053                &NO_PRESERVE_SETTINGS,
3054                false,
3055            )
3056            .await?;
3057            // the file should NOT be copied because it matches the exclude pattern
3058            assert_eq!(
3059                result.files_copied, 0,
3060                "file matching exclude pattern should not be copied"
3061            );
3062            assert!(
3063                !test_path.join("dst.txt").exists(),
3064                "excluded file should not exist at destination"
3065            );
3066            Ok(())
3067        }
3068        /// Test that filters apply to root directories with simple exclude patterns.
3069        #[tokio::test]
3070        #[traced_test]
3071        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
3072            let test_path = testutils::create_temp_dir().await?;
3073            // create a directory that should be excluded
3074            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
3075            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
3076            // create filter that excludes *_dir/ directories
3077            let mut filter = FilterSettings::new();
3078            filter.add_exclude("*_dir/").unwrap();
3079            let result = copy(
3080                &PROGRESS,
3081                &test_path.join("excluded_dir"),
3082                &test_path.join("dst"),
3083                &Settings {
3084                    dereference: false,
3085                    fail_early: false,
3086                    overwrite: false,
3087                    overwrite_compare: Default::default(),
3088                    overwrite_filter: None,
3089                    ignore_existing: false,
3090                    chunk_size: 0,
3091                    remote_copy_buffer_size: 0,
3092                    filter: Some(filter),
3093                    dry_run: None,
3094                },
3095                &NO_PRESERVE_SETTINGS,
3096                false,
3097            )
3098            .await?;
3099            // directory should NOT be copied because it matches exclude pattern
3100            assert_eq!(
3101                result.directories_created, 0,
3102                "root directory matching exclude should not be created"
3103            );
3104            assert!(
3105                !test_path.join("dst").exists(),
3106                "excluded root directory should not exist at destination"
3107            );
3108            Ok(())
3109        }
3110        /// Test that filters apply to root symlinks with simple exclude patterns.
3111        #[tokio::test]
3112        #[traced_test]
3113        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
3114            let test_path = testutils::create_temp_dir().await?;
3115            // create a target file and a symlink to it
3116            tokio::fs::write(test_path.join("target.txt"), "content").await?;
3117            tokio::fs::symlink(
3118                test_path.join("target.txt"),
3119                test_path.join("excluded_link"),
3120            )
3121            .await?;
3122            // create filter that excludes *_link
3123            let mut filter = FilterSettings::new();
3124            filter.add_exclude("*_link").unwrap();
3125            let result = copy(
3126                &PROGRESS,
3127                &test_path.join("excluded_link"),
3128                &test_path.join("dst"),
3129                &Settings {
3130                    dereference: false,
3131                    fail_early: false,
3132                    overwrite: false,
3133                    overwrite_compare: Default::default(),
3134                    overwrite_filter: None,
3135                    ignore_existing: false,
3136                    chunk_size: 0,
3137                    remote_copy_buffer_size: 0,
3138                    filter: Some(filter),
3139                    dry_run: None,
3140                },
3141                &NO_PRESERVE_SETTINGS,
3142                false,
3143            )
3144            .await?;
3145            // symlink should NOT be copied because it matches exclude pattern
3146            assert_eq!(
3147                result.symlinks_created, 0,
3148                "root symlink matching exclude should not be created"
3149            );
3150            assert!(
3151                !test_path.join("dst").exists(),
3152                "excluded root symlink should not exist at destination"
3153            );
3154            Ok(())
3155        }
3156        /// Test combined include and exclude patterns (exclude takes precedence).
3157        #[tokio::test]
3158        #[traced_test]
3159        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
3160            let tmp_dir = testutils::setup_test_dir().await?;
3161            let test_path = tmp_dir.as_path();
3162            // test structure from setup_test_dir:
3163            // foo/
3164            //   0.txt
3165            //   bar/ (1.txt, 2.txt, 3.txt)
3166            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
3167            // include all .txt files, but exclude bar/2.txt specifically
3168            let mut filter = FilterSettings::new();
3169            filter.add_include("**/*.txt").unwrap();
3170            filter.add_exclude("bar/2.txt").unwrap();
3171            let summary = copy(
3172                &PROGRESS,
3173                &test_path.join("foo"),
3174                &test_path.join("dst"),
3175                &Settings {
3176                    dereference: false,
3177                    fail_early: false,
3178                    overwrite: false,
3179                    overwrite_compare: Default::default(),
3180                    overwrite_filter: None,
3181                    ignore_existing: false,
3182                    chunk_size: 0,
3183                    remote_copy_buffer_size: 0,
3184                    filter: Some(filter),
3185                    dry_run: None,
3186                },
3187                &NO_PRESERVE_SETTINGS,
3188                false,
3189            )
3190            .await?;
3191            // should copy: 0.txt, bar/1.txt, bar/3.txt, baz/4.txt = 4 files
3192            // should skip: bar/2.txt (excluded by pattern) = 1 file
3193            // symlinks 5.txt and 6.txt don't match *.txt include pattern (symlinks, not files)
3194            assert_eq!(summary.files_copied, 4, "should copy 4 .txt files");
3195            assert_eq!(
3196                summary.files_skipped, 1,
3197                "should skip 1 file (bar/2.txt excluded)"
3198            );
3199            // verify specific files
3200            assert!(
3201                test_path.join("dst/bar/1.txt").exists(),
3202                "bar/1.txt should be copied"
3203            );
3204            assert!(
3205                !test_path.join("dst/bar/2.txt").exists(),
3206                "bar/2.txt should be excluded"
3207            );
3208            assert!(
3209                test_path.join("dst/bar/3.txt").exists(),
3210                "bar/3.txt should be copied"
3211            );
3212            Ok(())
3213        }
3214        /// Test that skipped counts accurately reflect what was filtered.
3215        #[tokio::test]
3216        #[traced_test]
3217        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
3218            let tmp_dir = testutils::setup_test_dir().await?;
3219            let test_path = tmp_dir.as_path();
3220            // test structure from setup_test_dir:
3221            // foo/
3222            //   0.txt
3223            //   bar/ (1.txt, 2.txt, 3.txt)
3224            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
3225            // exclude bar/ directory entirely
3226            let mut filter = FilterSettings::new();
3227            filter.add_exclude("bar/").unwrap();
3228            let summary = copy(
3229                &PROGRESS,
3230                &test_path.join("foo"),
3231                &test_path.join("dst"),
3232                &Settings {
3233                    dereference: false,
3234                    fail_early: false,
3235                    overwrite: false,
3236                    overwrite_compare: Default::default(),
3237                    overwrite_filter: None,
3238                    ignore_existing: false,
3239                    chunk_size: 0,
3240                    remote_copy_buffer_size: 0,
3241                    filter: Some(filter),
3242                    dry_run: None,
3243                },
3244                &NO_PRESERVE_SETTINGS,
3245                false,
3246            )
3247            .await?;
3248            // copied: 0.txt (1 file), baz/4.txt (1 file), 5.txt symlink, 6.txt symlink
3249            // skipped: bar directory (1 dir) - contents not counted since whole dir skipped
3250            // directories: foo (root), baz = 2
3251            assert_eq!(summary.files_copied, 2, "should copy 2 files");
3252            assert_eq!(summary.symlinks_created, 2, "should copy 2 symlinks");
3253            assert_eq!(
3254                summary.directories_created, 2,
3255                "should create 2 directories"
3256            );
3257            assert_eq!(
3258                summary.directories_skipped, 1,
3259                "should skip 1 directory (bar)"
3260            );
3261            assert_eq!(
3262                summary.files_skipped, 0,
3263                "no files skipped (bar contents not counted)"
3264            );
3265            Ok(())
3266        }
3267        /// Test that empty directories are not created when they were only traversed to look
3268        /// for matches (regression test for bug where --include='foo' would create empty dir baz).
3269        #[tokio::test]
3270        #[traced_test]
3271        async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
3272            let test_path = testutils::create_temp_dir().await?;
3273            // create structure:
3274            // src/
3275            //   foo (file)
3276            //   bar (file)
3277            //   baz/ (empty directory)
3278            let src_path = test_path.join("src");
3279            tokio::fs::create_dir(&src_path).await?;
3280            tokio::fs::write(src_path.join("foo"), "content").await?;
3281            tokio::fs::write(src_path.join("bar"), "content").await?;
3282            tokio::fs::create_dir(src_path.join("baz")).await?;
3283            // include only 'foo' file
3284            let mut filter = FilterSettings::new();
3285            filter.add_include("foo").unwrap();
3286            let summary = copy(
3287                &PROGRESS,
3288                &src_path,
3289                &test_path.join("dst"),
3290                &Settings {
3291                    dereference: false,
3292                    fail_early: false,
3293                    overwrite: false,
3294                    overwrite_compare: Default::default(),
3295                    overwrite_filter: None,
3296                    ignore_existing: false,
3297                    chunk_size: 0,
3298                    remote_copy_buffer_size: 0,
3299                    filter: Some(filter),
3300                    dry_run: None,
3301                },
3302                &NO_PRESERVE_SETTINGS,
3303                false,
3304            )
3305            .await?;
3306            // only 'foo' should be copied
3307            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3308            assert_eq!(
3309                summary.directories_created, 1,
3310                "should create only root directory (not empty 'baz')"
3311            );
3312            // verify foo was copied
3313            assert!(
3314                test_path.join("dst").join("foo").exists(),
3315                "foo should be copied"
3316            );
3317            // verify bar was not copied (not matching include pattern)
3318            assert!(
3319                !test_path.join("dst").join("bar").exists(),
3320                "bar should not be copied"
3321            );
3322            // verify empty baz directory was NOT created
3323            assert!(
3324                !test_path.join("dst").join("baz").exists(),
3325                "empty baz directory should NOT be created"
3326            );
3327            Ok(())
3328        }
3329        /// Test that directories with only non-matching content are not created at destination.
3330        /// This is different from empty directories - the source dir has content but none matches.
3331        #[tokio::test]
3332        #[traced_test]
3333        async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
3334            let test_path = testutils::create_temp_dir().await?;
3335            // create structure:
3336            // src/
3337            //   foo (file)
3338            //   baz/
3339            //     qux (file - doesn't match 'foo')
3340            //     quux (file - doesn't match 'foo')
3341            let src_path = test_path.join("src");
3342            tokio::fs::create_dir(&src_path).await?;
3343            tokio::fs::write(src_path.join("foo"), "content").await?;
3344            tokio::fs::create_dir(src_path.join("baz")).await?;
3345            tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
3346            tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
3347            // include only 'foo' file
3348            let mut filter = FilterSettings::new();
3349            filter.add_include("foo").unwrap();
3350            let summary = copy(
3351                &PROGRESS,
3352                &src_path,
3353                &test_path.join("dst"),
3354                &Settings {
3355                    dereference: false,
3356                    fail_early: false,
3357                    overwrite: false,
3358                    overwrite_compare: Default::default(),
3359                    overwrite_filter: None,
3360                    ignore_existing: false,
3361                    chunk_size: 0,
3362                    remote_copy_buffer_size: 0,
3363                    filter: Some(filter),
3364                    dry_run: None,
3365                },
3366                &NO_PRESERVE_SETTINGS,
3367                false,
3368            )
3369            .await?;
3370            // only 'foo' should be copied
3371            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3372            assert_eq!(
3373                summary.files_skipped, 2,
3374                "should skip 2 files (qux and quux)"
3375            );
3376            assert_eq!(
3377                summary.directories_created, 1,
3378                "should create only root directory (not 'baz' with non-matching content)"
3379            );
3380            // verify foo was copied
3381            assert!(
3382                test_path.join("dst").join("foo").exists(),
3383                "foo should be copied"
3384            );
3385            // verify baz directory was NOT created (even though source baz has content)
3386            assert!(
3387                !test_path.join("dst").join("baz").exists(),
3388                "baz directory should NOT be created (no matching content inside)"
3389            );
3390            Ok(())
3391        }
3392        /// Test that empty directories are not reported as created in dry-run mode
3393        /// when they were only traversed.
3394        #[tokio::test]
3395        #[traced_test]
3396        async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
3397            let test_path = testutils::create_temp_dir().await?;
3398            // create structure:
3399            // src/
3400            //   foo (file)
3401            //   bar (file)
3402            //   baz/ (empty directory)
3403            let src_path = test_path.join("src");
3404            tokio::fs::create_dir(&src_path).await?;
3405            tokio::fs::write(src_path.join("foo"), "content").await?;
3406            tokio::fs::write(src_path.join("bar"), "content").await?;
3407            tokio::fs::create_dir(src_path.join("baz")).await?;
3408            // include only 'foo' file
3409            let mut filter = FilterSettings::new();
3410            filter.add_include("foo").unwrap();
3411            let summary = copy(
3412                &PROGRESS,
3413                &src_path,
3414                &test_path.join("dst"),
3415                &Settings {
3416                    dereference: false,
3417                    fail_early: false,
3418                    overwrite: false,
3419                    overwrite_compare: Default::default(),
3420                    overwrite_filter: None,
3421                    ignore_existing: false,
3422                    chunk_size: 0,
3423                    remote_copy_buffer_size: 0,
3424                    filter: Some(filter),
3425                    dry_run: Some(crate::config::DryRunMode::Explain),
3426                },
3427                &NO_PRESERVE_SETTINGS,
3428                false,
3429            )
3430            .await?;
3431            // only 'foo' should be reported as would-be-copied
3432            assert_eq!(
3433                summary.files_copied, 1,
3434                "should report only 'foo' would be copied"
3435            );
3436            assert_eq!(
3437                summary.directories_created, 1,
3438                "should report only root directory would be created (not empty 'baz')"
3439            );
3440            // verify nothing was actually created (dry-run mode)
3441            assert!(
3442                !test_path.join("dst").exists(),
3443                "dst should not exist in dry-run"
3444            );
3445            Ok(())
3446        }
3447        /// Test that existing directories are NOT removed when using --overwrite,
3448        /// even if nothing is copied into them due to filters.
3449        #[tokio::test]
3450        #[traced_test]
3451        async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
3452            let test_path = testutils::create_temp_dir().await?;
3453            // create source structure:
3454            // src/
3455            //   foo (file)
3456            //   bar (file)
3457            //   baz/ (empty directory)
3458            let src_path = test_path.join("src");
3459            tokio::fs::create_dir(&src_path).await?;
3460            tokio::fs::write(src_path.join("foo"), "content").await?;
3461            tokio::fs::write(src_path.join("bar"), "content").await?;
3462            tokio::fs::create_dir(src_path.join("baz")).await?;
3463            // create destination with baz directory already existing
3464            let dst_path = test_path.join("dst");
3465            tokio::fs::create_dir(&dst_path).await?;
3466            tokio::fs::create_dir(dst_path.join("baz")).await?;
3467            // add a marker file inside dst/baz to verify we don't touch it
3468            tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
3469            // include only 'foo' file - baz should not match
3470            let mut filter = FilterSettings::new();
3471            filter.add_include("foo").unwrap();
3472            let summary = copy(
3473                &PROGRESS,
3474                &src_path,
3475                &dst_path,
3476                &Settings {
3477                    dereference: false,
3478                    fail_early: false,
3479                    overwrite: true, // enable overwrite mode
3480                    overwrite_compare: Default::default(),
3481                    overwrite_filter: None,
3482                    ignore_existing: false,
3483                    chunk_size: 0,
3484                    remote_copy_buffer_size: 0,
3485                    filter: Some(filter),
3486                    dry_run: None,
3487                },
3488                &NO_PRESERVE_SETTINGS,
3489                false,
3490            )
3491            .await?;
3492            // foo should be copied
3493            assert_eq!(summary.files_copied, 1, "should copy only 'foo' file");
3494            // dst and baz should be unchanged (both already existed)
3495            assert_eq!(
3496                summary.directories_unchanged, 2,
3497                "root dst and baz directories should be unchanged"
3498            );
3499            assert_eq!(
3500                summary.directories_created, 0,
3501                "should not create any directories"
3502            );
3503            // verify foo was copied
3504            assert!(dst_path.join("foo").exists(), "foo should be copied");
3505            // verify bar was NOT copied
3506            assert!(!dst_path.join("bar").exists(), "bar should not be copied");
3507            // verify existing baz directory still exists with its content
3508            assert!(
3509                dst_path.join("baz").exists(),
3510                "existing baz directory should still exist"
3511            );
3512            assert!(
3513                dst_path.join("baz").join("marker.txt").exists(),
3514                "existing content in baz should still exist"
3515            );
3516            Ok(())
3517        }
3518    }
3519    mod dry_run_tests {
3520        use super::*;
3521        /// Test that dry-run mode for directories doesn't create the destination
3522        /// and doesn't try to set metadata on non-existent directories.
3523        #[tokio::test]
3524        #[traced_test]
3525        async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
3526            let tmp_dir = testutils::setup_test_dir().await?;
3527            let test_path = tmp_dir.as_path();
3528            let dst_path = test_path.join("nonexistent_dst");
3529            // verify destination doesn't exist
3530            assert!(
3531                !dst_path.exists(),
3532                "destination should not exist before dry-run"
3533            );
3534            let summary = copy(
3535                &PROGRESS,
3536                &test_path.join("foo"),
3537                &dst_path,
3538                &Settings {
3539                    dereference: false,
3540                    fail_early: false,
3541                    overwrite: false,
3542                    overwrite_compare: Default::default(),
3543                    overwrite_filter: None,
3544                    ignore_existing: false,
3545                    chunk_size: 0,
3546                    remote_copy_buffer_size: 0,
3547                    filter: None,
3548                    dry_run: Some(crate::config::DryRunMode::Brief),
3549                },
3550                &NO_PRESERVE_SETTINGS,
3551                false,
3552            )
3553            .await?;
3554            // verify destination still doesn't exist
3555            assert!(
3556                !dst_path.exists(),
3557                "dry-run should not create destination directory"
3558            );
3559            // verify summary reports what would be created
3560            assert!(
3561                summary.directories_created > 0,
3562                "dry-run should report directories that would be created"
3563            );
3564            assert!(
3565                summary.files_copied > 0,
3566                "dry-run should report files that would be copied"
3567            );
3568            Ok(())
3569        }
3570        /// Test that root directory is always created even when nothing matches
3571        /// the include pattern. The root is the user-specified source — it should
3572        /// never be removed/skipped due to empty-dir cleanup.
3573        #[tokio::test]
3574        #[traced_test]
3575        async fn test_root_dir_preserved_when_nothing_matches() -> Result<(), anyhow::Error> {
3576            let test_path = testutils::create_temp_dir().await?;
3577            // create structure:
3578            // src/
3579            //   bar.log (doesn't match *.txt)
3580            //   baz/ (empty directory)
3581            let src_path = test_path.join("src");
3582            tokio::fs::create_dir(&src_path).await?;
3583            tokio::fs::write(src_path.join("bar.log"), "content").await?;
3584            tokio::fs::create_dir(src_path.join("baz")).await?;
3585            // include only *.txt - nothing in source matches
3586            let mut filter = FilterSettings::new();
3587            filter.add_include("*.txt").unwrap();
3588            let dst_path = test_path.join("dst");
3589            let summary = copy(
3590                &PROGRESS,
3591                &src_path,
3592                &dst_path,
3593                &Settings {
3594                    dereference: false,
3595                    fail_early: false,
3596                    overwrite: false,
3597                    overwrite_compare: Default::default(),
3598                    overwrite_filter: None,
3599                    ignore_existing: false,
3600                    chunk_size: 0,
3601                    remote_copy_buffer_size: 0,
3602                    filter: Some(filter),
3603                    dry_run: None,
3604                },
3605                &NO_PRESERVE_SETTINGS,
3606                false,
3607            )
3608            .await?;
3609            // no files should be copied
3610            assert_eq!(summary.files_copied, 0, "no files match *.txt");
3611            // root directory should still be created
3612            assert_eq!(
3613                summary.directories_created, 1,
3614                "root directory should always be created"
3615            );
3616            assert!(dst_path.exists(), "root destination directory should exist");
3617            // non-matching subdirectories should not be created
3618            assert!(
3619                !dst_path.join("baz").exists(),
3620                "empty baz should not be created"
3621            );
3622            Ok(())
3623        }
3624        /// Test that root directory is counted in dry-run even when nothing matches.
3625        #[tokio::test]
3626        #[traced_test]
3627        async fn test_root_dir_counted_in_dry_run_when_nothing_matches() -> Result<(), anyhow::Error>
3628        {
3629            let test_path = testutils::create_temp_dir().await?;
3630            let src_path = test_path.join("src");
3631            tokio::fs::create_dir(&src_path).await?;
3632            tokio::fs::write(src_path.join("bar.log"), "content").await?;
3633            // include only *.txt - nothing matches
3634            let mut filter = FilterSettings::new();
3635            filter.add_include("*.txt").unwrap();
3636            let dst_path = test_path.join("dst");
3637            let summary = copy(
3638                &PROGRESS,
3639                &src_path,
3640                &dst_path,
3641                &Settings {
3642                    dereference: false,
3643                    fail_early: false,
3644                    overwrite: false,
3645                    overwrite_compare: Default::default(),
3646                    overwrite_filter: None,
3647                    ignore_existing: false,
3648                    chunk_size: 0,
3649                    remote_copy_buffer_size: 0,
3650                    filter: Some(filter),
3651                    dry_run: Some(crate::config::DryRunMode::Explain),
3652                },
3653                &NO_PRESERVE_SETTINGS,
3654                false,
3655            )
3656            .await?;
3657            assert_eq!(summary.files_copied, 0, "no files match *.txt");
3658            assert_eq!(
3659                summary.directories_created, 1,
3660                "root directory should be counted in dry-run"
3661            );
3662            assert!(
3663                !dst_path.exists(),
3664                "nothing should be created in dry-run mode"
3665            );
3666            Ok(())
3667        }
3668    }
3669
3670    /// stress tests exercising max-open-files saturation during copy
3671    mod max_open_files_tests {
3672        use super::*;
3673
3674        /// wide copy: many files with a very low open-files limit.
3675        /// verifies all files are copied correctly under permit saturation.
3676        #[tokio::test]
3677        #[traced_test]
3678        async fn wide_copy_under_open_files_saturation() -> Result<(), anyhow::Error> {
3679            let tmp_dir = testutils::create_temp_dir().await?;
3680            let src = tmp_dir.join("src");
3681            let dst = tmp_dir.join("dst");
3682            tokio::fs::create_dir(&src).await?;
3683            let file_count = 200;
3684            for i in 0..file_count {
3685                tokio::fs::write(src.join(format!("{}.txt", i)), format!("content-{}", i)).await?;
3686            }
3687            // set a very low limit to force permit contention
3688            throttle::set_max_open_files(4);
3689            let summary = copy(
3690                &PROGRESS,
3691                &src,
3692                &dst,
3693                &Settings {
3694                    dereference: false,
3695                    fail_early: true,
3696                    overwrite: false,
3697                    overwrite_compare: Default::default(),
3698                    overwrite_filter: None,
3699                    ignore_existing: false,
3700                    chunk_size: 0,
3701                    remote_copy_buffer_size: 0,
3702                    filter: None,
3703                    dry_run: None,
3704                },
3705                &NO_PRESERVE_SETTINGS,
3706                false,
3707            )
3708            .await?;
3709            assert_eq!(summary.files_copied, file_count);
3710            assert_eq!(summary.directories_created, 1);
3711            for i in 0..file_count {
3712                let content = tokio::fs::read_to_string(dst.join(format!("{}.txt", i))).await?;
3713                assert_eq!(content, format!("content-{}", i));
3714            }
3715            Ok(())
3716        }
3717
3718        /// deep + wide copy: directory tree deeper than the open-files limit, with files
3719        /// at every level. verifies no deadlock occurs (directories don't consume permits).
3720        #[tokio::test]
3721        #[traced_test]
3722        async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
3723            let tmp_dir = testutils::create_temp_dir().await?;
3724            let src = tmp_dir.join("src");
3725            let dst = tmp_dir.join("dst");
3726            let depth = 20;
3727            let files_per_level = 5;
3728            let limit = 4;
3729            // create a directory chain deeper than the permit limit, with files at each level
3730            let mut dir = src.clone();
3731            for level in 0..depth {
3732                tokio::fs::create_dir_all(&dir).await?;
3733                for f in 0..files_per_level {
3734                    tokio::fs::write(
3735                        dir.join(format!("f{}_{}.txt", level, f)),
3736                        format!("L{}F{}", level, f),
3737                    )
3738                    .await?;
3739                }
3740                dir = dir.join(format!("d{}", level));
3741            }
3742            throttle::set_max_open_files(limit);
3743            let summary = tokio::time::timeout(
3744                std::time::Duration::from_secs(30),
3745                copy(
3746                    &PROGRESS,
3747                    &src,
3748                    &dst,
3749                    &Settings {
3750                        dereference: false,
3751                        fail_early: true,
3752                        overwrite: false,
3753                        overwrite_compare: Default::default(),
3754                        overwrite_filter: None,
3755                        ignore_existing: false,
3756                        chunk_size: 0,
3757                        remote_copy_buffer_size: 0,
3758                        filter: None,
3759                        dry_run: None,
3760                    },
3761                    &NO_PRESERVE_SETTINGS,
3762                    false,
3763                ),
3764            )
3765            .await
3766            .context("copy timed out — possible deadlock")?
3767            .context("copy failed")?;
3768            assert_eq!(summary.files_copied, depth * files_per_level);
3769            assert_eq!(summary.directories_created, depth);
3770            // spot-check content at a few levels
3771            let mut check_dir = dst.clone();
3772            for level in 0..depth {
3773                let content =
3774                    tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
3775                assert_eq!(content, format!("L{}F0", level));
3776                check_dir = check_dir.join(format!("d{}", level));
3777            }
3778            Ok(())
3779        }
3780    }
3781}