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                overwrite_filter: None,
847                ignore_existing: false,
848                chunk_size: 0,
849                remote_copy_buffer_size: 0,
850                filter: None,
851                dry_run: None,
852            },
853            update_compare: filecmp::MetadataCmpSettings {
854                size: true,
855                mtime: true,
856                ..Default::default()
857            },
858            update_exclusive: false,
859            filter: None,
860            dry_run: None,
861            preserve: preserve::preserve_all(),
862        }
863    }
864
865    #[tokio::test]
866    #[traced_test]
867    async fn test_basic_link() -> Result<(), anyhow::Error> {
868        let tmp_dir = testutils::setup_test_dir().await?;
869        let test_path = tmp_dir.as_path();
870        let summary = link(
871            &PROGRESS,
872            test_path,
873            &test_path.join("foo"),
874            &test_path.join("bar"),
875            &None,
876            &common_settings(false, false),
877            false,
878        )
879        .await?;
880        assert_eq!(summary.hard_links_created, 5);
881        assert_eq!(summary.copy_summary.files_copied, 0);
882        assert_eq!(summary.copy_summary.symlinks_created, 2);
883        assert_eq!(summary.copy_summary.directories_created, 3);
884        testutils::check_dirs_identical(
885            &test_path.join("foo"),
886            &test_path.join("bar"),
887            testutils::FileEqualityCheck::Timestamp,
888        )
889        .await?;
890        Ok(())
891    }
892
893    #[tokio::test]
894    #[traced_test]
895    async fn test_basic_link_update() -> Result<(), anyhow::Error> {
896        let tmp_dir = testutils::setup_test_dir().await?;
897        let test_path = tmp_dir.as_path();
898        let summary = link(
899            &PROGRESS,
900            test_path,
901            &test_path.join("foo"),
902            &test_path.join("bar"),
903            &Some(test_path.join("foo")),
904            &common_settings(false, false),
905            false,
906        )
907        .await?;
908        assert_eq!(summary.hard_links_created, 5);
909        assert_eq!(summary.copy_summary.files_copied, 0);
910        assert_eq!(summary.copy_summary.symlinks_created, 2);
911        assert_eq!(summary.copy_summary.directories_created, 3);
912        testutils::check_dirs_identical(
913            &test_path.join("foo"),
914            &test_path.join("bar"),
915            testutils::FileEqualityCheck::Timestamp,
916        )
917        .await?;
918        Ok(())
919    }
920
921    #[tokio::test]
922    #[traced_test]
923    async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
924        let tmp_dir = testutils::setup_test_dir().await?;
925        tokio::fs::create_dir(tmp_dir.join("baz")).await?;
926        let test_path = tmp_dir.as_path();
927        let summary = link(
928            &PROGRESS,
929            test_path,
930            &test_path.join("baz"), // empty source
931            &test_path.join("bar"),
932            &Some(test_path.join("foo")),
933            &common_settings(false, false),
934            false,
935        )
936        .await?;
937        assert_eq!(summary.hard_links_created, 0);
938        assert_eq!(summary.copy_summary.files_copied, 5);
939        assert_eq!(summary.copy_summary.symlinks_created, 2);
940        assert_eq!(summary.copy_summary.directories_created, 3);
941        testutils::check_dirs_identical(
942            &test_path.join("foo"),
943            &test_path.join("bar"),
944            testutils::FileEqualityCheck::Timestamp,
945        )
946        .await?;
947        Ok(())
948    }
949
950    #[tokio::test]
951    #[traced_test]
952    async fn test_link_destination_permission_error_includes_root_cause(
953    ) -> Result<(), anyhow::Error> {
954        let tmp_dir = testutils::setup_test_dir().await?;
955        let test_path = tmp_dir.as_path();
956        let readonly_parent = test_path.join("readonly_dest");
957        tokio::fs::create_dir(&readonly_parent).await?;
958        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
959            .await?;
960
961        let mut settings = common_settings(false, false);
962        settings.copy_settings.fail_early = true;
963
964        let result = link(
965            &PROGRESS,
966            test_path,
967            &test_path.join("foo"),
968            &readonly_parent.join("bar"),
969            &None,
970            &settings,
971            false,
972        )
973        .await;
974
975        // restore permissions to allow temporary directory cleanup
976        tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
977            .await?;
978
979        assert!(result.is_err(), "link into read-only parent should fail");
980        let err = result.unwrap_err();
981        let err_msg = format!("{:#}", err.source);
982        assert!(
983            err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
984            "Error message must include permission denied text. Got: {}",
985            err_msg
986        );
987        Ok(())
988    }
989
990    pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
991        // update
992        // |- 0.txt
993        // |- bar
994        //    |- 1.txt
995        //    |- 2.txt -> ../0.txt
996        let foo_path = tmp_dir.join("update");
997        tokio::fs::create_dir(&foo_path).await.unwrap();
998        tokio::fs::write(foo_path.join("0.txt"), "0-new")
999            .await
1000            .unwrap();
1001        let bar_path = foo_path.join("bar");
1002        tokio::fs::create_dir(&bar_path).await.unwrap();
1003        tokio::fs::write(bar_path.join("1.txt"), "1-new")
1004            .await
1005            .unwrap();
1006        tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
1007            .await
1008            .unwrap();
1009        tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
1010        Ok(())
1011    }
1012
1013    #[tokio::test]
1014    #[traced_test]
1015    async fn test_link_update() -> Result<(), anyhow::Error> {
1016        let tmp_dir = testutils::setup_test_dir().await?;
1017        setup_update_dir(&tmp_dir).await?;
1018        let test_path = tmp_dir.as_path();
1019        let summary = link(
1020            &PROGRESS,
1021            test_path,
1022            &test_path.join("foo"),
1023            &test_path.join("bar"),
1024            &Some(test_path.join("update")),
1025            &common_settings(false, false),
1026            false,
1027        )
1028        .await?;
1029        assert_eq!(summary.hard_links_created, 2);
1030        assert_eq!(summary.copy_summary.files_copied, 2);
1031        assert_eq!(summary.copy_summary.symlinks_created, 3);
1032        assert_eq!(summary.copy_summary.directories_created, 3);
1033        // compare subset of src and dst
1034        testutils::check_dirs_identical(
1035            &test_path.join("foo").join("baz"),
1036            &test_path.join("bar").join("baz"),
1037            testutils::FileEqualityCheck::HardLink,
1038        )
1039        .await?;
1040        // compare update and dst
1041        testutils::check_dirs_identical(
1042            &test_path.join("update"),
1043            &test_path.join("bar"),
1044            testutils::FileEqualityCheck::Timestamp,
1045        )
1046        .await?;
1047        Ok(())
1048    }
1049
1050    #[tokio::test]
1051    #[traced_test]
1052    async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
1053        let tmp_dir = testutils::setup_test_dir().await?;
1054        setup_update_dir(&tmp_dir).await?;
1055        let test_path = tmp_dir.as_path();
1056        let mut settings = common_settings(false, false);
1057        settings.update_exclusive = true;
1058        let summary = link(
1059            &PROGRESS,
1060            test_path,
1061            &test_path.join("foo"),
1062            &test_path.join("bar"),
1063            &Some(test_path.join("update")),
1064            &settings,
1065            false,
1066        )
1067        .await?;
1068        // we should end up with same directory as the update
1069        // |- 0.txt
1070        // |- bar
1071        //    |- 1.txt
1072        //    |- 2.txt -> ../0.txt
1073        assert_eq!(summary.hard_links_created, 0);
1074        assert_eq!(summary.copy_summary.files_copied, 2);
1075        assert_eq!(summary.copy_summary.symlinks_created, 1);
1076        assert_eq!(summary.copy_summary.directories_created, 2);
1077        // compare update and dst
1078        testutils::check_dirs_identical(
1079            &test_path.join("update"),
1080            &test_path.join("bar"),
1081            testutils::FileEqualityCheck::Timestamp,
1082        )
1083        .await?;
1084        Ok(())
1085    }
1086
1087    async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
1088        let tmp_dir = testutils::setup_test_dir().await?;
1089        let test_path = tmp_dir.as_path();
1090        let summary = link(
1091            &PROGRESS,
1092            test_path,
1093            &test_path.join("foo"),
1094            &test_path.join("bar"),
1095            &None,
1096            &common_settings(false, false),
1097            false,
1098        )
1099        .await?;
1100        assert_eq!(summary.hard_links_created, 5);
1101        assert_eq!(summary.copy_summary.symlinks_created, 2);
1102        assert_eq!(summary.copy_summary.directories_created, 3);
1103        Ok(tmp_dir)
1104    }
1105
1106    #[tokio::test]
1107    #[traced_test]
1108    async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
1109        let tmp_dir = setup_test_dir_and_link().await?;
1110        let output_path = &tmp_dir.join("bar");
1111        {
1112            // bar
1113            // |- 0.txt
1114            // |- bar  <---------------------------------------- REMOVE
1115            //    |- 1.txt  <----------------------------------- REMOVE
1116            //    |- 2.txt  <----------------------------------- REMOVE
1117            //    |- 3.txt  <----------------------------------- REMOVE
1118            // |- baz
1119            //    |- 4.txt
1120            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
1121            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1122            let summary = rm::rm(
1123                &PROGRESS,
1124                &output_path.join("bar"),
1125                &rm::Settings {
1126                    fail_early: false,
1127                    filter: None,
1128                    dry_run: None,
1129                },
1130            )
1131            .await?
1132                + rm::rm(
1133                    &PROGRESS,
1134                    &output_path.join("baz").join("5.txt"),
1135                    &rm::Settings {
1136                        fail_early: false,
1137                        filter: None,
1138                        dry_run: None,
1139                    },
1140                )
1141                .await?;
1142            assert_eq!(summary.files_removed, 3);
1143            assert_eq!(summary.symlinks_removed, 1);
1144            assert_eq!(summary.directories_removed, 1);
1145        }
1146        let summary = link(
1147            &PROGRESS,
1148            &tmp_dir,
1149            &tmp_dir.join("foo"),
1150            output_path,
1151            &None,
1152            &common_settings(false, true), // overwrite!
1153            false,
1154        )
1155        .await?;
1156        assert_eq!(summary.hard_links_created, 3);
1157        assert_eq!(summary.copy_summary.symlinks_created, 1);
1158        assert_eq!(summary.copy_summary.directories_created, 1);
1159        testutils::check_dirs_identical(
1160            &tmp_dir.join("foo"),
1161            output_path,
1162            testutils::FileEqualityCheck::Timestamp,
1163        )
1164        .await?;
1165        Ok(())
1166    }
1167
1168    #[tokio::test]
1169    #[traced_test]
1170    async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
1171        let tmp_dir = setup_test_dir_and_link().await?;
1172        let output_path = &tmp_dir.join("bar");
1173        {
1174            // bar
1175            // |- 0.txt
1176            // |- bar  <---------------------------------------- REMOVE
1177            //    |- 1.txt  <----------------------------------- REMOVE
1178            //    |- 2.txt  <----------------------------------- REMOVE
1179            //    |- 3.txt  <----------------------------------- REMOVE
1180            // |- baz
1181            //    |- 4.txt
1182            //    |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
1183            //    |- 6.txt -> (absolute path) .../foo/bar/3.txt
1184            let summary = rm::rm(
1185                &PROGRESS,
1186                &output_path.join("bar"),
1187                &rm::Settings {
1188                    fail_early: false,
1189                    filter: None,
1190                    dry_run: None,
1191                },
1192            )
1193            .await?
1194                + rm::rm(
1195                    &PROGRESS,
1196                    &output_path.join("baz").join("5.txt"),
1197                    &rm::Settings {
1198                        fail_early: false,
1199                        filter: None,
1200                        dry_run: None,
1201                    },
1202                )
1203                .await?;
1204            assert_eq!(summary.files_removed, 3);
1205            assert_eq!(summary.symlinks_removed, 1);
1206            assert_eq!(summary.directories_removed, 1);
1207        }
1208        setup_update_dir(&tmp_dir).await?;
1209        // update
1210        // |- 0.txt
1211        // |- bar
1212        //    |- 1.txt
1213        //    |- 2.txt -> ../0.txt
1214        let summary = link(
1215            &PROGRESS,
1216            &tmp_dir,
1217            &tmp_dir.join("foo"),
1218            output_path,
1219            &Some(tmp_dir.join("update")),
1220            &common_settings(false, true), // overwrite!
1221            false,
1222        )
1223        .await?;
1224        assert_eq!(summary.hard_links_created, 1); // 3.txt
1225        assert_eq!(summary.copy_summary.files_copied, 2); // 0.txt, 1.txt
1226        assert_eq!(summary.copy_summary.symlinks_created, 2); // 2.txt, 5.txt
1227        assert_eq!(summary.copy_summary.directories_created, 1);
1228        // compare subset of src and dst
1229        testutils::check_dirs_identical(
1230            &tmp_dir.join("foo").join("baz"),
1231            &tmp_dir.join("bar").join("baz"),
1232            testutils::FileEqualityCheck::HardLink,
1233        )
1234        .await?;
1235        // compare update and dst
1236        testutils::check_dirs_identical(
1237            &tmp_dir.join("update"),
1238            &tmp_dir.join("bar"),
1239            testutils::FileEqualityCheck::Timestamp,
1240        )
1241        .await?;
1242        Ok(())
1243    }
1244
1245    #[tokio::test]
1246    #[traced_test]
1247    async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
1248        let tmp_dir = setup_test_dir_and_link().await?;
1249        let output_path = &tmp_dir.join("bar");
1250        {
1251            // bar
1252            // |- 0.txt
1253            // |- bar
1254            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
1255            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
1256            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
1257            // |- baz    <-------------------------------------- REPLACE W/ FILE
1258            //    |- ...
1259            let bar_path = output_path.join("bar");
1260            let summary = rm::rm(
1261                &PROGRESS,
1262                &bar_path.join("1.txt"),
1263                &rm::Settings {
1264                    fail_early: false,
1265                    filter: None,
1266                    dry_run: None,
1267                },
1268            )
1269            .await?
1270                + rm::rm(
1271                    &PROGRESS,
1272                    &bar_path.join("2.txt"),
1273                    &rm::Settings {
1274                        fail_early: false,
1275                        filter: None,
1276                        dry_run: None,
1277                    },
1278                )
1279                .await?
1280                + rm::rm(
1281                    &PROGRESS,
1282                    &bar_path.join("3.txt"),
1283                    &rm::Settings {
1284                        fail_early: false,
1285                        filter: None,
1286                        dry_run: None,
1287                    },
1288                )
1289                .await?
1290                + rm::rm(
1291                    &PROGRESS,
1292                    &output_path.join("baz"),
1293                    &rm::Settings {
1294                        fail_early: false,
1295                        filter: None,
1296                        dry_run: None,
1297                    },
1298                )
1299                .await?;
1300            assert_eq!(summary.files_removed, 4);
1301            assert_eq!(summary.symlinks_removed, 2);
1302            assert_eq!(summary.directories_removed, 1);
1303            // REPLACE with a file, a symlink, a directory and a file
1304            tokio::fs::write(bar_path.join("1.txt"), "1-new")
1305                .await
1306                .unwrap();
1307            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1308                .await
1309                .unwrap();
1310            tokio::fs::create_dir(&bar_path.join("3.txt"))
1311                .await
1312                .unwrap();
1313            tokio::fs::write(&output_path.join("baz"), "baz")
1314                .await
1315                .unwrap();
1316        }
1317        let summary = link(
1318            &PROGRESS,
1319            &tmp_dir,
1320            &tmp_dir.join("foo"),
1321            output_path,
1322            &None,
1323            &common_settings(false, true), // overwrite!
1324            false,
1325        )
1326        .await?;
1327        assert_eq!(summary.hard_links_created, 4);
1328        assert_eq!(summary.copy_summary.files_copied, 0);
1329        assert_eq!(summary.copy_summary.symlinks_created, 2);
1330        assert_eq!(summary.copy_summary.directories_created, 1);
1331        testutils::check_dirs_identical(
1332            &tmp_dir.join("foo"),
1333            &tmp_dir.join("bar"),
1334            testutils::FileEqualityCheck::HardLink,
1335        )
1336        .await?;
1337        Ok(())
1338    }
1339
1340    #[tokio::test]
1341    #[traced_test]
1342    async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
1343        let tmp_dir = setup_test_dir_and_link().await?;
1344        let output_path = &tmp_dir.join("bar");
1345        {
1346            // bar
1347            // |- 0.txt
1348            // |- bar
1349            //    |- 1.txt  <----------------------------------- REPLACE W/ FILE
1350            //    |- 2.txt  <----------------------------------- REPLACE W/ SYMLINK
1351            //    |- 3.txt  <----------------------------------- REPLACE W/ DIRECTORY
1352            // |- baz    <-------------------------------------- REPLACE W/ FILE
1353            //    |- ...
1354            let bar_path = output_path.join("bar");
1355            let summary = rm::rm(
1356                &PROGRESS,
1357                &bar_path.join("1.txt"),
1358                &rm::Settings {
1359                    fail_early: false,
1360                    filter: None,
1361                    dry_run: None,
1362                },
1363            )
1364            .await?
1365                + rm::rm(
1366                    &PROGRESS,
1367                    &bar_path.join("2.txt"),
1368                    &rm::Settings {
1369                        fail_early: false,
1370                        filter: None,
1371                        dry_run: None,
1372                    },
1373                )
1374                .await?
1375                + rm::rm(
1376                    &PROGRESS,
1377                    &bar_path.join("3.txt"),
1378                    &rm::Settings {
1379                        fail_early: false,
1380                        filter: None,
1381                        dry_run: None,
1382                    },
1383                )
1384                .await?
1385                + rm::rm(
1386                    &PROGRESS,
1387                    &output_path.join("baz"),
1388                    &rm::Settings {
1389                        fail_early: false,
1390                        filter: None,
1391                        dry_run: None,
1392                    },
1393                )
1394                .await?;
1395            assert_eq!(summary.files_removed, 4);
1396            assert_eq!(summary.symlinks_removed, 2);
1397            assert_eq!(summary.directories_removed, 1);
1398            // REPLACE with a file, a symlink, a directory and a file
1399            tokio::fs::write(bar_path.join("1.txt"), "1-new")
1400                .await
1401                .unwrap();
1402            tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
1403                .await
1404                .unwrap();
1405            tokio::fs::create_dir(&bar_path.join("3.txt"))
1406                .await
1407                .unwrap();
1408            tokio::fs::write(&output_path.join("baz"), "baz")
1409                .await
1410                .unwrap();
1411        }
1412        let source_path = &tmp_dir.join("foo");
1413        // unreadable
1414        tokio::fs::set_permissions(
1415            &source_path.join("baz"),
1416            std::fs::Permissions::from_mode(0o000),
1417        )
1418        .await?;
1419        // bar
1420        // |- ...
1421        // |- baz <- NON READABLE
1422        match link(
1423            &PROGRESS,
1424            &tmp_dir,
1425            &tmp_dir.join("foo"),
1426            output_path,
1427            &None,
1428            &common_settings(false, true), // overwrite!
1429            false,
1430        )
1431        .await
1432        {
1433            Ok(_) => panic!("Expected the link to error!"),
1434            Err(error) => {
1435                tracing::info!("{}", &error);
1436                assert_eq!(error.summary.hard_links_created, 3);
1437                assert_eq!(error.summary.copy_summary.files_copied, 0);
1438                assert_eq!(error.summary.copy_summary.symlinks_created, 0);
1439                assert_eq!(error.summary.copy_summary.directories_created, 0);
1440                assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
1441                assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
1442                assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
1443            }
1444        }
1445        Ok(())
1446    }
1447
1448    /// Verify that directory metadata is applied even when child link operations fail.
1449    /// This is a regression test for a bug where directory permissions were not preserved
1450    /// when linking with fail_early=false and some children failed to link.
1451    #[tokio::test]
1452    #[traced_test]
1453    async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
1454        let tmp_dir = testutils::create_temp_dir().await?;
1455        let test_path = tmp_dir.as_path();
1456        // create source directory with specific permissions
1457        let src_dir = test_path.join("src");
1458        tokio::fs::create_dir(&src_dir).await?;
1459        tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
1460        // create a readable file (will be linked successfully)
1461        tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
1462        // create a subdirectory with a file, then make the subdirectory unreadable
1463        // this will cause the recursive walk to fail when trying to read subdirectory contents
1464        let unreadable_subdir = src_dir.join("unreadable_subdir");
1465        tokio::fs::create_dir(&unreadable_subdir).await?;
1466        tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
1467        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
1468            .await?;
1469        let dst_dir = test_path.join("dst");
1470        // link with fail_early=false
1471        let result = link(
1472            &PROGRESS,
1473            test_path,
1474            &src_dir,
1475            &dst_dir,
1476            &None,
1477            &common_settings(false, false),
1478            false,
1479        )
1480        .await;
1481        // restore permissions so cleanup can succeed
1482        tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
1483            .await?;
1484        // verify the operation returned an error (unreadable subdirectory should fail)
1485        assert!(
1486            result.is_err(),
1487            "link should fail due to unreadable subdirectory"
1488        );
1489        let error = result.unwrap_err();
1490        // verify the readable file was linked successfully
1491        assert_eq!(error.summary.hard_links_created, 1);
1492        // verify the destination directory exists and has the correct permissions
1493        let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
1494        assert!(dst_metadata.is_dir());
1495        let actual_mode = dst_metadata.permissions().mode() & 0o7777;
1496        assert_eq!(
1497            actual_mode, 0o750,
1498            "directory should have preserved source permissions (0o750), got {:o}",
1499            actual_mode
1500        );
1501        Ok(())
1502    }
1503    mod filter_tests {
1504        use super::*;
1505        use crate::filter::FilterSettings;
1506        /// Test that path-based patterns (with /) work correctly with nested paths.
1507        #[tokio::test]
1508        #[traced_test]
1509        async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
1510            let tmp_dir = testutils::setup_test_dir().await?;
1511            let test_path = tmp_dir.as_path();
1512            // create filter that should only link files in bar/ directory
1513            let mut filter = FilterSettings::new();
1514            filter.add_include("bar/*.txt").unwrap();
1515            let summary = link(
1516                &PROGRESS,
1517                test_path,
1518                &test_path.join("foo"),
1519                &test_path.join("dst"),
1520                &None,
1521                &Settings {
1522                    copy_settings: CopySettings {
1523                        dereference: false,
1524                        fail_early: false,
1525                        overwrite: false,
1526                        overwrite_compare: Default::default(),
1527                        overwrite_filter: None,
1528                        ignore_existing: false,
1529                        chunk_size: 0,
1530                        remote_copy_buffer_size: 0,
1531                        filter: None,
1532                        dry_run: None,
1533                    },
1534                    update_compare: Default::default(),
1535                    update_exclusive: false,
1536                    filter: Some(filter),
1537                    dry_run: None,
1538                    preserve: preserve::preserve_all(),
1539                },
1540                false,
1541            )
1542            .await?;
1543            // should only link files matching bar/*.txt pattern (bar/1.txt, bar/2.txt, bar/3.txt)
1544            assert_eq!(
1545                summary.hard_links_created, 3,
1546                "should link 3 files matching bar/*.txt"
1547            );
1548            // verify the right files were linked
1549            assert!(
1550                test_path.join("dst/bar/1.txt").exists(),
1551                "bar/1.txt should be linked"
1552            );
1553            assert!(
1554                test_path.join("dst/bar/2.txt").exists(),
1555                "bar/2.txt should be linked"
1556            );
1557            assert!(
1558                test_path.join("dst/bar/3.txt").exists(),
1559                "bar/3.txt should be linked"
1560            );
1561            // verify files outside the pattern don't exist
1562            assert!(
1563                !test_path.join("dst/0.txt").exists(),
1564                "0.txt should not be linked"
1565            );
1566            Ok(())
1567        }
1568        /// Test that filters are applied to top-level file arguments.
1569        #[tokio::test]
1570        #[traced_test]
1571        async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
1572            let tmp_dir = testutils::setup_test_dir().await?;
1573            let test_path = tmp_dir.as_path();
1574            // create filter that excludes .txt files
1575            let mut filter = FilterSettings::new();
1576            filter.add_exclude("*.txt").unwrap();
1577            let summary = link(
1578                &PROGRESS,
1579                test_path,
1580                &test_path.join("foo/0.txt"), // single file source
1581                &test_path.join("dst/0.txt"),
1582                &None,
1583                &Settings {
1584                    copy_settings: CopySettings {
1585                        dereference: false,
1586                        fail_early: false,
1587                        overwrite: false,
1588                        overwrite_compare: Default::default(),
1589                        overwrite_filter: None,
1590                        ignore_existing: false,
1591                        chunk_size: 0,
1592                        remote_copy_buffer_size: 0,
1593                        filter: None,
1594                        dry_run: None,
1595                    },
1596                    update_compare: Default::default(),
1597                    update_exclusive: false,
1598                    filter: Some(filter),
1599                    dry_run: None,
1600                    preserve: preserve::preserve_all(),
1601                },
1602                false,
1603            )
1604            .await?;
1605            // the file should NOT be linked because it matches the exclude pattern
1606            assert_eq!(
1607                summary.hard_links_created, 0,
1608                "file matching exclude pattern should not be linked"
1609            );
1610            assert!(
1611                !test_path.join("dst/0.txt").exists(),
1612                "excluded file should not exist at destination"
1613            );
1614            Ok(())
1615        }
1616        /// Test that filters apply to root directories with simple exclude patterns.
1617        #[tokio::test]
1618        #[traced_test]
1619        async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
1620            let test_path = testutils::create_temp_dir().await?;
1621            // create a directory that should be excluded
1622            tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
1623            tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
1624            // create filter that excludes *_dir/ directories
1625            let mut filter = FilterSettings::new();
1626            filter.add_exclude("*_dir/").unwrap();
1627            let result = link(
1628                &PROGRESS,
1629                &test_path,
1630                &test_path.join("excluded_dir"),
1631                &test_path.join("dst"),
1632                &None,
1633                &Settings {
1634                    copy_settings: CopySettings {
1635                        dereference: false,
1636                        fail_early: false,
1637                        overwrite: false,
1638                        overwrite_compare: Default::default(),
1639                        overwrite_filter: None,
1640                        ignore_existing: false,
1641                        chunk_size: 0,
1642                        remote_copy_buffer_size: 0,
1643                        filter: None,
1644                        dry_run: None,
1645                    },
1646                    update_compare: Default::default(),
1647                    update_exclusive: false,
1648                    filter: Some(filter),
1649                    dry_run: None,
1650                    preserve: preserve::preserve_all(),
1651                },
1652                false,
1653            )
1654            .await?;
1655            // directory should NOT be linked because it matches exclude pattern
1656            assert_eq!(
1657                result.copy_summary.directories_created, 0,
1658                "root directory matching exclude should not be created"
1659            );
1660            assert!(
1661                !test_path.join("dst").exists(),
1662                "excluded root directory should not exist at destination"
1663            );
1664            Ok(())
1665        }
1666        /// Test that filters apply to root symlinks with simple exclude patterns.
1667        #[tokio::test]
1668        #[traced_test]
1669        async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
1670            let test_path = testutils::create_temp_dir().await?;
1671            // create a target file and a symlink to it
1672            tokio::fs::write(test_path.join("target.txt"), "content").await?;
1673            tokio::fs::symlink(
1674                test_path.join("target.txt"),
1675                test_path.join("excluded_link"),
1676            )
1677            .await?;
1678            // create filter that excludes *_link
1679            let mut filter = FilterSettings::new();
1680            filter.add_exclude("*_link").unwrap();
1681            let result = link(
1682                &PROGRESS,
1683                &test_path,
1684                &test_path.join("excluded_link"),
1685                &test_path.join("dst"),
1686                &None,
1687                &Settings {
1688                    copy_settings: CopySettings {
1689                        dereference: false,
1690                        fail_early: false,
1691                        overwrite: false,
1692                        overwrite_compare: Default::default(),
1693                        overwrite_filter: None,
1694                        ignore_existing: false,
1695                        chunk_size: 0,
1696                        remote_copy_buffer_size: 0,
1697                        filter: None,
1698                        dry_run: None,
1699                    },
1700                    update_compare: Default::default(),
1701                    update_exclusive: false,
1702                    filter: Some(filter),
1703                    dry_run: None,
1704                    preserve: preserve::preserve_all(),
1705                },
1706                false,
1707            )
1708            .await?;
1709            // symlink should NOT be copied because it matches exclude pattern
1710            assert_eq!(
1711                result.copy_summary.symlinks_created, 0,
1712                "root symlink matching exclude should not be created"
1713            );
1714            assert!(
1715                !test_path.join("dst").exists(),
1716                "excluded root symlink should not exist at destination"
1717            );
1718            Ok(())
1719        }
1720        /// Test combined include and exclude patterns (exclude takes precedence).
1721        #[tokio::test]
1722        #[traced_test]
1723        async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
1724            let tmp_dir = testutils::setup_test_dir().await?;
1725            let test_path = tmp_dir.as_path();
1726            // test structure from setup_test_dir:
1727            // foo/
1728            //   0.txt
1729            //   bar/ (1.txt, 2.txt, 3.txt)
1730            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1731            // include all .txt files in bar/, but exclude 2.txt specifically
1732            let mut filter = FilterSettings::new();
1733            filter.add_include("bar/*.txt").unwrap();
1734            filter.add_exclude("bar/2.txt").unwrap();
1735            let summary = link(
1736                &PROGRESS,
1737                test_path,
1738                &test_path.join("foo"),
1739                &test_path.join("dst"),
1740                &None,
1741                &Settings {
1742                    copy_settings: CopySettings {
1743                        dereference: false,
1744                        fail_early: false,
1745                        overwrite: false,
1746                        overwrite_compare: Default::default(),
1747                        overwrite_filter: None,
1748                        ignore_existing: false,
1749                        chunk_size: 0,
1750                        remote_copy_buffer_size: 0,
1751                        filter: None,
1752                        dry_run: None,
1753                    },
1754                    update_compare: Default::default(),
1755                    update_exclusive: false,
1756                    filter: Some(filter),
1757                    dry_run: None,
1758                    preserve: preserve::preserve_all(),
1759                },
1760                false,
1761            )
1762            .await?;
1763            // should link: bar/1.txt, bar/3.txt = 2 hard links
1764            // should skip: bar/2.txt (excluded by pattern), 0.txt (excluded by default - no match) = 2 files
1765            assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
1766            assert_eq!(
1767                summary.copy_summary.files_skipped, 2,
1768                "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
1769            );
1770            // verify
1771            assert!(
1772                test_path.join("dst/bar/1.txt").exists(),
1773                "bar/1.txt should be linked"
1774            );
1775            assert!(
1776                !test_path.join("dst/bar/2.txt").exists(),
1777                "bar/2.txt should be excluded"
1778            );
1779            assert!(
1780                test_path.join("dst/bar/3.txt").exists(),
1781                "bar/3.txt should be linked"
1782            );
1783            Ok(())
1784        }
1785        /// Test that skipped counts accurately reflect what was filtered.
1786        #[tokio::test]
1787        #[traced_test]
1788        async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
1789            let tmp_dir = testutils::setup_test_dir().await?;
1790            let test_path = tmp_dir.as_path();
1791            // test structure from setup_test_dir:
1792            // foo/
1793            //   0.txt
1794            //   bar/ (1.txt, 2.txt, 3.txt)
1795            //   baz/ (4.txt, 5.txt symlink, 6.txt symlink)
1796            // exclude bar/ directory entirely
1797            let mut filter = FilterSettings::new();
1798            filter.add_exclude("bar/").unwrap();
1799            let summary = link(
1800                &PROGRESS,
1801                test_path,
1802                &test_path.join("foo"),
1803                &test_path.join("dst"),
1804                &None,
1805                &Settings {
1806                    copy_settings: CopySettings {
1807                        dereference: false,
1808                        fail_early: false,
1809                        overwrite: false,
1810                        overwrite_compare: Default::default(),
1811                        overwrite_filter: None,
1812                        ignore_existing: false,
1813                        chunk_size: 0,
1814                        remote_copy_buffer_size: 0,
1815                        filter: None,
1816                        dry_run: None,
1817                    },
1818                    update_compare: Default::default(),
1819                    update_exclusive: false,
1820                    filter: Some(filter),
1821                    dry_run: None,
1822                    preserve: preserve::preserve_all(),
1823                },
1824                false,
1825            )
1826            .await?;
1827            // linked: 0.txt (1 hard link), baz/4.txt (1 hard link)
1828            // symlinks copied: 5.txt, 6.txt
1829            // skipped: bar directory (1 dir)
1830            assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
1831            assert_eq!(
1832                summary.copy_summary.symlinks_created, 2,
1833                "should copy 2 symlinks"
1834            );
1835            assert_eq!(
1836                summary.copy_summary.directories_skipped, 1,
1837                "should skip 1 directory (bar)"
1838            );
1839            // bar should not exist in dst
1840            assert!(
1841                !test_path.join("dst/bar").exists(),
1842                "bar directory should not be linked"
1843            );
1844            Ok(())
1845        }
1846        /// Test that empty directories are not created when they were only traversed to look
1847        /// for matches (regression test for bug where --include='foo' would create empty dir baz).
1848        #[tokio::test]
1849        #[traced_test]
1850        async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
1851            let test_path = testutils::create_temp_dir().await?;
1852            // create structure:
1853            // src/
1854            //   foo (file)
1855            //   bar (file)
1856            //   baz/ (empty directory)
1857            let src_path = test_path.join("src");
1858            tokio::fs::create_dir(&src_path).await?;
1859            tokio::fs::write(src_path.join("foo"), "content").await?;
1860            tokio::fs::write(src_path.join("bar"), "content").await?;
1861            tokio::fs::create_dir(src_path.join("baz")).await?;
1862            // include only 'foo' file
1863            let mut filter = FilterSettings::new();
1864            filter.add_include("foo").unwrap();
1865            let summary = link(
1866                &PROGRESS,
1867                &test_path,
1868                &src_path,
1869                &test_path.join("dst"),
1870                &None,
1871                &Settings {
1872                    copy_settings: copy::Settings {
1873                        dereference: false,
1874                        fail_early: false,
1875                        overwrite: false,
1876                        overwrite_compare: Default::default(),
1877                        overwrite_filter: None,
1878                        ignore_existing: false,
1879                        chunk_size: 0,
1880                        remote_copy_buffer_size: 0,
1881                        filter: None,
1882                        dry_run: None,
1883                    },
1884                    update_compare: Default::default(),
1885                    update_exclusive: false,
1886                    filter: Some(filter),
1887                    dry_run: None,
1888                    preserve: preserve::preserve_all(),
1889                },
1890                false,
1891            )
1892            .await?;
1893            // only 'foo' should be linked
1894            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
1895            assert_eq!(
1896                summary.copy_summary.directories_created, 1,
1897                "should create only root directory (not empty 'baz')"
1898            );
1899            // verify foo was linked
1900            assert!(
1901                test_path.join("dst").join("foo").exists(),
1902                "foo should be linked"
1903            );
1904            // verify bar was not linked (not matching include pattern)
1905            assert!(
1906                !test_path.join("dst").join("bar").exists(),
1907                "bar should not be linked"
1908            );
1909            // verify empty baz directory was NOT created
1910            assert!(
1911                !test_path.join("dst").join("baz").exists(),
1912                "empty baz directory should NOT be created"
1913            );
1914            Ok(())
1915        }
1916        /// Test that directories with only non-matching content are not created at destination.
1917        /// This is different from empty directories - the source dir has content but none matches.
1918        #[tokio::test]
1919        #[traced_test]
1920        async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
1921            let test_path = testutils::create_temp_dir().await?;
1922            // create structure:
1923            // src/
1924            //   foo (file)
1925            //   baz/
1926            //     qux (file - doesn't match 'foo')
1927            //     quux (file - doesn't match 'foo')
1928            let src_path = test_path.join("src");
1929            tokio::fs::create_dir(&src_path).await?;
1930            tokio::fs::write(src_path.join("foo"), "content").await?;
1931            tokio::fs::create_dir(src_path.join("baz")).await?;
1932            tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
1933            tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
1934            // include only 'foo' file
1935            let mut filter = FilterSettings::new();
1936            filter.add_include("foo").unwrap();
1937            let summary = link(
1938                &PROGRESS,
1939                &test_path,
1940                &src_path,
1941                &test_path.join("dst"),
1942                &None,
1943                &Settings {
1944                    copy_settings: copy::Settings {
1945                        dereference: false,
1946                        fail_early: false,
1947                        overwrite: false,
1948                        overwrite_compare: Default::default(),
1949                        overwrite_filter: None,
1950                        ignore_existing: false,
1951                        chunk_size: 0,
1952                        remote_copy_buffer_size: 0,
1953                        filter: None,
1954                        dry_run: None,
1955                    },
1956                    update_compare: Default::default(),
1957                    update_exclusive: false,
1958                    filter: Some(filter),
1959                    dry_run: None,
1960                    preserve: preserve::preserve_all(),
1961                },
1962                false,
1963            )
1964            .await?;
1965            // only 'foo' should be linked
1966            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
1967            assert_eq!(
1968                summary.copy_summary.files_skipped, 2,
1969                "should skip 2 files (qux and quux)"
1970            );
1971            assert_eq!(
1972                summary.copy_summary.directories_created, 1,
1973                "should create only root directory (not 'baz' with non-matching content)"
1974            );
1975            // verify foo was linked
1976            assert!(
1977                test_path.join("dst").join("foo").exists(),
1978                "foo should be linked"
1979            );
1980            // verify baz directory was NOT created (even though source baz has content)
1981            assert!(
1982                !test_path.join("dst").join("baz").exists(),
1983                "baz directory should NOT be created (no matching content inside)"
1984            );
1985            Ok(())
1986        }
1987        /// Test that empty directories are not reported as created in dry-run mode
1988        /// when they were only traversed.
1989        #[tokio::test]
1990        #[traced_test]
1991        async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
1992            let test_path = testutils::create_temp_dir().await?;
1993            // create structure:
1994            // src/
1995            //   foo (file)
1996            //   bar (file)
1997            //   baz/ (empty directory)
1998            let src_path = test_path.join("src");
1999            tokio::fs::create_dir(&src_path).await?;
2000            tokio::fs::write(src_path.join("foo"), "content").await?;
2001            tokio::fs::write(src_path.join("bar"), "content").await?;
2002            tokio::fs::create_dir(src_path.join("baz")).await?;
2003            // include only 'foo' file
2004            let mut filter = FilterSettings::new();
2005            filter.add_include("foo").unwrap();
2006            let summary = link(
2007                &PROGRESS,
2008                &test_path,
2009                &src_path,
2010                &test_path.join("dst"),
2011                &None,
2012                &Settings {
2013                    copy_settings: copy::Settings {
2014                        dereference: false,
2015                        fail_early: false,
2016                        overwrite: false,
2017                        overwrite_compare: Default::default(),
2018                        overwrite_filter: None,
2019                        ignore_existing: false,
2020                        chunk_size: 0,
2021                        remote_copy_buffer_size: 0,
2022                        filter: None,
2023                        dry_run: None,
2024                    },
2025                    update_compare: Default::default(),
2026                    update_exclusive: false,
2027                    filter: Some(filter),
2028                    dry_run: Some(crate::config::DryRunMode::Explain),
2029                    preserve: preserve::preserve_all(),
2030                },
2031                false,
2032            )
2033            .await?;
2034            // only 'foo' should be reported as would-be-linked
2035            assert_eq!(
2036                summary.hard_links_created, 1,
2037                "should report only 'foo' would be linked"
2038            );
2039            assert_eq!(
2040                summary.copy_summary.directories_created, 1,
2041                "should report only root directory would be created (not empty 'baz')"
2042            );
2043            // verify nothing was actually created (dry-run mode)
2044            assert!(
2045                !test_path.join("dst").exists(),
2046                "dst should not exist in dry-run"
2047            );
2048            Ok(())
2049        }
2050        /// Test that existing directories are NOT removed when using --overwrite,
2051        /// even if nothing is linked into them due to filters.
2052        #[tokio::test]
2053        #[traced_test]
2054        async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
2055            let test_path = testutils::create_temp_dir().await?;
2056            // create source structure:
2057            // src/
2058            //   foo (file)
2059            //   bar (file)
2060            //   baz/ (empty directory)
2061            let src_path = test_path.join("src");
2062            tokio::fs::create_dir(&src_path).await?;
2063            tokio::fs::write(src_path.join("foo"), "content").await?;
2064            tokio::fs::write(src_path.join("bar"), "content").await?;
2065            tokio::fs::create_dir(src_path.join("baz")).await?;
2066            // create destination with baz directory already existing
2067            let dst_path = test_path.join("dst");
2068            tokio::fs::create_dir(&dst_path).await?;
2069            tokio::fs::create_dir(dst_path.join("baz")).await?;
2070            // add a marker file inside dst/baz to verify we don't touch it
2071            tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
2072            // include only 'foo' file - baz should not match
2073            let mut filter = FilterSettings::new();
2074            filter.add_include("foo").unwrap();
2075            let summary = link(
2076                &PROGRESS,
2077                &test_path,
2078                &src_path,
2079                &dst_path,
2080                &None,
2081                &Settings {
2082                    copy_settings: copy::Settings {
2083                        dereference: false,
2084                        fail_early: false,
2085                        overwrite: true, // enable overwrite mode
2086                        overwrite_compare: Default::default(),
2087                        overwrite_filter: None,
2088                        ignore_existing: false,
2089                        chunk_size: 0,
2090                        remote_copy_buffer_size: 0,
2091                        filter: None,
2092                        dry_run: None,
2093                    },
2094                    update_compare: Default::default(),
2095                    update_exclusive: false,
2096                    filter: Some(filter),
2097                    dry_run: None,
2098                    preserve: preserve::preserve_all(),
2099                },
2100                false,
2101            )
2102            .await?;
2103            // foo should be linked
2104            assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
2105            // dst and baz should be unchanged (both already existed)
2106            assert_eq!(
2107                summary.copy_summary.directories_unchanged, 2,
2108                "root dst and baz directories should be unchanged"
2109            );
2110            assert_eq!(
2111                summary.copy_summary.directories_created, 0,
2112                "should not create any directories"
2113            );
2114            // verify foo was linked
2115            assert!(dst_path.join("foo").exists(), "foo should be linked");
2116            // verify bar was NOT linked
2117            assert!(!dst_path.join("bar").exists(), "bar should not be linked");
2118            // verify existing baz directory still exists with its content
2119            assert!(
2120                dst_path.join("baz").exists(),
2121                "existing baz directory should still exist"
2122            );
2123            assert!(
2124                dst_path.join("baz").join("marker.txt").exists(),
2125                "existing content in baz should still exist"
2126            );
2127            Ok(())
2128        }
2129    }
2130    mod dry_run_tests {
2131        use super::*;
2132        /// Test that dry-run mode for files doesn't create hard links.
2133        #[tokio::test]
2134        #[traced_test]
2135        async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
2136            let tmp_dir = testutils::setup_test_dir().await?;
2137            let test_path = tmp_dir.as_path();
2138            let src_file = test_path.join("foo/0.txt");
2139            let dst_file = test_path.join("dst_link.txt");
2140            // verify destination doesn't exist
2141            assert!(
2142                !dst_file.exists(),
2143                "destination should not exist before dry-run"
2144            );
2145            let summary = link(
2146                &PROGRESS,
2147                test_path,
2148                &src_file,
2149                &dst_file,
2150                &None,
2151                &Settings {
2152                    copy_settings: CopySettings {
2153                        dereference: false,
2154                        fail_early: false,
2155                        overwrite: false,
2156                        overwrite_compare: Default::default(),
2157                        overwrite_filter: None,
2158                        ignore_existing: false,
2159                        chunk_size: 0,
2160                        remote_copy_buffer_size: 0,
2161                        filter: None,
2162                        dry_run: None,
2163                    },
2164                    update_compare: Default::default(),
2165                    update_exclusive: false,
2166                    filter: None,
2167                    dry_run: Some(crate::config::DryRunMode::Brief),
2168                    preserve: preserve::preserve_all(),
2169                },
2170                false,
2171            )
2172            .await?;
2173            // verify destination still doesn't exist
2174            assert!(!dst_file.exists(), "dry-run should not create hard link");
2175            // verify summary reports what would be created
2176            assert_eq!(
2177                summary.hard_links_created, 1,
2178                "dry-run should report 1 hard link that would be created"
2179            );
2180            Ok(())
2181        }
2182        /// Test that dry-run mode for directories doesn't create the destination directory.
2183        #[tokio::test]
2184        #[traced_test]
2185        async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
2186            let tmp_dir = testutils::setup_test_dir().await?;
2187            let test_path = tmp_dir.as_path();
2188            let dst_path = test_path.join("nonexistent_dst");
2189            // verify destination doesn't exist
2190            assert!(
2191                !dst_path.exists(),
2192                "destination should not exist before dry-run"
2193            );
2194            let summary = link(
2195                &PROGRESS,
2196                test_path,
2197                &test_path.join("foo"),
2198                &dst_path,
2199                &None,
2200                &Settings {
2201                    copy_settings: CopySettings {
2202                        dereference: false,
2203                        fail_early: false,
2204                        overwrite: false,
2205                        overwrite_compare: Default::default(),
2206                        overwrite_filter: None,
2207                        ignore_existing: false,
2208                        chunk_size: 0,
2209                        remote_copy_buffer_size: 0,
2210                        filter: None,
2211                        dry_run: None,
2212                    },
2213                    update_compare: Default::default(),
2214                    update_exclusive: false,
2215                    filter: None,
2216                    dry_run: Some(crate::config::DryRunMode::Brief),
2217                    preserve: preserve::preserve_all(),
2218                },
2219                false,
2220            )
2221            .await?;
2222            // verify destination still doesn't exist
2223            assert!(
2224                !dst_path.exists(),
2225                "dry-run should not create destination directory"
2226            );
2227            // verify summary reports what would be created
2228            assert!(
2229                summary.hard_links_created > 0,
2230                "dry-run should report hard links that would be created"
2231            );
2232            Ok(())
2233        }
2234        /// Test that dry-run mode correctly reports symlinks (not as hard links).
2235        #[tokio::test]
2236        #[traced_test]
2237        async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
2238            let tmp_dir = testutils::setup_test_dir().await?;
2239            let test_path = tmp_dir.as_path();
2240            // baz contains: 4.txt (file), 5.txt (symlink), 6.txt (symlink)
2241            let src_path = test_path.join("foo/baz");
2242            let dst_path = test_path.join("dst_baz");
2243            // verify destination doesn't exist
2244            assert!(
2245                !dst_path.exists(),
2246                "destination should not exist before dry-run"
2247            );
2248            let summary = link(
2249                &PROGRESS,
2250                test_path,
2251                &src_path,
2252                &dst_path,
2253                &None,
2254                &Settings {
2255                    copy_settings: CopySettings {
2256                        dereference: false,
2257                        fail_early: false,
2258                        overwrite: false,
2259                        overwrite_compare: Default::default(),
2260                        overwrite_filter: None,
2261                        ignore_existing: false,
2262                        chunk_size: 0,
2263                        remote_copy_buffer_size: 0,
2264                        filter: None,
2265                        dry_run: None,
2266                    },
2267                    update_compare: Default::default(),
2268                    update_exclusive: false,
2269                    filter: None,
2270                    dry_run: Some(crate::config::DryRunMode::Brief),
2271                    preserve: preserve::preserve_all(),
2272                },
2273                false,
2274            )
2275            .await?;
2276            // verify destination still doesn't exist
2277            assert!(!dst_path.exists(), "dry-run should not create destination");
2278            // baz contains 1 regular file (4.txt) and 2 symlinks (5.txt, 6.txt)
2279            assert_eq!(
2280                summary.hard_links_created, 1,
2281                "dry-run should report 1 hard link (for 4.txt)"
2282            );
2283            assert_eq!(
2284                summary.copy_summary.symlinks_created, 2,
2285                "dry-run should report 2 symlinks (5.txt and 6.txt)"
2286            );
2287            Ok(())
2288        }
2289    }
2290
2291    /// Verify that fail-early preserves the summary from the failing subtree.
2292    ///
2293    /// Regression test: the fail-early return path in the join loop must
2294    /// accumulate error.summary from the failing child into the parent's
2295    /// link_summary. Without this, directories_created from the child subtree
2296    /// would be lost.
2297    #[tokio::test]
2298    #[traced_test]
2299    async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
2300        let tmp_dir = testutils::create_temp_dir().await?;
2301        let test_path = tmp_dir.as_path();
2302        // src/sub/  has a file and an unreadable subdirectory:
2303        //   src/sub/good.txt            <-- links successfully
2304        //   src/sub/unreadable_dir/     <-- mode 000, can't be traversed
2305        //     src/sub/unreadable_dir/f.txt
2306        let src_dir = test_path.join("src");
2307        let sub_dir = src_dir.join("sub");
2308        let bad_dir = sub_dir.join("unreadable_dir");
2309        tokio::fs::create_dir_all(&bad_dir).await?;
2310        tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
2311        tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
2312        tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
2313        let dst_dir = test_path.join("dst");
2314        let result = link(
2315            &PROGRESS,
2316            test_path,
2317            &src_dir,
2318            &dst_dir,
2319            &None,
2320            &Settings {
2321                copy_settings: CopySettings {
2322                    fail_early: true,
2323                    ..common_settings(false, false).copy_settings
2324                },
2325                ..common_settings(false, false)
2326            },
2327            false,
2328        )
2329        .await;
2330        // restore permissions for cleanup
2331        tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
2332        let error = result.expect_err("link should fail due to unreadable directory");
2333        // sub/'s link_internal created dst/sub/ (directories_created=1) before
2334        // its join loop encountered the unreadable_dir error. that directory
2335        // creation must be reflected in the error summary propagated up to the
2336        // top-level caller.
2337        assert!(
2338            error.summary.copy_summary.directories_created >= 2,
2339            "fail-early summary should include directories from the failing subtree, \
2340             got directories_created={} (expected >= 2: dst/ and dst/sub/)",
2341            error.summary.copy_summary.directories_created
2342        );
2343        Ok(())
2344    }
2345}