uu_mv/
mv.rs

1// This file is part of the uutils coreutils package.
2//
3// For the full copyright and license information, please view the LICENSE
4// file that was distributed with this source code.
5
6// spell-checker:ignore (ToDO) sourcepath targetpath nushell canonicalized
7
8mod error;
9
10use clap::builder::ValueParser;
11use clap::{crate_version, error::ErrorKind, Arg, ArgAction, ArgMatches, Command};
12use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
13use std::collections::HashSet;
14use std::env;
15use std::ffi::OsString;
16use std::fs;
17use std::io;
18#[cfg(unix)]
19use std::os::unix;
20#[cfg(windows)]
21use std::os::windows;
22use std::path::{absolute, Path, PathBuf};
23use uucore::backup_control::{self, source_is_target_backup};
24use uucore::display::Quotable;
25use uucore::error::{set_exit_code, FromIo, UResult, USimpleError, UUsageError};
26use uucore::fs::{
27    are_hardlinks_or_one_way_symlink_to_same_file, are_hardlinks_to_same_file, canonicalize,
28    path_ends_with_terminator, MissingHandling, ResolveMode,
29};
30#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
31use uucore::fsxattr;
32use uucore::update_control;
33
34// These are exposed for projects (e.g. nushell) that want to create an `Options` value, which
35// requires these enums
36pub use uucore::{backup_control::BackupMode, update_control::UpdateMode};
37use uucore::{format_usage, help_about, help_section, help_usage, prompt_yes, show};
38
39use fs_extra::dir::{
40    get_size as dir_get_size, move_dir, move_dir_with_progress, CopyOptions as DirCopyOptions,
41    TransitProcess, TransitProcessResult,
42};
43
44use crate::error::MvError;
45
46/// Options contains all the possible behaviors and flags for mv.
47///
48/// All options are public so that the options can be programmatically
49/// constructed by other crates, such as nushell. That means that this struct is
50/// part of our public API. It should therefore not be changed without good reason.
51///
52/// The fields are documented with the arguments that determine their value.
53#[derive(Debug, Clone, Eq, PartialEq)]
54pub struct Options {
55    /// specifies overwrite behavior
56    /// '-n' '--no-clobber'
57    /// '-i' '--interactive'
58    /// '-f' '--force'
59    pub overwrite: OverwriteMode,
60
61    /// `--backup[=CONTROL]`, `-b`
62    pub backup: BackupMode,
63
64    /// '-S' --suffix' backup suffix
65    pub suffix: String,
66
67    /// Available update mode "--update-mode=all|none|older"
68    pub update: UpdateMode,
69
70    /// Specifies target directory
71    /// '-t, --target-directory=DIRECTORY'
72    pub target_dir: Option<OsString>,
73
74    /// Treat destination as a normal file
75    /// '-T, --no-target-directory
76    pub no_target_dir: bool,
77
78    /// '-v, --verbose'
79    pub verbose: bool,
80
81    /// '--strip-trailing-slashes'
82    pub strip_slashes: bool,
83
84    /// '-g, --progress'
85    pub progress_bar: bool,
86
87    /// `--debug`
88    pub debug: bool,
89}
90
91/// specifies behavior of the overwrite flag
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub enum OverwriteMode {
94    /// '-n' '--no-clobber'   do not overwrite
95    NoClobber,
96    /// '-i' '--interactive'  prompt before overwrite
97    Interactive,
98    ///'-f' '--force'         overwrite without prompt
99    Force,
100}
101
102const ABOUT: &str = help_about!("mv.md");
103const USAGE: &str = help_usage!("mv.md");
104const AFTER_HELP: &str = help_section!("after help", "mv.md");
105
106static OPT_FORCE: &str = "force";
107static OPT_INTERACTIVE: &str = "interactive";
108static OPT_NO_CLOBBER: &str = "no-clobber";
109static OPT_STRIP_TRAILING_SLASHES: &str = "strip-trailing-slashes";
110static OPT_TARGET_DIRECTORY: &str = "target-directory";
111static OPT_NO_TARGET_DIRECTORY: &str = "no-target-directory";
112static OPT_VERBOSE: &str = "verbose";
113static OPT_PROGRESS: &str = "progress";
114static ARG_FILES: &str = "files";
115static OPT_DEBUG: &str = "debug";
116
117#[uucore::main]
118pub fn uumain(args: impl uucore::Args) -> UResult<()> {
119    let mut app = uu_app();
120    let matches = app.try_get_matches_from_mut(args)?;
121
122    let files: Vec<OsString> = matches
123        .get_many::<OsString>(ARG_FILES)
124        .unwrap_or_default()
125        .cloned()
126        .collect();
127
128    if files.len() == 1 && !matches.contains_id(OPT_TARGET_DIRECTORY) {
129        app.error(
130            ErrorKind::TooFewValues,
131            format!(
132                "The argument '<{ARG_FILES}>...' requires at least 2 values, but only 1 was provided"
133            ),
134        )
135        .exit();
136    }
137
138    let overwrite_mode = determine_overwrite_mode(&matches);
139    let backup_mode = backup_control::determine_backup_mode(&matches)?;
140    let update_mode = update_control::determine_update_mode(&matches);
141
142    if backup_mode != BackupMode::NoBackup
143        && (overwrite_mode == OverwriteMode::NoClobber
144            || update_mode == UpdateMode::ReplaceNone
145            || update_mode == UpdateMode::ReplaceNoneFail)
146    {
147        return Err(UUsageError::new(
148            1,
149            "cannot combine --backup with -n/--no-clobber or --update=none-fail",
150        ));
151    }
152
153    let backup_suffix = backup_control::determine_backup_suffix(&matches);
154
155    let target_dir = matches
156        .get_one::<OsString>(OPT_TARGET_DIRECTORY)
157        .map(OsString::from);
158
159    if let Some(ref maybe_dir) = target_dir {
160        if !Path::new(&maybe_dir).is_dir() {
161            return Err(MvError::TargetNotADirectory(maybe_dir.quote().to_string()).into());
162        }
163    }
164
165    let opts = Options {
166        overwrite: overwrite_mode,
167        backup: backup_mode,
168        suffix: backup_suffix,
169        update: update_mode,
170        target_dir,
171        no_target_dir: matches.get_flag(OPT_NO_TARGET_DIRECTORY),
172        verbose: matches.get_flag(OPT_VERBOSE) || matches.get_flag(OPT_DEBUG),
173        strip_slashes: matches.get_flag(OPT_STRIP_TRAILING_SLASHES),
174        progress_bar: matches.get_flag(OPT_PROGRESS),
175        debug: matches.get_flag(OPT_DEBUG),
176    };
177
178    mv(&files[..], &opts)
179}
180
181pub fn uu_app() -> Command {
182    Command::new(uucore::util_name())
183        .version(crate_version!())
184        .about(ABOUT)
185        .override_usage(format_usage(USAGE))
186        .after_help(format!(
187            "{AFTER_HELP}\n\n{}",
188            backup_control::BACKUP_CONTROL_LONG_HELP
189        ))
190        .infer_long_args(true)
191        .arg(
192            Arg::new(OPT_FORCE)
193                .short('f')
194                .long(OPT_FORCE)
195                .help("do not prompt before overwriting")
196                .overrides_with_all([OPT_INTERACTIVE, OPT_NO_CLOBBER])
197                .action(ArgAction::SetTrue),
198        )
199        .arg(
200            Arg::new(OPT_INTERACTIVE)
201                .short('i')
202                .long(OPT_INTERACTIVE)
203                .help("prompt before override")
204                .overrides_with_all([OPT_FORCE, OPT_NO_CLOBBER])
205                .action(ArgAction::SetTrue),
206        )
207        .arg(
208            Arg::new(OPT_NO_CLOBBER)
209                .short('n')
210                .long(OPT_NO_CLOBBER)
211                .help("do not overwrite an existing file")
212                .overrides_with_all([OPT_FORCE, OPT_INTERACTIVE])
213                .action(ArgAction::SetTrue),
214        )
215        .arg(
216            Arg::new(OPT_STRIP_TRAILING_SLASHES)
217                .long(OPT_STRIP_TRAILING_SLASHES)
218                .help("remove any trailing slashes from each SOURCE argument")
219                .action(ArgAction::SetTrue),
220        )
221        .arg(backup_control::arguments::backup())
222        .arg(backup_control::arguments::backup_no_args())
223        .arg(backup_control::arguments::suffix())
224        .arg(update_control::arguments::update())
225        .arg(update_control::arguments::update_no_args())
226        .arg(
227            Arg::new(OPT_TARGET_DIRECTORY)
228                .short('t')
229                .long(OPT_TARGET_DIRECTORY)
230                .help("move all SOURCE arguments into DIRECTORY")
231                .value_name("DIRECTORY")
232                .value_hint(clap::ValueHint::DirPath)
233                .conflicts_with(OPT_NO_TARGET_DIRECTORY)
234                .value_parser(ValueParser::os_string()),
235        )
236        .arg(
237            Arg::new(OPT_NO_TARGET_DIRECTORY)
238                .short('T')
239                .long(OPT_NO_TARGET_DIRECTORY)
240                .help("treat DEST as a normal file")
241                .action(ArgAction::SetTrue),
242        )
243        .arg(
244            Arg::new(OPT_VERBOSE)
245                .short('v')
246                .long(OPT_VERBOSE)
247                .help("explain what is being done")
248                .action(ArgAction::SetTrue),
249        )
250        .arg(
251            Arg::new(OPT_PROGRESS)
252                .short('g')
253                .long(OPT_PROGRESS)
254                .help(
255                    "Display a progress bar. \n\
256                Note: this feature is not supported by GNU coreutils.",
257                )
258                .action(ArgAction::SetTrue),
259        )
260        .arg(
261            Arg::new(ARG_FILES)
262                .action(ArgAction::Append)
263                .num_args(1..)
264                .required(true)
265                .value_parser(ValueParser::os_string())
266                .value_hint(clap::ValueHint::AnyPath),
267        )
268        .arg(
269            Arg::new(OPT_DEBUG)
270                .long(OPT_DEBUG)
271                .help("explain how a file is copied. Implies -v")
272                .action(ArgAction::SetTrue),
273        )
274}
275
276fn determine_overwrite_mode(matches: &ArgMatches) -> OverwriteMode {
277    // This does not exactly match the GNU implementation:
278    // The GNU mv defaults to Force, but if more than one of the
279    // overwrite options are supplied, only the last takes effect.
280    // To default to no-clobber in that situation seems safer:
281    //
282    if matches.get_flag(OPT_NO_CLOBBER) {
283        OverwriteMode::NoClobber
284    } else if matches.get_flag(OPT_INTERACTIVE) {
285        OverwriteMode::Interactive
286    } else {
287        OverwriteMode::Force
288    }
289}
290
291fn parse_paths(files: &[OsString], opts: &Options) -> Vec<PathBuf> {
292    let paths = files.iter().map(Path::new);
293
294    if opts.strip_slashes {
295        paths
296            .map(|p| p.components().as_path().to_owned())
297            .collect::<Vec<PathBuf>>()
298    } else {
299        paths.map(|p| p.to_owned()).collect::<Vec<PathBuf>>()
300    }
301}
302
303fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> {
304    if opts.backup == BackupMode::SimpleBackup
305        && source_is_target_backup(source, target, &opts.suffix)
306    {
307        return Err(io::Error::new(
308            io::ErrorKind::NotFound,
309            format!(
310                "backing up {} might destroy source;  {} not moved",
311                target.quote(),
312                source.quote()
313            ),
314        )
315        .into());
316    }
317    if source.symlink_metadata().is_err() {
318        return Err(if path_ends_with_terminator(source) {
319            MvError::CannotStatNotADirectory(source.quote().to_string()).into()
320        } else {
321            MvError::NoSuchFile(source.quote().to_string()).into()
322        });
323    }
324
325    let target_is_dir = target.is_dir();
326    let source_is_dir = source.is_dir();
327
328    if path_ends_with_terminator(target)
329        && (!target_is_dir && !source_is_dir)
330        && !opts.no_target_dir
331        && opts.update != UpdateMode::ReplaceIfOlder
332    {
333        return Err(MvError::FailedToAccessNotADirectory(target.quote().to_string()).into());
334    }
335
336    assert_not_same_file(source, target, target_is_dir, opts)?;
337
338    if target_is_dir {
339        if opts.no_target_dir {
340            if source.is_dir() {
341                rename(source, target, opts, None).map_err_context(|| {
342                    format!("cannot move {} to {}", source.quote(), target.quote())
343                })
344            } else {
345                Err(MvError::DirectoryToNonDirectory(target.quote().to_string()).into())
346            }
347        } else {
348            move_files_into_dir(&[source.to_path_buf()], target, opts)
349        }
350    } else if target.exists() && source.is_dir() {
351        match opts.overwrite {
352            OverwriteMode::NoClobber => return Ok(()),
353            OverwriteMode::Interactive => {
354                if !prompt_yes!("overwrite {}? ", target.quote()) {
355                    return Err(io::Error::new(io::ErrorKind::Other, "").into());
356                }
357            }
358            OverwriteMode::Force => {}
359        };
360        Err(MvError::NonDirectoryToDirectory(
361            source.quote().to_string(),
362            target.quote().to_string(),
363        )
364        .into())
365    } else {
366        rename(source, target, opts, None).map_err(|e| USimpleError::new(1, format!("{e}")))
367    }
368}
369
370fn assert_not_same_file(
371    source: &Path,
372    target: &Path,
373    target_is_dir: bool,
374    opts: &Options,
375) -> UResult<()> {
376    // we'll compare canonicalized_source and canonicalized_target for same file detection
377    let canonicalized_source = match canonicalize(
378        absolute(source)?,
379        MissingHandling::Normal,
380        ResolveMode::Logical,
381    ) {
382        Ok(source) if source.exists() => source,
383        _ => absolute(source)?, // file or symlink target doesn't exist but its absolute path is still used for comparison
384    };
385
386    // special case if the target exists, is a directory, and the `-T` flag wasn't used
387    let target_is_dir = target_is_dir && !opts.no_target_dir;
388    let canonicalized_target = if target_is_dir {
389        // `mv source_file target_dir` => target_dir/source_file
390        // canonicalize the path that exists (target directory) and join the source file name
391        canonicalize(
392            absolute(target)?,
393            MissingHandling::Normal,
394            ResolveMode::Logical,
395        )?
396        .join(source.file_name().unwrap_or_default())
397    } else {
398        // `mv source target_dir/target` => target_dir/target
399        // we canonicalize target_dir and join /target
400        match absolute(target)?.parent() {
401            Some(parent) if parent.to_str() != Some("") => {
402                canonicalize(parent, MissingHandling::Normal, ResolveMode::Logical)?
403                    .join(target.file_name().unwrap_or_default())
404            }
405            // path.parent() returns Some("") or None if there's no parent
406            _ => absolute(target)?, // absolute paths should always have a parent, but we'll fall back just in case
407        }
408    };
409
410    let same_file = (canonicalized_source.eq(&canonicalized_target)
411        || are_hardlinks_to_same_file(source, target)
412        || are_hardlinks_or_one_way_symlink_to_same_file(source, target))
413        && opts.backup == BackupMode::NoBackup;
414
415    // get the expected target path to show in errors
416    // this is based on the argument and not canonicalized
417    let target_display = match source.file_name() {
418        Some(file_name) if target_is_dir => {
419            // join target_dir/source_file in a platform-independent manner
420            let mut path = target
421                .display()
422                .to_string()
423                .trim_end_matches("/")
424                .to_owned();
425
426            path.push('/');
427            path.push_str(&file_name.to_string_lossy());
428
429            path.quote().to_string()
430        }
431        _ => target.quote().to_string(),
432    };
433
434    if same_file
435        && (canonicalized_source.eq(&canonicalized_target)
436            || source.eq(Path::new("."))
437            || source.ends_with("/.")
438            || source.is_file())
439    {
440        return Err(MvError::SameFile(source.quote().to_string(), target_display).into());
441    } else if (same_file || canonicalized_target.starts_with(canonicalized_source))
442        // don't error if we're moving a symlink of a directory into itself
443        && !source.is_symlink()
444    {
445        return Err(
446            MvError::SelfTargetSubdirectory(source.quote().to_string(), target_display).into(),
447        );
448    }
449    Ok(())
450}
451
452fn handle_multiple_paths(paths: &[PathBuf], opts: &Options) -> UResult<()> {
453    if opts.no_target_dir {
454        return Err(UUsageError::new(
455            1,
456            format!("mv: extra operand {}", paths[2].quote()),
457        ));
458    }
459    let target_dir = paths.last().unwrap();
460    let sources = &paths[..paths.len() - 1];
461
462    move_files_into_dir(sources, target_dir, opts)
463}
464
465/// Execute the mv command. This moves 'source' to 'target', where
466/// 'target' is a directory. If 'target' does not exist, and source is a single
467/// file or directory, then 'source' will be renamed to 'target'.
468pub fn mv(files: &[OsString], opts: &Options) -> UResult<()> {
469    let paths = parse_paths(files, opts);
470
471    if let Some(ref name) = opts.target_dir {
472        return move_files_into_dir(&paths, &PathBuf::from(name), opts);
473    }
474
475    match paths.len() {
476        2 => handle_two_paths(&paths[0], &paths[1], opts),
477        _ => handle_multiple_paths(&paths, opts),
478    }
479}
480
481#[allow(clippy::cognitive_complexity)]
482fn move_files_into_dir(files: &[PathBuf], target_dir: &Path, options: &Options) -> UResult<()> {
483    // remember the moved destinations for further usage
484    let mut moved_destinations: HashSet<PathBuf> = HashSet::with_capacity(files.len());
485
486    if !target_dir.is_dir() {
487        return Err(MvError::NotADirectory(target_dir.quote().to_string()).into());
488    }
489
490    let multi_progress = options.progress_bar.then(MultiProgress::new);
491
492    let count_progress = if let Some(ref multi_progress) = multi_progress {
493        if files.len() > 1 {
494            Some(multi_progress.add(
495                ProgressBar::new(files.len().try_into().unwrap()).with_style(
496                    ProgressStyle::with_template("moving {msg} {wide_bar} {pos}/{len}").unwrap(),
497                ),
498            ))
499        } else {
500            None
501        }
502    } else {
503        None
504    };
505
506    for sourcepath in files {
507        if !sourcepath.exists() {
508            show!(MvError::NoSuchFile(sourcepath.quote().to_string()));
509            continue;
510        }
511
512        if let Some(ref pb) = count_progress {
513            pb.set_message(sourcepath.to_string_lossy().to_string());
514        }
515
516        let targetpath = match sourcepath.file_name() {
517            Some(name) => target_dir.join(name),
518            None => {
519                show!(MvError::NoSuchFile(sourcepath.quote().to_string()));
520                continue;
521            }
522        };
523
524        if moved_destinations.contains(&targetpath) && options.backup != BackupMode::NumberedBackup
525        {
526            // If the target file was already created in this mv call, do not overwrite
527            show!(USimpleError::new(
528                1,
529                format!(
530                    "will not overwrite just-created '{}' with '{}'",
531                    targetpath.display(),
532                    sourcepath.display()
533                ),
534            ));
535            continue;
536        }
537
538        // Check if we have mv dir1 dir2 dir2
539        // And generate an error if this is the case
540        if let Err(e) = assert_not_same_file(sourcepath, target_dir, true, options) {
541            show!(e);
542            continue;
543        }
544
545        match rename(sourcepath, &targetpath, options, multi_progress.as_ref()) {
546            Err(e) if e.to_string().is_empty() => set_exit_code(1),
547            Err(e) => {
548                let e = e.map_err_context(|| {
549                    format!(
550                        "cannot move {} to {}",
551                        sourcepath.quote(),
552                        targetpath.quote()
553                    )
554                });
555                match multi_progress {
556                    Some(ref pb) => pb.suspend(|| show!(e)),
557                    None => show!(e),
558                };
559            }
560            Ok(()) => (),
561        }
562        if let Some(ref pb) = count_progress {
563            pb.inc(1);
564        }
565        moved_destinations.insert(targetpath.clone());
566    }
567    Ok(())
568}
569
570fn rename(
571    from: &Path,
572    to: &Path,
573    opts: &Options,
574    multi_progress: Option<&MultiProgress>,
575) -> io::Result<()> {
576    let mut backup_path = None;
577
578    if to.exists() {
579        if opts.update == UpdateMode::ReplaceNone {
580            if opts.debug {
581                println!("skipped {}", to.quote());
582            }
583            return Ok(());
584        }
585
586        if (opts.update == UpdateMode::ReplaceIfOlder)
587            && fs::metadata(from)?.modified()? <= fs::metadata(to)?.modified()?
588        {
589            return Ok(());
590        }
591
592        if opts.update == UpdateMode::ReplaceNoneFail {
593            let err_msg = format!("not replacing {}", to.quote());
594            return Err(io::Error::new(io::ErrorKind::Other, err_msg));
595        }
596
597        match opts.overwrite {
598            OverwriteMode::NoClobber => {
599                if opts.debug {
600                    println!("skipped {}", to.quote());
601                }
602                return Ok(());
603            }
604            OverwriteMode::Interactive => {
605                if !prompt_yes!("overwrite {}?", to.quote()) {
606                    return Err(io::Error::new(io::ErrorKind::Other, ""));
607                }
608            }
609            OverwriteMode::Force => {}
610        };
611
612        backup_path = backup_control::get_backup_path(opts.backup, to, &opts.suffix);
613        if let Some(ref backup_path) = backup_path {
614            rename_with_fallback(to, backup_path, multi_progress)?;
615        }
616    }
617
618    // "to" may no longer exist if it was backed up
619    if to.exists() && to.is_dir() {
620        // normalize behavior between *nix and windows
621        if from.is_dir() {
622            if is_empty_dir(to) {
623                fs::remove_dir(to)?;
624            } else {
625                return Err(io::Error::new(io::ErrorKind::Other, "Directory not empty"));
626            }
627        }
628    }
629
630    rename_with_fallback(from, to, multi_progress)?;
631
632    if opts.verbose {
633        let message = match backup_path {
634            Some(path) => format!(
635                "renamed {} -> {} (backup: {})",
636                from.quote(),
637                to.quote(),
638                path.quote()
639            ),
640            None => format!("renamed {} -> {}", from.quote(), to.quote()),
641        };
642
643        match multi_progress {
644            Some(pb) => pb.suspend(|| {
645                println!("{message}");
646            }),
647            None => println!("{message}"),
648        };
649    }
650    Ok(())
651}
652
653/// A wrapper around `fs::rename`, so that if it fails, we try falling back on
654/// copying and removing.
655fn rename_with_fallback(
656    from: &Path,
657    to: &Path,
658    multi_progress: Option<&MultiProgress>,
659) -> io::Result<()> {
660    if let Err(err) = fs::rename(from, to) {
661        #[cfg(windows)]
662        const EXDEV: i32 = windows_sys::Win32::Foundation::ERROR_NOT_SAME_DEVICE as _;
663        #[cfg(unix)]
664        const EXDEV: i32 = libc::EXDEV as _;
665
666        // We will only copy if:
667        // 1. Files are on different devices (EXDEV error)
668        // 2. On Windows, if the target file exists and source file is opened by another process
669        //    (MoveFileExW fails with "Access Denied" even if the source file has FILE_SHARE_DELETE permission)
670        let should_fallback = matches!(err.raw_os_error(), Some(EXDEV))
671            || (from.is_file() && can_delete_file(from).unwrap_or(false));
672        if !should_fallback {
673            return Err(err);
674        }
675
676        // Get metadata without following symlinks
677        let metadata = from.symlink_metadata()?;
678        let file_type = metadata.file_type();
679
680        if file_type.is_symlink() {
681            rename_symlink_fallback(from, to)?;
682        } else if file_type.is_dir() {
683            // We remove the destination directory if it exists to match the
684            // behavior of `fs::rename`. As far as I can tell, `fs_extra`'s
685            // `move_dir` would otherwise behave differently.
686            if to.exists() {
687                fs::remove_dir_all(to)?;
688            }
689            let options = DirCopyOptions {
690                // From the `fs_extra` documentation:
691                // "Recursively copy a directory with a new name or place it
692                // inside the destination. (same behaviors like cp -r in Unix)"
693                copy_inside: true,
694                ..DirCopyOptions::new()
695            };
696
697            // Calculate total size of directory
698            // Silently degrades:
699            //    If finding the total size fails for whatever reason,
700            //    the progress bar wont be shown for this file / dir.
701            //    (Move will probably fail due to permission error later?)
702            let total_size = dir_get_size(from).ok();
703
704            let progress_bar =
705                if let (Some(multi_progress), Some(total_size)) = (multi_progress, total_size) {
706                    let bar = ProgressBar::new(total_size).with_style(
707                        ProgressStyle::with_template(
708                            "{msg}: [{elapsed_precise}] {wide_bar} {bytes:>7}/{total_bytes:7}",
709                        )
710                        .unwrap(),
711                    );
712
713                    Some(multi_progress.add(bar))
714                } else {
715                    None
716                };
717
718            #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
719            let xattrs =
720                fsxattr::retrieve_xattrs(from).unwrap_or_else(|_| std::collections::HashMap::new());
721
722            let result = if let Some(ref pb) = progress_bar {
723                move_dir_with_progress(from, to, &options, |process_info: TransitProcess| {
724                    pb.set_position(process_info.copied_bytes);
725                    pb.set_message(process_info.file_name);
726                    TransitProcessResult::ContinueOrAbort
727                })
728            } else {
729                move_dir(from, to, &options)
730            };
731
732            #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
733            fsxattr::apply_xattrs(to, xattrs)?;
734
735            if let Err(err) = result {
736                return match err.kind {
737                    fs_extra::error::ErrorKind::PermissionDenied => Err(io::Error::new(
738                        io::ErrorKind::PermissionDenied,
739                        "Permission denied",
740                    )),
741                    _ => Err(io::Error::new(io::ErrorKind::Other, format!("{err:?}"))),
742                };
743            }
744        } else {
745            if to.is_symlink() {
746                fs::remove_file(to).map_err(|err| {
747                    let to = to.to_string_lossy();
748                    let from = from.to_string_lossy();
749                    io::Error::new(
750                        err.kind(),
751                        format!(
752                            "inter-device move failed: '{from}' to '{to}'\
753                            ; unable to remove target: {err}"
754                        ),
755                    )
756                })?;
757            }
758            #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
759            fs::copy(from, to)
760                .and_then(|_| fsxattr::copy_xattrs(&from, &to))
761                .and_then(|_| fs::remove_file(from))?;
762            #[cfg(any(target_os = "macos", target_os = "redox", not(unix)))]
763            fs::copy(from, to).and_then(|_| fs::remove_file(from))?;
764        }
765    }
766    Ok(())
767}
768
769/// Move the given symlink to the given destination. On Windows, dangling
770/// symlinks return an error.
771#[inline]
772fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
773    let path_symlink_points_to = fs::read_link(from)?;
774    #[cfg(unix)]
775    {
776        unix::fs::symlink(path_symlink_points_to, to).and_then(|_| fs::remove_file(from))?;
777    }
778    #[cfg(windows)]
779    {
780        if path_symlink_points_to.exists() {
781            if path_symlink_points_to.is_dir() {
782                windows::fs::symlink_dir(&path_symlink_points_to, to)?;
783            } else {
784                windows::fs::symlink_file(&path_symlink_points_to, to)?;
785            }
786            fs::remove_file(from)?;
787        } else {
788            return Err(io::Error::new(
789                io::ErrorKind::NotFound,
790                "can't determine symlink type, since it is dangling",
791            ));
792        }
793    }
794    #[cfg(not(any(windows, unix)))]
795    {
796        return Err(io::Error::new(
797            io::ErrorKind::Other,
798            "your operating system does not support symlinks",
799        ));
800    }
801    Ok(())
802}
803
804fn is_empty_dir(path: &Path) -> bool {
805    match fs::read_dir(path) {
806        Ok(contents) => contents.peekable().peek().is_none(),
807        Err(_e) => false,
808    }
809}
810
811/// Checks if a file can be deleted by attempting to open it with delete permissions.
812#[cfg(windows)]
813fn can_delete_file(path: &Path) -> Result<bool, io::Error> {
814    use std::{
815        os::windows::ffi::OsStrExt as _,
816        ptr::{null, null_mut},
817    };
818
819    use windows_sys::Win32::{
820        Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
821        Storage::FileSystem::{
822            CreateFileW, DELETE, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_DELETE, FILE_SHARE_READ,
823            FILE_SHARE_WRITE, OPEN_EXISTING,
824        },
825    };
826
827    let wide_path = path
828        .as_os_str()
829        .encode_wide()
830        .chain([0])
831        .collect::<Vec<u16>>();
832
833    let handle = unsafe {
834        CreateFileW(
835            wide_path.as_ptr(),
836            DELETE,
837            FILE_SHARE_DELETE | FILE_SHARE_READ | FILE_SHARE_WRITE,
838            null(),
839            OPEN_EXISTING,
840            FILE_ATTRIBUTE_NORMAL,
841            null_mut(),
842        )
843    };
844
845    if handle == INVALID_HANDLE_VALUE {
846        return Err(io::Error::last_os_error());
847    }
848
849    unsafe { CloseHandle(handle) };
850
851    Ok(true)
852}
853
854#[cfg(not(windows))]
855fn can_delete_file(_: &Path) -> Result<bool, io::Error> {
856    // On non-Windows platforms, always return false to indicate that we don't need
857    // to try the copy+delete fallback. This is because on Unix-like systems,
858    // rename() failing with errors other than EXDEV means the operation cannot
859    // succeed even with a copy+delete approach (e.g. permission errors).
860    Ok(false)
861}