Skip to main content

common/
link.rs

1use anyhow::{anyhow, Context};
2use async_recursion::async_recursion;
3use std::os::linux::fs::MetadataExt as LinuxMetadataExt;
4use tracing::instrument;
5
6use crate::config::DryRunMode;
7use crate::copy;
8use crate::copy::{
9    check_empty_dir_cleanup, EmptyDirAction, Settings as CopySettings, Summary as CopySummary,
10};
11use crate::filecmp;
12use crate::filter::{FilterResult, FilterSettings};
13use crate::preserve;
14use crate::progress;
15use crate::rm;
16
17/// Error type for link operations that preserves operation summary even on failure.
18///
19/// # Logging Convention
20/// When logging this error, use `{:#}` or `{:?}` format to preserve the error chain:
21/// ```ignore
22/// tracing::error!("operation failed: {:#}", &error); // ✅ Shows full chain
23/// tracing::error!("operation failed: {:?}", &error); // ✅ Shows full chain
24/// ```
25/// The Display implementation also shows the full chain, but workspace linting enforces `{:#}`
26/// for consistency.
27#[derive(Debug, thiserror::Error)]
28#[error("{source:#}")]
29pub struct Error {
30    #[source]
31    pub source: anyhow::Error,
32    pub summary: Summary,
33}
34
35impl Error {
36    #[must_use]
37    pub fn new(source: anyhow::Error, summary: Summary) -> Self {
38        Error { source, summary }
39    }
40}
41
42#[derive(Debug, Clone)]
43pub struct Settings {
44    pub copy_settings: CopySettings,
45    pub update_compare: filecmp::MetadataCmpSettings,
46    pub update_exclusive: bool,
47    /// filter settings for include/exclude patterns
48    pub filter: Option<crate::filter::FilterSettings>,
49    /// dry-run mode for previewing operations
50    pub dry_run: Option<crate::config::DryRunMode>,
51    /// metadata preservation settings
52    pub preserve: preserve::Settings,
53}
54
55/// Reports a dry-run action for link operations
56fn report_dry_run_link(src: &std::path::Path, dst: &std::path::Path, entry_type: &str) {
57    println!("would link {} {:?} -> {:?}", entry_type, src, dst);
58}
59
60/// Reports a skipped entry during dry-run
61fn report_dry_run_skip(
62    path: &std::path::Path,
63    result: &FilterResult,
64    mode: DryRunMode,
65    entry_type: &str,
66) {
67    match mode {
68        DryRunMode::Brief => { /* brief mode doesn't show skipped files */ }
69        DryRunMode::All => {
70            println!("skip {} {:?}", entry_type, path);
71        }
72        DryRunMode::Explain => match result {
73            FilterResult::ExcludedByDefault => {
74                println!(
75                    "skip {} {:?} (no include pattern matched)",
76                    entry_type, path
77                );
78            }
79            FilterResult::ExcludedByPattern(pattern) => {
80                println!("skip {} {:?} (excluded by '{}')", entry_type, path, pattern);
81            }
82            FilterResult::Included => { /* shouldn't happen */ }
83        },
84    }
85}
86
87/// Check if a path should be filtered out
88fn should_skip_entry(
89    filter: &Option<FilterSettings>,
90    relative_path: &std::path::Path,
91    is_dir: bool,
92) -> Option<FilterResult> {
93    if let Some(ref f) = filter {
94        let result = f.should_include(relative_path, is_dir);
95        match result {
96            FilterResult::Included => None,
97            _ => Some(result),
98        }
99    } else {
100        None
101    }
102}
103
104#[derive(Copy, Clone, Debug, Default)]
105pub struct Summary {
106    pub hard_links_created: usize,
107    pub hard_links_unchanged: usize,
108    pub copy_summary: CopySummary,
109}
110
111impl std::ops::Add for Summary {
112    type Output = Self;
113    fn add(self, other: Self) -> Self {
114        Self {
115            hard_links_created: self.hard_links_created + other.hard_links_created,
116            hard_links_unchanged: self.hard_links_unchanged + other.hard_links_unchanged,
117            copy_summary: self.copy_summary + other.copy_summary,
118        }
119    }
120}
121
122impl std::fmt::Display for Summary {
123    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
124        write!(
125            f,
126            "{}\n\
127            link:\n\
128            -----\n\
129            hard-links created: {}\n\
130            hard links unchanged: {}\n",
131            &self.copy_summary, self.hard_links_created, self.hard_links_unchanged
132        )
133    }
134}
135
136fn is_hard_link(md1: &std::fs::Metadata, md2: &std::fs::Metadata) -> bool {
137    copy::is_file_type_same(md1, md2)
138        && md2.st_dev() == md1.st_dev()
139        && md2.st_ino() == md1.st_ino()
140}
141
142#[instrument(skip(prog_track, settings))]
143async fn hard_link_helper(
144    prog_track: &'static progress::Progress,
145    src: &std::path::Path,
146    src_metadata: &std::fs::Metadata,
147    dst: &std::path::Path,
148    settings: &Settings,
149) -> Result<Summary, Error> {
150    let mut link_summary = Summary::default();
151    if let Err(error) = tokio::fs::hard_link(src, dst).await {
152        if settings.copy_settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
153            tracing::debug!("'dst' already exists, check if we need to update");
154            let dst_metadata = tokio::fs::symlink_metadata(dst)
155                .await
156                .with_context(|| format!("cannot read {dst:?} metadata"))
157                .map_err(|err| Error::new(err, Default::default()))?;
158            if is_hard_link(src_metadata, &dst_metadata) {
159                tracing::debug!("no change, leaving file as is");
160                prog_track.hard_links_unchanged.inc();
161                return Ok(Summary {
162                    hard_links_unchanged: 1,
163                    ..Default::default()
164                });
165            }
166            tracing::info!("'dst' file type changed, removing and hard-linking");
167            let rm_summary = rm::rm(
168                prog_track,
169                dst,
170                &rm::Settings {
171                    fail_early: settings.copy_settings.fail_early,
172                    filter: None,
173                    dry_run: None,
174                },
175            )
176            .await
177            .map_err(|err| {
178                let rm_summary = err.summary;
179                link_summary.copy_summary.rm_summary = rm_summary;
180                Error::new(err.source, link_summary)
181            })?;
182            link_summary.copy_summary.rm_summary = rm_summary;
183            tokio::fs::hard_link(src, dst)
184                .await
185                .with_context(|| format!("failed to hard link {:?} to {:?}", src, dst))
186                .map_err(|err| Error::new(err, link_summary))?;
187        }
188    }
189    prog_track.hard_links_created.inc();
190    link_summary.hard_links_created = 1;
191    Ok(link_summary)
192}
193
194/// Public entry point for link operations.
195/// Internally delegates to link_internal with source_root tracking for proper filter matching.
196#[instrument(skip(prog_track, settings))]
197pub async fn link(
198    prog_track: &'static progress::Progress,
199    cwd: &std::path::Path,
200    src: &std::path::Path,
201    dst: &std::path::Path,
202    update: &Option<std::path::PathBuf>,
203    settings: &Settings,
204    is_fresh: bool,
205) -> Result<Summary, Error> {
206    // check filter for top-level source (files, directories, and symlinks)
207    if let Some(ref filter) = settings.filter {
208        let src_name = src.file_name().map(std::path::Path::new);
209        if let Some(name) = src_name {
210            let src_metadata = tokio::fs::symlink_metadata(src)
211                .await
212                .with_context(|| format!("failed reading metadata from {:?}", &src))
213                .map_err(|err| Error::new(err, Default::default()))?;
214            let is_dir = src_metadata.is_dir();
215            let result = filter.should_include_root_item(name, is_dir);
216            match result {
217                crate::filter::FilterResult::Included => {}
218                result => {
219                    if let Some(mode) = settings.dry_run {
220                        let entry_type = if src_metadata.is_dir() {
221                            "directory"
222                        } else if src_metadata.file_type().is_symlink() {
223                            "symlink"
224                        } else {
225                            "file"
226                        };
227                        report_dry_run_skip(src, &result, mode, entry_type);
228                    }
229                    // return summary with skipped count
230                    let skipped_summary = if src_metadata.is_dir() {
231                        prog_track.directories_skipped.inc();
232                        Summary {
233                            copy_summary: CopySummary {
234                                directories_skipped: 1,
235                                ..Default::default()
236                            },
237                            ..Default::default()
238                        }
239                    } else if src_metadata.file_type().is_symlink() {
240                        prog_track.symlinks_skipped.inc();
241                        Summary {
242                            copy_summary: CopySummary {
243                                symlinks_skipped: 1,
244                                ..Default::default()
245                            },
246                            ..Default::default()
247                        }
248                    } else {
249                        prog_track.files_skipped.inc();
250                        Summary {
251                            copy_summary: CopySummary {
252                                files_skipped: 1,
253                                ..Default::default()
254                            },
255                            ..Default::default()
256                        }
257                    };
258                    return Ok(skipped_summary);
259                }
260            }
261        }
262    }
263    link_internal(prog_track, cwd, src, dst, src, update, settings, is_fresh).await
264}
265#[instrument(skip(prog_track, settings))]
266#[async_recursion]
267#[allow(clippy::too_many_arguments)]
268async fn link_internal(
269    prog_track: &'static progress::Progress,
270    cwd: &std::path::Path,
271    src: &std::path::Path,
272    dst: &std::path::Path,
273    source_root: &std::path::Path,
274    update: &Option<std::path::PathBuf>,
275    settings: &Settings,
276    mut is_fresh: bool,
277) -> Result<Summary, Error> {
278    let _prog_guard = prog_track.ops.guard();
279    tracing::debug!("reading source metadata");
280    let src_metadata = tokio::fs::symlink_metadata(src)
281        .await
282        .with_context(|| format!("failed reading metadata from {:?}", &src))
283        .map_err(|err| Error::new(err, Default::default()))?;
284    let update_metadata_opt = match update {
285        Some(update) => {
286            tracing::debug!("reading 'update' metadata");
287            let update_metadata_res = tokio::fs::symlink_metadata(update).await;
288            match update_metadata_res {
289                Ok(update_metadata) => Some(update_metadata),
290                Err(error) => {
291                    if error.kind() == std::io::ErrorKind::NotFound {
292                        if settings.update_exclusive {
293                            // the path is missing from update, we're done
294                            return Ok(Default::default());
295                        }
296                        None
297                    } else {
298                        return Err(Error::new(
299                            anyhow!("failed reading metadata from {:?}", &update),
300                            Default::default(),
301                        ));
302                    }
303                }
304            }
305        }
306        None => None,
307    };
308    if let Some(update_metadata) = update_metadata_opt.as_ref() {
309        let update = update.as_ref().unwrap();
310        if !copy::is_file_type_same(&src_metadata, update_metadata) {
311            // file type changed, just copy the updated one
312            tracing::debug!(
313                "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
314                src,
315                src_metadata.file_type(),
316                update,
317                update_metadata.file_type()
318            );
319            let copy_summary = copy::copy(
320                prog_track,
321                update,
322                dst,
323                &settings.copy_settings,
324                &settings.preserve,
325                is_fresh,
326            )
327            .await
328            .map_err(|err| {
329                let copy_summary = err.summary;
330                let link_summary = Summary {
331                    copy_summary,
332                    ..Default::default()
333                };
334                Error::new(err.source, link_summary)
335            })?;
336            return Ok(Summary {
337                copy_summary,
338                ..Default::default()
339            });
340        }
341        if update_metadata.is_file() {
342            // check if the file is unchanged and if so hard-link, otherwise copy from the updated one
343            if filecmp::metadata_equal(&settings.update_compare, &src_metadata, update_metadata) {
344                tracing::debug!("no change, hard link 'src'");
345                return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
346            }
347            tracing::debug!(
348                "link: {:?} metadata has changed, copying from {:?}",
349                src,
350                update
351            );
352            let _open_file_guard = throttle::open_file_permit().await;
353            return Ok(Summary {
354                copy_summary: copy::copy_file(
355                    prog_track,
356                    update,
357                    dst,
358                    update_metadata,
359                    &settings.copy_settings,
360                    &settings.preserve,
361                    is_fresh,
362                )
363                .await
364                .map_err(|err| {
365                    let copy_summary = err.summary;
366                    let link_summary = Summary {
367                        copy_summary,
368                        ..Default::default()
369                    };
370                    Error::new(err.source, link_summary)
371                })?,
372                ..Default::default()
373            });
374        }
375        if update_metadata.is_symlink() {
376            tracing::debug!("'update' is a symlink so just symlink that");
377            // use "copy" function to handle the overwrite logic
378            let copy_summary = copy::copy(
379                prog_track,
380                update,
381                dst,
382                &settings.copy_settings,
383                &settings.preserve,
384                is_fresh,
385            )
386            .await
387            .map_err(|err| {
388                let copy_summary = err.summary;
389                let link_summary = Summary {
390                    copy_summary,
391                    ..Default::default()
392                };
393                Error::new(err.source, link_summary)
394            })?;
395            return Ok(Summary {
396                copy_summary,
397                ..Default::default()
398            });
399        }
400    } else {
401        // update hasn't been specified, if this is a file just hard-link the source or symlink if it's a symlink
402        tracing::debug!("no 'update' specified");
403        if src_metadata.is_file() {
404            // handle dry-run mode for top-level files
405            if settings.dry_run.is_some() {
406                report_dry_run_link(src, dst, "file");
407                return Ok(Summary {
408                    hard_links_created: 1,
409                    ..Default::default()
410                });
411            }
412            return hard_link_helper(prog_track, src, &src_metadata, dst, settings).await;
413        }
414        if src_metadata.is_symlink() {
415            tracing::debug!("'src' is a symlink so just symlink that");
416            // use "copy" function to handle the overwrite logic
417            let copy_summary = copy::copy(
418                prog_track,
419                src,
420                dst,
421                &settings.copy_settings,
422                &settings.preserve,
423                is_fresh,
424            )
425            .await
426            .map_err(|err| {
427                let copy_summary = err.summary;
428                let link_summary = Summary {
429                    copy_summary,
430                    ..Default::default()
431                };
432                Error::new(err.source, link_summary)
433            })?;
434            return Ok(Summary {
435                copy_summary,
436                ..Default::default()
437            });
438        }
439    }
440    if !src_metadata.is_dir() {
441        return Err(Error::new(
442            anyhow!(
443                "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
444                src,
445                dst,
446                src_metadata.file_type()
447            ),
448            Default::default(),
449        ));
450    }
451    assert!(update_metadata_opt.is_none() || update_metadata_opt.as_ref().unwrap().is_dir());
452    tracing::debug!("process contents of 'src' directory");
453    let mut src_entries = tokio::fs::read_dir(src)
454        .await
455        .with_context(|| format!("cannot open directory {src:?} for reading"))
456        .map_err(|err| Error::new(err, Default::default()))?;
457    // handle dry-run mode for directories at the top level
458    if settings.dry_run.is_some() {
459        report_dry_run_link(src, dst, "dir");
460        // still need to recurse to show contents
461    }
462    let copy_summary = if settings.dry_run.is_some() {
463        // skip actual directory creation in dry-run mode
464        CopySummary {
465            directories_created: 1,
466            ..Default::default()
467        }
468    } else if let Err(error) = tokio::fs::create_dir(dst).await {
469        assert!(!is_fresh, "unexpected error creating directory: {:?}", &dst);
470        if settings.copy_settings.overwrite && error.kind() == std::io::ErrorKind::AlreadyExists {
471            // check if the destination is a directory - if so, leave it
472            //
473            // N.B. the permissions may prevent us from writing to it but the alternative is to open up the directory
474            // while we're writing to it which isn't safe
475            let dst_metadata = tokio::fs::metadata(dst)
476                .await
477                .with_context(|| format!("failed reading metadata from {:?}", &dst))
478                .map_err(|err| Error::new(err, Default::default()))?;
479            if dst_metadata.is_dir() {
480                tracing::debug!("'dst' is a directory, leaving it as is");
481                CopySummary {
482                    directories_unchanged: 1,
483                    ..Default::default()
484                }
485            } else {
486                tracing::info!("'dst' is not a directory, removing and creating a new one");
487                let mut copy_summary = CopySummary::default();
488                let rm_summary = rm::rm(
489                    prog_track,
490                    dst,
491                    &rm::Settings {
492                        fail_early: settings.copy_settings.fail_early,
493                        filter: None,
494                        dry_run: None,
495                    },
496                )
497                .await
498                .map_err(|err| {
499                    let rm_summary = err.summary;
500                    copy_summary.rm_summary = rm_summary;
501                    Error::new(
502                        err.source,
503                        Summary {
504                            copy_summary,
505                            ..Default::default()
506                        },
507                    )
508                })?;
509                tokio::fs::create_dir(dst)
510                    .await
511                    .with_context(|| format!("cannot create directory {dst:?}"))
512                    .map_err(|err| {
513                        copy_summary.rm_summary = rm_summary;
514                        Error::new(
515                            err,
516                            Summary {
517                                copy_summary,
518                                ..Default::default()
519                            },
520                        )
521                    })?;
522                // anything copied into dst may assume they don't need to check for conflicts
523                is_fresh = true;
524                CopySummary {
525                    rm_summary,
526                    directories_created: 1,
527                    ..Default::default()
528                }
529            }
530        } else {
531            return Err(error)
532                .with_context(|| format!("cannot create directory {dst:?}"))
533                .map_err(|err| Error::new(err, Default::default()))?;
534        }
535    } else {
536        // new directory created, anything copied into dst may assume they don't need to check for conflicts
537        is_fresh = true;
538        CopySummary {
539            directories_created: 1,
540            ..Default::default()
541        }
542    };
543    // track whether we created this directory (vs it already existing)
544    // this is used later to decide if we should clean up an empty directory
545    let we_created_this_dir = copy_summary.directories_created == 1;
546    let mut link_summary = Summary {
547        copy_summary,
548        ..Default::default()
549    };
550    let mut join_set = tokio::task::JoinSet::new();
551    let errors = crate::error_collector::ErrorCollector::default();
552    // create a set of all the files we already processed
553    let mut processed_files = std::collections::HashSet::new();
554    // iterate through src entries and recursively call "link" on each one
555    while let Some(src_entry) = src_entries
556        .next_entry()
557        .await
558        .with_context(|| format!("failed traversing directory {:?}", &src))
559        .map_err(|err| Error::new(err, link_summary))?
560    {
561        // it's better to await the token here so that we throttle the syscalls generated by the
562        // DirEntry call. the ops-throttle will never cause a deadlock (unlike max-open-files limit)
563        // so it's safe to do here.
564        throttle::get_ops_token().await;
565        let cwd_path = cwd.to_owned();
566        let entry_path = src_entry.path();
567        let entry_name = entry_path.file_name().unwrap();
568        // check entry type for filter matching and dry-run reporting
569        let entry_file_type = src_entry.file_type().await.ok();
570        let entry_is_dir = entry_file_type.map(|ft| ft.is_dir()).unwrap_or(false);
571        let entry_is_symlink = entry_file_type.map(|ft| ft.is_symlink()).unwrap_or(false);
572        // compute relative path from source_root for filter matching
573        let relative_path = entry_path.strip_prefix(source_root).unwrap_or(&entry_path);
574        // apply filter if configured
575        if let Some(skip_result) = should_skip_entry(&settings.filter, relative_path, entry_is_dir)
576        {
577            if let Some(mode) = settings.dry_run {
578                let entry_type = if entry_is_dir {
579                    "dir"
580                } else if entry_is_symlink {
581                    "symlink"
582                } else {
583                    "file"
584                };
585                report_dry_run_skip(&entry_path, &skip_result, mode, entry_type);
586            }
587            tracing::debug!("skipping {:?} due to filter", &entry_path);
588            // increment skipped counters
589            if entry_is_dir {
590                link_summary.copy_summary.directories_skipped += 1;
591                prog_track.directories_skipped.inc();
592            } else if entry_is_symlink {
593                link_summary.copy_summary.symlinks_skipped += 1;
594                prog_track.symlinks_skipped.inc();
595            } else {
596                link_summary.copy_summary.files_skipped += 1;
597                prog_track.files_skipped.inc();
598            }
599            continue;
600        }
601        processed_files.insert(entry_name.to_owned());
602        let dst_path = dst.join(entry_name);
603        let update_path = update.as_ref().map(|s| s.join(entry_name));
604        // handle dry-run mode for link operations
605        if let Some(_mode) = settings.dry_run {
606            let entry_type = if entry_is_dir {
607                "dir"
608            } else if entry_is_symlink {
609                "symlink"
610            } else {
611                "file"
612            };
613            report_dry_run_link(&entry_path, &dst_path, entry_type);
614            // for directories in dry-run, still need to recurse to show all entries
615            if entry_is_dir {
616                let settings = settings.clone();
617                let source_root = source_root.to_owned();
618                let do_link = || async move {
619                    link_internal(
620                        prog_track,
621                        &cwd_path,
622                        &entry_path,
623                        &dst_path,
624                        &source_root,
625                        &update_path,
626                        &settings,
627                        true,
628                    )
629                    .await
630                };
631                join_set.spawn(do_link());
632            } else if entry_is_symlink {
633                // for symlinks in dry-run, count as symlink (in copy_summary)
634                link_summary.copy_summary.symlinks_created += 1;
635            } else {
636                // for files in dry-run, count the "would be created" hard link
637                link_summary.hard_links_created += 1;
638            }
639            continue;
640        }
641        let settings = settings.clone();
642        let source_root = source_root.to_owned();
643        let do_link = || async move {
644            link_internal(
645                prog_track,
646                &cwd_path,
647                &entry_path,
648                &dst_path,
649                &source_root,
650                &update_path,
651                &settings,
652                is_fresh,
653            )
654            .await
655        };
656        join_set.spawn(do_link());
657    }
658    // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
659    // one thing we CAN do however is to drop it as soon as we're done with it
660    drop(src_entries);
661    // only process update if the path was provided and the directory is present
662    if update_metadata_opt.is_some() {
663        let update = update.as_ref().unwrap();
664        tracing::debug!("process contents of 'update' directory");
665        let mut update_entries = tokio::fs::read_dir(update)
666            .await
667            .with_context(|| format!("cannot open directory {:?} for reading", &update))
668            .map_err(|err| Error::new(err, link_summary))?;
669        // iterate through update entries and for each one that's not present in src call "copy"
670        while let Some(update_entry) = update_entries
671            .next_entry()
672            .await
673            .with_context(|| format!("failed traversing directory {:?}", &update))
674            .map_err(|err| Error::new(err, link_summary))?
675        {
676            let entry_path = update_entry.path();
677            let entry_name = entry_path.file_name().unwrap();
678            if processed_files.contains(entry_name) {
679                // we already must have considered this file, skip it
680                continue;
681            }
682            tracing::debug!("found a new entry in the 'update' directory");
683            let dst_path = dst.join(entry_name);
684            let update_path = update.join(entry_name);
685            let settings = settings.clone();
686            let do_copy = || async move {
687                let copy_summary = copy::copy(
688                    prog_track,
689                    &update_path,
690                    &dst_path,
691                    &settings.copy_settings,
692                    &settings.preserve,
693                    is_fresh,
694                )
695                .await
696                .map_err(|err| {
697                    link_summary.copy_summary = link_summary.copy_summary + err.summary;
698                    Error::new(err.source, link_summary)
699                })?;
700                Ok(Summary {
701                    copy_summary,
702                    ..Default::default()
703                })
704            };
705            join_set.spawn(do_copy());
706        }
707        // unfortunately ReadDir is opening file-descriptors and there's not a good way to limit this,
708        // one thing we CAN do however is to drop it as soon as we're done with it
709        drop(update_entries);
710    }
711    while let Some(res) = join_set.join_next().await {
712        match res {
713            Ok(result) => match result {
714                Ok(summary) => link_summary = link_summary + summary,
715                Err(error) => {
716                    tracing::error!(
717                        "link: {:?} {:?} -> {:?} failed with: {:#}",
718                        src,
719                        update,
720                        dst,
721                        &error
722                    );
723                    link_summary = link_summary + error.summary;
724                    if settings.copy_settings.fail_early {
725                        return Err(Error::new(error.source, link_summary));
726                    }
727                    errors.push(error.source);
728                }
729            },
730            Err(error) => {
731                if settings.copy_settings.fail_early {
732                    return Err(Error::new(error.into(), link_summary));
733                }
734                errors.push(error.into());
735            }
736        }
737    }
738    // when filtering is active and we created this directory, check if anything was actually
739    // linked/copied into it. if nothing was linked, we may need to clean up the empty directory.
740    let this_dir_count = usize::from(we_created_this_dir);
741    let child_dirs_created = link_summary
742        .copy_summary
743        .directories_created
744        .saturating_sub(this_dir_count);
745    let anything_linked = link_summary.hard_links_created > 0
746        || link_summary.copy_summary.files_copied > 0
747        || link_summary.copy_summary.symlinks_created > 0
748        || child_dirs_created > 0;
749    let relative_path = src.strip_prefix(source_root).unwrap_or(src);
750    let is_root = src == source_root;
751    match check_empty_dir_cleanup(
752        settings.filter.as_ref(),
753        we_created_this_dir,
754        anything_linked,
755        relative_path,
756        is_root,
757        settings.dry_run.is_some(),
758    ) {
759        EmptyDirAction::Keep => { /* proceed with metadata application */ }
760        EmptyDirAction::DryRunSkip => {
761            tracing::debug!(
762                "dry-run: directory {:?} would not be created (nothing to link inside)",
763                &dst
764            );
765            link_summary.copy_summary.directories_created = 0;
766            return Ok(link_summary);
767        }
768        EmptyDirAction::Remove => {
769            tracing::debug!(
770                "directory {:?} has nothing to link inside, removing empty directory",
771                &dst
772            );
773            match tokio::fs::remove_dir(dst).await {
774                Ok(()) => {
775                    link_summary.copy_summary.directories_created = 0;
776                    return Ok(link_summary);
777                }
778                Err(err) => {
779                    // removal failed (not empty, permission error, etc.) — keep directory
780                    tracing::debug!(
781                        "failed to remove empty directory {:?}: {:#}, keeping",
782                        &dst,
783                        &err
784                    );
785                    // fall through to apply metadata
786                }
787            }
788        }
789    }
790    // apply directory metadata regardless of whether all children linked successfully.
791    // the directory itself was created earlier in this function (we would have returned
792    // early if create_dir failed), so we should preserve the source metadata.
793    // skip metadata setting in dry-run mode since directory wasn't actually created
794    tracing::debug!("set 'dst' directory metadata");
795    let metadata_result = if settings.dry_run.is_some() {
796        Ok(()) // skip metadata setting in dry-run mode
797    } else {
798        let preserve_metadata = if let Some(update_metadata) = update_metadata_opt.as_ref() {
799            update_metadata
800        } else {
801            &src_metadata
802        };
803        preserve::set_dir_metadata(&settings.preserve, preserve_metadata, dst).await
804    };
805    if errors.has_errors() {
806        // child failures take precedence - log metadata error if it also failed
807        if let Err(metadata_err) = metadata_result {
808            tracing::error!(
809                "link: {:?} {:?} -> {:?} failed to set directory metadata: {:#}",
810                src,
811                update,
812                dst,
813                &metadata_err
814            );
815        }
816        // unwrap is safe: has_errors() guarantees into_error() returns Some
817        return Err(Error::new(errors.into_error().unwrap(), link_summary));
818    }
819    // no child failures, so metadata error is the primary error
820    metadata_result.map_err(|err| Error::new(err, link_summary))?;
821    Ok(link_summary)
822}
823
824#[cfg(test)]
825mod link_tests {
826    use crate::testutils;
827    use std::os::unix::fs::PermissionsExt;
828    use tracing_test::traced_test;
829
830    use super::*;
831
832    static PROGRESS: std::sync::LazyLock<progress::Progress> =
833        std::sync::LazyLock::new(progress::Progress::new);
834
835    fn common_settings(dereference: bool, overwrite: bool) -> Settings {
836        Settings {
837            copy_settings: CopySettings {
838                dereference,
839                fail_early: false,
840                overwrite,
841                overwrite_compare: filecmp::MetadataCmpSettings {
842                    size: true,
843                    mtime: true,
844                    ..Default::default()
845                },
846                chunk_size: 0,
847                remote_copy_buffer_size: 0,
848                filter: None,
849                dry_run: None,
850            },
851            update_compare: filecmp::MetadataCmpSettings {
852                size: true,
853                mtime: true,
854                ..Default::default()
855            },
856            update_exclusive: false,
857            filter: None,
858            dry_run: None,
859            preserve: preserve::preserve_all(),
860        }
861    }
862
863    #[tokio::test]
864    #[traced_test]
865    async fn test_basic_link() -> Result<(), anyhow::Error> {
866        let tmp_dir = testutils::setup_test_dir().await?;
867        let test_path = tmp_dir.as_path();
868        let summary = link(
869            &PROGRESS,
870            test_path,
871            &test_path.join("foo"),
872            &test_path.join("bar"),
873            &None,
874            &common_settings(false, false),
875            false,
876        )
877        .await?;
878        assert_eq!(summary.hard_links_created, 5);
879        assert_eq!(summary.copy_summary.files_copied, 0);
880        assert_eq!(summary.copy_summary.symlinks_created, 2);
881        assert_eq!(summary.copy_summary.directories_created, 3);
882        testutils::check_dirs_identical(
883            &test_path.join("foo"),
884            &test_path.join("bar"),
885            testutils::FileEqualityCheck::Timestamp,
886        )
887        .await?;
888        Ok(())
889    }
890
891    #[tokio::test]
892    #[traced_test]
893    async fn test_basic_link_update() -> Result<(), anyhow::Error> {
894        let tmp_dir = testutils::setup_test_dir().await?;
895        let test_path = tmp_dir.as_path();
896        let summary = link(
897            &PROGRESS,
898            test_path,
899            &test_path.join("foo"),
900            &test_path.join("bar"),
901            &Some(test_path.join("foo")),
902            &common_settings(false, false),
903            false,
904        )
905        .await?;
906        assert_eq!(summary.hard_links_created, 5);
907        assert_eq!(summary.copy_summary.files_copied, 0);
908        assert_eq!(summary.copy_summary.symlinks_created, 2);
909        assert_eq!(summary.copy_summary.directories_created, 3);
910        testutils::check_dirs_identical(
911            &test_path.join("foo"),
912            &test_path.join("bar"),
913            testutils::FileEqualityCheck::Timestamp,
914        )
915        .await?;
916        Ok(())
917    }
918
919    #[tokio::test]
920    #[traced_test]
921    async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
922        let tmp_dir = testutils::setup_test_dir().await?;
923        tokio::fs::create_dir(tmp_dir.join("baz")).await?;
924        let test_path = tmp_dir.as_path();
925        let summary = link(
926            &PROGRESS,
927            test_path,
928            &test_path.join("baz"), // empty source
929            &test_path.join("bar"),
930            &Some(test_path.join("foo")),
931            &common_settings(false, false),
932            false,
933        )
934        .await?;
935        assert_eq!(summary.hard_links_created, 0);
936        assert_eq!(summary.copy_summary.files_copied, 5);
937        assert_eq!(summary.copy_summary.symlinks_created, 2);
938        assert_eq!(summary.copy_summary.directories_created, 3);
939        testutils::check_dirs_identical(
940            &test_path.join("foo"),
941            &test_path.join("bar"),
942            testutils::FileEqualityCheck::Timestamp,
943        )
944        .await?;
945        Ok(())
946    }
947
948    #[tokio::test]
949    #[traced_test]
950    async fn test_link_destination_permission_error_includes_root_cause(
951    ) -> Result<(), anyhow::Error> {
952        let tmp_dir = testutils::setup_test_dir().await?;
953        let test_path = tmp_dir.as_path();
954        let readonly_parent = test_path.join("readonly_dest");
955        tokio::fs::create_dir(&readonly_parent).await?;
956        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
957            .await?;
958
959        let mut settings = common_settings(false, false);
960        settings.copy_settings.fail_early = true;
961
962        let result = link(
963            &PROGRESS,
964            test_path,
965            &test_path.join("foo"),
966            &readonly_parent.join("bar"),
967            &None,
968            &settings,
969            false,
970        )
971        .await;
972
973        // restore permissions to allow temporary directory cleanup
974        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
975            .await?;
976
977        assert!(result.is_err(), "link into read-only parent should fail");
978        let err = result.unwrap_err();
979        let err_msg = format!("{:#}", err.source);
980        assert!(
981            err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
982            "Error message must include permission denied text. Got: {}",
983            err_msg
984        );
985        Ok(())
986    }
987
988    pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
989        // update
990        // |- 0.txt
991        // |- bar
992        //    |- 1.txt
993        //    |- 2.txt -> ../0.txt
994        let foo_path = tmp_dir.join("update");
995        tokio::fs::create_dir(&foo_path).await.unwrap();
996        tokio::fs::write(foo_path.join("0.txt"), "0-new")
997            .await
998            .unwrap();
999        let bar_path = foo_path.join("bar");
1000        tokio::fs::create_dir(&bar_path).await.unwrap();
1001        tokio::fs::write(bar_path.join("1.txt"), "1-new")
1002            .await
1003            .unwrap();
1004        tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
1005            .await
1006            .unwrap();
1007        tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1008        Ok(())
1009    }
1010
1011    #[tokio::test]
1012    #[traced_test]
1013    async fn test_link_update() -> Result<(), anyhow::Error> {
1014        let tmp_dir = testutils::setup_test_dir().await?;
1015        setup_update_dir(&tmp_dir).await?;
1016        let test_path = tmp_dir.as_path();
1017        let summary = link(
1018            &PROGRESS,
1019            test_path,
1020            &test_path.join("foo"),
1021            &test_path.join("bar"),
1022            &Some(test_path.join("update")),
1023            &common_settings(false, false),
1024            false,
1025        )
1026        .await?;
1027        assert_eq!(summary.hard_links_created, 2);
1028        assert_eq!(summary.copy_summary.files_copied, 2);
1029        assert_eq!(summary.copy_summary.symlinks_created, 3);
1030        assert_eq!(summary.copy_summary.directories_created, 3);
1031        // compare subset of src and dst
1032        testutils::check_dirs_identical(
1033            &test_path.join("foo").join("baz"),
1034            &test_path.join("bar").join("baz"),
1035            testutils::FileEqualityCheck::HardLink,
1036        )
1037        .await?;
1038        // compare update and dst
1039        testutils::check_dirs_identical(
1040            &test_path.join("update"),
1041            &test_path.join("bar"),
1042            testutils::FileEqualityCheck::Timestamp,
1043        )
1044        .await?;
1045        Ok(())
1046    }
1047
1048    #[tokio::test]
1049    #[traced_test]
1050    async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
1051        let tmp_dir = testutils::setup_test_dir().await?;
1052        setup_update_dir(&tmp_dir).await?;
1053        let test_path = tmp_dir.as_path();
1054        let mut settings = common_settings(false, false);
1055        settings.update_exclusive = true;
1056        let summary = link(
1057            &PROGRESS,
1058            test_path,
1059            &test_path.join("foo"),
1060            &test_path.join("bar"),
1061            &Some(test_path.join("update")),
1062            &settings,
1063            false,
1064        )
1065        .await?;
1066        // we should end up with same directory as the update
1067        // |- 0.txt
1068        // |- bar
1069        //    |- 1.txt
1070        //    |- 2.txt -> ../0.txt
1071        assert_eq!(summary.hard_links_created, 0);
1072        assert_eq!(summary.copy_summary.files_copied, 2);
1073        assert_eq!(summary.copy_summary.symlinks_created, 1);
1074        assert_eq!(summary.copy_summary.directories_created, 2);
1075        // compare update and dst
1076        testutils::check_dirs_identical(
1077            &test_path.join("update"),
1078            &test_path.join("bar"),
1079            testutils::FileEqualityCheck::Timestamp,
1080        )
1081        .await?;
1082        Ok(())
1083    }
1084
1085    async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
1086        let tmp_dir = testutils::setup_test_dir().await?;
1087        let test_path = tmp_dir.as_path();
1088        let summary = link(
1089            &PROGRESS,
1090            test_path,
1091            &test_path.join("foo"),
1092            &test_path.join("bar"),
1093            &None,
1094            &common_settings(false, false),
1095            false,
1096        )
1097        .await?;
1098        assert_eq!(summary.hard_links_created, 5);
1099        assert_eq!(summary.copy_summary.symlinks_created, 2);
1100        assert_eq!(summary.copy_summary.directories_created, 3);
1101        Ok(tmp_dir)
1102    }
1103
1104    #[tokio::test]
1105    #[traced_test]
1106    async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
1107        let tmp_dir = setup_test_dir_and_link().await?;
1108        let output_path = &tmp_dir.join("bar");
1109        {
1110            // bar
1111            // |- 0.txt
1112            // |- bar  <---------------------------------------- REMOVE
1113            //    |- 1.txt  <----------------------------------- REMOVE
1114            //    |- 2.txt  <----------------------------------- REMOVE
1115            //    |- 3.txt  <----------------------------------- REMOVE
1116            // |- baz
1117            //    |- 4.txt
1118            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
1119            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1120            let summary = rm::rm(
1121                &PROGRESS,
1122                &output_path.join("bar"),
1123                &rm::Settings {
1124                    fail_early: false,
1125                    filter: None,
1126                    dry_run: None,
1127                },
1128            )
1129            .await?
1130                + rm::rm(
1131                    &PROGRESS,
1132                    &output_path.join("baz").join("5.txt"),
1133                    &rm::Settings {
1134                        fail_early: false,
1135                        filter: None,
1136                        dry_run: None,
1137                    },
1138                )
1139                .await?;
1140            assert_eq!(summary.files_removed, 3);
1141            assert_eq!(summary.symlinks_removed, 1);
1142            assert_eq!(summary.directories_removed, 1);
1143        }
1144        let summary = link(
1145            &PROGRESS,
1146            &tmp_dir,
1147            &tmp_dir.join("foo"),
1148            output_path,
1149            &None,
1150            &common_settings(false, true), // overwrite!
1151            false,
1152        )
1153        .await?;
1154        assert_eq!(summary.hard_links_created, 3);
1155        assert_eq!(summary.copy_summary.symlinks_created, 1);
1156        assert_eq!(summary.copy_summary.directories_created, 1);
1157        testutils::check_dirs_identical(
1158            &tmp_dir.join("foo"),
1159            output_path,
1160            testutils::FileEqualityCheck::Timestamp,
1161        )
1162        .await?;
1163        Ok(())
1164    }
1165
1166    #[tokio::test]
1167    #[traced_test]
1168    async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
1169        let tmp_dir = setup_test_dir_and_link().await?;
1170        let output_path = &tmp_dir.join("bar");
1171        {
1172            // bar
1173            // |- 0.txt
1174            // |- bar  <---------------------------------------- REMOVE
1175            //    |- 1.txt  <----------------------------------- REMOVE
1176            //    |- 2.txt  <----------------------------------- REMOVE
1177            //    |- 3.txt  <----------------------------------- REMOVE
1178            // |- baz
1179            //    |- 4.txt
1180            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
1181            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1182            let summary = rm::rm(
1183                &PROGRESS,
1184                &output_path.join("bar"),
1185                &rm::Settings {
1186                    fail_early: false,
1187                    filter: None,
1188                    dry_run: None,
1189                },
1190            )
1191            .await?
1192                + rm::rm(
1193                    &PROGRESS,
1194                    &output_path.join("baz").join("5.txt"),
1195                    &rm::Settings {
1196                        fail_early: false,
1197                        filter: None,
1198                        dry_run: None,
1199                    },
1200                )
1201                .await?;
1202            assert_eq!(summary.files_removed, 3);
1203            assert_eq!(summary.symlinks_removed, 1);
1204            assert_eq!(summary.directories_removed, 1);
1205        }
1206        setup_update_dir(&tmp_dir).await?;
1207        // update
1208        // |- 0.txt
1209        // |- bar
1210        //    |- 1.txt
1211        //    |- 2.txt -> ../0.txt
1212        let summary = link(
1213            &PROGRESS,
1214            &tmp_dir,
1215            &tmp_dir.join("foo"),
1216            output_path,
1217            &Some(tmp_dir.join("update")),
1218            &common_settings(false, true), // overwrite!
1219            false,
1220        )
1221        .await?;
1222        assert_eq!(summary.hard_links_created, 1); // 3.txt
1223        assert_eq!(summary.copy_summary.files_copied, 2); // 0.txt, 1.txt
1224        assert_eq!(summary.copy_summary.symlinks_created, 2); // 2.txt, 5.txt
1225        assert_eq!(summary.copy_summary.directories_created, 1);
1226        // compare subset of src and dst
1227        testutils::check_dirs_identical(
1228            &tmp_dir.join("foo").join("baz"),
1229            &tmp_dir.join("bar").join("baz"),
1230            testutils::FileEqualityCheck::HardLink,
1231        )
1232        .await?;
1233        // compare update and dst
1234        testutils::check_dirs_identical(
1235            &tmp_dir.join("update"),
1236            &tmp_dir.join("bar"),
1237            testutils::FileEqualityCheck::Timestamp,
1238        )
1239        .await?;
1240        Ok(())
1241    }
1242
1243    #[tokio::test]
1244    #[traced_test]
1245    async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
1246        let tmp_dir = setup_test_dir_and_link().await?;
1247        let output_path = &tmp_dir.join("bar");
1248        {
1249            // bar
1250            // |- 0.txt
1251            // |- bar
1252            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
1253            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
1254            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
1255            // |- baz    <-------------------------------------- REPLACE W/ FILE
1256            //    |- ...
1257            let bar_path = output_path.join("bar");
1258            let summary = rm::rm(
1259                &PROGRESS,
1260                &bar_path.join("1.txt"),
1261                &rm::Settings {
1262                    fail_early: false,
1263                    filter: None,
1264                    dry_run: None,
1265                },
1266            )
1267            .await?
1268                + rm::rm(
1269                    &PROGRESS,
1270                    &bar_path.join("2.txt"),
1271                    &rm::Settings {
1272                        fail_early: false,
1273                        filter: None,
1274                        dry_run: None,
1275                    },
1276                )
1277                .await?
1278                + rm::rm(
1279                    &PROGRESS,
1280                    &bar_path.join("3.txt"),
1281                    &rm::Settings {
1282                        fail_early: false,
1283                        filter: None,
1284                        dry_run: None,
1285                    },
1286                )
1287                .await?
1288                + rm::rm(
1289                    &PROGRESS,
1290                    &output_path.join("baz"),
1291                    &rm::Settings {
1292                        fail_early: false,
1293                        filter: None,
1294                        dry_run: None,
1295                    },
1296                )
1297                .await?;
1298            assert_eq!(summary.files_removed, 4);
1299            assert_eq!(summary.symlinks_removed, 2);
1300            assert_eq!(summary.directories_removed, 1);
1301            // REPLACE with a file, a symlink, a directory and a file
1302            tokio::fs::write(bar_path.join("1.txt"), "1-new")
1303                .await
1304                .unwrap();
1305            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1306                .await
1307                .unwrap();
1308            tokio::fs::create_dir(&bar_path.join("3.txt"))
1309                .await
1310                .unwrap();
1311            tokio::fs::write(&output_path.join("baz"), "baz")
1312                .await
1313                .unwrap();
1314        }
1315        let summary = link(
1316            &PROGRESS,
1317            &tmp_dir,
1318            &tmp_dir.join("foo"),
1319            output_path,
1320            &None,
1321            &common_settings(false, true), // overwrite!
1322            false,
1323        )
1324        .await?;
1325        assert_eq!(summary.hard_links_created, 4);
1326        assert_eq!(summary.copy_summary.files_copied, 0);
1327        assert_eq!(summary.copy_summary.symlinks_created, 2);
1328        assert_eq!(summary.copy_summary.directories_created, 1);
1329        testutils::check_dirs_identical(
1330            &tmp_dir.join("foo"),
1331            &tmp_dir.join("bar"),
1332            testutils::FileEqualityCheck::HardLink,
1333        )
1334        .await?;
1335        Ok(())
1336    }
1337
1338    #[tokio::test]
1339    #[traced_test]
1340    async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
1341        let tmp_dir = setup_test_dir_and_link().await?;
1342        let output_path = &tmp_dir.join("bar");
1343        {
1344            // bar
1345            // |- 0.txt
1346            // |- bar
1347            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
1348            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
1349            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
1350            // |- baz    <-------------------------------------- REPLACE W/ FILE
1351            //    |- ...
1352            let bar_path = output_path.join("bar");
1353            let summary = rm::rm(
1354                &PROGRESS,
1355                &bar_path.join("1.txt"),
1356                &rm::Settings {
1357                    fail_early: false,
1358                    filter: None,
1359                    dry_run: None,
1360                },
1361            )
1362            .await?
1363                + rm::rm(
1364                    &PROGRESS,
1365                    &bar_path.join("2.txt"),
1366                    &rm::Settings {
1367                        fail_early: false,
1368                        filter: None,
1369                        dry_run: None,
1370                    },
1371                )
1372                .await?
1373                + rm::rm(
1374                    &PROGRESS,
1375                    &bar_path.join("3.txt"),
1376                    &rm::Settings {
1377                        fail_early: false,
1378                        filter: None,
1379                        dry_run: None,
1380                    },
1381                )
1382                .await?
1383                + rm::rm(
1384                    &PROGRESS,
1385                    &output_path.join("baz"),
1386                    &rm::Settings {
1387                        fail_early: false,
1388                        filter: None,
1389                        dry_run: None,
1390                    },
1391                )
1392                .await?;
1393            assert_eq!(summary.files_removed, 4);
1394            assert_eq!(summary.symlinks_removed, 2);
1395            assert_eq!(summary.directories_removed, 1);
1396            // REPLACE with a file, a symlink, a directory and a file
1397            tokio::fs::write(bar_path.join("1.txt"), "1-new")
1398                .await
1399                .unwrap();
1400            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1401                .await
1402                .unwrap();
1403            tokio::fs::create_dir(&bar_path.join("3.txt"))
1404                .await
1405                .unwrap();
1406            tokio::fs::write(&output_path.join("baz"), "baz")
1407                .await
1408                .unwrap();
1409        }
1410        let source_path = &tmp_dir.join("foo");
1411        // unreadable
1412        tokio::fs::set_permissions(
1413            &source_path.join("baz"),
1414            std::fs::Permissions::from_mode(0o000),
1415        )
1416        .await?;
1417        // bar
1418        // |- ...
1419        // |- baz <- NON READABLE
1420        match link(
1421            &PROGRESS,
1422            &tmp_dir,
1423            &tmp_dir.join("foo"),
1424            output_path,
1425            &None,
1426            &common_settings(false, true), // overwrite!
1427            false,
1428        )
1429        .await
1430        {
1431            Ok(_) => panic!("Expected the link to error!"),
1432            Err(error) => {
1433                tracing::info!("{}", &error);
1434                assert_eq!(error.summary.hard_links_created, 3);
1435                assert_eq!(error.summary.copy_summary.files_copied, 0);
1436                assert_eq!(error.summary.copy_summary.symlinks_created, 0);
1437                assert_eq!(error.summary.copy_summary.directories_created, 0);
1438                assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
1439                assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
1440                assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
1441            }
1442        }
1443        Ok(())
1444    }
1445
1446    /// Verify that directory metadata is applied even when child link operations fail.
1447    /// This is a regression test for a bug where directory permissions were not preserved
1448    /// when linking with fail_early=false and some children failed to link.
1449    #[tokio::test]
1450    #[traced_test]
1451    async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
1452        let tmp_dir = testutils::create_temp_dir().await?;
1453        let test_path = tmp_dir.as_path();
1454        // create source directory with specific permissions
1455        let src_dir = test_path.join("src");
1456        tokio::fs::create_dir(&src_dir).await?;
1457        tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
1458        // create a readable file (will be linked successfully)
1459        tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
1460        // create a subdirectory with a file, then make the subdirectory unreadable
1461        // this will cause the recursive walk to fail when trying to read subdirectory contents
1462        let unreadable_subdir = src_dir.join("unreadable_subdir");
1463        tokio::fs::create_dir(&unreadable_subdir).await?;
1464        tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
1465        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
1466            .await?;
1467        let dst_dir = test_path.join("dst");
1468        // link with fail_early=false
1469        let result = link(
1470            &PROGRESS,
1471            test_path,
1472            &src_dir,
1473            &dst_dir,
1474            &None,
1475            &common_settings(false, false),
1476            false,
1477        )
1478        .await;
1479        // restore permissions so cleanup can succeed
1480        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
1481            .await?;
1482        // verify the operation returned an error (unreadable subdirectory should fail)
1483        assert!(
1484            result.is_err(),
1485            "link should fail due to unreadable subdirectory"
1486        );
1487        let error = result.unwrap_err();
1488        // verify the readable file was linked successfully
1489        assert_eq!(error.summary.hard_links_created, 1);
1490        // verify the destination directory exists and has the correct permissions
1491        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
1492        assert!(dst_metadata.is_dir());
1493        let actual_mode = dst_metadata.permissions().mode() & 0o7777;
1494        assert_eq!(
1495            actual_mode, 0o750,
1496            "directory should have preserved source permissions (0o750), got {:o}",
1497            actual_mode
1498        );
1499        Ok(())
1500    }
1501    mod filter_tests {
1502        use super::*;
1503        use crate::filter::FilterSettings;
1504        /// Test that path-based patterns (with /) work correctly with nested paths.
1505        #[tokio::test]
1506        #[traced_test]
1507        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
1508            let tmp_dir = testutils::setup_test_dir().await?;
1509            let test_path = tmp_dir.as_path();
1510            // create filter that should only link files in bar/ directory
1511            let mut filter = FilterSettings::new();
1512            filter.add_include("bar/*.txt").unwrap();
1513            let summary = link(
1514                &PROGRESS,
1515                test_path,
1516                &test_path.join("foo"),
1517                &test_path.join("dst"),
1518                &None,
1519                &Settings {
1520                    copy_settings: CopySettings {
1521                        dereference: false,
1522                        fail_early: false,
1523                        overwrite: false,
1524                        overwrite_compare: Default::default(),
1525                        chunk_size: 0,
1526                        remote_copy_buffer_size: 0,
1527                        filter: None,
1528                        dry_run: None,
1529                    },
1530                    update_compare: Default::default(),
1531                    update_exclusive: false,
1532                    filter: Some(filter),
1533                    dry_run: None,
1534                    preserve: preserve::preserve_all(),
1535                },
1536                false,
1537            )
1538            .await?;
1539            // should only link files matching bar/*.txt pattern (bar/1.txt, bar/2.txt, bar/3.txt)
1540            assert_eq!(
1541                summary.hard_links_created, 3,
1542                "should link 3 files matching bar/*.txt"
1543            );
1544            // verify the right files were linked
1545            assert!(
1546                test_path.join("dst/bar/1.txt").exists(),
1547                "bar/1.txt should be linked"
1548            );
1549            assert!(
1550                test_path.join("dst/bar/2.txt").exists(),
1551                "bar/2.txt should be linked"
1552            );
1553            assert!(
1554                test_path.join("dst/bar/3.txt").exists(),
1555                "bar/3.txt should be linked"
1556            );
1557            // verify files outside the pattern don't exist
1558            assert!(
1559                !test_path.join("dst/0.txt").exists(),
1560                "0.txt should not be linked"
1561            );
1562            Ok(())
1563        }
1564        /// Test that filters are applied to top-level file arguments.
1565        #[tokio::test]
1566        #[traced_test]
1567        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
1568            let tmp_dir = testutils::setup_test_dir().await?;
1569            let test_path = tmp_dir.as_path();
1570            // create filter that excludes .txt files
1571            let mut filter = FilterSettings::new();
1572            filter.add_exclude("*.txt").unwrap();
1573            let summary = link(
1574                &PROGRESS,
1575                test_path,
1576                &test_path.join("foo/0.txt"), // single file source
1577                &test_path.join("dst/0.txt"),
1578                &None,
1579                &Settings {
1580                    copy_settings: CopySettings {
1581                        dereference: false,
1582                        fail_early: false,
1583                        overwrite: false,
1584                        overwrite_compare: Default::default(),
1585                        chunk_size: 0,
1586                        remote_copy_buffer_size: 0,
1587                        filter: None,
1588                        dry_run: None,
1589                    },
1590                    update_compare: Default::default(),
1591                    update_exclusive: false,
1592                    filter: Some(filter),
1593                    dry_run: None,
1594                    preserve: preserve::preserve_all(),
1595                },
1596                false,
1597            )
1598            .await?;
1599            // the file should NOT be linked because it matches the exclude pattern
1600            assert_eq!(
1601                summary.hard_links_created, 0,
1602                "file matching exclude pattern should not be linked"
1603            );
1604            assert!(
1605                !test_path.join("dst/0.txt").exists(),
1606                "excluded file should not exist at destination"
1607            );
1608            Ok(())
1609        }
1610        /// Test that filters apply to root directories with simple exclude patterns.
1611        #[tokio::test]
1612        #[traced_test]
1613        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
1614            let test_path = testutils::create_temp_dir().await?;
1615            // create a directory that should be excluded
1616            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
1617            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
1618            // create filter that excludes *_dir/ directories
1619            let mut filter = FilterSettings::new();
1620            filter.add_exclude("*_dir/").unwrap();
1621            let result = link(
1622                &PROGRESS,
1623                &test_path,
1624                &test_path.join("excluded_dir"),
1625                &test_path.join("dst"),
1626                &None,
1627                &Settings {
1628                    copy_settings: CopySettings {
1629                        dereference: false,
1630                        fail_early: false,
1631                        overwrite: false,
1632                        overwrite_compare: Default::default(),
1633                        chunk_size: 0,
1634                        remote_copy_buffer_size: 0,
1635                        filter: None,
1636                        dry_run: None,
1637                    },
1638                    update_compare: Default::default(),
1639                    update_exclusive: false,
1640                    filter: Some(filter),
1641                    dry_run: None,
1642                    preserve: preserve::preserve_all(),
1643                },
1644                false,
1645            )
1646            .await?;
1647            // directory should NOT be linked because it matches exclude pattern
1648            assert_eq!(
1649                result.copy_summary.directories_created, 0,
1650                "root directory matching exclude should not be created"
1651            );
1652            assert!(
1653                !test_path.join("dst").exists(),
1654                "excluded root directory should not exist at destination"
1655            );
1656            Ok(())
1657        }
1658        /// Test that filters apply to root symlinks with simple exclude patterns.
1659        #[tokio::test]
1660        #[traced_test]
1661        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
1662            let test_path = testutils::create_temp_dir().await?;
1663            // create a target file and a symlink to it
1664            tokio::fs::write(test_path.join("target.txt"), "content").await?;
1665            tokio::fs::symlink(
1666                test_path.join("target.txt"),
1667                test_path.join("excluded_link"),
1668            )
1669            .await?;
1670            // create filter that excludes *_link
1671            let mut filter = FilterSettings::new();
1672            filter.add_exclude("*_link").unwrap();
1673            let result = link(
1674                &PROGRESS,
1675                &test_path,
1676                &test_path.join("excluded_link"),
1677                &test_path.join("dst"),
1678                &None,
1679                &Settings {
1680                    copy_settings: CopySettings {
1681                        dereference: false,
1682                        fail_early: false,
1683                        overwrite: false,
1684                        overwrite_compare: Default::default(),
1685                        chunk_size: 0,
1686                        remote_copy_buffer_size: 0,
1687                        filter: None,
1688                        dry_run: None,
1689                    },
1690                    update_compare: Default::default(),
1691                    update_exclusive: false,
1692                    filter: Some(filter),
1693                    dry_run: None,
1694                    preserve: preserve::preserve_all(),
1695                },
1696                false,
1697            )
1698            .await?;
1699            // symlink should NOT be copied because it matches exclude pattern
1700            assert_eq!(
1701                result.copy_summary.symlinks_created, 0,
1702                "root symlink matching exclude should not be created"
1703            );
1704            assert!(
1705                !test_path.join("dst").exists(),
1706                "excluded root symlink should not exist at destination"
1707            );
1708            Ok(())
1709        }
1710        /// Test combined include and exclude patterns (exclude takes precedence).
1711        #[tokio::test]
1712        #[traced_test]
1713        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
1714            let tmp_dir = testutils::setup_test_dir().await?;
1715            let test_path = tmp_dir.as_path();
1716            // test structure from setup_test_dir:
1717            // foo/
1718            //   0.txt
1719            //   bar/ (1.txt, 2.txt, 3.txt)
1720            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1721            // include all .txt files in bar/, but exclude 2.txt specifically
1722            let mut filter = FilterSettings::new();
1723            filter.add_include("bar/*.txt").unwrap();
1724            filter.add_exclude("bar/2.txt").unwrap();
1725            let summary = link(
1726                &PROGRESS,
1727                test_path,
1728                &test_path.join("foo"),
1729                &test_path.join("dst"),
1730                &None,
1731                &Settings {
1732                    copy_settings: CopySettings {
1733                        dereference: false,
1734                        fail_early: false,
1735                        overwrite: false,
1736                        overwrite_compare: Default::default(),
1737                        chunk_size: 0,
1738                        remote_copy_buffer_size: 0,
1739                        filter: None,
1740                        dry_run: None,
1741                    },
1742                    update_compare: Default::default(),
1743                    update_exclusive: false,
1744                    filter: Some(filter),
1745                    dry_run: None,
1746                    preserve: preserve::preserve_all(),
1747                },
1748                false,
1749            )
1750            .await?;
1751            // should link: bar/1.txt, bar/3.txt = 2 hard links
1752            // should skip: bar/2.txt (excluded by pattern), 0.txt (excluded by default - no match) = 2 files
1753            assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
1754            assert_eq!(
1755                summary.copy_summary.files_skipped, 2,
1756                "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
1757            );
1758            // verify
1759            assert!(
1760                test_path.join("dst/bar/1.txt").exists(),
1761                "bar/1.txt should be linked"
1762            );
1763            assert!(
1764                !test_path.join("dst/bar/2.txt").exists(),
1765                "bar/2.txt should be excluded"
1766            );
1767            assert!(
1768                test_path.join("dst/bar/3.txt").exists(),
1769                "bar/3.txt should be linked"
1770            );
1771            Ok(())
1772        }
1773        /// Test that skipped counts accurately reflect what was filtered.
1774        #[tokio::test]
1775        #[traced_test]
1776        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
1777            let tmp_dir = testutils::setup_test_dir().await?;
1778            let test_path = tmp_dir.as_path();
1779            // test structure from setup_test_dir:
1780            // foo/
1781            //   0.txt
1782            //   bar/ (1.txt, 2.txt, 3.txt)
1783            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1784            // exclude bar/ directory entirely
1785            let mut filter = FilterSettings::new();
1786            filter.add_exclude("bar/").unwrap();
1787            let summary = link(
1788                &PROGRESS,
1789                test_path,
1790                &test_path.join("foo"),
1791                &test_path.join("dst"),
1792                &None,
1793                &Settings {
1794                    copy_settings: CopySettings {
1795                        dereference: false,
1796                        fail_early: false,
1797                        overwrite: false,
1798                        overwrite_compare: Default::default(),
1799                        chunk_size: 0,
1800                        remote_copy_buffer_size: 0,
1801                        filter: None,
1802                        dry_run: None,
1803                    },
1804                    update_compare: Default::default(),
1805                    update_exclusive: false,
1806                    filter: Some(filter),
1807                    dry_run: None,
1808                    preserve: preserve::preserve_all(),
1809                },
1810                false,
1811            )
1812            .await?;
1813            // linked: 0.txt (1 hard link), baz/4.txt (1 hard link)
1814            // symlinks copied: 5.txt, 6.txt
1815            // skipped: bar directory (1 dir)
1816            assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
1817            assert_eq!(
1818                summary.copy_summary.symlinks_created, 2,
1819                "should copy 2 symlinks"
1820            );
1821            assert_eq!(
1822                summary.copy_summary.directories_skipped, 1,
1823                "should skip 1 directory (bar)"
1824            );
1825            // bar should not exist in dst
1826            assert!(
1827                !test_path.join("dst/bar").exists(),
1828                "bar directory should not be linked"
1829            );
1830            Ok(())
1831        }
1832        /// Test that empty directories are not created when they were only traversed to look
1833        /// for matches (regression test for bug where --include='foo' would create empty dir baz).
1834        #[tokio::test]
1835        #[traced_test]
1836        async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
1837            let test_path = testutils::create_temp_dir().await?;
1838            // create structure:
1839            // src/
1840            //   foo (file)
1841            //   bar (file)
1842            //   baz/ (empty directory)
1843            let src_path = test_path.join("src");
1844            tokio::fs::create_dir(&src_path).await?;
1845            tokio::fs::write(src_path.join("foo"), "content").await?;
1846            tokio::fs::write(src_path.join("bar"), "content").await?;
1847            tokio::fs::create_dir(src_path.join("baz")).await?;
1848            // include only 'foo' file
1849            let mut filter = FilterSettings::new();
1850            filter.add_include("foo").unwrap();
1851            let summary = link(
1852                &PROGRESS,
1853                &test_path,
1854                &src_path,
1855                &test_path.join("dst"),
1856                &None,
1857                &Settings {
1858                    copy_settings: copy::Settings {
1859                        dereference: false,
1860                        fail_early: false,
1861                        overwrite: false,
1862                        overwrite_compare: Default::default(),
1863                        chunk_size: 0,
1864                        remote_copy_buffer_size: 0,
1865                        filter: None,
1866                        dry_run: None,
1867                    },
1868                    update_compare: Default::default(),
1869                    update_exclusive: false,
1870                    filter: Some(filter),
1871                    dry_run: None,
1872                    preserve: preserve::preserve_all(),
1873                },
1874                false,
1875            )
1876            .await?;
1877            // only 'foo' should be linked
1878            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
1879            assert_eq!(
1880                summary.copy_summary.directories_created, 1,
1881                "should create only root directory (not empty 'baz')"
1882            );
1883            // verify foo was linked
1884            assert!(
1885                test_path.join("dst").join("foo").exists(),
1886                "foo should be linked"
1887            );
1888            // verify bar was not linked (not matching include pattern)
1889            assert!(
1890                !test_path.join("dst").join("bar").exists(),
1891                "bar should not be linked"
1892            );
1893            // verify empty baz directory was NOT created
1894            assert!(
1895                !test_path.join("dst").join("baz").exists(),
1896                "empty baz directory should NOT be created"
1897            );
1898            Ok(())
1899        }
1900        /// Test that directories with only non-matching content are not created at destination.
1901        /// This is different from empty directories - the source dir has content but none matches.
1902        #[tokio::test]
1903        #[traced_test]
1904        async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
1905            let test_path = testutils::create_temp_dir().await?;
1906            // create structure:
1907            // src/
1908            //   foo (file)
1909            //   baz/
1910            //     qux (file - doesn't match 'foo')
1911            //     quux (file - doesn't match 'foo')
1912            let src_path = test_path.join("src");
1913            tokio::fs::create_dir(&src_path).await?;
1914            tokio::fs::write(src_path.join("foo"), "content").await?;
1915            tokio::fs::create_dir(src_path.join("baz")).await?;
1916            tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
1917            tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
1918            // include only 'foo' file
1919            let mut filter = FilterSettings::new();
1920            filter.add_include("foo").unwrap();
1921            let summary = link(
1922                &PROGRESS,
1923                &test_path,
1924                &src_path,
1925                &test_path.join("dst"),
1926                &None,
1927                &Settings {
1928                    copy_settings: copy::Settings {
1929                        dereference: false,
1930                        fail_early: false,
1931                        overwrite: false,
1932                        overwrite_compare: Default::default(),
1933                        chunk_size: 0,
1934                        remote_copy_buffer_size: 0,
1935                        filter: None,
1936                        dry_run: None,
1937                    },
1938                    update_compare: Default::default(),
1939                    update_exclusive: false,
1940                    filter: Some(filter),
1941                    dry_run: None,
1942                    preserve: preserve::preserve_all(),
1943                },
1944                false,
1945            )
1946            .await?;
1947            // only 'foo' should be linked
1948            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
1949            assert_eq!(
1950                summary.copy_summary.files_skipped, 2,
1951                "should skip 2 files (qux and quux)"
1952            );
1953            assert_eq!(
1954                summary.copy_summary.directories_created, 1,
1955                "should create only root directory (not 'baz' with non-matching content)"
1956            );
1957            // verify foo was linked
1958            assert!(
1959                test_path.join("dst").join("foo").exists(),
1960                "foo should be linked"
1961            );
1962            // verify baz directory was NOT created (even though source baz has content)
1963            assert!(
1964                !test_path.join("dst").join("baz").exists(),
1965                "baz directory should NOT be created (no matching content inside)"
1966            );
1967            Ok(())
1968        }
1969        /// Test that empty directories are not reported as created in dry-run mode
1970        /// when they were only traversed.
1971        #[tokio::test]
1972        #[traced_test]
1973        async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
1974            let test_path = testutils::create_temp_dir().await?;
1975            // create structure:
1976            // src/
1977            //   foo (file)
1978            //   bar (file)
1979            //   baz/ (empty directory)
1980            let src_path = test_path.join("src");
1981            tokio::fs::create_dir(&src_path).await?;
1982            tokio::fs::write(src_path.join("foo"), "content").await?;
1983            tokio::fs::write(src_path.join("bar"), "content").await?;
1984            tokio::fs::create_dir(src_path.join("baz")).await?;
1985            // include only 'foo' file
1986            let mut filter = FilterSettings::new();
1987            filter.add_include("foo").unwrap();
1988            let summary = link(
1989                &PROGRESS,
1990                &test_path,
1991                &src_path,
1992                &test_path.join("dst"),
1993                &None,
1994                &Settings {
1995                    copy_settings: copy::Settings {
1996                        dereference: false,
1997                        fail_early: false,
1998                        overwrite: false,
1999                        overwrite_compare: Default::default(),
2000                        chunk_size: 0,
2001                        remote_copy_buffer_size: 0,
2002                        filter: None,
2003                        dry_run: None,
2004                    },
2005                    update_compare: Default::default(),
2006                    update_exclusive: false,
2007                    filter: Some(filter),
2008                    dry_run: Some(crate::config::DryRunMode::Explain),
2009                    preserve: preserve::preserve_all(),
2010                },
2011                false,
2012            )
2013            .await?;
2014            // only 'foo' should be reported as would-be-linked
2015            assert_eq!(
2016                summary.hard_links_created, 1,
2017                "should report only 'foo' would be linked"
2018            );
2019            assert_eq!(
2020                summary.copy_summary.directories_created, 1,
2021                "should report only root directory would be created (not empty 'baz')"
2022            );
2023            // verify nothing was actually created (dry-run mode)
2024            assert!(
2025                !test_path.join("dst").exists(),
2026                "dst should not exist in dry-run"
2027            );
2028            Ok(())
2029        }
2030        /// Test that existing directories are NOT removed when using --overwrite,
2031        /// even if nothing is linked into them due to filters.
2032        #[tokio::test]
2033        #[traced_test]
2034        async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
2035            let test_path = testutils::create_temp_dir().await?;
2036            // create source structure:
2037            // src/
2038            //   foo (file)
2039            //   bar (file)
2040            //   baz/ (empty directory)
2041            let src_path = test_path.join("src");
2042            tokio::fs::create_dir(&src_path).await?;
2043            tokio::fs::write(src_path.join("foo"), "content").await?;
2044            tokio::fs::write(src_path.join("bar"), "content").await?;
2045            tokio::fs::create_dir(src_path.join("baz")).await?;
2046            // create destination with baz directory already existing
2047            let dst_path = test_path.join("dst");
2048            tokio::fs::create_dir(&dst_path).await?;
2049            tokio::fs::create_dir(dst_path.join("baz")).await?;
2050            // add a marker file inside dst/baz to verify we don't touch it
2051            tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
2052            // include only 'foo' file - baz should not match
2053            let mut filter = FilterSettings::new();
2054            filter.add_include("foo").unwrap();
2055            let summary = link(
2056                &PROGRESS,
2057                &test_path,
2058                &src_path,
2059                &dst_path,
2060                &None,
2061                &Settings {
2062                    copy_settings: copy::Settings {
2063                        dereference: false,
2064                        fail_early: false,
2065                        overwrite: true, // enable overwrite mode
2066                        overwrite_compare: Default::default(),
2067                        chunk_size: 0,
2068                        remote_copy_buffer_size: 0,
2069                        filter: None,
2070                        dry_run: None,
2071                    },
2072                    update_compare: Default::default(),
2073                    update_exclusive: false,
2074                    filter: Some(filter),
2075                    dry_run: None,
2076                    preserve: preserve::preserve_all(),
2077                },
2078                false,
2079            )
2080            .await?;
2081            // foo should be linked
2082            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2083            // dst and baz should be unchanged (both already existed)
2084            assert_eq!(
2085                summary.copy_summary.directories_unchanged, 2,
2086                "root dst and baz directories should be unchanged"
2087            );
2088            assert_eq!(
2089                summary.copy_summary.directories_created, 0,
2090                "should not create any directories"
2091            );
2092            // verify foo was linked
2093            assert!(dst_path.join("foo").exists(), "foo should be linked");
2094            // verify bar was NOT linked
2095            assert!(!dst_path.join("bar").exists(), "bar should not be linked");
2096            // verify existing baz directory still exists with its content
2097            assert!(
2098                dst_path.join("baz").exists(),
2099                "existing baz directory should still exist"
2100            );
2101            assert!(
2102                dst_path.join("baz").join("marker.txt").exists(),
2103                "existing content in baz should still exist"
2104            );
2105            Ok(())
2106        }
2107    }
2108    mod dry_run_tests {
2109        use super::*;
2110        /// Test that dry-run mode for files doesn't create hard links.
2111        #[tokio::test]
2112        #[traced_test]
2113        async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
2114            let tmp_dir = testutils::setup_test_dir().await?;
2115            let test_path = tmp_dir.as_path();
2116            let src_file = test_path.join("foo/0.txt");
2117            let dst_file = test_path.join("dst_link.txt");
2118            // verify destination doesn't exist
2119            assert!(
2120                !dst_file.exists(),
2121                "destination should not exist before dry-run"
2122            );
2123            let summary = link(
2124                &PROGRESS,
2125                test_path,
2126                &src_file,
2127                &dst_file,
2128                &None,
2129                &Settings {
2130                    copy_settings: CopySettings {
2131                        dereference: false,
2132                        fail_early: false,
2133                        overwrite: false,
2134                        overwrite_compare: Default::default(),
2135                        chunk_size: 0,
2136                        remote_copy_buffer_size: 0,
2137                        filter: None,
2138                        dry_run: None,
2139                    },
2140                    update_compare: Default::default(),
2141                    update_exclusive: false,
2142                    filter: None,
2143                    dry_run: Some(crate::config::DryRunMode::Brief),
2144                    preserve: preserve::preserve_all(),
2145                },
2146                false,
2147            )
2148            .await?;
2149            // verify destination still doesn't exist
2150            assert!(!dst_file.exists(), "dry-run should not create hard link");
2151            // verify summary reports what would be created
2152            assert_eq!(
2153                summary.hard_links_created, 1,
2154                "dry-run should report 1 hard link that would be created"
2155            );
2156            Ok(())
2157        }
2158        /// Test that dry-run mode for directories doesn't create the destination directory.
2159        #[tokio::test]
2160        #[traced_test]
2161        async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
2162            let tmp_dir = testutils::setup_test_dir().await?;
2163            let test_path = tmp_dir.as_path();
2164            let dst_path = test_path.join("nonexistent_dst");
2165            // verify destination doesn't exist
2166            assert!(
2167                !dst_path.exists(),
2168                "destination should not exist before dry-run"
2169            );
2170            let summary = link(
2171                &PROGRESS,
2172                test_path,
2173                &test_path.join("foo"),
2174                &dst_path,
2175                &None,
2176                &Settings {
2177                    copy_settings: CopySettings {
2178                        dereference: false,
2179                        fail_early: false,
2180                        overwrite: false,
2181                        overwrite_compare: Default::default(),
2182                        chunk_size: 0,
2183                        remote_copy_buffer_size: 0,
2184                        filter: None,
2185                        dry_run: None,
2186                    },
2187                    update_compare: Default::default(),
2188                    update_exclusive: false,
2189                    filter: None,
2190                    dry_run: Some(crate::config::DryRunMode::Brief),
2191                    preserve: preserve::preserve_all(),
2192                },
2193                false,
2194            )
2195            .await?;
2196            // verify destination still doesn't exist
2197            assert!(
2198                !dst_path.exists(),
2199                "dry-run should not create destination directory"
2200            );
2201            // verify summary reports what would be created
2202            assert!(
2203                summary.hard_links_created > 0,
2204                "dry-run should report hard links that would be created"
2205            );
2206            Ok(())
2207        }
2208        /// Test that dry-run mode correctly reports symlinks (not as hard links).
2209        #[tokio::test]
2210        #[traced_test]
2211        async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
2212            let tmp_dir = testutils::setup_test_dir().await?;
2213            let test_path = tmp_dir.as_path();
2214            // baz contains: 4.txt (file), 5.txt (symlink), 6.txt (symlink)
2215            let src_path = test_path.join("foo/baz");
2216            let dst_path = test_path.join("dst_baz");
2217            // verify destination doesn't exist
2218            assert!(
2219                !dst_path.exists(),
2220                "destination should not exist before dry-run"
2221            );
2222            let summary = link(
2223                &PROGRESS,
2224                test_path,
2225                &src_path,
2226                &dst_path,
2227                &None,
2228                &Settings {
2229                    copy_settings: CopySettings {
2230                        dereference: false,
2231                        fail_early: false,
2232                        overwrite: false,
2233                        overwrite_compare: Default::default(),
2234                        chunk_size: 0,
2235                        remote_copy_buffer_size: 0,
2236                        filter: None,
2237                        dry_run: None,
2238                    },
2239                    update_compare: Default::default(),
2240                    update_exclusive: false,
2241                    filter: None,
2242                    dry_run: Some(crate::config::DryRunMode::Brief),
2243                    preserve: preserve::preserve_all(),
2244                },
2245                false,
2246            )
2247            .await?;
2248            // verify destination still doesn't exist
2249            assert!(!dst_path.exists(), "dry-run should not create destination");
2250            // baz contains 1 regular file (4.txt) and 2 symlinks (5.txt, 6.txt)
2251            assert_eq!(
2252                summary.hard_links_created, 1,
2253                "dry-run should report 1 hard link (for 4.txt)"
2254            );
2255            assert_eq!(
2256                summary.copy_summary.symlinks_created, 2,
2257                "dry-run should report 2 symlinks (5.txt and 6.txt)"
2258            );
2259            Ok(())
2260        }
2261    }
2262
2263    /// Verify that fail-early preserves the summary from the failing subtree.
2264    ///
2265    /// Regression test: the fail-early return path in the join loop must
2266    /// accumulate error.summary from the failing child into the parent's
2267    /// link_summary. Without this, directories_created from the child subtree
2268    /// would be lost.
2269    #[tokio::test]
2270    #[traced_test]
2271    async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
2272        let tmp_dir = testutils::create_temp_dir().await?;
2273        let test_path = tmp_dir.as_path();
2274        // src/sub/  has a file and an unreadable subdirectory:
2275        //   src/sub/good.txt            <-- links successfully
2276        //   src/sub/unreadable_dir/     <-- mode 000, can't be traversed
2277        //     src/sub/unreadable_dir/f.txt
2278        let src_dir = test_path.join("src");
2279        let sub_dir = src_dir.join("sub");
2280        let bad_dir = sub_dir.join("unreadable_dir");
2281        tokio::fs::create_dir_all(&bad_dir).await?;
2282        tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
2283        tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
2284        tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
2285        let dst_dir = test_path.join("dst");
2286        let result = link(
2287            &PROGRESS,
2288            test_path,
2289            &src_dir,
2290            &dst_dir,
2291            &None,
2292            &Settings {
2293                copy_settings: CopySettings {
2294                    fail_early: true,
2295                    ..common_settings(false, false).copy_settings
2296                },
2297                ..common_settings(false, false)
2298            },
2299            false,
2300        )
2301        .await;
2302        // restore permissions for cleanup
2303        tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
2304        let error = result.expect_err("link should fail due to unreadable directory");
2305        // sub/'s link_internal created dst/sub/ (directories_created=1) before
2306        // its join loop encountered the unreadable_dir error. that directory
2307        // creation must be reflected in the error summary propagated up to the
2308        // top-level caller.
2309        assert!(
2310            error.summary.copy_summary.directories_created >= 2,
2311            "fail-early summary should include directories from the failing subtree, \
2312             got directories_created={} (expected >= 2: dst/ and dst/sub/)",
2313            error.summary.copy_summary.directories_created
2314        );
2315        Ok(())
2316    }
2317}