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#[cfg(unix)]
10mod hardlink;
11
12use clap::builder::ValueParser;
13use clap::error::ErrorKind;
14use clap::{Arg, ArgAction, ArgMatches, Command};
15use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
16
17#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
18use std::collections::HashMap;
19use std::collections::HashSet;
20use std::env;
21use std::ffi::OsString;
22use std::fs;
23use std::io;
24#[cfg(unix)]
25use std::os::unix;
26#[cfg(unix)]
27use std::os::unix::fs::FileTypeExt;
28#[cfg(windows)]
29use std::os::windows;
30use std::path::{Path, PathBuf, absolute};
31
32#[cfg(unix)]
33use crate::hardlink::{
34    HardlinkGroupScanner, HardlinkOptions, HardlinkTracker, create_hardlink_context,
35    with_optional_hardlink_context,
36};
37use uucore::backup_control::{self, source_is_target_backup};
38use uucore::display::Quotable;
39use uucore::error::{FromIo, UResult, USimpleError, UUsageError, set_exit_code};
40#[cfg(unix)]
41use uucore::fs::make_fifo;
42use uucore::fs::{
43    MissingHandling, ResolveMode, are_hardlinks_or_one_way_symlink_to_same_file,
44    are_hardlinks_to_same_file, canonicalize, path_ends_with_terminator,
45};
46#[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
47use uucore::fsxattr;
48#[cfg(feature = "selinux")]
49use uucore::selinux::set_selinux_security_context;
50use uucore::translate;
51use uucore::update_control;
52
53// These are exposed for projects (e.g. nushell) that want to create an `Options` value, which
54// requires these enums
55pub use uucore::{backup_control::BackupMode, update_control::UpdateMode};
56use uucore::{format_usage, prompt_yes, show};
57
58use fs_extra::dir::get_size as dir_get_size;
59
60use crate::error::MvError;
61
62/// Options contains all the possible behaviors and flags for mv.
63///
64/// All options are public so that the options can be programmatically
65/// constructed by other crates, such as nushell. That means that this struct is
66/// part of our public API. It should therefore not be changed without good reason.
67///
68/// The fields are documented with the arguments that determine their value.
69#[derive(Debug, Clone, Eq, PartialEq)]
70pub struct Options {
71    /// specifies overwrite behavior
72    /// '-n' '--no-clobber'
73    /// '-i' '--interactive'
74    /// '-f' '--force'
75    pub overwrite: OverwriteMode,
76
77    /// `--backup[=CONTROL]`, `-b`
78    pub backup: BackupMode,
79
80    /// '-S' --suffix' backup suffix
81    pub suffix: String,
82
83    /// Available update mode "--update-mode=all|none|older"
84    pub update: UpdateMode,
85
86    /// Specifies target directory
87    /// '-t, --target-directory=DIRECTORY'
88    pub target_dir: Option<OsString>,
89
90    /// Treat destination as a normal file
91    /// '-T, --no-target-directory
92    pub no_target_dir: bool,
93
94    /// '-v, --verbose'
95    pub verbose: bool,
96
97    /// '--strip-trailing-slashes'
98    pub strip_slashes: bool,
99
100    /// '-g, --progress'
101    pub progress_bar: bool,
102
103    /// `--debug`
104    pub debug: bool,
105
106    /// `-Z, --context`
107    pub context: Option<String>,
108}
109
110impl Default for Options {
111    fn default() -> Self {
112        Self {
113            overwrite: OverwriteMode::default(),
114            backup: BackupMode::default(),
115            suffix: backup_control::DEFAULT_BACKUP_SUFFIX.to_owned(),
116            update: UpdateMode::default(),
117            target_dir: None,
118            no_target_dir: false,
119            verbose: false,
120            strip_slashes: false,
121            progress_bar: false,
122            debug: false,
123            context: None,
124        }
125    }
126}
127
128/// specifies behavior of the overwrite flag
129#[derive(Clone, Debug, Eq, PartialEq, Default)]
130pub enum OverwriteMode {
131    /// '-n' '--no-clobber'   do not overwrite
132    NoClobber,
133    /// '-i' '--interactive'  prompt before overwrite
134    Interactive,
135    ///'-f' '--force'         overwrite without prompt
136    #[default]
137    Force,
138}
139
140static OPT_FORCE: &str = "force";
141static OPT_INTERACTIVE: &str = "interactive";
142static OPT_NO_CLOBBER: &str = "no-clobber";
143static OPT_STRIP_TRAILING_SLASHES: &str = "strip-trailing-slashes";
144static OPT_TARGET_DIRECTORY: &str = "target-directory";
145static OPT_NO_TARGET_DIRECTORY: &str = "no-target-directory";
146static OPT_VERBOSE: &str = "verbose";
147static OPT_PROGRESS: &str = "progress";
148static ARG_FILES: &str = "files";
149static OPT_DEBUG: &str = "debug";
150static OPT_CONTEXT: &str = "context";
151static OPT_SELINUX: &str = "selinux";
152
153#[uucore::main]
154pub fn uumain(args: impl uucore::Args) -> UResult<()> {
155    let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?;
156
157    let files: Vec<OsString> = matches
158        .get_many::<OsString>(ARG_FILES)
159        .unwrap_or_default()
160        .cloned()
161        .collect();
162
163    if files.len() == 1 && !matches.contains_id(OPT_TARGET_DIRECTORY) {
164        let err = uu_app().error(
165            ErrorKind::TooFewValues,
166            translate!("mv-error-insufficient-arguments", "arg_files" => ARG_FILES),
167        );
168        uucore::clap_localization::handle_clap_error_with_exit_code(err, 1);
169    }
170
171    let overwrite_mode = determine_overwrite_mode(&matches);
172    let backup_mode = backup_control::determine_backup_mode(&matches)?;
173    let update_mode = update_control::determine_update_mode(&matches);
174
175    if backup_mode != BackupMode::None
176        && (overwrite_mode == OverwriteMode::NoClobber
177            || update_mode == UpdateMode::None
178            || update_mode == UpdateMode::NoneFail)
179    {
180        return Err(UUsageError::new(
181            1,
182            translate!("mv-error-backup-with-no-clobber"),
183        ));
184    }
185
186    let backup_suffix = backup_control::determine_backup_suffix(&matches);
187
188    let target_dir = matches
189        .get_one::<OsString>(OPT_TARGET_DIRECTORY)
190        .map(OsString::from);
191
192    if let Some(ref maybe_dir) = target_dir {
193        if !Path::new(&maybe_dir).is_dir() {
194            return Err(MvError::TargetNotADirectory(maybe_dir.quote().to_string()).into());
195        }
196    }
197
198    // Handle -Z and --context options
199    // If -Z is used, use the default context (empty string)
200    // If --context=value is used, use that specific value
201    let context = if matches.get_flag(OPT_SELINUX) {
202        Some(String::new())
203    } else {
204        matches.get_one::<String>(OPT_CONTEXT).cloned()
205    };
206
207    let opts = Options {
208        overwrite: overwrite_mode,
209        backup: backup_mode,
210        suffix: backup_suffix,
211        update: update_mode,
212        target_dir,
213        no_target_dir: matches.get_flag(OPT_NO_TARGET_DIRECTORY),
214        verbose: matches.get_flag(OPT_VERBOSE) || matches.get_flag(OPT_DEBUG),
215        strip_slashes: matches.get_flag(OPT_STRIP_TRAILING_SLASHES),
216        progress_bar: matches.get_flag(OPT_PROGRESS),
217        debug: matches.get_flag(OPT_DEBUG),
218        context,
219    };
220
221    mv(&files[..], &opts)
222}
223
224pub fn uu_app() -> Command {
225    Command::new(uucore::util_name())
226        .version(uucore::crate_version!())
227        .about(translate!("mv-about"))
228        .help_template(uucore::localized_help_template(uucore::util_name()))
229        .override_usage(format_usage(&translate!("mv-usage")))
230        .after_help(format!(
231            "{}\n\n{}",
232            translate!("mv-after-help"),
233            backup_control::BACKUP_CONTROL_LONG_HELP
234        ))
235        .infer_long_args(true)
236        .arg(
237            Arg::new(OPT_FORCE)
238                .short('f')
239                .long(OPT_FORCE)
240                .help(translate!("mv-help-force"))
241                .overrides_with_all([OPT_INTERACTIVE, OPT_NO_CLOBBER])
242                .action(ArgAction::SetTrue),
243        )
244        .arg(
245            Arg::new(OPT_INTERACTIVE)
246                .short('i')
247                .long(OPT_INTERACTIVE)
248                .help(translate!("mv-help-interactive"))
249                .overrides_with_all([OPT_FORCE, OPT_NO_CLOBBER])
250                .action(ArgAction::SetTrue),
251        )
252        .arg(
253            Arg::new(OPT_NO_CLOBBER)
254                .short('n')
255                .long(OPT_NO_CLOBBER)
256                .help(translate!("mv-help-no-clobber"))
257                .overrides_with_all([OPT_FORCE, OPT_INTERACTIVE])
258                .action(ArgAction::SetTrue),
259        )
260        .arg(
261            Arg::new(OPT_STRIP_TRAILING_SLASHES)
262                .long(OPT_STRIP_TRAILING_SLASHES)
263                .help(translate!("mv-help-strip-trailing-slashes"))
264                .action(ArgAction::SetTrue),
265        )
266        .arg(backup_control::arguments::backup())
267        .arg(backup_control::arguments::backup_no_args())
268        .arg(backup_control::arguments::suffix())
269        .arg(update_control::arguments::update())
270        .arg(update_control::arguments::update_no_args())
271        .arg(
272            Arg::new(OPT_TARGET_DIRECTORY)
273                .short('t')
274                .long(OPT_TARGET_DIRECTORY)
275                .help(translate!("mv-help-target-directory"))
276                .value_name("DIRECTORY")
277                .value_hint(clap::ValueHint::DirPath)
278                .conflicts_with(OPT_NO_TARGET_DIRECTORY)
279                .value_parser(ValueParser::os_string()),
280        )
281        .arg(
282            Arg::new(OPT_NO_TARGET_DIRECTORY)
283                .short('T')
284                .long(OPT_NO_TARGET_DIRECTORY)
285                .help(translate!("mv-help-no-target-directory"))
286                .action(ArgAction::SetTrue),
287        )
288        .arg(
289            Arg::new(OPT_VERBOSE)
290                .short('v')
291                .long(OPT_VERBOSE)
292                .help(translate!("mv-help-verbose"))
293                .action(ArgAction::SetTrue),
294        )
295        .arg(
296            Arg::new(OPT_PROGRESS)
297                .short('g')
298                .long(OPT_PROGRESS)
299                .help(translate!("mv-help-progress"))
300                .action(ArgAction::SetTrue),
301        )
302        .arg(
303            Arg::new(OPT_SELINUX)
304                .short('Z')
305                .help(translate!("mv-help-selinux"))
306                .action(ArgAction::SetTrue),
307        )
308        .arg(
309            Arg::new(OPT_CONTEXT)
310                .long(OPT_CONTEXT)
311                .value_name("CTX")
312                .value_parser(clap::value_parser!(String))
313                .help(translate!("mv-help-context"))
314                .num_args(0..=1)
315                .require_equals(true)
316                .default_missing_value(""),
317        )
318        .arg(
319            Arg::new(ARG_FILES)
320                .action(ArgAction::Append)
321                .num_args(1..)
322                .required(true)
323                .value_parser(ValueParser::os_string())
324                .value_hint(clap::ValueHint::AnyPath),
325        )
326        .arg(
327            Arg::new(OPT_DEBUG)
328                .long(OPT_DEBUG)
329                .help(translate!("mv-help-debug"))
330                .action(ArgAction::SetTrue),
331        )
332}
333
334fn determine_overwrite_mode(matches: &ArgMatches) -> OverwriteMode {
335    // This does not exactly match the GNU implementation:
336    // The GNU mv defaults to Force, but if more than one of the
337    // overwrite options are supplied, only the last takes effect.
338    // To default to no-clobber in that situation seems safer:
339    //
340    if matches.get_flag(OPT_NO_CLOBBER) {
341        OverwriteMode::NoClobber
342    } else if matches.get_flag(OPT_INTERACTIVE) {
343        OverwriteMode::Interactive
344    } else {
345        OverwriteMode::Force
346    }
347}
348
349fn parse_paths(files: &[OsString], opts: &Options) -> Vec<PathBuf> {
350    let paths = files.iter().map(Path::new);
351
352    if opts.strip_slashes {
353        paths
354            .map(|p| p.components().as_path().to_owned())
355            .collect::<Vec<PathBuf>>()
356    } else {
357        paths.map(|p| p.to_owned()).collect::<Vec<PathBuf>>()
358    }
359}
360
361fn handle_two_paths(source: &Path, target: &Path, opts: &Options) -> UResult<()> {
362    if opts.backup == BackupMode::Simple && source_is_target_backup(source, target, &opts.suffix) {
363        return Err(io::Error::new(
364            io::ErrorKind::NotFound,
365            translate!("mv-error-backup-might-destroy-source", "target" => target.quote(), "source" => source.quote()),
366        )
367        .into());
368    }
369    if source.symlink_metadata().is_err() {
370        return Err(if path_ends_with_terminator(source) {
371            MvError::CannotStatNotADirectory(source.quote().to_string()).into()
372        } else {
373            MvError::NoSuchFile(source.quote().to_string()).into()
374        });
375    }
376
377    let source_is_dir = source.is_dir() && !source.is_symlink();
378    let target_is_dir = if target.is_symlink() {
379        fs::canonicalize(target).is_ok_and(|p| p.is_dir())
380    } else {
381        target.is_dir()
382    };
383
384    if path_ends_with_terminator(target)
385        && (!target_is_dir && !source_is_dir)
386        && !opts.no_target_dir
387        && opts.update != UpdateMode::IfOlder
388    {
389        return Err(MvError::FailedToAccessNotADirectory(target.quote().to_string()).into());
390    }
391
392    assert_not_same_file(source, target, target_is_dir, opts)?;
393
394    if target_is_dir {
395        if opts.no_target_dir {
396            if source.is_dir() {
397                #[cfg(unix)]
398                let (mut hardlink_tracker, hardlink_scanner) = create_hardlink_context();
399                #[cfg(unix)]
400                let hardlink_params = (Some(&mut hardlink_tracker), Some(&hardlink_scanner));
401                #[cfg(not(unix))]
402                let hardlink_params = (None, None);
403
404                rename(
405                    source,
406                    target,
407                    opts,
408                    None,
409                    hardlink_params.0,
410                    hardlink_params.1,
411                )
412                .map_err_context(|| {
413                    translate!("mv-error-cannot-move", "source" => source.quote(), "target" => target.quote())
414                })
415            } else {
416                Err(MvError::DirectoryToNonDirectory(target.quote().to_string()).into())
417            }
418        } else {
419            move_files_into_dir(&[source.to_path_buf()], target, opts)
420        }
421    } else if target.exists() && source_is_dir {
422        match opts.overwrite {
423            OverwriteMode::NoClobber => return Ok(()),
424            OverwriteMode::Interactive => {
425                if !prompt_yes!(
426                    "{}",
427                    translate!("mv-prompt-overwrite", "target" => target.quote())
428                ) {
429                    return Err(io::Error::other("").into());
430                }
431            }
432            OverwriteMode::Force => {}
433        }
434        Err(MvError::NonDirectoryToDirectory(
435            source.quote().to_string(),
436            target.quote().to_string(),
437        )
438        .into())
439    } else {
440        #[cfg(unix)]
441        let (mut hardlink_tracker, hardlink_scanner) = create_hardlink_context();
442        #[cfg(unix)]
443        let hardlink_params = (Some(&mut hardlink_tracker), Some(&hardlink_scanner));
444        #[cfg(not(unix))]
445        let hardlink_params = (None, None);
446
447        rename(
448            source,
449            target,
450            opts,
451            None,
452            hardlink_params.0,
453            hardlink_params.1,
454        )
455        .map_err(|e| USimpleError::new(1, format!("{e}")))
456    }
457}
458
459fn assert_not_same_file(
460    source: &Path,
461    target: &Path,
462    target_is_dir: bool,
463    opts: &Options,
464) -> UResult<()> {
465    // we'll compare canonicalized_source and canonicalized_target for same file detection
466    let canonicalized_source = match canonicalize(
467        absolute(source)?,
468        MissingHandling::Normal,
469        ResolveMode::Logical,
470    ) {
471        Ok(source) if source.exists() => source,
472        _ => absolute(source)?, // file or symlink target doesn't exist but its absolute path is still used for comparison
473    };
474
475    // special case if the target exists, is a directory, and the `-T` flag wasn't used
476    let target_is_dir = target_is_dir && !opts.no_target_dir;
477    let canonicalized_target = if target_is_dir {
478        // `mv source_file target_dir` => target_dir/source_file
479        // canonicalize the path that exists (target directory) and join the source file name
480        canonicalize(
481            absolute(target)?,
482            MissingHandling::Normal,
483            ResolveMode::Logical,
484        )?
485        .join(source.file_name().unwrap_or_default())
486    } else {
487        // `mv source target_dir/target` => target_dir/target
488        // we canonicalize target_dir and join /target
489        match absolute(target)?.parent() {
490            Some(parent) if parent.to_str() != Some("") => {
491                canonicalize(parent, MissingHandling::Normal, ResolveMode::Logical)?
492                    .join(target.file_name().unwrap_or_default())
493            }
494            // path.parent() returns Some("") or None if there's no parent
495            _ => absolute(target)?, // absolute paths should always have a parent, but we'll fall back just in case
496        }
497    };
498
499    let same_file = (canonicalized_source.eq(&canonicalized_target)
500        || are_hardlinks_to_same_file(source, target)
501        || are_hardlinks_or_one_way_symlink_to_same_file(source, target))
502        && opts.backup == BackupMode::None;
503
504    // get the expected target path to show in errors
505    // this is based on the argument and not canonicalized
506    let target_display = match source.file_name() {
507        Some(file_name) if target_is_dir => {
508            // join target_dir/source_file in a platform-independent manner
509            let mut path = target
510                .display()
511                .to_string()
512                .trim_end_matches('/')
513                .to_owned();
514
515            path.push('/');
516            path.push_str(&file_name.to_string_lossy());
517
518            path.quote().to_string()
519        }
520        _ => target.quote().to_string(),
521    };
522
523    if same_file
524        && (canonicalized_source.eq(&canonicalized_target)
525            || source.eq(Path::new("."))
526            || source.ends_with("/.")
527            || source.is_file())
528    {
529        return Err(MvError::SameFile(source.quote().to_string(), target_display).into());
530    } else if (same_file || canonicalized_target.starts_with(canonicalized_source))
531        // don't error if we're moving a symlink of a directory into itself
532        && !source.is_symlink()
533    {
534        return Err(
535            MvError::SelfTargetSubdirectory(source.quote().to_string(), target_display).into(),
536        );
537    }
538    Ok(())
539}
540
541fn handle_multiple_paths(paths: &[PathBuf], opts: &Options) -> UResult<()> {
542    if opts.no_target_dir {
543        return Err(UUsageError::new(
544            1,
545            translate!("mv-error-extra-operand", "operand" => paths.last().unwrap().quote()),
546        ));
547    }
548    let target_dir = paths.last().unwrap();
549    let sources = &paths[..paths.len() - 1];
550
551    move_files_into_dir(sources, target_dir, opts)
552}
553
554/// Execute the mv command. This moves 'source' to 'target', where
555/// 'target' is a directory. If 'target' does not exist, and source is a single
556/// file or directory, then 'source' will be renamed to 'target'.
557pub fn mv(files: &[OsString], opts: &Options) -> UResult<()> {
558    let paths = parse_paths(files, opts);
559
560    if let Some(ref name) = opts.target_dir {
561        return move_files_into_dir(&paths, &PathBuf::from(name), opts);
562    }
563
564    match paths.len() {
565        2 => handle_two_paths(&paths[0], &paths[1], opts),
566        _ => handle_multiple_paths(&paths, opts),
567    }
568}
569
570#[allow(clippy::cognitive_complexity)]
571fn move_files_into_dir(files: &[PathBuf], target_dir: &Path, options: &Options) -> UResult<()> {
572    // remember the moved destinations for further usage
573    let mut moved_destinations: HashSet<PathBuf> = HashSet::with_capacity(files.len());
574    // Create hardlink tracking context
575    #[cfg(unix)]
576    let (mut hardlink_tracker, hardlink_scanner) = {
577        let (tracker, mut scanner) = create_hardlink_context();
578
579        // Use hardlink options
580        let hardlink_options = HardlinkOptions {
581            verbose: options.verbose || options.debug,
582        };
583
584        // Pre-scan files if needed
585        if let Err(e) = scanner.scan_files(files, &hardlink_options) {
586            if hardlink_options.verbose {
587                eprintln!("mv: warning: failed to scan files for hardlinks: {e}");
588                eprintln!("mv: continuing without hardlink preservation");
589            } else {
590                // Show warning in non-verbose mode for serious errors
591                eprintln!(
592                    "mv: warning: hardlink scanning failed, continuing without hardlink preservation"
593                );
594            }
595            // Continue without hardlink tracking on scan failure
596            // This provides graceful degradation rather than failing completely
597        }
598
599        (tracker, scanner)
600    };
601
602    if !target_dir.is_dir() {
603        return Err(MvError::NotADirectory(target_dir.quote().to_string()).into());
604    }
605
606    let display_manager = options.progress_bar.then(MultiProgress::new);
607
608    let count_progress = if let Some(ref display_manager) = display_manager {
609        if files.len() > 1 {
610            Some(
611                display_manager.add(
612                    ProgressBar::new(files.len().try_into().unwrap()).with_style(
613                        ProgressStyle::with_template(&format!(
614                            "{} {{msg}} {{wide_bar}} {{pos}}/{{len}}",
615                            translate!("mv-progress-moving")
616                        ))
617                        .unwrap(),
618                    ),
619                ),
620            )
621        } else {
622            None
623        }
624    } else {
625        None
626    };
627
628    for sourcepath in files {
629        if sourcepath.symlink_metadata().is_err() {
630            show!(MvError::NoSuchFile(sourcepath.quote().to_string()));
631            continue;
632        }
633
634        if let Some(ref pb) = count_progress {
635            let msg = format!("{} (scanning hardlinks)", sourcepath.to_string_lossy());
636            pb.set_message(msg);
637        }
638
639        let targetpath = match sourcepath.file_name() {
640            Some(name) => target_dir.join(name),
641            None => {
642                show!(MvError::NoSuchFile(sourcepath.quote().to_string()));
643                continue;
644            }
645        };
646
647        if moved_destinations.contains(&targetpath) && options.backup != BackupMode::Numbered {
648            // If the target file was already created in this mv call, do not overwrite
649            show!(USimpleError::new(
650                1,
651                translate!("mv-error-will-not-overwrite-just-created", "target" => targetpath.display(), "source" => sourcepath.display()),
652            ));
653            continue;
654        }
655
656        // Check if we have mv dir1 dir2 dir2
657        // And generate an error if this is the case
658        if let Err(e) = assert_not_same_file(sourcepath, target_dir, true, options) {
659            show!(e);
660            continue;
661        }
662
663        #[cfg(unix)]
664        let hardlink_params = (Some(&mut hardlink_tracker), Some(&hardlink_scanner));
665        #[cfg(not(unix))]
666        let hardlink_params = (None, None);
667
668        match rename(
669            sourcepath,
670            &targetpath,
671            options,
672            display_manager.as_ref(),
673            hardlink_params.0,
674            hardlink_params.1,
675        ) {
676            Err(e) if e.to_string().is_empty() => set_exit_code(1),
677            Err(e) => {
678                let e = e.map_err_context(|| {
679                    translate!("mv-error-cannot-move", "source" => sourcepath.quote(), "target" => targetpath.quote())
680                });
681                match display_manager {
682                    Some(ref pb) => pb.suspend(|| show!(e)),
683                    None => show!(e),
684                }
685            }
686            Ok(()) => (),
687        }
688        if let Some(ref pb) = count_progress {
689            pb.inc(1);
690        }
691        moved_destinations.insert(targetpath.clone());
692    }
693    Ok(())
694}
695
696fn rename(
697    from: &Path,
698    to: &Path,
699    opts: &Options,
700    display_manager: Option<&MultiProgress>,
701    #[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
702    #[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
703    #[cfg(not(unix))] _hardlink_tracker: Option<()>,
704    #[cfg(not(unix))] _hardlink_scanner: Option<()>,
705) -> io::Result<()> {
706    let mut backup_path = None;
707
708    if to.exists() {
709        if opts.update == UpdateMode::None {
710            if opts.debug {
711                println!("{}", translate!("mv-debug-skipped", "target" => to.quote()));
712            }
713            return Ok(());
714        }
715
716        if (opts.update == UpdateMode::IfOlder)
717            && fs::metadata(from)?.modified()? <= fs::metadata(to)?.modified()?
718        {
719            return Ok(());
720        }
721
722        if opts.update == UpdateMode::NoneFail {
723            let err_msg = translate!("mv-error-not-replacing", "target" => to.quote());
724            return Err(io::Error::other(err_msg));
725        }
726
727        match opts.overwrite {
728            OverwriteMode::NoClobber => {
729                if opts.debug {
730                    println!("{}", translate!("mv-debug-skipped", "target" => to.quote()));
731                }
732                return Ok(());
733            }
734            OverwriteMode::Interactive => {
735                if !prompt_yes!(
736                    "{}",
737                    translate!("mv-prompt-overwrite", "target" => to.quote())
738                ) {
739                    return Err(io::Error::other(""));
740                }
741            }
742            OverwriteMode::Force => {}
743        }
744
745        backup_path = backup_control::get_backup_path(opts.backup, to, &opts.suffix);
746        if let Some(ref backup_path) = backup_path {
747            // For backup renames, we don't need to track hardlinks as we're just moving the existing file
748            rename_with_fallback(to, backup_path, display_manager, false, None, None)?;
749        }
750    }
751
752    // "to" may no longer exist if it was backed up
753    if to.exists() && to.is_dir() && !to.is_symlink() {
754        // normalize behavior between *nix and windows
755        if from.is_dir() {
756            if is_empty_dir(to) {
757                fs::remove_dir(to)?;
758            } else {
759                return Err(io::Error::other(translate!("mv-error-directory-not-empty")));
760            }
761        }
762    }
763
764    #[cfg(unix)]
765    {
766        rename_with_fallback(
767            from,
768            to,
769            display_manager,
770            opts.verbose,
771            hardlink_tracker,
772            hardlink_scanner,
773        )?;
774    }
775    #[cfg(not(unix))]
776    {
777        rename_with_fallback(from, to, display_manager, opts.verbose, None, None)?;
778    }
779
780    #[cfg(feature = "selinux")]
781    if let Some(ref context) = opts.context {
782        set_selinux_security_context(to, Some(context))
783            .map_err(|e| io::Error::other(e.to_string()))?;
784    }
785
786    if opts.verbose {
787        let message = match backup_path {
788            Some(path) => {
789                translate!("mv-verbose-renamed-with-backup", "from" => from.quote(), "to" => to.quote(), "backup" => path.quote())
790            }
791            None => translate!("mv-verbose-renamed", "from" => from.quote(), "to" => to.quote()),
792        };
793
794        match display_manager {
795            Some(pb) => pb.suspend(|| {
796                println!("{message}");
797            }),
798            None => println!("{message}"),
799        }
800    }
801    Ok(())
802}
803
804#[cfg(unix)]
805fn is_fifo(filetype: fs::FileType) -> bool {
806    filetype.is_fifo()
807}
808
809#[cfg(not(unix))]
810fn is_fifo(_filetype: fs::FileType) -> bool {
811    false
812}
813
814/// A wrapper around `fs::rename`, so that if it fails, we try falling back on
815/// copying and removing.
816fn rename_with_fallback(
817    from: &Path,
818    to: &Path,
819    display_manager: Option<&MultiProgress>,
820    verbose: bool,
821    #[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
822    #[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
823    #[cfg(not(unix))] _hardlink_tracker: Option<()>,
824    #[cfg(not(unix))] _hardlink_scanner: Option<()>,
825) -> io::Result<()> {
826    fs::rename(from, to).or_else(|err| {
827        #[cfg(windows)]
828        const EXDEV: i32 = windows_sys::Win32::Foundation::ERROR_NOT_SAME_DEVICE as _;
829        #[cfg(unix)]
830        const EXDEV: i32 = libc::EXDEV as _;
831
832        // We will only copy if:
833        // 1. Files are on different devices (EXDEV error)
834        // 2. On Windows, if the target file exists and source file is opened by another process
835        //    (MoveFileExW fails with "Access Denied" even if the source file has FILE_SHARE_DELETE permission)
836        let should_fallback =
837            matches!(err.raw_os_error(), Some(EXDEV)) || (from.is_file() && can_delete_file(from));
838        if !should_fallback {
839            return Err(err);
840        }
841        // Get metadata without following symlinks
842        let metadata = from.symlink_metadata()?;
843        let file_type = metadata.file_type();
844        if file_type.is_symlink() {
845            rename_symlink_fallback(from, to)
846        } else if file_type.is_dir() {
847            #[cfg(unix)]
848            {
849                with_optional_hardlink_context(
850                    hardlink_tracker,
851                    hardlink_scanner,
852                    |tracker, scanner| {
853                        rename_dir_fallback(
854                            from,
855                            to,
856                            display_manager,
857                            verbose,
858                            Some(tracker),
859                            Some(scanner),
860                        )
861                    },
862                )
863            }
864            #[cfg(not(unix))]
865            {
866                rename_dir_fallback(from, to, display_manager, verbose)
867            }
868        } else if is_fifo(file_type) {
869            rename_fifo_fallback(from, to)
870        } else {
871            #[cfg(unix)]
872            {
873                with_optional_hardlink_context(
874                    hardlink_tracker,
875                    hardlink_scanner,
876                    |tracker, scanner| rename_file_fallback(from, to, Some(tracker), Some(scanner)),
877                )
878            }
879            #[cfg(not(unix))]
880            {
881                rename_file_fallback(from, to)
882            }
883        }
884    })
885}
886
887/// Replace the destination with a new pipe with the same name as the source.
888#[cfg(unix)]
889fn rename_fifo_fallback(from: &Path, to: &Path) -> io::Result<()> {
890    if to.try_exists()? {
891        fs::remove_file(to)?;
892    }
893    make_fifo(to).and_then(|_| fs::remove_file(from))
894}
895
896#[cfg(not(unix))]
897fn rename_fifo_fallback(_from: &Path, _to: &Path) -> io::Result<()> {
898    Ok(())
899}
900
901/// Move the given symlink to the given destination. On Windows, dangling
902/// symlinks return an error.
903#[cfg(unix)]
904fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
905    let path_symlink_points_to = fs::read_link(from)?;
906    unix::fs::symlink(path_symlink_points_to, to).and_then(|_| fs::remove_file(from))
907}
908
909#[cfg(windows)]
910fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
911    let path_symlink_points_to = fs::read_link(from)?;
912    if path_symlink_points_to.exists() {
913        if path_symlink_points_to.is_dir() {
914            windows::fs::symlink_dir(&path_symlink_points_to, to)?;
915        } else {
916            windows::fs::symlink_file(&path_symlink_points_to, to)?;
917        }
918        fs::remove_file(from)
919    } else {
920        Err(io::Error::new(
921            io::ErrorKind::NotFound,
922            translate!("mv-error-dangling-symlink"),
923        ))
924    }
925}
926
927#[cfg(not(any(windows, unix)))]
928fn rename_symlink_fallback(from: &Path, to: &Path) -> io::Result<()> {
929    let path_symlink_points_to = fs::read_link(from)?;
930    Err(io::Error::new(
931        io::ErrorKind::Other,
932        translate!("mv-error-no-symlink-support"),
933    ))
934}
935
936fn rename_dir_fallback(
937    from: &Path,
938    to: &Path,
939    display_manager: Option<&MultiProgress>,
940    verbose: bool,
941    #[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
942    #[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
943) -> io::Result<()> {
944    // We remove the destination directory if it exists to match the
945    // behavior of `fs::rename`. As far as I can tell, `fs_extra`'s
946    // `move_dir` would otherwise behave differently.
947    if to.exists() {
948        fs::remove_dir_all(to)?;
949    }
950
951    // Calculate total size of directory
952    // Silently degrades:
953    //    If finding the total size fails for whatever reason,
954    //    the progress bar wont be shown for this file / dir.
955    //    (Move will probably fail due to permission error later?)
956    let total_size = dir_get_size(from).ok();
957
958    let progress_bar = match (display_manager, total_size) {
959        (Some(display_manager), Some(total_size)) => {
960            let template = "{msg}: [{elapsed_precise}] {wide_bar} {bytes:>7}/{total_bytes:7}";
961            let style = ProgressStyle::with_template(template).unwrap();
962            let bar = ProgressBar::new(total_size).with_style(style);
963            Some(display_manager.add(bar))
964        }
965        (_, _) => None,
966    };
967
968    #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
969    let xattrs = fsxattr::retrieve_xattrs(from).unwrap_or_else(|_| HashMap::new());
970
971    // Use directory copying (with or without hardlink support)
972    let result = copy_dir_contents(
973        from,
974        to,
975        #[cfg(unix)]
976        hardlink_tracker,
977        #[cfg(unix)]
978        hardlink_scanner,
979        verbose,
980        progress_bar.as_ref(),
981        display_manager,
982    );
983
984    #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
985    fsxattr::apply_xattrs(to, xattrs)?;
986
987    result?;
988
989    // Remove the source directory after successful copy
990    fs::remove_dir_all(from)?;
991
992    Ok(())
993}
994
995/// Copy directory recursively, optionally preserving hardlinks
996fn copy_dir_contents(
997    from: &Path,
998    to: &Path,
999    #[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
1000    #[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
1001    verbose: bool,
1002    progress_bar: Option<&ProgressBar>,
1003    display_manager: Option<&MultiProgress>,
1004) -> io::Result<()> {
1005    // Create the destination directory
1006    fs::create_dir_all(to)?;
1007
1008    // Recursively copy contents
1009    #[cfg(unix)]
1010    {
1011        if let (Some(tracker), Some(scanner)) = (hardlink_tracker, hardlink_scanner) {
1012            copy_dir_contents_recursive(
1013                from,
1014                to,
1015                tracker,
1016                scanner,
1017                verbose,
1018                progress_bar,
1019                display_manager,
1020            )?;
1021        }
1022    }
1023    #[cfg(not(unix))]
1024    {
1025        copy_dir_contents_recursive(from, to, None, None, verbose, progress_bar, display_manager)?;
1026    }
1027
1028    Ok(())
1029}
1030
1031fn copy_dir_contents_recursive(
1032    from_dir: &Path,
1033    to_dir: &Path,
1034    #[cfg(unix)] hardlink_tracker: &mut HardlinkTracker,
1035    #[cfg(unix)] hardlink_scanner: &HardlinkGroupScanner,
1036    #[cfg(not(unix))] _hardlink_tracker: Option<()>,
1037    #[cfg(not(unix))] _hardlink_scanner: Option<()>,
1038    verbose: bool,
1039    progress_bar: Option<&ProgressBar>,
1040    display_manager: Option<&MultiProgress>,
1041) -> io::Result<()> {
1042    let entries = fs::read_dir(from_dir)?;
1043
1044    for entry in entries {
1045        let entry = entry?;
1046        let from_path = entry.path();
1047        let file_name = from_path.file_name().unwrap();
1048        let to_path = to_dir.join(file_name);
1049
1050        if let Some(pb) = progress_bar {
1051            pb.set_message(from_path.to_string_lossy().to_string());
1052        }
1053
1054        if from_path.is_dir() {
1055            // Recursively copy subdirectory
1056            fs::create_dir_all(&to_path)?;
1057
1058            // Print verbose message for directory
1059            if verbose {
1060                let message = translate!("mv-verbose-renamed", "from" => from_path.quote(), "to" => to_path.quote());
1061                match display_manager {
1062                    Some(pb) => pb.suspend(|| {
1063                        println!("{message}");
1064                    }),
1065                    None => println!("{message}"),
1066                }
1067            }
1068
1069            copy_dir_contents_recursive(
1070                &from_path,
1071                &to_path,
1072                #[cfg(unix)]
1073                hardlink_tracker,
1074                #[cfg(unix)]
1075                hardlink_scanner,
1076                #[cfg(not(unix))]
1077                _hardlink_tracker,
1078                #[cfg(not(unix))]
1079                _hardlink_scanner,
1080                verbose,
1081                progress_bar,
1082                display_manager,
1083            )?;
1084        } else {
1085            // Copy file with or without hardlink support based on platform
1086            #[cfg(unix)]
1087            {
1088                copy_file_with_hardlinks_helper(
1089                    &from_path,
1090                    &to_path,
1091                    hardlink_tracker,
1092                    hardlink_scanner,
1093                )?;
1094            }
1095            #[cfg(not(unix))]
1096            {
1097                fs::copy(&from_path, &to_path)?;
1098            }
1099
1100            // Print verbose message for file
1101            if verbose {
1102                let message = translate!("mv-verbose-renamed", "from" => from_path.quote(), "to" => to_path.quote());
1103                match display_manager {
1104                    Some(pb) => pb.suspend(|| {
1105                        println!("{message}");
1106                    }),
1107                    None => println!("{message}"),
1108                }
1109            }
1110        }
1111
1112        if let Some(pb) = progress_bar {
1113            if let Ok(metadata) = from_path.metadata() {
1114                pb.inc(metadata.len());
1115            }
1116        }
1117    }
1118
1119    Ok(())
1120}
1121
1122#[cfg(unix)]
1123fn copy_file_with_hardlinks_helper(
1124    from: &Path,
1125    to: &Path,
1126    hardlink_tracker: &mut HardlinkTracker,
1127    hardlink_scanner: &HardlinkGroupScanner,
1128) -> io::Result<()> {
1129    // Check if this file should be a hardlink to an already-copied file
1130    use crate::hardlink::HardlinkOptions;
1131    let hardlink_options = HardlinkOptions::default();
1132    // Create a hardlink instead of copying
1133    if let Some(existing_target) =
1134        hardlink_tracker.check_hardlink(from, to, hardlink_scanner, &hardlink_options)?
1135    {
1136        fs::hard_link(&existing_target, to)?;
1137        return Ok(());
1138    }
1139
1140    // Regular file copy
1141    #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
1142    {
1143        fs::copy(from, to).and_then(|_| fsxattr::copy_xattrs(&from, &to))?;
1144    }
1145    #[cfg(any(target_os = "macos", target_os = "redox"))]
1146    {
1147        fs::copy(from, to)?;
1148    }
1149
1150    Ok(())
1151}
1152
1153fn rename_file_fallback(
1154    from: &Path,
1155    to: &Path,
1156    #[cfg(unix)] hardlink_tracker: Option<&mut HardlinkTracker>,
1157    #[cfg(unix)] hardlink_scanner: Option<&HardlinkGroupScanner>,
1158) -> io::Result<()> {
1159    // Remove existing target file if it exists
1160    if to.is_symlink() {
1161        fs::remove_file(to).map_err(|err| {
1162            let inter_device_msg = translate!("mv-error-inter-device-move-failed", "from" => from.display(), "to" => to.display(), "err" => err);
1163            io::Error::new(err.kind(), inter_device_msg)
1164        })?;
1165    } else if to.exists() {
1166        // For non-symlinks, just remove the file without special error handling
1167        fs::remove_file(to)?;
1168    }
1169
1170    // Check if this file is part of a hardlink group and if so, create a hardlink instead of copying
1171    #[cfg(unix)]
1172    {
1173        if let (Some(tracker), Some(scanner)) = (hardlink_tracker, hardlink_scanner) {
1174            use crate::hardlink::HardlinkOptions;
1175            let hardlink_options = HardlinkOptions::default();
1176            if let Some(existing_target) =
1177                tracker.check_hardlink(from, to, scanner, &hardlink_options)?
1178            {
1179                // Create a hardlink to the first moved file instead of copying
1180                fs::hard_link(&existing_target, to)?;
1181                fs::remove_file(from)?;
1182                return Ok(());
1183            }
1184        }
1185    }
1186
1187    // Regular file copy
1188    #[cfg(all(unix, not(any(target_os = "macos", target_os = "redox"))))]
1189    fs::copy(from, to)
1190        .and_then(|_| fsxattr::copy_xattrs(&from, &to))
1191        .and_then(|_| fs::remove_file(from))
1192        .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;
1193    #[cfg(any(target_os = "macos", target_os = "redox", not(unix)))]
1194    fs::copy(from, to)
1195        .and_then(|_| fs::remove_file(from))
1196        .map_err(|err| io::Error::new(err.kind(), translate!("mv-error-permission-denied")))?;
1197    Ok(())
1198}
1199
1200fn is_empty_dir(path: &Path) -> bool {
1201    fs::read_dir(path).is_ok_and(|mut contents| contents.next().is_none())
1202}
1203
1204/// Checks if a file can be deleted by attempting to open it with delete permissions.
1205#[cfg(windows)]
1206fn can_delete_file(path: &Path) -> bool {
1207    use std::{
1208        os::windows::ffi::OsStrExt as _,
1209        ptr::{null, null_mut},
1210    };
1211
1212    use windows_sys::Win32::{
1213        Foundation::{CloseHandle, INVALID_HANDLE_VALUE},
1214        Storage::FileSystem::{
1215            CreateFileW, DELETE, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_DELETE, FILE_SHARE_READ,
1216            FILE_SHARE_WRITE, OPEN_EXISTING,
1217        },
1218    };
1219
1220    let wide_path = path
1221        .as_os_str()
1222        .encode_wide()
1223        .chain([0])
1224        .collect::<Vec<u16>>();
1225
1226    let handle = unsafe {
1227        CreateFileW(
1228            wide_path.as_ptr(),
1229            DELETE,
1230            FILE_SHARE_DELETE | FILE_SHARE_READ | FILE_SHARE_WRITE,
1231            null(),
1232            OPEN_EXISTING,
1233            FILE_ATTRIBUTE_NORMAL,
1234            null_mut(),
1235        )
1236    };
1237
1238    if handle == INVALID_HANDLE_VALUE {
1239        return false;
1240    }
1241
1242    unsafe { CloseHandle(handle) };
1243
1244    true
1245}
1246
1247#[cfg(not(windows))]
1248fn can_delete_file(_: &Path) -> bool {
1249    // On non-Windows platforms, always return false to indicate that we don't need
1250    // to try the copy+delete fallback. This is because on Unix-like systems,
1251    // rename() failing with errors other than EXDEV means the operation cannot
1252    // succeed even with a copy+delete approach (e.g. permission errors).
1253    false
1254}