fs_more/file/
move.rs

1use std::path::Path;
2
3use_enabled_fs_module!();
4
5use super::{
6    copy::copy_file_with_progress_unchecked,
7    validate_destination_file_path,
8    validate_source_file_path,
9    CollidingFileBehaviour,
10    DestinationValidationAction,
11    FileCopyWithProgressOptions,
12    FileProgress,
13};
14use crate::{
15    error::{FileError, FileRemoveError},
16    file::ValidatedSourceFilePath,
17    DEFAULT_PROGRESS_UPDATE_BYTE_INTERVAL,
18    DEFAULT_READ_BUFFER_SIZE,
19    DEFAULT_WRITE_BUFFER_SIZE,
20};
21
22
23/// Options that influence the [`move_file`] function.
24#[derive(Clone, Copy, PartialEq, Eq, Debug)]
25pub struct FileMoveOptions {
26    /// How to behave when the destination file already exists.
27    pub colliding_file_behaviour: CollidingFileBehaviour,
28}
29
30#[allow(clippy::derivable_impls)]
31impl Default for FileMoveOptions {
32    /// Constructs a default [`FileMoveOptions`]:
33    /// - existing destination files will not be overwritten, and will cause an error ([`CollidingFileBehaviour::Abort`]).
34    fn default() -> Self {
35        Self {
36            colliding_file_behaviour: CollidingFileBehaviour::Abort,
37        }
38    }
39}
40
41
42
43/// Information about a successful file move operation.
44///
45/// See also: [`move_file`].
46#[derive(Clone, Copy, PartialEq, Eq, Debug)]
47pub enum FileMoveFinished {
48    /// Destination file was freshly created and the contents of the source
49    /// file were moved. `method` will describe how the move was made.
50    Created {
51        /// The number of bytes transferred in the move (i.e. the file size).
52        bytes_copied: u64,
53
54        /// How the move was accomplished.
55        method: FileMoveMethod,
56    },
57
58    /// Destination file existed, and was overwritten with the contents of
59    /// the source file.
60    Overwritten {
61        /// The number of bytes transferred in the move (i.e. the file size).
62        bytes_copied: u64,
63
64        /// How the move was accomplished.
65        method: FileMoveMethod,
66    },
67
68    /// File was not moved because the destination file already existed.
69    ///
70    /// This can be returned by [`move_file`] or [`move_file_with_progress`]
71    /// if `options.colliding_file_behaviour` is set to [`CollidingFileBehaviour::Skip`].
72    ///
73    /// Note that this means the source file still exists.
74    Skipped,
75}
76
77
78/// A method used for moving a file.
79#[derive(Clone, Copy, PartialEq, Eq, Debug)]
80pub enum FileMoveMethod {
81    /// The source file was renamed to the destination file.
82    ///
83    /// This is very highly performant on most file systems,
84    /// to the point of being near instantaneous.
85    Rename,
86
87    /// The source file was copied to the destination,
88    /// and the source file was deleted afterwards.
89    ///
90    /// This is generally used only if [`Self::Rename`] is impossible,
91    /// and is as fast as writes normally are.
92    CopyAndDelete,
93}
94
95
96/// Moves a single file from the source to the destination path.
97///
98/// The destination path must be a *file* path, and must not point to a directory.
99///
100///
101/// # Symbolic links
102/// Symbolic links are generally preserved, unless renaming them fails.
103///
104/// This means the following: if `source_file_path` leads to a symbolic link that points to a file,
105/// we'll try to move the file by renaming it to the destination path, even if it is a symbolic link to a file.
106/// If that fails, the contents of the file the symlink points to will instead
107/// be *copied*, then the symlink at `source_file_path` itself will be removed.
108///
109/// This matches `mv` behaviour on Unix[^unix-mv].
110///
111///
112/// # Options
113/// See [`FileMoveOptions`] for available file moving options.
114///
115///
116/// # Return value
117/// If the move succeeds, the function returns [`FileMoveFinished`],
118/// which indicates whether the file was created,
119/// overwritten or skipped. The struct also includes the number of bytes moved,
120/// if relevant.
121///
122///
123/// # Errors
124/// If the file cannot be moved to the destination, a [`FileError`] is returned;
125/// see its documentation for more details. Here is a non-exhaustive list of error causes:
126/// - If the source path has issues (does not exist, does not have the correct permissions, etc.),
127///   one of [`SourceFileNotFound`], [`SourcePathNotAFile`] or [`UnableToAccessSourceFile`]
128///   variants will be returned.
129/// - If the destination already exists, and [`options.colliding_file_behaviour`]
130///   is set to [`CollidingFileBehaviour::Abort`], then a [`DestinationPathAlreadyExists`]
131///   will be returned.
132/// - If the source and destination paths are canonically actually the same file,
133///   then copying will be aborted with [`SourceAndDestinationAreTheSame`].
134/// - If the destination path has other issues (is a directory, does not have the correct permissions, etc.),
135///   [`UnableToAccessDestinationFile`] will be returned.
136///
137/// There do exist other failure points, mostly due to unavoidable
138/// [time-of-check time-of-use](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use)
139/// issues and other potential IO errors that can prop up.
140/// These errors are grouped under the [`OtherIoError`] variant.
141///
142///
143/// <br>
144///
145/// #### See also
146/// If you are looking for a file moving function that reports progress,
147/// see [`move_file_with_progress`].
148///
149///
150/// <br>
151///
152/// <details>
153/// <summary><h4>Implementation details</h4></summary>
154///
155/// *This section describes internal implementations details.
156/// They should not be relied on, because they are informative
157/// and may change in the future.*
158///
159/// <br>
160///
161/// This function will first attempt to move the file by renaming it using [`std::fs::rename`]
162/// (or [`fs_err::rename`](https://docs.rs/fs-err/latest/fs_err/fn.rename.html) if
163/// the `fs-err` feature flag is enabled).
164///
165/// If the rename fails, for example when the source and destination path are on different
166/// mount points or drives, a copy-and-delete will be performed instead.
167///
168/// The method used will be reflected in the [`FileMoveMethod`] used in the return value.
169///
170/// </details>
171///
172///
173/// [`options.colliding_file_behaviour`]: FileMoveOptions::colliding_file_behaviour
174/// [`SourceFileNotFound`]: FileError::SourceFileNotFound
175/// [`SourcePathNotAFile`]: FileError::SourcePathNotAFile
176/// [`UnableToAccessSourceFile`]: FileError::UnableToAccessSourceFile
177/// [`DestinationPathAlreadyExists`]: FileError::DestinationPathAlreadyExists
178/// [`UnableToAccessDestinationFile`]: FileError::UnableToAccessDestinationFile
179/// [`SourceAndDestinationAreTheSame`]: FileError::SourceAndDestinationAreTheSame
180/// [`OtherIoError`]: FileError::OtherIoError
181/// [^unix-mv]: Source for coreutils' `mv` is available
182///   [here](https://github.com/coreutils/coreutils/blob/ccf47cad93bc0b85da0401b0a9d4b652e4c930e4/src/mv.c#L196-L244).
183pub fn move_file<S, D>(
184    source_file_path: S,
185    destination_file_path: D,
186    options: FileMoveOptions,
187) -> Result<FileMoveFinished, FileError>
188where
189    S: AsRef<Path>,
190    D: AsRef<Path>,
191{
192    let source_file_path = source_file_path.as_ref();
193    let destination_file_path = destination_file_path.as_ref();
194
195
196    let validated_source_path = validate_source_file_path(source_file_path)?;
197
198    let (validated_destination_file_path, destination_file_exists) =
199        match validate_destination_file_path(
200            &validated_source_path,
201            destination_file_path,
202            options.colliding_file_behaviour,
203        )? {
204            DestinationValidationAction::SkipCopyOrMove => {
205                return Ok(FileMoveFinished::Skipped);
206            }
207            DestinationValidationAction::Continue(info) => {
208                (info.destination_file_path, info.exists)
209            }
210        };
211
212    let ValidatedSourceFilePath {
213        source_file_path: validated_source_file_path,
214        original_was_symlink_to_file: source_file_was_symlink_to_file,
215    } = validated_source_path;
216
217
218    // All checks have passed. Now we do the following:
219    // - Try to move by renaming the source file. If that succeeds,
220    //   that's nice and fast (and symlink-preserving).
221    // - Otherwise, we need to copy the source (or the file underneath it,
222    //   if it a symlink) to target and remove the source.
223
224
225    let source_file_path_to_rename = if source_file_was_symlink_to_file {
226        source_file_path
227    } else {
228        validated_source_file_path.as_path()
229    };
230
231    if fs::rename(source_file_path_to_rename, &validated_destination_file_path).is_ok() {
232        // Get size of file that we just renamed.
233        let target_file_path_metadata = fs::metadata(&validated_destination_file_path)
234            .map_err(|error| FileError::OtherIoError { error })?;
235
236        match destination_file_exists {
237            true => Ok(FileMoveFinished::Overwritten {
238                bytes_copied: target_file_path_metadata.len(),
239                method: FileMoveMethod::Rename,
240            }),
241            false => Ok(FileMoveFinished::Created {
242                bytes_copied: target_file_path_metadata.len(),
243                method: FileMoveMethod::Rename,
244            }),
245        }
246    } else {
247        // Copy to destination, then delete original file.
248        // Special case: if the original was a symlink to a file, we need to
249        // delete the symlink, not the file it points to.
250
251        let num_bytes_copied =
252            fs::copy(&validated_source_file_path, validated_destination_file_path)
253                .map_err(|error| FileError::OtherIoError { error })?;
254
255        let source_file_path_to_remove = if source_file_was_symlink_to_file {
256            // `source_file_path` instead of `validated_source_file_path` is intentional:
257            // if the source was a symlink, we should remove the link, not its destination.
258            source_file_path
259        } else {
260            validated_source_file_path.as_path()
261        };
262
263        super::remove_file(source_file_path_to_remove).map_err(|error| match error {
264            FileRemoveError::NotFound { path } => FileError::SourceFileNotFound { path },
265            FileRemoveError::NotAFile { path } => FileError::SourcePathNotAFile { path },
266            FileRemoveError::UnableToAccessFile { path, error } => {
267                FileError::UnableToAccessSourceFile { path, error }
268            }
269            FileRemoveError::OtherIoError { error } => FileError::OtherIoError { error },
270        })?;
271
272
273        match destination_file_exists {
274            true => Ok(FileMoveFinished::Overwritten {
275                bytes_copied: num_bytes_copied,
276                method: FileMoveMethod::CopyAndDelete,
277            }),
278            false => Ok(FileMoveFinished::Created {
279                bytes_copied: num_bytes_copied,
280                method: FileMoveMethod::CopyAndDelete,
281            }),
282        }
283    }
284}
285
286
287
288/// Options that influence the [`move_file_with_progress`] function.
289#[derive(Clone, Copy, PartialEq, Eq, Debug)]
290pub struct FileMoveWithProgressOptions {
291    /// How to behave when the destination file already exists.
292    pub colliding_file_behaviour: CollidingFileBehaviour,
293
294    /// Internal buffer size used for reading the source file.
295    ///
296    /// Defaults to 64 KiB.
297    pub read_buffer_size: usize,
298
299    /// Internal buffer size used for writing to the destination file.
300    ///
301    /// Defaults to 64 KiB.
302    pub write_buffer_size: usize,
303
304    /// The smallest number of bytes to be copied between two consecutive progress reports.
305    ///
306    /// Increase this value to make progress reports less frequent, and decrease it
307    /// to make them more frequent.
308    ///
309    /// *Note that this is the minimum;* the real reporting interval can be larger.
310    /// Consult [`copy_file_with_progress`] documentation for more details.
311    ///
312    /// Defaults to 512 KiB.
313    ///
314    ///
315    /// [`copy_file_with_progress`]: super::copy_file_with_progress
316    pub progress_update_byte_interval: u64,
317}
318
319impl Default for FileMoveWithProgressOptions {
320    /// Constructs a default [`FileMoveOptions`]:
321    /// - existing destination files will not be overwritten, and will cause an error ([`CollidingFileBehaviour::Abort`]),
322    /// - read and write buffers with be 64 KiB large,
323    /// - the progress report closure interval will be 512 KiB.
324    fn default() -> Self {
325        Self {
326            colliding_file_behaviour: CollidingFileBehaviour::Abort,
327            read_buffer_size: DEFAULT_READ_BUFFER_SIZE,
328            write_buffer_size: DEFAULT_WRITE_BUFFER_SIZE,
329            progress_update_byte_interval: DEFAULT_PROGRESS_UPDATE_BYTE_INTERVAL,
330        }
331    }
332}
333
334
335/// Moves a single file from the source to the destination path, with progress reporting
336///
337/// The destination path must be a *file* path, and must not point to a directory.
338///
339///
340/// # Symbolic links
341/// Symbolic links are generally preserved, unless renaming them fails.
342///
343/// This means the following: if `source_file_path` leads to a symbolic link that points to a file,
344/// we'll try to move the file by renaming it to the destination path, even if it is a symbolic link to a file.
345/// If that fails, the contents of the file the symlink points to will instead
346/// be *copied*, then the symlink at `source_file_path` itself will be removed.
347///
348/// This matches `mv` behaviour on Unix[^unix-mv].
349///
350///
351/// # Options
352/// See [`FileMoveWithProgressOptions`] for available file moving options.
353///
354///
355/// # Return value
356/// If the move succeeds, the function returns [`FileMoveFinished`],
357/// which indicates whether the file was created,
358/// overwritten or skipped. The struct also includes the number of bytes moved,
359/// if relevant.
360///
361///
362/// ## Progress handling
363/// This function allows you to receive progress reports by passing
364/// a `progress_handler` closure. It will be called with
365/// a reference to [`FileProgress`] regularly.
366///
367/// You can control the progress update frequency with the
368/// [`options.progress_update_byte_interval`] option.
369/// The value of this option is the minimum number of bytes written to a file between
370/// two calls to the provided `progress_handler`.
371///
372/// This function does not guarantee a precise number of progress reports per file size
373/// and progress reporting interval. However, it does guarantee at least one progress report:
374/// the final one, which happens when the file has been completely copied.
375/// In most cases though, the number of calls to the closure will be near the expected number,
376/// which is `file_size / progress_update_byte_interval`.
377///
378///
379/// # Errors
380/// If the file cannot be moved to the destination, a [`FileError`] is returned;
381/// see its documentation for more details. Here is a non-exhaustive list of error causes:
382/// - If the source path has issues (does not exist, does not have the correct permissions, etc.),
383///   one of [`SourceFileNotFound`], [`SourcePathNotAFile`], or [`UnableToAccessSourceFile`],
384///   variants will be returned.
385/// - If the destination already exists, and [`options.colliding_file_behaviour`]
386///   is set to [`CollidingFileBehaviour::Abort`], then a [`DestinationPathAlreadyExists`]
387///   will be returned.
388/// - If the source and destination paths are canonically actually the same file,
389///   then copying will be aborted with [`SourceAndDestinationAreTheSame`].
390/// - If the destination path has other issues (is a directory, does not have the correct permissions, etc.),
391///   [`UnableToAccessDestinationFile`] will be returned.
392///
393/// There do exist other failure points, mostly due to unavoidable
394/// [time-of-check time-of-use](https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use)
395/// issues and other potential IO errors that can prop up.
396/// These errors are grouped under the [`OtherIoError`] variant.
397///
398///
399/// <br>
400///
401/// #### See also
402/// If you are looking for a file moving function that does not report progress,
403/// see [`move_file`].
404///
405///
406/// <br>
407///
408/// <details>
409/// <summary><h4>Implementation details</h4></summary>
410///
411/// *This section describes internal implementations details.
412/// They should not be relied on, because they are informative
413/// and may change in the future.*
414///
415/// <br>
416///
417/// This function will first attempt to move the file by renaming it using [`std::fs::rename`]
418/// (or [`fs_err::rename`](https://docs.rs/fs-err/latest/fs_err/fn.rename.html) if
419/// the `fs-err` feature flag is enabled).
420///
421/// If the rename fails, for example when the source and destination path are on different
422/// mount points or drives, a copy-and-delete will be performed instead.
423///
424/// The method used will be reflected in the [`FileMoveMethod`] used in the return value.
425///
426/// </details>
427///
428///
429/// [`options.progress_update_byte_interval`]: FileMoveWithProgressOptions::progress_update_byte_interval
430/// [`options.colliding_file_behaviour`]: FileMoveWithProgressOptions::colliding_file_behaviour
431/// [`SourceFileNotFound`]: FileError::SourceFileNotFound
432/// [`SourcePathNotAFile`]: FileError::SourcePathNotAFile
433/// [`UnableToAccessSourceFile`]: FileError::UnableToAccessSourceFile
434/// [`DestinationPathAlreadyExists`]: FileError::DestinationPathAlreadyExists
435/// [`UnableToAccessDestinationFile`]: FileError::UnableToAccessDestinationFile
436/// [`SourceAndDestinationAreTheSame`]: FileError::SourceAndDestinationAreTheSame
437/// [`OtherIoError`]: FileError::OtherIoError
438/// [^unix-mv]: Source for coreutils' `mv` is available
439///   [here](https://github.com/coreutils/coreutils/blob/ccf47cad93bc0b85da0401b0a9d4b652e4c930e4/src/mv.c#L196-L244).
440pub fn move_file_with_progress<S, D, P>(
441    source_file_path: S,
442    destination_file_path: D,
443    options: FileMoveWithProgressOptions,
444    mut progress_handler: P,
445) -> Result<FileMoveFinished, FileError>
446where
447    S: AsRef<Path>,
448    D: AsRef<Path>,
449    P: FnMut(&FileProgress),
450{
451    let source_file_path = source_file_path.as_ref();
452    let destination_file_path = destination_file_path.as_ref();
453
454
455    let validated_source_path = validate_source_file_path(source_file_path)?;
456
457    let (validated_destination_file_path, destination_file_exists) =
458        match validate_destination_file_path(
459            &validated_source_path,
460            destination_file_path,
461            options.colliding_file_behaviour,
462        )? {
463            DestinationValidationAction::SkipCopyOrMove => {
464                return Ok(FileMoveFinished::Skipped);
465            }
466            DestinationValidationAction::Continue(info) => {
467                (info.destination_file_path, info.exists)
468            }
469        };
470
471    let ValidatedSourceFilePath {
472        source_file_path: validated_source_file_path,
473        original_was_symlink_to_file: source_file_was_symlink_to_file,
474    } = validated_source_path;
475
476
477    // All checks have passed. Now we do the following:
478    // - Try to move by renaming the source file. If that succeeds,
479    //   that's nice and fast (and symlink-preserving). We must also not forget
480    //   to do one progress report.
481    // - Otherwise, we need to copy the source (or the file underneath it,
482    //   if it a symlink) to target and remove the source.
483
484    let source_file_path_to_rename = if source_file_was_symlink_to_file {
485        source_file_path
486    } else {
487        validated_source_file_path.as_path()
488    };
489
490    if fs::rename(source_file_path_to_rename, &validated_destination_file_path).is_ok() {
491        // Get size of file that we just renamed, emit one progress report, and return.
492
493        let target_file_path_size_bytes = fs::metadata(&validated_destination_file_path)
494            .map_err(|error| FileError::OtherIoError { error })?
495            .len();
496
497        progress_handler(&FileProgress {
498            bytes_finished: target_file_path_size_bytes,
499            bytes_total: target_file_path_size_bytes,
500        });
501
502
503        match destination_file_exists {
504            true => Ok(FileMoveFinished::Overwritten {
505                bytes_copied: target_file_path_size_bytes,
506                method: FileMoveMethod::Rename,
507            }),
508            false => Ok(FileMoveFinished::Created {
509                bytes_copied: target_file_path_size_bytes,
510                method: FileMoveMethod::Rename,
511            }),
512        }
513    } else {
514        // It's impossible for us to just rename the file,
515        // so we need to copy and delete the original.
516
517        let bytes_written = copy_file_with_progress_unchecked(
518            &validated_source_file_path,
519            &validated_destination_file_path,
520            FileCopyWithProgressOptions {
521                colliding_file_behaviour: options.colliding_file_behaviour,
522                read_buffer_size: options.read_buffer_size,
523                write_buffer_size: options.write_buffer_size,
524                progress_update_byte_interval: options.progress_update_byte_interval,
525            },
526            progress_handler,
527        )?;
528
529
530        let source_file_path_to_remove = if source_file_was_symlink_to_file {
531            // `source_file_path` instead of `validated_source_file_path` is intentional:
532            // if the source was a symlink, we should remove the link, not its destination.
533            source_file_path
534        } else {
535            validated_source_file_path.as_path()
536        };
537
538        super::remove_file(source_file_path_to_remove).map_err(|error| match error {
539            FileRemoveError::NotFound { path } => FileError::SourceFileNotFound { path },
540            FileRemoveError::NotAFile { path } => FileError::SourcePathNotAFile { path },
541            FileRemoveError::UnableToAccessFile { path, error } => {
542                FileError::UnableToAccessSourceFile { path, error }
543            }
544            FileRemoveError::OtherIoError { error } => FileError::OtherIoError { error },
545        })?;
546
547
548        match destination_file_exists {
549            true => Ok(FileMoveFinished::Overwritten {
550                bytes_copied: bytes_written,
551                method: FileMoveMethod::CopyAndDelete,
552            }),
553            false => Ok(FileMoveFinished::Created {
554                bytes_copied: bytes_written,
555                method: FileMoveMethod::CopyAndDelete,
556            }),
557        }
558    }
559}