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}