use std::{
collections::HashSet,
env, fs, io,
path::{Component, Path, PathBuf},
result,
};
use ignore::{overrides::OverrideBuilder, WalkBuilder};
use reflink_copy::{reflink, reflink_or_copy};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("IO error: {0}")]
Io(#[from] io::Error),
#[error("Failed to create directory at {path}: {source}")]
CreateDirectory {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("Failed to copy file from {src} to {dest}: {source}")]
Copy {
src: PathBuf,
dest: PathBuf,
#[source]
source: io::Error,
},
#[error("Invalid glob pattern '{pattern}': {source}")]
InvalidGlob {
pattern: String,
#[source]
source: ignore::Error,
},
#[error("Destination already exists: {path}")]
DestinationExists {
path: PathBuf,
},
#[error("Destination is not a directory: {path}")]
DestinationNotDirectory {
path: PathBuf,
},
#[error("Source is not a directory: {path}")]
SourceNotDirectory {
path: PathBuf,
},
#[error("Source does not exist: {path}")]
SourceNotFound {
path: PathBuf,
},
#[error("Operation error: {0}")]
Other(String),
#[error("Single-call cloning is only available on macOS")]
SingleCallUnsupported,
#[error("Single-call cloning cannot be combined with glob filters: {patterns:?}")]
IncompatibleOptions {
patterns: Vec<String>,
},
#[error("Source and destination resolve to the same path: {path}")]
IdenticalPaths {
path: PathBuf,
},
#[error("Destination lies inside the source tree: src={src}, dest={dest}")]
DestinationInsideSource {
src: PathBuf,
dest: PathBuf,
},
#[error("Source lies inside the destination tree: src={src}, dest={dest}")]
SourceInsideDestination {
src: PathBuf,
dest: PathBuf,
},
#[error("Error while walking source tree: {source}")]
Walk {
#[source]
source: ignore::Error,
},
}
pub type Result<T> = result::Result<T, Error>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CloneStrategy {
#[default]
Auto,
SingleCall,
FullTraversal,
}
#[derive(Debug, Default)]
pub struct Options {
globs: Vec<String>,
strategy: CloneStrategy,
}
impl Options {
pub fn new() -> Self {
Self::default()
}
pub fn glob<S: Into<String>>(mut self, pattern: S) -> Self {
self.globs.push(pattern.into());
self
}
pub fn strategy(mut self, strategy: CloneStrategy) -> Self {
self.strategy = strategy;
self
}
}
pub fn clone_tree<P: AsRef<Path>, Q: AsRef<Path>>(
src: P,
dest: Q,
options: &Options,
) -> Result<()> {
let src = src.as_ref();
let dest = dest.as_ref();
if !src.exists() {
return Err(Error::SourceNotFound {
path: src.to_path_buf(),
});
}
if !src.is_dir() {
return Err(Error::SourceNotDirectory {
path: src.to_path_buf(),
});
}
if dest.exists() && !dest.is_dir() {
return Err(Error::DestinationNotDirectory {
path: dest.to_path_buf(),
});
}
let src_canon = canonicalize_existing(src)?;
let dest_resolved = canonicalize_for_destination(dest)?;
if src_canon == dest_resolved {
return Err(Error::IdenticalPaths { path: src_canon });
}
if dest_resolved.starts_with(&src_canon) {
return Err(Error::DestinationInsideSource {
src: src_canon,
dest: dest_resolved,
});
}
if src_canon.starts_with(&dest_resolved) {
return Err(Error::SourceInsideDestination {
src: src_canon,
dest: dest_resolved,
});
}
if dest.exists() {
return Err(Error::DestinationExists {
path: dest.to_path_buf(),
});
}
let use_single_call = should_use_single_call(options)?;
if use_single_call {
return clone_tree_single_call(src, dest, options);
}
clone_tree_full_traversal(src, dest, options)
}
fn should_use_single_call(options: &Options) -> Result<bool> {
if !options.globs.is_empty() {
if matches!(options.strategy, CloneStrategy::SingleCall) {
return Err(Error::IncompatibleOptions {
patterns: options.globs.clone(),
});
}
return Ok(false);
}
match options.strategy {
CloneStrategy::SingleCall => {
if cfg!(target_os = "macos") {
Ok(true)
} else {
Err(Error::SingleCallUnsupported)
}
}
CloneStrategy::Auto => Ok(cfg!(target_os = "macos")),
CloneStrategy::FullTraversal => Ok(false),
}
}
#[cfg(target_os = "macos")]
fn clone_tree_single_call<P: AsRef<Path>, Q: AsRef<Path>>(
src: P,
dest: Q,
_options: &Options,
) -> Result<()> {
let src = src.as_ref();
let dest = dest.as_ref();
if let Some(parent) = dest.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|source| Error::CreateDirectory {
path: parent.to_path_buf(),
source,
})?;
}
}
reflink(src, dest).map_err(|source| Error::Copy {
src: src.to_path_buf(),
dest: dest.to_path_buf(),
source,
})
}
#[cfg(not(target_os = "macos"))]
fn clone_tree_single_call<P: AsRef<Path>, Q: AsRef<Path>>(
_src: P,
_dest: Q,
_options: &Options,
) -> Result<()> {
Err(Error::SingleCallUnsupported)
}
fn clone_tree_full_traversal<P: AsRef<Path>, Q: AsRef<Path>>(
src: P,
dest: Q,
options: &Options,
) -> Result<()> {
let src = src.as_ref();
let dest = dest.as_ref();
fs::create_dir_all(dest).map_err(|source| Error::CreateDirectory {
path: dest.to_path_buf(),
source,
})?;
let mut created_dirs = HashSet::new();
created_dirs.insert(dest.to_path_buf());
let mut builder = WalkBuilder::new(src);
builder.standard_filters(false);
if !options.globs.is_empty() {
let mut overrides = OverrideBuilder::new(src);
for pattern in &options.globs {
overrides
.add(pattern)
.map_err(|source| Error::InvalidGlob {
pattern: pattern.clone(),
source,
})?;
}
builder.overrides(
overrides
.build()
.map_err(|e| Error::Other(format!("Failed to build glob overrides: {e}")))?,
);
}
for entry in builder.build() {
let entry = entry.map_err(|source| Error::Walk { source })?;
let path = entry.path();
if path == src {
continue;
}
let relative_path = path
.strip_prefix(src)
.map_err(|e| Error::Other(format!("Failed to strip prefix from path: {e}")))?;
let dest_path = dest.join(relative_path);
if let Some(parent) = dest_path.parent() {
if !created_dirs.contains(parent) {
fs::create_dir_all(parent).map_err(|source| Error::CreateDirectory {
path: parent.to_path_buf(),
source,
})?;
created_dirs.insert(parent.to_path_buf());
}
}
if entry.path_is_symlink() {
let target = fs::read_link(path).map_err(|source| Error::Copy {
src: path.to_path_buf(),
dest: dest_path.clone(),
source,
})?;
create_symlink(&target, &dest_path, path).map_err(|source| Error::Copy {
src: path.to_path_buf(),
dest: dest_path.clone(),
source,
})?;
} else if entry.file_type().is_some_and(|ft| ft.is_file()) {
reflink_or_copy(path, &dest_path).map_err(|source| Error::Copy {
src: path.to_path_buf(),
dest: dest_path.clone(),
source,
})?;
} else if entry.file_type().is_some_and(|ft| ft.is_dir()) {
if !created_dirs.contains(&dest_path) {
fs::create_dir_all(&dest_path).map_err(|source| Error::CreateDirectory {
path: dest_path.clone(),
source,
})?;
created_dirs.insert(dest_path);
}
}
}
Ok(())
}
#[cfg(unix)]
#[allow(clippy::absolute_paths)] fn create_symlink(target: &Path, dest: &Path, _original_path: &Path) -> io::Result<()> {
std::os::unix::fs::symlink(target, dest)
}
#[cfg(windows)]
#[allow(clippy::absolute_paths)] fn create_symlink(target: &Path, dest: &Path, original_path: &Path) -> io::Result<()> {
let target_is_dir = original_path
.parent()
.map(|p| p.join(target))
.and_then(|full| fs::metadata(full).ok())
.is_some_and(|m| m.is_dir());
if target_is_dir {
std::os::windows::fs::symlink_dir(target, dest)
} else {
std::os::windows::fs::symlink_file(target, dest)
}
}
fn canonicalize_existing(path: &Path) -> Result<PathBuf> {
fs::canonicalize(path).map_err(Error::from)
}
fn canonicalize_for_destination(path: &Path) -> Result<PathBuf> {
if path.exists() {
return canonicalize_existing(path);
}
let absolute = absolutize(path)?;
let mut existing = absolute.as_path();
while !existing.exists() {
existing = existing
.parent()
.ok_or_else(|| Error::Other("Destination path must include a parent".to_owned()))?;
}
let resolved_existing = canonicalize_existing(existing)?;
let remainder = absolute
.strip_prefix(existing)
.map_err(|e| Error::Other(format!("Failed to strip prefix: {e}")))?;
Ok(clean_path(&resolved_existing.join(remainder)))
}
fn absolutize(path: &Path) -> Result<PathBuf> {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
env::current_dir()?.join(path)
};
Ok(clean_path(&joined))
}
fn clean_path(path: &Path) -> PathBuf {
let mut cleaned = PathBuf::new();
let mut depth = 0usize; let mut has_root = false;
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if depth > 0 {
cleaned.pop();
depth -= 1;
} else if !has_root {
cleaned.push(component);
}
}
Component::RootDir | Component::Prefix(_) => {
cleaned.push(component);
has_root = true;
}
Component::Normal(_) => {
cleaned.push(component);
depth += 1;
}
}
}
cleaned
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn write_file(path: &Path, contents: &str) {
fs::write(path, contents).unwrap();
}
fn mkdir(path: &Path) {
fs::create_dir_all(path).unwrap();
}
#[test]
fn test_clone_tree_basic() -> Result<()> {
let temp_dir = TempDir::new()?;
let src = temp_dir.path().join("src");
let dest = temp_dir.path().join("dest");
fs::create_dir_all(&src)?;
write_file(&src.join("file1.txt"), "content1");
fs::create_dir(src.join("subdir"))?;
write_file(&src.join("subdir/file2.txt"), "content2");
let opts = Options::new();
clone_tree(&src, &dest, &opts)?;
assert!(dest.join("file1.txt").exists());
assert!(dest.join("subdir/file2.txt").exists());
assert_eq!(fs::read_to_string(dest.join("file1.txt"))?, "content1");
assert_eq!(
fs::read_to_string(dest.join("subdir/file2.txt"))?,
"content2"
);
Ok(())
}
#[test]
fn test_clone_tree_with_excludes() -> Result<()> {
let temp_dir = TempDir::new()?;
let src = temp_dir.path().join("src");
let dest = temp_dir.path().join("dest");
fs::create_dir_all(&src)?;
fs::write(src.join("file.txt"), "keep")?;
fs::create_dir(src.join("target"))?;
fs::write(src.join("target/build.out"), "exclude")?;
fs::create_dir(src.join(".git"))?;
write_file(&src.join(".git/config"), "exclude");
let opts = Options::new().glob("!target/").glob("!.git/");
clone_tree(&src, &dest, &opts)?;
assert!(dest.join("file.txt").exists());
assert!(!dest.join("target").exists());
assert!(!dest.join(".git").exists());
Ok(())
}
#[test]
fn test_clone_tree_with_positive_globs() -> Result<()> {
let temp_dir = TempDir::new()?;
let src = temp_dir.path().join("src");
let dest = temp_dir.path().join("dest");
fs::create_dir_all(&src)?;
write_file(&src.join("include1.txt"), "include");
write_file(&src.join("include2.txt"), "include");
write_file(&src.join("exclude.log"), "exclude");
fs::create_dir(src.join("data"))?;
write_file(&src.join("data/file.txt"), "include");
write_file(&src.join("data/debug.log"), "exclude");
let opts = Options::new().glob("**/*.txt");
clone_tree(&src, &dest, &opts)?;
assert!(dest.join("include1.txt").exists());
assert!(dest.join("include2.txt").exists());
assert!(dest.join("data/file.txt").exists());
assert!(!dest.join("exclude.log").exists());
assert!(!dest.join("data/debug.log").exists());
Ok(())
}
#[test]
fn test_source_not_found() {
let temp_dir = TempDir::new().unwrap();
let src = temp_dir.path().join("nonexistent");
let dest = temp_dir.path().join("dest");
let opts = Options::new();
let result = clone_tree(&src, &dest, &opts);
assert!(matches!(result, Err(Error::SourceNotFound { .. })));
}
#[test]
fn test_source_not_directory() {
let temp_dir = TempDir::new().unwrap();
let src = temp_dir.path().join("file.txt");
let dest = temp_dir.path().join("dest");
fs::write(&src, "content").unwrap();
let opts = Options::new();
let result = clone_tree(&src, &dest, &opts);
assert!(matches!(result, Err(Error::SourceNotDirectory { .. })));
}
#[test]
fn test_destination_exists() {
let temp_dir = TempDir::new().unwrap();
let src = temp_dir.path().join("src");
let dest = temp_dir.path().join("dest");
fs::create_dir_all(&src).unwrap();
fs::create_dir_all(&dest).unwrap();
let opts = Options::new();
let result = clone_tree(&src, &dest, &opts);
assert!(matches!(result, Err(Error::DestinationExists { .. })));
}
#[test]
fn single_call_strategy_rejected_with_globs() {
let opts = Options::new()
.glob("**/*.rs")
.strategy(CloneStrategy::SingleCall);
let temp_dir = TempDir::new().unwrap();
let src = temp_dir.path().join("src");
let dest = temp_dir.path().join("dest");
fs::create_dir_all(&src).unwrap();
let result = clone_tree(&src, &dest, &opts);
assert!(matches!(result, Err(Error::IncompatibleOptions { .. })));
}
#[test]
fn identical_paths_are_rejected() {
let temp_dir = TempDir::new().unwrap();
let src = temp_dir.path().join("dir");
mkdir(&src);
let opts = Options::new();
let result = clone_tree(&src, &src, &opts);
assert!(matches!(result, Err(Error::IdenticalPaths { .. })));
}
#[test]
fn destination_inside_source_is_rejected() {
let temp_dir = TempDir::new().unwrap();
let src = temp_dir.path().join("src");
let dest = src.join("nested/dest");
mkdir(&src);
let opts = Options::new();
let result = clone_tree(&src, &dest, &opts);
assert!(matches!(result, Err(Error::DestinationInsideSource { .. })));
}
#[test]
fn source_inside_destination_is_rejected() {
let temp_dir = TempDir::new().unwrap();
let dest = temp_dir.path().join("dest");
let src = dest.join("inner/src");
mkdir(&src);
let opts = Options::new();
let result = clone_tree(&src, &dest, &opts);
assert!(matches!(result, Err(Error::SourceInsideDestination { .. })));
}
#[cfg(unix)]
#[test]
fn symlinks_are_recreated() -> Result<()> {
use std::os::unix::fs as unix_fs;
let temp_dir = TempDir::new()?;
let src = temp_dir.path().join("src");
let dest = temp_dir.path().join("dest");
fs::create_dir_all(&src)?;
write_file(&src.join("file.txt"), "content");
unix_fs::symlink("file.txt", src.join("link_to_file.txt"))?;
fs::create_dir(src.join("subdir"))?;
write_file(&src.join("subdir/nested.txt"), "nested");
unix_fs::symlink("subdir", src.join("link_to_dir"))?;
let opts = Options::new().strategy(CloneStrategy::FullTraversal);
clone_tree(&src, &dest, &opts)?;
assert!(dest.join("file.txt").exists());
assert_eq!(fs::read_to_string(dest.join("file.txt"))?, "content");
let link_meta = fs::symlink_metadata(dest.join("link_to_file.txt"))?;
assert!(link_meta.file_type().is_symlink(), "should be a symlink");
assert_eq!(fs::read_link(dest.join("link_to_file.txt"))?, PathBuf::from("file.txt"));
assert_eq!(fs::read_to_string(dest.join("link_to_file.txt"))?, "content");
let dir_link_meta = fs::symlink_metadata(dest.join("link_to_dir"))?;
assert!(dir_link_meta.file_type().is_symlink(), "should be a symlink");
assert_eq!(fs::read_link(dest.join("link_to_dir"))?, PathBuf::from("subdir"));
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn single_call_preserves_symlinks() -> Result<()> {
use std::os::unix::fs as unix_fs;
let temp_dir = TempDir::new()?;
let src = temp_dir.path().join("src");
let dest = temp_dir.path().join("dest");
fs::create_dir_all(&src)?;
write_file(&src.join("file.txt"), "content");
unix_fs::symlink("file.txt", src.join("link_to_file.txt"))?;
fs::create_dir(src.join("subdir"))?;
unix_fs::symlink("subdir", src.join("link_to_dir"))?;
let opts = Options::new().strategy(CloneStrategy::SingleCall);
clone_tree(&src, &dest, &opts)?;
let link_meta = fs::symlink_metadata(dest.join("link_to_file.txt"))?;
assert!(link_meta.file_type().is_symlink(), "should be a symlink");
assert_eq!(
fs::read_link(dest.join("link_to_file.txt"))?,
PathBuf::from("file.txt")
);
let dir_link_meta = fs::symlink_metadata(dest.join("link_to_dir"))?;
assert!(dir_link_meta.file_type().is_symlink(), "should be a symlink");
assert_eq!(
fs::read_link(dest.join("link_to_dir"))?,
PathBuf::from("subdir")
);
Ok(())
}
#[cfg(unix)]
#[test]
fn file_permissions_are_preserved() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new()?;
let src = temp_dir.path().join("src");
let dest = temp_dir.path().join("dest");
fs::create_dir_all(&src)?;
let executable = src.join("script.sh");
write_file(&executable, "#!/bin/bash\necho hello");
fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))?;
let readonly = src.join("readonly.txt");
write_file(&readonly, "read only content");
fs::set_permissions(&readonly, fs::Permissions::from_mode(0o444))?;
let normal = src.join("normal.txt");
write_file(&normal, "normal content");
let opts = Options::new().strategy(CloneStrategy::FullTraversal);
clone_tree(&src, &dest, &opts)?;
let dest_exec_perms = fs::metadata(dest.join("script.sh"))?.permissions().mode();
let src_exec_perms = fs::metadata(&executable)?.permissions().mode();
assert_eq!(
dest_exec_perms & 0o777,
src_exec_perms & 0o777,
"executable permissions should be preserved"
);
let dest_ro_perms = fs::metadata(dest.join("readonly.txt"))?.permissions().mode();
let src_ro_perms = fs::metadata(&readonly)?.permissions().mode();
assert_eq!(
dest_ro_perms & 0o777,
src_ro_perms & 0o777,
"read-only permissions should be preserved"
);
Ok(())
}
#[test]
fn empty_directories_are_preserved() -> Result<()> {
let temp_dir = TempDir::new()?;
let src = temp_dir.path().join("src");
let dest = temp_dir.path().join("dest");
fs::create_dir_all(&src)?;
fs::create_dir(src.join("empty1"))?;
fs::create_dir(src.join("empty2"))?;
fs::create_dir_all(src.join("nested/empty"))?;
fs::create_dir(src.join("nonempty"))?;
write_file(&src.join("nonempty/file.txt"), "content");
let opts = Options::new().strategy(CloneStrategy::FullTraversal);
clone_tree(&src, &dest, &opts)?;
assert!(
dest.join("empty1").exists() && dest.join("empty1").is_dir(),
"empty1 directory should exist"
);
assert!(
dest.join("empty2").exists() && dest.join("empty2").is_dir(),
"empty2 directory should exist"
);
assert!(
dest.join("nested/empty").exists() && dest.join("nested/empty").is_dir(),
"nested/empty directory should exist"
);
assert!(
dest.join("nonempty/file.txt").exists(),
"nonempty directory with file should exist"
);
Ok(())
}
#[test]
fn clean_path_basic() {
assert_eq!(clean_path(Path::new("/foo/bar")), PathBuf::from("/foo/bar"));
assert_eq!(clean_path(Path::new("/foo/../bar")), PathBuf::from("/bar"));
assert_eq!(clean_path(Path::new("/foo/./bar")), PathBuf::from("/foo/bar"));
assert_eq!(
clean_path(Path::new("/foo/bar/../baz")),
PathBuf::from("/foo/baz")
);
}
#[test]
fn clean_path_preserves_root() {
assert_eq!(clean_path(Path::new("/../foo")), PathBuf::from("/foo"));
assert_eq!(clean_path(Path::new("/foo/../../bar")), PathBuf::from("/bar"));
assert_eq!(
clean_path(Path::new("/foo/../../../bar")),
PathBuf::from("/bar")
);
assert_eq!(clean_path(Path::new("/..")), PathBuf::from("/"));
assert_eq!(clean_path(Path::new("/../..")), PathBuf::from("/"));
}
#[test]
fn clean_path_relative() {
assert_eq!(clean_path(Path::new("foo/bar")), PathBuf::from("foo/bar"));
assert_eq!(clean_path(Path::new("foo/../bar")), PathBuf::from("bar"));
assert_eq!(clean_path(Path::new("foo/../../bar")), PathBuf::from("../bar"));
assert_eq!(clean_path(Path::new("../foo")), PathBuf::from("../foo"));
assert_eq!(clean_path(Path::new("../../foo")), PathBuf::from("../../foo"));
}
#[test]
fn clean_path_empty_and_dot() {
assert_eq!(clean_path(Path::new("")), PathBuf::from(""));
assert_eq!(clean_path(Path::new(".")), PathBuf::from(""));
assert_eq!(clean_path(Path::new("..")), PathBuf::from(".."));
assert_eq!(clean_path(Path::new("./.")), PathBuf::from(""));
}
}