use std::io;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rustc_hash::FxHashMap;
use tracing::debug;
use uv_warnings::warn_user_once;
use walkdir::WalkDir;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(
feature = "serde",
serde(deny_unknown_fields, rename_all = "kebab-case")
)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum LinkMode {
#[cfg_attr(feature = "serde", serde(alias = "reflink"))]
#[cfg_attr(feature = "clap", value(alias = "reflink"))]
Clone,
Copy,
Hardlink,
Symlink,
}
impl Default for LinkMode {
fn default() -> Self {
if cfg!(any(
target_os = "macos",
target_os = "ios",
target_os = "linux"
)) {
Self::Clone
} else {
Self::Hardlink
}
}
}
impl LinkMode {
pub fn is_symlink(&self) -> bool {
matches!(self, Self::Symlink)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum OnExistingDirectory {
#[default]
Fail,
Merge,
}
pub fn link_dir<F>(
src: &Path,
dst: &Path,
options: &LinkOptions<'_, F>,
) -> Result<LinkMode, LinkError>
where
F: Fn(&Path) -> bool,
{
match options.mode {
LinkMode::Clone => clone_dir(src, dst, options),
mode => walk_and_link(src, dst, mode, options),
}
}
#[derive(Debug, Default)]
pub struct CopyLocks {
dir_locks: Mutex<FxHashMap<PathBuf, Arc<Mutex<()>>>>,
}
impl CopyLocks {
pub fn synchronized_copy(&self, from: &Path, to: &Path) -> io::Result<()> {
let dir_lock = {
let mut locks_guard = self.dir_locks.lock().unwrap();
locks_guard
.entry(to.parent().unwrap().to_path_buf())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone()
};
let _dir_guard = dir_lock.lock().unwrap();
fs_err::copy(from, to)?;
Ok(())
}
}
#[derive(Debug)]
pub struct LinkOptions<'a, F = fn(&Path) -> bool> {
mode: LinkMode,
needs_mutable_copy: F,
copy_locks: Option<&'a CopyLocks>,
on_existing_directory: OnExistingDirectory,
}
impl LinkOptions<'static> {
pub fn new(mode: LinkMode) -> Self {
Self {
mode,
needs_mutable_copy: |_| false,
copy_locks: None,
on_existing_directory: OnExistingDirectory::default(),
}
}
}
impl<'a, F> LinkOptions<'a, F> {
pub fn with_mutable_copy_filter<G>(self, f: G) -> LinkOptions<'a, G>
where
G: Fn(&Path) -> bool,
{
LinkOptions {
mode: self.mode,
needs_mutable_copy: f,
copy_locks: self.copy_locks,
on_existing_directory: self.on_existing_directory,
}
}
#[must_use]
pub fn with_copy_locks(self, locks: &'a CopyLocks) -> Self {
LinkOptions {
mode: self.mode,
needs_mutable_copy: self.needs_mutable_copy,
copy_locks: Some(locks),
on_existing_directory: self.on_existing_directory,
}
}
#[must_use]
pub fn with_on_existing_directory(self, on_existing_directory: OnExistingDirectory) -> Self {
LinkOptions {
mode: self.mode,
needs_mutable_copy: self.needs_mutable_copy,
copy_locks: self.copy_locks,
on_existing_directory,
}
}
fn copy_file(&self, from: &Path, to: &Path) -> io::Result<()>
where
F: Fn(&Path) -> bool,
{
if let Some(copy_locks) = self.copy_locks {
copy_locks.synchronized_copy(from, to)
} else {
fs_err::copy(from, to)?;
Ok(())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinkAttempt {
Initial,
Subsequent,
}
#[derive(Debug, Clone, Copy)]
struct LinkState {
mode: LinkMode,
attempt: LinkAttempt,
}
impl LinkState {
fn new(mode: LinkMode) -> Self {
Self {
mode,
attempt: LinkAttempt::Initial,
}
}
fn mode_working(self) -> Self {
Self {
attempt: LinkAttempt::Subsequent,
..self
}
}
fn next_mode(self) -> Self {
debug_assert!(
self.mode != LinkMode::Copy,
"Copy is the terminal fallback strategy and has no next mode"
);
Self::new(match self.mode {
LinkMode::Clone => LinkMode::Hardlink,
LinkMode::Hardlink | LinkMode::Symlink | LinkMode::Copy => LinkMode::Copy,
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum LinkError {
#[error("Failed to read directory `{}`", path.display())]
WalkDir {
path: PathBuf,
#[source]
err: walkdir::Error,
},
#[error("Failed to copy to `{}`", to.display())]
Copy {
to: PathBuf,
#[source]
err: io::Error,
},
#[error("Failed to create directory `{}`", path.display())]
CreateDir {
path: PathBuf,
#[source]
err: io::Error,
},
#[error("Failed to clone `{}` to `{}`", from.display(), to.display())]
Reflink {
from: PathBuf,
to: PathBuf,
#[source]
err: io::Error,
},
#[error("Failed to create symlink from `{}` to `{}`", from.display(), to.display())]
Symlink {
from: PathBuf,
to: PathBuf,
#[source]
err: io::Error,
},
#[error(transparent)]
Io(#[from] io::Error),
}
fn clone_dir<F>(src: &Path, dst: &Path, options: &LinkOptions<'_, F>) -> Result<LinkMode, LinkError>
where
F: Fn(&Path) -> bool,
{
#[cfg(target_os = "macos")]
{
match try_clone_dir_recursive(src, dst, options) {
Ok(()) => return Ok(LinkMode::Clone),
Err(e) => {
debug!(
"Failed to clone `{}` to `{}`: {}, falling back to per-file reflink",
src.display(),
dst.display(),
e
);
}
}
}
walk_and_link(src, dst, LinkMode::Clone, options)
}
fn walk_and_link<F>(
src: &Path,
dst: &Path,
mode: LinkMode,
options: &LinkOptions<'_, F>,
) -> Result<LinkMode, LinkError>
where
F: Fn(&Path) -> bool,
{
let mut state = LinkState::new(mode);
for entry in WalkDir::new(src) {
let entry = entry.map_err(|err| LinkError::WalkDir {
path: src.to_path_buf(),
err,
})?;
let path = entry.path();
let relative = path.strip_prefix(src).expect("walkdir starts with root");
let target = dst.join(relative);
if entry.file_type().is_dir() {
fs_err::create_dir_all(&target).map_err(|err| LinkError::CreateDir {
path: target.clone(),
err,
})?;
continue;
}
state = link_file(path, &target, state, options)?;
}
Ok(state.mode)
}
fn link_file<F>(
path: &Path,
target: &Path,
state: LinkState,
options: &LinkOptions<'_, F>,
) -> Result<LinkState, LinkError>
where
F: Fn(&Path) -> bool,
{
match state.mode {
LinkMode::Clone => reflink_file_with_fallback(path, target, state, options),
LinkMode::Hardlink => hardlink_file_with_fallback(path, target, state, options),
LinkMode::Symlink => symlink_file_with_fallback(path, target, state, options),
LinkMode::Copy => {
if options.on_existing_directory == OnExistingDirectory::Merge {
atomic_copy_overwrite(path, target, options)?;
} else {
copy_file(path, target, options)?;
}
Ok(state)
}
}
}
#[cfg(target_os = "linux")]
fn reflink_with_permissions(from: &Path, to: &Path) -> io::Result<()> {
use fs_err::os::unix::fs::OpenOptionsExt;
use std::os::unix::fs::PermissionsExt;
let src = fs_err::File::open(from)?;
let mode = src.metadata()?.permissions().mode();
let dest = fs_err::OpenOptions::new()
.write(true)
.create_new(true)
.mode(mode)
.open(to)?;
if let Err(err) = rustix::fs::ioctl_ficlone(&dest, &src) {
let _ = fs_err::remove_file(to);
return Err(err.into());
}
Ok(())
}
#[cfg(not(target_os = "linux"))]
fn reflink_with_permissions(from: &Path, to: &Path) -> io::Result<()> {
reflink_copy::reflink(from, to)
}
fn reflink_file_with_fallback<F>(
path: &Path,
target: &Path,
state: LinkState,
options: &LinkOptions<'_, F>,
) -> Result<LinkState, LinkError>
where
F: Fn(&Path) -> bool,
{
match state.attempt {
LinkAttempt::Initial => match reflink_with_permissions(path, target) {
Ok(()) => Ok(state.mode_working()),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
if options.on_existing_directory == OnExistingDirectory::Merge {
let parent = target.parent().unwrap();
let tempdir = tempfile::tempdir_in(parent)?;
let tempfile = tempdir.path().join(target.file_name().unwrap());
if reflink_with_permissions(path, &tempfile).is_ok() {
fs_err::rename(&tempfile, target)?;
Ok(state.mode_working())
} else {
debug!(
"Failed to reflink `{}` to temp location, falling back",
path.display()
);
link_file(path, target, state.next_mode(), options)
}
} else {
Err(LinkError::Reflink {
from: path.to_path_buf(),
to: target.to_path_buf(),
err,
})
}
}
Err(err) => {
debug!(
"Failed to reflink `{}` to `{}`: {}, falling back",
path.display(),
target.display(),
err
);
link_file(path, target, state.next_mode(), options)
}
},
LinkAttempt::Subsequent => match reflink_with_permissions(path, target) {
Ok(()) => Ok(state),
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
if options.on_existing_directory == OnExistingDirectory::Merge {
let parent = target.parent().unwrap();
let tempdir = tempfile::tempdir_in(parent)?;
let tempfile = tempdir.path().join(target.file_name().unwrap());
reflink_with_permissions(path, &tempfile).map_err(|err| {
LinkError::Reflink {
from: path.to_path_buf(),
to: tempfile.clone(),
err,
}
})?;
fs_err::rename(&tempfile, target)?;
Ok(state)
} else {
Err(LinkError::Reflink {
from: path.to_path_buf(),
to: target.to_path_buf(),
err,
})
}
}
Err(err) => Err(LinkError::Reflink {
from: path.to_path_buf(),
to: target.to_path_buf(),
err,
}),
},
}
}
#[cfg(target_os = "macos")]
fn try_clone_dir_recursive<F>(
src: &Path,
dst: &Path,
options: &LinkOptions<'_, F>,
) -> Result<(), LinkError>
where
F: Fn(&Path) -> bool,
{
match reflink_copy::reflink(src, dst) {
Ok(()) => {
debug!(
"Cloned directory `{}` to `{}`",
src.display(),
dst.display()
);
Ok(())
}
Err(err)
if err.kind() == io::ErrorKind::AlreadyExists
&& options.on_existing_directory == OnExistingDirectory::Merge =>
{
clone_dir_merge(src, dst, options)
}
Err(err) => Err(LinkError::Reflink {
from: src.to_path_buf(),
to: dst.to_path_buf(),
err,
}),
}
}
#[cfg(target_os = "macos")]
fn clone_dir_merge<F>(
src: &Path,
dst: &Path,
_options: &LinkOptions<'_, F>,
) -> Result<(), LinkError>
where
F: Fn(&Path) -> bool,
{
for entry in fs_err::read_dir(src)? {
let entry = entry?;
let src_path = entry.path();
let dst_path = dst.join(entry.file_name());
if entry.file_type()?.is_dir() {
match reflink_copy::reflink(&src_path, &dst_path) {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
clone_dir_merge(&src_path, &dst_path, _options)?;
}
Err(err) => {
return Err(LinkError::Reflink {
from: src_path,
to: dst_path,
err,
});
}
}
} else {
match reflink_copy::reflink(&src_path, &dst_path) {
Ok(()) => {}
Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
let tempdir = tempfile::tempdir_in(dst)?;
let tempfile = tempdir.path().join(entry.file_name());
reflink_copy::reflink(&src_path, &tempfile).map_err(|err| {
LinkError::Reflink {
from: src_path.clone(),
to: tempfile.clone(),
err,
}
})?;
fs_err::rename(&tempfile, &dst_path)?;
}
Err(err) => {
return Err(LinkError::Reflink {
from: src_path,
to: dst_path,
err,
});
}
}
}
}
Ok(())
}
fn hardlink_file_with_fallback<F>(
path: &Path,
target: &Path,
state: LinkState,
options: &LinkOptions<'_, F>,
) -> Result<LinkState, LinkError>
where
F: Fn(&Path) -> bool,
{
if (options.needs_mutable_copy)(path) {
copy_file(path, target, options)?;
return Ok(state);
}
match state.attempt {
LinkAttempt::Initial => {
if let Err(err) = try_hardlink_file(path, target) {
if err.kind() == io::ErrorKind::AlreadyExists
&& options.on_existing_directory == OnExistingDirectory::Merge
{
atomic_hardlink_overwrite(path, target, state, options)
} else {
debug!(
"Failed to hard link `{}` to `{}`: {}; falling back to copy",
path.display(),
target.display(),
err
);
warn_user_once!(
"Failed to hardlink files; falling back to full copy. This may lead to degraded performance.\n \
If the cache and target directories are on different filesystems, hardlinking may not be supported.\n \
If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning."
);
link_file(path, target, state.next_mode(), options)
}
} else {
Ok(state.mode_working())
}
}
LinkAttempt::Subsequent => {
if let Err(err) = try_hardlink_file(path, target) {
if err.kind() == io::ErrorKind::AlreadyExists
&& options.on_existing_directory == OnExistingDirectory::Merge
{
atomic_hardlink_overwrite(path, target, state, options)
} else {
Err(LinkError::Io(err))
}
} else {
Ok(state)
}
}
}
}
fn symlink_file_with_fallback<F>(
path: &Path,
target: &Path,
state: LinkState,
options: &LinkOptions<'_, F>,
) -> Result<LinkState, LinkError>
where
F: Fn(&Path) -> bool,
{
if (options.needs_mutable_copy)(path) {
copy_file(path, target, options)?;
return Ok(state);
}
match state.attempt {
LinkAttempt::Initial => {
if let Err(err) = create_symlink(path, target) {
if err.kind() == io::ErrorKind::AlreadyExists
&& options.on_existing_directory == OnExistingDirectory::Merge
{
atomic_symlink_overwrite(path, target, state, options)
} else {
debug!(
"Failed to symlink `{}` to `{}`: {}; falling back to copy",
path.display(),
target.display(),
err
);
warn_user_once!(
"Failed to symlink files; falling back to full copy. This may lead to degraded performance.\n \
If the cache and target directories are on different filesystems, symlinking may not be supported.\n \
If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning."
);
link_file(path, target, state.next_mode(), options)
}
} else {
Ok(state.mode_working())
}
}
LinkAttempt::Subsequent => {
if let Err(err) = create_symlink(path, target) {
if err.kind() == io::ErrorKind::AlreadyExists
&& options.on_existing_directory == OnExistingDirectory::Merge
{
atomic_symlink_overwrite(path, target, state, options)
} else {
Err(LinkError::Symlink {
from: path.to_path_buf(),
to: target.to_path_buf(),
err,
})
}
} else {
Ok(state)
}
}
}
}
fn copy_file<F>(path: &Path, target: &Path, options: &LinkOptions<'_, F>) -> Result<(), LinkError>
where
F: Fn(&Path) -> bool,
{
options
.copy_file(path, target)
.map_err(|err| LinkError::Copy {
to: target.to_path_buf(),
err,
})
}
fn try_hardlink_file(src: &Path, dst: &Path) -> io::Result<()> {
match fs_err::hard_link(src, dst) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::TooManyLinks => {
debug!(
"Hit link limit for {}, creating a fresh copy",
src.display()
);
let mut parent = src.parent().unwrap_or(Path::new("."));
if parent.as_os_str().is_empty() {
parent = Path::new(".");
}
let temp = tempfile::NamedTempFile::new_in(parent)?;
fs_err::copy(src, temp.path())?;
fs_err::hard_link(temp.path(), dst)?;
fs_err::rename(temp.path(), src)?;
Ok(())
}
Err(err) => Err(err),
}
}
fn atomic_hardlink_overwrite<F>(
src: &Path,
dst: &Path,
state: LinkState,
options: &LinkOptions<'_, F>,
) -> Result<LinkState, LinkError>
where
F: Fn(&Path) -> bool,
{
let parent = dst.parent().unwrap();
let tempdir = tempfile::tempdir_in(parent)?;
let tempfile = tempdir.path().join(dst.file_name().unwrap());
if try_hardlink_file(src, &tempfile).is_ok() {
fs_err::rename(&tempfile, dst)?;
Ok(state.mode_working())
} else {
debug!(
"Failed to hardlink `{}` to temp location, falling back to copy",
src.display()
);
warn_user_once!(
"Failed to hardlink files; falling back to full copy. This may lead to degraded performance.\n \
If the cache and target directories are on different filesystems, hardlinking may not be supported.\n \
If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning."
);
let state = state.next_mode();
atomic_copy_overwrite(src, dst, options)?;
Ok(state)
}
}
fn atomic_copy_overwrite<F>(
src: &Path,
dst: &Path,
options: &LinkOptions<'_, F>,
) -> Result<(), LinkError>
where
F: Fn(&Path) -> bool,
{
let parent = dst.parent().unwrap();
let tempdir = tempfile::tempdir_in(parent)?;
let tempfile = tempdir.path().join(dst.file_name().unwrap());
options
.copy_file(src, &tempfile)
.map_err(|err| LinkError::Copy {
to: tempfile.clone(),
err,
})?;
fs_err::rename(&tempfile, dst)?;
Ok(())
}
fn atomic_symlink_overwrite<F>(
src: &Path,
dst: &Path,
state: LinkState,
options: &LinkOptions<'_, F>,
) -> Result<LinkState, LinkError>
where
F: Fn(&Path) -> bool,
{
let parent = dst.parent().unwrap();
let tempdir = tempfile::tempdir_in(parent)?;
let tempfile = tempdir.path().join(dst.file_name().unwrap());
if create_symlink(src, &tempfile).is_ok() {
fs_err::rename(&tempfile, dst)?;
Ok(state.mode_working())
} else {
debug!(
"Failed to symlink `{}` to temp location, falling back to copy",
src.display()
);
warn_user_once!(
"Failed to symlink files; falling back to full copy. This may lead to degraded performance.\n \
If the cache and target directories are on different filesystems, symlinking may not be supported.\n \
If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning."
);
let state = state.next_mode();
atomic_copy_overwrite(src, dst, options)?;
Ok(state)
}
}
#[cfg(unix)]
fn create_symlink(original: &Path, link: &Path) -> io::Result<()> {
fs_err::os::unix::fs::symlink(original, link)
}
#[cfg(windows)]
fn create_symlink(original: &Path, link: &Path) -> io::Result<()> {
if original.is_dir() {
fs_err::os::windows::fs::symlink_dir(original, link)
} else {
fs_err::os::windows::fs::symlink_file(original, link)
}
}
#[cfg(test)]
#[expect(clippy::print_stderr)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_tempdir() -> TempDir {
TempDir::new().unwrap()
}
fn cow_tempdir() -> Option<TempDir> {
let dir = std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_COW_FS).ok()?;
fs_err::create_dir_all(&dir).unwrap();
Some(TempDir::new_in(dir).unwrap())
}
fn nocow_tempdir() -> Option<TempDir> {
let dir = std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_NOCOW_FS).ok()?;
fs_err::create_dir_all(&dir).unwrap();
Some(TempDir::new_in(dir).unwrap())
}
fn alt_tempdir() -> Option<TempDir> {
let dir = std::env::var(uv_static::EnvVars::UV_INTERNAL__TEST_ALT_FS).ok()?;
fs_err::create_dir_all(&dir).unwrap();
Some(TempDir::new_in(dir).unwrap())
}
fn create_test_tree(root: &Path) {
fs_err::create_dir_all(root.join("subdir")).unwrap();
fs_err::write(root.join("file1.txt"), "content1").unwrap();
fs_err::write(root.join("file2.txt"), "content2").unwrap();
fs_err::write(root.join("subdir/nested.txt"), "nested content").unwrap();
}
fn verify_test_tree(root: &Path) {
assert!(root.join("file1.txt").exists());
assert!(root.join("file2.txt").exists());
assert!(root.join("subdir/nested.txt").exists());
assert_eq!(
fs_err::read_to_string(root.join("file1.txt")).unwrap(),
"content1"
);
assert_eq!(
fs_err::read_to_string(root.join("file2.txt")).unwrap(),
"content2"
);
assert_eq!(
fs_err::read_to_string(root.join("subdir/nested.txt")).unwrap(),
"nested content"
);
}
#[test]
fn test_copy_dir_basic() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Copy);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(result, LinkMode::Copy);
verify_test_tree(dst_dir.path());
}
#[test]
fn test_hardlink_dir_basic() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Hardlink);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert!(result == LinkMode::Hardlink || result == LinkMode::Copy);
verify_test_tree(dst_dir.path());
#[cfg(unix)]
if result == LinkMode::Hardlink {
use std::os::unix::fs::MetadataExt;
let src_meta = fs_err::metadata(src_dir.path().join("file1.txt")).unwrap();
let dst_meta = fs_err::metadata(dst_dir.path().join("file1.txt")).unwrap();
assert_eq!(src_meta.ino(), dst_meta.ino());
}
}
#[test]
#[cfg(unix)] fn test_symlink_dir_basic() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Symlink);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert!(result == LinkMode::Symlink || result == LinkMode::Copy);
verify_test_tree(dst_dir.path());
if result == LinkMode::Symlink {
assert!(dst_dir.path().join("file1.txt").is_symlink());
}
}
#[test]
fn test_clone_dir_basic() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Clone);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert!(
result == LinkMode::Clone || result == LinkMode::Hardlink || result == LinkMode::Copy
);
verify_test_tree(dst_dir.path());
}
fn reflink_supported(dir: &Path) -> bool {
let src = dir.join("reflink_test_src");
let dst = dir.join("reflink_test_dst");
fs_err::write(&src, "test").unwrap();
let supported = reflink_copy::reflink(&src, &dst).is_ok();
let _ = fs_err::remove_file(&src);
let _ = fs_err::remove_file(&dst);
supported
}
#[test]
fn test_reflink_file_on_reflink_fs() {
let Some(tmp_dir) = cow_tempdir() else {
eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set");
return;
};
assert!(
reflink_supported(tmp_dir.path()),
"UV_INTERNAL__TEST_COW_FS points to a filesystem that does not support reflink"
);
let src = tmp_dir.path().join("src.txt");
let dst = tmp_dir.path().join("dst.txt");
fs_err::write(&src, "reflink content").unwrap();
reflink_copy::reflink(&src, &dst).unwrap();
assert_eq!(fs_err::read_to_string(&dst).unwrap(), "reflink content");
fs_err::write(&dst, "modified").unwrap();
assert_eq!(fs_err::read_to_string(&src).unwrap(), "reflink content");
assert_eq!(fs_err::read_to_string(&dst).unwrap(), "modified");
}
#[test]
fn test_clone_dir_reflink_on_reflink_fs() {
let Some(src_dir) = cow_tempdir() else {
eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set");
return;
};
let Some(dst_dir) = cow_tempdir() else {
unreachable!();
};
assert!(
reflink_supported(src_dir.path()),
"UV_INTERNAL__TEST_COW_FS points to a filesystem that does not support reflink"
);
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Clone);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(result, LinkMode::Clone);
verify_test_tree(dst_dir.path());
fs_err::write(dst_dir.path().join("file1.txt"), "modified").unwrap();
assert_eq!(
fs_err::read_to_string(src_dir.path().join("file1.txt")).unwrap(),
"content1"
);
}
#[test]
fn test_clone_merge_on_reflink_fs() {
let Some(src_dir) = cow_tempdir() else {
eprintln!("Skipping: UV_INTERNAL__TEST_COW_FS not set");
return;
};
let Some(dst_dir) = cow_tempdir() else {
unreachable!();
};
assert!(
reflink_supported(src_dir.path()),
"UV_INTERNAL__TEST_COW_FS points to a filesystem that does not support reflink"
);
create_test_tree(src_dir.path());
fs_err::create_dir_all(dst_dir.path()).unwrap();
fs_err::write(dst_dir.path().join("file1.txt"), "old content").unwrap();
fs_err::write(dst_dir.path().join("extra.txt"), "extra").unwrap();
let options = LinkOptions::new(LinkMode::Clone)
.with_on_existing_directory(OnExistingDirectory::Merge);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(result, LinkMode::Clone);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
"content1"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("extra.txt")).unwrap(),
"extra"
);
}
#[test]
fn test_clone_fallback_on_nocow_fs() {
let Some(src_dir) = nocow_tempdir() else {
eprintln!("Skipping: UV_INTERNAL__TEST_NOCOW_FS not set");
return;
};
let Some(dst_dir) = nocow_tempdir() else {
unreachable!();
};
assert!(
!reflink_supported(src_dir.path()),
"UV_INTERNAL__TEST_NOCOW_FS points to a filesystem that supports reflink"
);
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Clone);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert!(
result == LinkMode::Hardlink || result == LinkMode::Copy,
"Expected fallback to Hardlink or Copy on non-reflink fs, got {result:?}"
);
verify_test_tree(dst_dir.path());
}
#[test]
fn test_clone_cross_device() {
let Some(src_dir) = alt_tempdir() else {
eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set");
return;
};
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Clone);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(
result,
LinkMode::Copy,
"Expected fallback to Copy for cross-device clone, got {result:?}"
);
verify_test_tree(dst_dir.path());
}
#[test]
fn test_hardlink_cross_device() {
let Some(src_dir) = alt_tempdir() else {
eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set");
return;
};
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Hardlink);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(
result,
LinkMode::Copy,
"Expected fallback to Copy for cross-device hardlink, got {result:?}"
);
verify_test_tree(dst_dir.path());
}
#[test]
fn test_clone_merge_cross_device() {
let Some(src_dir) = alt_tempdir() else {
eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set");
return;
};
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::create_dir_all(dst_dir.path()).unwrap();
fs_err::write(dst_dir.path().join("file1.txt"), "old content").unwrap();
fs_err::write(dst_dir.path().join("extra.txt"), "extra").unwrap();
let options = LinkOptions::new(LinkMode::Clone)
.with_on_existing_directory(OnExistingDirectory::Merge);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(
result,
LinkMode::Copy,
"Expected fallback to Copy for cross-device clone merge, got {result:?}"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
"content1"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("extra.txt")).unwrap(),
"extra"
);
}
#[test]
fn test_copy_cross_device() {
let Some(src_dir) = alt_tempdir() else {
eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set");
return;
};
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Copy);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(result, LinkMode::Copy);
verify_test_tree(dst_dir.path());
}
#[test]
#[cfg(unix)]
fn test_symlink_cross_device() {
let Some(src_dir) = alt_tempdir() else {
eprintln!("Skipping: UV_INTERNAL__TEST_ALT_FS not set");
return;
};
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Symlink);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(result, LinkMode::Symlink);
verify_test_tree(dst_dir.path());
}
#[test]
fn test_reflink_fallback_to_hardlink() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Clone);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert!(matches!(
result,
LinkMode::Clone | LinkMode::Hardlink | LinkMode::Copy
));
verify_test_tree(dst_dir.path());
}
#[test]
fn test_merge_overwrites_existing_files() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::create_dir_all(dst_dir.path().join("subdir")).unwrap();
fs_err::write(dst_dir.path().join("file1.txt"), "old content").unwrap();
fs_err::write(dst_dir.path().join("existing.txt"), "should remain").unwrap();
let options =
LinkOptions::new(LinkMode::Copy).with_on_existing_directory(OnExistingDirectory::Merge);
link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
"content1"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("existing.txt")).unwrap(),
"should remain"
);
}
#[test]
fn test_fail_mode_errors_on_existing_hardlink() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::write(dst_dir.path().join("file1.txt"), "existing").unwrap();
let options = LinkOptions::new(LinkMode::Hardlink)
.with_on_existing_directory(OnExistingDirectory::Fail);
let result = link_dir(src_dir.path(), dst_dir.path(), &options);
if result.is_ok() {
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
"content1"
);
}
}
#[test]
fn test_copy_mode_overwrites_in_fail_mode() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::write(dst_dir.path().join("file1.txt"), "existing").unwrap();
let options =
LinkOptions::new(LinkMode::Copy).with_on_existing_directory(OnExistingDirectory::Fail);
let result = link_dir(src_dir.path(), dst_dir.path(), &options);
assert!(result.is_ok());
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
"content1"
);
}
#[test]
fn test_mutable_copy_filter() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::write(src_dir.path().join("RECORD"), "record content").unwrap();
let options = LinkOptions::new(LinkMode::Hardlink)
.with_mutable_copy_filter(|p: &Path| p.ends_with("RECORD"));
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("RECORD")).unwrap(),
"record content"
);
if result == LinkMode::Hardlink {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let src_meta = fs_err::metadata(src_dir.path().join("RECORD")).unwrap();
let dst_meta = fs_err::metadata(dst_dir.path().join("RECORD")).unwrap();
assert_ne!(src_meta.ino(), dst_meta.ino());
let src_file_meta = fs_err::metadata(src_dir.path().join("file1.txt")).unwrap();
let dst_file_meta = fs_err::metadata(dst_dir.path().join("file1.txt")).unwrap();
assert_eq!(src_file_meta.ino(), dst_file_meta.ino());
}
}
}
#[test]
fn test_synchronized_copy() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let locks = CopyLocks::default();
let options = LinkOptions::new(LinkMode::Copy).with_copy_locks(&locks);
link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
verify_test_tree(dst_dir.path());
}
#[test]
fn test_empty_directory() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
fs_err::create_dir_all(src_dir.path().join("empty_subdir")).unwrap();
let options = LinkOptions::new(LinkMode::Copy);
link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert!(dst_dir.path().join("empty_subdir").is_dir());
}
#[test]
fn test_nested_directories() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
let deep_path = src_dir.path().join("a/b/c/d/e");
fs_err::create_dir_all(&deep_path).unwrap();
fs_err::write(deep_path.join("deep.txt"), "deep content").unwrap();
let options = LinkOptions::new(LinkMode::Copy);
link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("a/b/c/d/e/deep.txt")).unwrap(),
"deep content"
);
}
#[test]
fn test_hardlink_merge_with_existing() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::create_dir_all(dst_dir.path()).unwrap();
fs_err::write(dst_dir.path().join("file1.txt"), "old").unwrap();
let options = LinkOptions::new(LinkMode::Hardlink)
.with_on_existing_directory(OnExistingDirectory::Merge);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert!(result == LinkMode::Hardlink || result == LinkMode::Copy);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
"content1"
);
}
#[test]
fn test_copy_locks_synchronization() {
use std::sync::Arc;
use std::thread;
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
fs_err::write(src_dir.path().join("file.txt"), "content").unwrap();
let locks = Arc::new(CopyLocks::default());
let src = src_dir.path().to_path_buf();
let dst = dst_dir.path().to_path_buf();
let handles: Vec<_> = (0..4)
.map(|_| {
let locks = Arc::clone(&locks);
let src = src.clone();
let dst = dst.clone();
thread::spawn(move || {
let options = LinkOptions::new(LinkMode::Copy)
.with_copy_locks(&locks)
.with_on_existing_directory(OnExistingDirectory::Merge);
link_dir(&src, &dst, &options)
})
})
.collect();
for handle in handles {
handle.join().unwrap().unwrap();
}
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file.txt")).unwrap(),
"content"
);
}
#[test]
#[cfg(unix)]
fn test_symlink_merge_with_existing() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::create_dir_all(dst_dir.path()).unwrap();
fs_err::write(dst_dir.path().join("file1.txt"), "old").unwrap();
let options = LinkOptions::new(LinkMode::Symlink)
.with_on_existing_directory(OnExistingDirectory::Merge);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert!(result == LinkMode::Symlink || result == LinkMode::Copy);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
"content1"
);
if result == LinkMode::Symlink {
assert!(dst_dir.path().join("file1.txt").is_symlink());
}
}
#[test]
#[cfg(unix)]
fn test_symlink_mutable_copy_filter() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::write(src_dir.path().join("RECORD"), "record content").unwrap();
let options = LinkOptions::new(LinkMode::Symlink)
.with_mutable_copy_filter(|p: &Path| p.ends_with("RECORD"));
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("RECORD")).unwrap(),
"record content"
);
if result == LinkMode::Symlink {
assert!(!dst_dir.path().join("RECORD").is_symlink());
assert!(dst_dir.path().join("file1.txt").is_symlink());
}
}
#[test]
fn test_source_not_found() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
let nonexistent = src_dir.path().join("nonexistent");
let options = LinkOptions::new(LinkMode::Copy);
let result = link_dir(&nonexistent, dst_dir.path(), &options);
assert!(result.is_err());
}
#[test]
fn test_clone_mutable_copy_filter_ignored() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::write(src_dir.path().join("RECORD"), "record content").unwrap();
let options = LinkOptions::new(LinkMode::Clone)
.with_mutable_copy_filter(|p: &Path| p.ends_with("RECORD"));
let result = link_dir(src_dir.path(), dst_dir.path(), &options);
assert!(result.is_ok());
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("RECORD")).unwrap(),
"record content"
);
}
#[test]
fn test_copy_mutable_copy_filter_ignored() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::write(src_dir.path().join("RECORD"), "record content").unwrap();
let options = LinkOptions::new(LinkMode::Copy)
.with_mutable_copy_filter(|p: &Path| p.ends_with("RECORD"));
let result = link_dir(src_dir.path(), dst_dir.path(), &options);
assert!(result.is_ok());
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("RECORD")).unwrap(),
"record content"
);
}
#[test]
fn test_special_characters_in_filenames() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
fs_err::write(src_dir.path().join("file with spaces.txt"), "spaces").unwrap();
fs_err::write(src_dir.path().join("file-with-dashes.txt"), "dashes").unwrap();
fs_err::write(
src_dir.path().join("file_with_underscores.txt"),
"underscores",
)
.unwrap();
fs_err::write(src_dir.path().join("file.multiple.dots.txt"), "dots").unwrap();
let options = LinkOptions::new(LinkMode::Copy);
link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file with spaces.txt")).unwrap(),
"spaces"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file-with-dashes.txt")).unwrap(),
"dashes"
);
}
#[test]
fn test_hidden_files() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
fs_err::write(src_dir.path().join(".hidden"), "hidden content").unwrap();
fs_err::write(src_dir.path().join(".gitignore"), "*.pyc").unwrap();
fs_err::create_dir_all(src_dir.path().join(".hidden_dir")).unwrap();
fs_err::write(src_dir.path().join(".hidden_dir/file.txt"), "nested hidden").unwrap();
let options = LinkOptions::new(LinkMode::Copy);
link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(
fs_err::read_to_string(dst_dir.path().join(".hidden")).unwrap(),
"hidden content"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join(".gitignore")).unwrap(),
"*.pyc"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join(".hidden_dir/file.txt")).unwrap(),
"nested hidden"
);
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos_clone_directory_recursive() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
let options = LinkOptions::new(LinkMode::Clone);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(result, LinkMode::Clone);
verify_test_tree(dst_dir.path());
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos_clone_dir_merge_nested() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
fs_err::create_dir_all(src_dir.path().join("a/b/c")).unwrap();
fs_err::write(src_dir.path().join("a/file1.txt"), "a1").unwrap();
fs_err::write(src_dir.path().join("a/b/file2.txt"), "b2").unwrap();
fs_err::write(src_dir.path().join("a/b/c/file3.txt"), "c3").unwrap();
fs_err::create_dir_all(dst_dir.path().join("a/b")).unwrap();
fs_err::write(dst_dir.path().join("a/existing.txt"), "existing").unwrap();
let options = LinkOptions::new(LinkMode::Clone)
.with_on_existing_directory(OnExistingDirectory::Merge);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(result, LinkMode::Clone);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("a/file1.txt")).unwrap(),
"a1"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("a/b/file2.txt")).unwrap(),
"b2"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("a/b/c/file3.txt")).unwrap(),
"c3"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("a/existing.txt")).unwrap(),
"existing"
);
}
#[test]
#[cfg(target_os = "macos")]
fn test_macos_clone_merge_overwrites_files() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
fs_err::write(src_dir.path().join("file.txt"), "new content").unwrap();
fs_err::write(dst_dir.path().join("file.txt"), "old content").unwrap();
let options = LinkOptions::new(LinkMode::Clone)
.with_on_existing_directory(OnExistingDirectory::Merge);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert_eq!(result, LinkMode::Clone);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file.txt")).unwrap(),
"new content"
);
}
#[test]
fn test_clone_fail_mode_on_existing() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::write(dst_dir.path().join("file1.txt"), "existing").unwrap();
let options =
LinkOptions::new(LinkMode::Clone).with_on_existing_directory(OnExistingDirectory::Fail);
let result = link_dir(src_dir.path(), dst_dir.path(), &options);
if result.is_ok() {
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
"content1"
);
}
}
#[test]
#[cfg(unix)]
fn test_symlink_fail_mode_on_existing() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
create_test_tree(src_dir.path());
fs_err::write(dst_dir.path().join("file1.txt"), "existing").unwrap();
let options = LinkOptions::new(LinkMode::Symlink)
.with_on_existing_directory(OnExistingDirectory::Fail);
let result = link_dir(src_dir.path(), dst_dir.path(), &options);
if result.is_ok() {
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file1.txt")).unwrap(),
"content1"
);
}
}
#[test]
#[cfg(windows)]
fn test_windows_symlink_file_vs_dir() {
let src_dir = test_tempdir();
let dst_dir = test_tempdir();
fs_err::write(src_dir.path().join("file.txt"), "content").unwrap();
fs_err::create_dir_all(src_dir.path().join("subdir")).unwrap();
fs_err::write(src_dir.path().join("subdir/nested.txt"), "nested").unwrap();
let options = LinkOptions::new(LinkMode::Symlink);
let result = link_dir(src_dir.path(), dst_dir.path(), &options);
if let Ok(mode) = result {
if mode == LinkMode::Symlink {
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("file.txt")).unwrap(),
"content"
);
assert_eq!(
fs_err::read_to_string(dst_dir.path().join("subdir/nested.txt")).unwrap(),
"nested"
);
}
}
}
#[test]
fn test_link_state_new() {
let state = LinkState::new(LinkMode::Clone);
assert_eq!(state.mode, LinkMode::Clone);
assert_eq!(state.attempt, LinkAttempt::Initial);
}
#[test]
fn test_link_state_mode_working() {
let state = LinkState::new(LinkMode::Hardlink).mode_working();
assert_eq!(state.mode, LinkMode::Hardlink);
assert_eq!(state.attempt, LinkAttempt::Subsequent);
}
#[test]
fn test_link_state_next_mode_clone_to_hardlink() {
let state = LinkState::new(LinkMode::Clone).next_mode();
assert_eq!(state.mode, LinkMode::Hardlink);
assert_eq!(state.attempt, LinkAttempt::Initial);
}
#[test]
fn test_link_state_next_mode_hardlink_to_copy() {
let state = LinkState::new(LinkMode::Hardlink).next_mode();
assert_eq!(state.mode, LinkMode::Copy);
assert_eq!(state.attempt, LinkAttempt::Initial);
}
#[test]
fn test_link_state_next_mode_symlink_to_copy() {
let state = LinkState::new(LinkMode::Symlink).next_mode();
assert_eq!(state.mode, LinkMode::Copy);
assert_eq!(state.attempt, LinkAttempt::Initial);
}
#[test]
fn test_link_state_full_fallback_chain() {
let state = LinkState::new(LinkMode::Clone);
let state = state.next_mode();
assert_eq!(state.mode, LinkMode::Hardlink);
let state = state.next_mode();
assert_eq!(state.mode, LinkMode::Copy);
}
#[test]
fn test_link_state_mode_working_resets_on_next_mode() {
let state = LinkState::new(LinkMode::Clone).mode_working();
assert_eq!(state.attempt, LinkAttempt::Subsequent);
let state = state.next_mode();
assert_eq!(state.mode, LinkMode::Hardlink);
assert_eq!(state.attempt, LinkAttempt::Initial);
}
#[test]
fn test_hardlink_merge_confirms_mode_working() {
let src_dir = TempDir::new().unwrap();
let dst_dir = TempDir::new().unwrap();
create_test_tree(src_dir.path());
fs_err::create_dir_all(dst_dir.path().join("subdir")).unwrap();
fs_err::write(dst_dir.path().join("file1.txt"), "old1").unwrap();
fs_err::write(dst_dir.path().join("file2.txt"), "old2").unwrap();
fs_err::write(dst_dir.path().join("subdir/nested.txt"), "old nested").unwrap();
let options = LinkOptions::new(LinkMode::Hardlink)
.with_on_existing_directory(OnExistingDirectory::Merge);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert!(result == LinkMode::Hardlink || result == LinkMode::Copy);
verify_test_tree(dst_dir.path());
}
#[test]
fn test_clone_mode_returns_hardlink_or_copy_when_reflink_unsupported() {
let src_dir = TempDir::new().unwrap();
let dst_dir = TempDir::new().unwrap();
create_test_tree(src_dir.path());
if reflink_supported(src_dir.path()) {
eprintln!("Skipping test: reflink is supported on this filesystem");
return;
}
let options = LinkOptions::new(LinkMode::Clone);
let result = link_dir(src_dir.path(), dst_dir.path(), &options).unwrap();
assert!(
result == LinkMode::Hardlink || result == LinkMode::Copy,
"Expected Hardlink or Copy fallback, got {result:?}"
);
verify_test_tree(dst_dir.path());
}
}