use std::fs;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::Context;
use camino::{Utf8Path, Utf8PathBuf};
use ignore::WalkBuilder;
use tempfile::TempDir;
use tracing::{debug, warn};
use crate::options::Options;
use crate::{Console, Result, check_interrupted};
#[cfg(unix)]
mod unix;
#[cfg(unix)]
use unix::copy_symlink;
#[cfg(windows)]
mod windows;
#[cfg(windows)]
use windows::copy_symlink;
static VCS_DIRS: &[&str] = &[".git", ".hg", ".bzr", ".svn", "_darcs", ".jj", ".pijul"];
#[cfg(not(target_env = "musl"))] fn copy_file(src: &Path, dest: &Path, reflink_supported: &AtomicBool) -> Result<u64> {
if reflink_supported.load(Ordering::Relaxed) {
match reflink::reflink(src, dest) {
Ok(()) => {
let metadata = fs::metadata(dest)
.with_context(|| format!("Failed to get metadata for {}", dest.display()))?;
return Ok(metadata.len());
}
Err(e) => {
reflink_supported.store(false, Ordering::Relaxed);
debug!("Reflink failed: {}, falling back to regular copy", e);
}
}
}
fs::copy(src, dest)
.with_context(|| format!("Failed to copy {} to {}", src.display(), dest.display()))
}
#[cfg(target_env = "musl")] #[mutants::skip]
fn copy_file(src: &Path, dest: &Path, _reflink_supported: &AtomicBool) -> Result<u64> {
fs::copy(src, dest)
.with_context(|| format!("Failed to copy {} to {}", src.display(), dest.display()))
}
pub fn copy_tree(
from_path: &Utf8Path,
name_base: &str,
options: &Options,
console: &Console,
) -> Result<TempDir> {
let mut total_bytes = 0;
let mut total_files = 0;
let reflink_supported = AtomicBool::new(true);
let temp_dir = tempfile::Builder::new()
.prefix(name_base)
.suffix(".tmp")
.tempdir()
.context("create temp dir")?;
let dest = temp_dir
.path()
.try_into()
.context("Convert path to UTF-8")?;
console.start_copy(dest);
let mut walk_builder = WalkBuilder::new(from_path);
let copy_vcs = options.copy_vcs; let from_path_owned = from_path.to_owned(); let copy_target = options.copy_target;
walk_builder
.git_ignore(options.gitignore)
.git_exclude(options.gitignore)
.git_global(options.gitignore)
.hidden(false) .ignore(false) .require_git(true) .filter_entry(move |entry| {
let name = entry.file_name().to_string_lossy();
let is_top_level_target = name == "target"
&& entry
.path()
.parent()
.is_some_and(|p| p == from_path_owned.as_path());
name != "mutants.out"
&& name != "mutants.out.old"
&& (copy_target || !is_top_level_target)
&& (copy_vcs || !VCS_DIRS.contains(&name.as_ref()))
});
debug!(?walk_builder);
for entry in walk_builder.build() {
check_interrupted()?;
let entry = entry?;
let relative_path = entry
.path()
.strip_prefix(from_path)
.expect("entry path is in from_path");
let dest_path: Utf8PathBuf = temp_dir
.path()
.join(relative_path)
.try_into()
.context("Convert path to UTF-8")?;
let ft = entry.file_type().with_context(|| {
format!(
"Expected file to have a file type: {}",
entry.path().display()
)
})?;
if ft.is_file() {
let bytes_copied =
copy_file(entry.path(), dest_path.as_std_path(), &reflink_supported)?;
total_bytes += bytes_copied;
total_files += 1;
console.copy_progress(dest, total_bytes);
} else if ft.is_dir() {
std::fs::create_dir_all(&dest_path)
.with_context(|| format!("Failed to create directory {dest_path:?}"))?;
} else if ft.is_symlink() {
copy_symlink(
ft,
entry
.path()
.try_into()
.context("Convert filename to UTF-8")?,
&dest_path,
)?;
} else {
warn!("Unexpected file type: {:?}", entry.path());
}
}
console.finish_copy(dest);
let reflink_used = reflink_supported.load(Ordering::Relaxed);
debug!(?total_bytes, ?total_files, ?reflink_used, temp_dir = ?temp_dir.path(), "Copied source tree");
Ok(temp_dir)
}
#[cfg(test)]
mod test {
use std::fs::{create_dir, write};
use camino::Utf8PathBuf;
use tempfile::TempDir;
use crate::Result;
use crate::console::Console;
use crate::options::Options;
use super::copy_tree;
#[test]
fn copy_tree_with_parent_ignoring_star() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = tmp_dir.path();
write(tmp.join(".gitignore"), "*\n")?;
let a = Utf8PathBuf::try_from(tmp.join("a")).unwrap();
create_dir(&a)?;
write(a.join("Cargo.toml"), "[package]\nname = a")?;
let src = a.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
let options = Options::from_arg_strs(["--gitignore=true"]);
let dest_tmpdir = copy_tree(&a, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(dest.join("Cargo.toml").is_file());
assert!(dest.join("src").is_dir());
assert!(dest.join("src/main.rs").is_file());
Ok(())
}
#[test]
fn copy_with_gitignore_but_without_git_dir() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
write(tmp.join(".gitignore"), "foo\n")?;
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
let src = tmp.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
write(tmp.join("foo"), "bar")?;
let options = Options::from_arg_strs(["--gitignore=true"]);
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(
dest.join("foo").is_file(),
"foo should be copied because gitignore is not used without .git"
);
Ok(())
}
#[test]
fn copy_without_gitignore_by_default() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
write(tmp.join(".gitignore"), "foo\n")?;
create_dir(tmp.join(".git"))?;
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
let src = tmp.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
write(tmp.join("foo"), "bar")?;
let options = Options::from_arg_strs(["mutants"]);
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(dest.join("foo").is_file());
Ok(())
}
#[test]
fn copy_with_gitignore_and_git_dir() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
write(tmp.join(".gitignore"), "foo\n")?;
create_dir(tmp.join(".git"))?;
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
let src = tmp.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
write(tmp.join("foo"), "bar")?;
let options = Options::from_arg_strs(["mutants", "--gitignore=true"]);
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(
!dest.join("foo").is_file(),
"foo should have been excluded by gitignore"
);
Ok(())
}
#[test]
fn copy_with_gitignore_true_in_config_and_git_dir_excludes_ignored_files() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
write(tmp.join(".gitignore"), "foo\n")?;
create_dir(tmp.join(".git"))?;
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
let src = tmp.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
write(tmp.join("foo"), "bar")?;
let options = Options::from_arg_strs_and_config(["mutants"], "gitignore=true");
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(
!dest.join("foo").is_file(),
"foo should have been excluded by gitignore"
);
Ok(())
}
#[test]
fn copy_without_gitignore() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
write(tmp.join(".gitignore"), "foo\n")?;
create_dir(tmp.join(".git"))?;
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
let src = tmp.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
write(tmp.join("foo"), "bar")?;
let options = Options::from_arg_strs(["mutants", "--gitignore=false"]);
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(dest.join("foo").is_file());
Ok(())
}
#[test]
fn dont_copy_git_dir_or_mutants_out_by_default() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
create_dir(tmp.join(".git"))?;
write(tmp.join(".git/foo"), "bar")?;
create_dir(tmp.join("mutants.out"))?;
write(tmp.join("mutants.out/foo"), "bar")?;
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
let src = tmp.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
let options = Options::from_arg_strs(["mutants"]);
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(!dest.join(".git").is_dir(), ".git should not be copied");
assert!(
!dest.join(".git/foo").is_file(),
".git/foo should not be copied"
);
assert!(
!dest.join("mutants.out").exists(),
"mutants.out should not be copied"
);
assert!(!dest.join("target").exists(), "target should not be copied");
assert!(
dest.join("Cargo.toml").is_file(),
"Cargo.toml should be copied"
);
Ok(())
}
#[test]
fn copy_git_dir_when_requested() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
create_dir(tmp.join(".git"))?;
write(tmp.join(".git/foo"), "bar")?;
create_dir(tmp.join("mutants.out"))?;
write(tmp.join("mutants.out/foo"), "bar")?;
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
let src = tmp.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
let options = Options::from_arg_strs(["mutants", "--copy-vcs=true"]);
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(dest.join(".git").is_dir(), ".git should be copied");
assert!(dest.join(".git/foo").is_file(), ".git/foo should be copied");
assert!(
!dest.join("mutants.out").exists(),
"mutants.out should not be copied"
);
assert!(
dest.join("Cargo.toml").is_file(),
"Cargo.toml should be copied"
);
Ok(())
}
#[test]
fn dont_copy_target_dir_by_default_when_copy_target_false() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
create_dir(tmp.join("target"))?;
write(tmp.join("target/foo"), "bar")?;
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
let src = tmp.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
let options = Options::from_arg_strs(["mutants"]);
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(
!dest.join("target").exists(),
"target should not be copied by default"
);
assert!(
dest.join("Cargo.toml").is_file(),
"Cargo.toml should be copied"
);
Ok(())
}
#[test]
fn copy_target_dir_when_requested() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
create_dir(tmp.join("target"))?;
write(tmp.join("target/foo"), "bar")?;
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
let src = tmp.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
let options = Options::from_arg_strs(["mutants", "--copy-target=true"]);
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(
dest.join("target").exists(),
"target should be copied when --copy-target=true"
);
assert!(
dest.join("target/foo").is_file(),
"target/foo should be copied when --copy-target=true"
);
assert!(
dest.join("Cargo.toml").is_file(),
"Cargo.toml should be copied"
);
Ok(())
}
#[test]
fn copy_non_top_level_target_files() -> Result<()> {
let tmp_dir = TempDir::new().unwrap();
let tmp = Utf8PathBuf::try_from(tmp_dir.path().to_owned()).unwrap();
create_dir(tmp.join("target"))?;
write(tmp.join("target/build_artifact"), "should not be copied")?;
let testdata = tmp.join("testdata");
create_dir(&testdata)?;
write(testdata.join("target"), "should be copied")?;
let subdir = tmp.join("subdir");
create_dir(&subdir)?;
create_dir(subdir.join("target"))?;
write(subdir.join("target/file"), "should be copied")?;
write(tmp.join("Cargo.toml"), "[package]\nname = a")?;
let src = tmp.join("src");
create_dir(&src)?;
write(src.join("main.rs"), "fn main() {}")?;
let options = Options::from_arg_strs(["mutants"]);
let dest_tmpdir = copy_tree(&tmp, "a", &options, &Console::new())?;
let dest = dest_tmpdir.path();
assert!(
!dest.join("target").exists(),
"top-level target directory should not be copied"
);
assert!(
dest.join("testdata/target").is_file(),
"testdata/target file should be copied"
);
assert!(
dest.join("subdir/target").is_dir(),
"subdir/target directory should be copied"
);
assert!(
dest.join("subdir/target/file").is_file(),
"subdir/target/file should be copied"
);
Ok(())
}
}