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}