cp_r/
lib.rs

1// Copyright 2021-2024 Martin Pool
2
3//! Copy a directory tree, including mtimes and permissions.
4//!
5//! To copy a tree, first configure parameters on [CopyOptions] and then call
6//! [CopyOptions::copy_tree].
7//!
8//! # Features
9//!
10//! * Minimal dependencies: currently just `filetime` to support copying mtimes.
11//! * Returns [CopyStats] describing how much data and how many files were
12//!   copied.
13//! * Tested on Linux, macOS and Windows.
14//! * Copies mtimes and permissions.
15//! * Takes an optional callback to decide which entries are copied or skipped,
16//!   [CopyOptions::filter].
17//! * Takes an optional callback to show progress or record which files are copied,
18//!   [CopyOptions::after_entry_copied].
19//!
20//! # Missing features that could be added
21//!
22//! * Options to _not_ copy mtimes or permissions.
23//! * A callback that can decide whether to continue after an error.
24//! * Overwrite existing directories or files.
25//! * Copy single files: don't assume the source path is a directory.
26//! * A dry-run mode.
27//!
28//! # Example
29//!
30//! ```
31//! use std::path::Path;
32//! use cp_r::{CopyOptions, CopyStats};
33//! use tempfile;
34//!
35//! // Copy this crate's `src` directory.
36//! let dest = tempfile::tempdir().unwrap();
37//! let stats = CopyOptions::new().copy_tree(Path::new("src"), dest.path()).unwrap();
38//! assert_eq!(stats.files, 2);
39//! assert_eq!(stats.dirs, 0, "no children");
40//! assert_eq!(stats.symlinks, 0, "no symlinks");
41//! ```
42//!
43//! # Release history
44//!
45//! ## 0.5.2
46//!
47//! Released 2024-10-07.
48//!
49//! * New: Copy symlinks on Windows.
50//!
51//! * New: Declared and tested MSRV of 1.63.
52//!
53//! ## 0.5.1
54//!
55//! Released 2022-03-24.
56//!
57//! * Change: Ignore errors when trying to set the mtime on copied files. This can
58//!   happen on Windows if the file is read-only.
59//!
60//! ## 0.5.0
61//!
62//! Released 2022-02-15
63//!
64//! ### API changes
65//!
66//! * The callback passed to [CopyOptions::after_entry_copied] now returns `Result<()>`
67//!   (previously `()`), so it can return an Err to abort copying.
68//!
69//! ## 0.4.0
70//!
71//! Released 2021-11-30
72//!
73//! ### API changes
74//!
75//! * Remove `copy_buffer_size`, `file_buffers_copied`: these are too niche to have in the public
76//!   API, and anyhow become meaningless when we use [std::fs::copy].
77//!
78//! * New [ErrorKind::DestinationDoesNotExist].
79//!
80//! * [Error::io_error] returns `Option<io::Error>` (previously just an `io::Error`):
81//!   errors from this crate may not have a direct `io::Error` source.
82//!
83//! * [CopyOptions::copy_tree] arguments are relaxed to `AsRef<Path>` so that they will accept
84//!   `&str`, `PathBuf`, `tempfile::TempDir`, etc.
85//!
86//! ### Improvements
87//!
88//! * Use [std::fs::copy], which is more efficient, and makes this crate simpler.
89//!
90//! ## 0.3.1
91//!
92//! Released 2021-11-07
93//!
94//! ### API changes
95//!
96//! * [CopyOptions::copy_tree] consumes `self` (rather than taking `&mut self`),
97//!   which reduces lifetime issues in accessing values owned by callbacks.
98//!
99//! ### New features
100//!
101//! * [CopyOptions::after_entry_copied] callback added, which can be used for
102//!   example to draw a progress bar.
103//!
104//! ## 0.3.0
105//!
106//! Released 2021-11-06
107//!
108//! ### API changes
109//!
110//! * [CopyOptions] builder functions now return `self` rather than `&mut self`.
111//! * The actual copy operation is run by calling [CopyOptions::copy_tree],
112//!   rather than passing the options as a parameter to `copy_tree`.
113//! * Rename `with_copy_buffer_size` to `copy_buffer_size`.
114//!
115//! ### New features
116//! * A new option to provide a filter on which entries should be copied,
117//!   through [CopyOptions::filter].
118//!
119//! ## 0.2.0
120//! * `copy_tree` will create the immediate destination directory by default,
121//!   but this can be controlled by [CopyOptions::create_destination]. The
122//!   destination, if created, is counted in [CopyStats::dirs] and inherits its
123//!   permissions from the source.
124//!
125//! ## 0.1.1
126//! * [Error] implements [std::error::Error] and [std::fmt::Display].
127//!
128//! * [Error] is tested to be compatible with [Anyhow](https://docs.rs/anyhow).
129//!   (There is only a dev-dependency on Anyhow; users of this library won't
130//!   pull it in.)
131//!
132//! ## 0.1.0
133//! * Initial release.
134
135#![warn(missing_docs)]
136
137use std::collections::VecDeque;
138use std::fmt;
139use std::fs::{self, DirEntry};
140use std::io;
141use std::path::{Path, PathBuf};
142
143#[cfg(windows)]
144mod windows;
145
146#[cfg(windows)]
147use windows::copy_symlink;
148
149/// Options for copying file trees.
150///
151/// Default options may be OK for many callers:
152/// * Preserve mtime and permissions.
153/// * Create the destination if it does not exist.
154pub struct CopyOptions<'f> {
155    // TODO: Continue or stop on error?
156    // TODO: Option controlling whether to copy mtimes?
157    // TODO: Copy permissions?
158    create_destination: bool,
159
160    // I agree with Clippy that the callbacks are complex types, but stable Rust
161    // seems to have no other way to spell it, because you can't make a type or
162    // trait alias for a Fn.
163    #[allow(clippy::type_complexity)]
164    filter: Option<Box<dyn FnMut(&Path, &DirEntry) -> Result<bool> + 'f>>,
165
166    #[allow(clippy::type_complexity)]
167    after_entry_copied: Option<Box<dyn FnMut(&Path, &fs::FileType, &CopyStats) -> Result<()> + 'f>>,
168}
169
170impl<'f> Default for CopyOptions<'f> {
171    fn default() -> CopyOptions<'f> {
172        CopyOptions {
173            create_destination: true,
174            filter: None,
175            after_entry_copied: None,
176        }
177    }
178}
179
180impl<'f> CopyOptions<'f> {
181    /// Construct reasonable default options.
182    pub fn new() -> CopyOptions<'f> {
183        CopyOptions::default()
184    }
185
186    /// Set whether to create the destination if it does not exist (the default), or return an error.
187    ///
188    /// Only the immediate destination is created, not all its parents.
189    #[must_use]
190    pub fn create_destination(self, create_destination: bool) -> CopyOptions<'f> {
191        CopyOptions {
192            create_destination,
193            ..self
194        }
195    }
196
197    /// Set a filter callback that can determine which files should be copied.
198    ///
199    /// The filter can return
200    /// * `Ok(true)` to copy an entry (and recursively continue into directories)
201    /// * `Ok(false)` to skip an entry (and anything inside the directory)
202    /// * `Err(_)` to stop copying and return this error
203    ///
204    /// The path is relative to the top of the tree. The [std::fs::DirEntry] gives access to the file type and other metadata of the source file.
205    ///
206    /// ```
207    /// use std::fs;
208    /// use std::path::Path;
209    /// use cp_r::CopyOptions;
210    ///
211    /// let src = tempfile::tempdir().unwrap();
212    /// fs::write(src.path().join("transient.tmp"), b"hello?").unwrap();
213    /// fs::write(src.path().join("permanent.txt"), b"hello?").unwrap();
214    /// let dest = tempfile::tempdir().unwrap();
215    ///
216    /// let stats = CopyOptions::new()
217    ///     .filter(
218    ///         |path, _| Ok(path.extension().and_then(|s| s.to_str()) != Some("tmp")))
219    ///     .copy_tree(&src.path(), &dest.path())
220    ///     .unwrap();
221    ///
222    /// assert!(dest.path().join("permanent.txt").exists());
223    /// assert!(!dest.path().join("transient.tmp").exists());
224    /// assert_eq!(stats.filtered_out, 1);
225    /// assert_eq!(stats.files, 1);
226    /// ```
227    ///
228    /// *Note:* Due to limitations in the current Rust compiler's type inference
229    /// for closures, filter closures may give errors about lifetimes if they are
230    /// assigned to to a variable rather than declared inline in the parameter.
231    #[must_use]
232    pub fn filter<F>(self, filter: F) -> CopyOptions<'f>
233    where
234        F: FnMut(&Path, &DirEntry) -> Result<bool> + 'f,
235    {
236        CopyOptions {
237            filter: Some(Box::new(filter)),
238            ..self
239        }
240    }
241
242    /// Set a progress callback that's called after each entry is successfully copied.
243    ///
244    /// The callback is passed:
245    /// * The path, relative to the top of the tree, that was just copied.
246    /// * The [std::fs::FileType] of the entry that was copied.
247    /// * The [stats](CopyStats) so far, including the number of files copied.
248    ///
249    /// If the callback returns an error, it will abort the copy and the same
250    /// error will be returned from [CopyOptions::copy_tree].
251    #[must_use]
252    pub fn after_entry_copied<F>(self, after_entry_copied: F) -> CopyOptions<'f>
253    where
254        F: FnMut(&Path, &fs::FileType, &CopyStats) -> Result<()> + 'f,
255    {
256        CopyOptions {
257            after_entry_copied: Some(Box::new(after_entry_copied)),
258            ..self
259        }
260    }
261
262    /// Copy the tree according to the options.
263    ///
264    /// Returns [CopyStats] describing how many files were copied, etc.
265    pub fn copy_tree<P, Q>(mut self, src: P, dest: Q) -> Result<CopyStats>
266    where
267        P: AsRef<Path>,
268        Q: AsRef<Path>,
269    {
270        let src = src.as_ref();
271        let dest = dest.as_ref();
272
273        let mut stats = CopyStats::default();
274
275        // TODO: Handle the src not being a dir: copy that single entry.
276        if self.create_destination {
277            if !dest.is_dir() {
278                copy_dir(src, dest, &mut stats)?;
279            }
280        } else if !dest.is_dir() {
281            return Err(Error::new(ErrorKind::DestinationDoesNotExist, dest));
282        }
283
284        let mut subdir_queue: VecDeque<PathBuf> = VecDeque::new();
285        subdir_queue.push_back(PathBuf::from(""));
286
287        while let Some(subdir) = subdir_queue.pop_front() {
288            let subdir_full_path = src.join(&subdir);
289            for entry in fs::read_dir(&subdir_full_path)
290                .map_err(|io| Error::from_io_error(io, ErrorKind::ReadDir, &subdir_full_path))?
291            {
292                let dir_entry = entry.map_err(|io| {
293                    Error::from_io_error(io, ErrorKind::ReadDir, &subdir_full_path)
294                })?;
295                let entry_subpath = subdir.join(dir_entry.file_name());
296                if let Some(filter) = &mut self.filter {
297                    if !filter(&entry_subpath, &dir_entry)? {
298                        stats.filtered_out += 1;
299                        continue;
300                    }
301                }
302                let src_fullpath = src.join(&entry_subpath);
303                let dest_fullpath = dest.join(&entry_subpath);
304                let file_type = dir_entry
305                    .file_type()
306                    .map_err(|io| Error::from_io_error(io, ErrorKind::ReadDir, &src_fullpath))?;
307                if file_type.is_file() {
308                    copy_file(&src_fullpath, &dest_fullpath, &mut stats)?
309                } else if file_type.is_dir() {
310                    copy_dir(&src_fullpath, &dest_fullpath, &mut stats)?;
311                    subdir_queue.push_back(entry_subpath.clone());
312                } else if file_type.is_symlink() {
313                    copy_symlink(&src_fullpath, &dest_fullpath, &mut stats)?
314                } else {
315                    // TODO: Include the file type.
316                    return Err(Error::new(ErrorKind::UnsupportedFileType, src_fullpath));
317                }
318                if let Some(ref mut f) = self.after_entry_copied {
319                    f(&entry_subpath, &file_type, &stats)?;
320                }
321            }
322        }
323        Ok(stats)
324    }
325}
326
327/// Counters of how many things were copied.
328#[derive(Debug, Default, PartialEq, Eq, Clone)]
329pub struct CopyStats {
330    /// The number of plain files copied.
331    pub files: usize,
332    /// The number of directories copied.
333    pub dirs: usize,
334    /// The number of symlinks copied.
335    pub symlinks: usize,
336    /// The number of bytes of file content copied, across all files.
337    pub file_bytes: u64,
338    /// The number of entries filtered out by the [CopyOptions::filter] callback.
339    pub filtered_out: usize,
340}
341
342/// An error from copying a tree.
343///
344/// At present this library does not support continuing after an error, so only the first error is
345/// returned by [CopyOptions::copy_tree].
346#[derive(Debug)]
347pub struct Error {
348    path: PathBuf,
349    /// The original IO error, if any.
350    io: Option<io::Error>,
351    kind: ErrorKind,
352}
353
354/// A [std::result::Result] possibly containing a `cp_r` [Error].
355pub type Result<T> = std::result::Result<T, Error>;
356
357impl Error {
358    /// Construct a new error with no source.
359    pub fn new<P>(kind: ErrorKind, path: P) -> Error
360    where
361        P: Into<PathBuf>,
362    {
363        Error {
364            path: path.into(),
365            kind,
366            io: None,
367        }
368    }
369
370    /// Construct a new error from a [std::io::Error].
371    pub fn from_io_error<P>(io: io::Error, kind: ErrorKind, path: P) -> Error
372    where
373        P: Into<PathBuf>,
374    {
375        Error {
376            path: path.into(),
377            kind,
378            io: Some(io),
379        }
380    }
381
382    /// The path where this error occurred.
383    pub fn path(&self) -> &Path {
384        // TODO: Be consistent about whether this is relative to the root etc.
385        &self.path
386    }
387
388    /// The IO error that caused this error, if any.
389    pub fn io_error(&self) -> Option<&io::Error> {
390        self.io.as_ref()
391    }
392
393    /// The kind of error that occurred.
394    pub fn kind(&self) -> ErrorKind {
395        self.kind
396    }
397}
398
399impl std::error::Error for Error {
400    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
401        // It seems like you should be able to spell this like `self.io.as_ref().into()` but that
402        // doesn't work and I'm not sure why...
403        if let Some(io) = &self.io {
404            Some(io)
405        } else {
406            None
407        }
408    }
409}
410
411impl fmt::Display for Error {
412    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
413        use ErrorKind::*;
414        let kind_msg = match self.kind {
415            ReadDir => "reading source directory",
416            ReadFile => "reading source file",
417            WriteFile => "writing file",
418            CreateDir => "creating directory",
419            ReadSymlink => "reading symlink",
420            CreateSymlink => "creating symlink",
421            UnsupportedFileType => "unsupported file type",
422            CopyFile => "copying file",
423            DestinationDoesNotExist => "destination directory does not exist",
424            Interrupted => "interrupted",
425        };
426        if let Some(io) = &self.io {
427            write!(f, "{}: {}: {}", kind_msg, self.path.display(), io)
428        } else {
429            write!(f, "{}: {}", kind_msg, self.path.display())
430        }
431    }
432}
433
434/// Various kinds of errors that can occur while copying a tree.
435#[derive(Debug, PartialEq, Eq, Clone, Copy)]
436#[non_exhaustive]
437pub enum ErrorKind {
438    /// Error listing a source directory.
439    ReadDir,
440    /// Error opening or reading a source file.
441    ReadFile,
442    /// Error creating or writing a destination file.
443    WriteFile,
444    /// Error in copying a file: might be a read or write error.
445    CopyFile,
446    /// Error creating a destination directory.
447    CreateDir,
448    /// Error reading a symlink.
449    ReadSymlink,
450    /// Error creating a symlink in the destination.
451    CreateSymlink,
452    /// The source tree contains a type of file that this library can't copy, such as a Unix
453    /// FIFO.
454    UnsupportedFileType,
455    /// The destination directory does not exist.
456    DestinationDoesNotExist,
457    /// The copy was interrupted by the user.
458    ///
459    /// This is not currently generated internally by `cp_r` but can be returned
460    /// by a callback.
461    Interrupted,
462}
463
464fn copy_file(src: &Path, dest: &Path, stats: &mut CopyStats) -> Result<()> {
465    // TODO: Optionally first check and error if the destination exists.
466    let bytes_copied =
467        fs::copy(src, dest).map_err(|io| Error::from_io_error(io, ErrorKind::CopyFile, src))?;
468    stats.file_bytes += bytes_copied;
469
470    let src_metadata = src
471        .metadata()
472        .map_err(|io| Error::from_io_error(io, ErrorKind::ReadFile, src))?;
473    let src_mtime = filetime::FileTime::from_last_modification_time(&src_metadata);
474    // It's OK if we can't set the mtime.
475    let _ = filetime::set_file_mtime(dest, src_mtime);
476
477    // Permissions should have already been set by fs::copy.
478    stats.files += 1;
479    Ok(())
480}
481
482fn copy_dir(_src: &Path, dest: &Path, stats: &mut CopyStats) -> Result<()> {
483    fs::create_dir(dest)
484        .map_err(|io| Error::from_io_error(io, ErrorKind::CreateDir, dest))
485        .map(|()| stats.dirs += 1)
486}
487
488#[cfg(unix)]
489fn copy_symlink(src: &Path, dest: &Path, stats: &mut CopyStats) -> Result<()> {
490    let target =
491        fs::read_link(src).map_err(|io| Error::from_io_error(io, ErrorKind::ReadSymlink, src))?;
492    std::os::unix::fs::symlink(target, dest)
493        .map_err(|io| Error::from_io_error(io, ErrorKind::CreateSymlink, dest))?;
494    stats.symlinks += 1;
495    Ok(())
496}