diff --git a/crates/uv-fs/src/link.rs b/crates/uv-fs/src/link.rs index 5914c6ac3..9f5e796ca 100644 --- a/crates/uv-fs/src/link.rs +++ b/crates/uv-fs/src/link.rs @@ -77,9 +77,7 @@ where { match options.mode { LinkMode::Clone => clone_dir(src, dst, options), - LinkMode::Copy => copy_dir(src, dst, options), - LinkMode::Hardlink => hardlink_dir(src, dst, options), - LinkMode::Symlink => symlink_dir(src, dst, options), + mode => walk_and_link(src, dst, mode, options), } } @@ -213,18 +211,68 @@ impl<'a, F> LinkOptions<'a, F> { } } -/// Tracks the state of linking attempts to handle fallback gracefully. +/// Whether the current linking strategy has been confirmed to work. /// -/// Hard linking / reflinking might not be supported but we can't detect this ahead of time, -/// so we'll try the operation on the first file - if this succeeds we'll know later -/// errors are not due to lack of OS/filesystem support. If it fails, we'll switch -/// to copying for the rest of the operation. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -enum Attempt { - #[default] +/// Some linking strategies (reflink, hardlink, symlink) might not be supported on a given +/// filesystem, but we can't always detect this ahead of time. We try the operation on the +/// first file — if it succeeds, we know later errors are real failures. If it fails, we +/// switch to the next fallback strategy for the rest of the operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LinkAttempt { + /// The strategy has not yet been attempted on any file. Initial, + /// The strategy succeeded for at least one file; continue using it. Subsequent, - UseCopyFallback, +} + +/// Tracks the active linking strategy and whether it has been confirmed to work. +/// +/// When the strategy's [`LinkAttempt`] is [`Initial`](LinkAttempt::Initial) and the first +/// file operation fails, [`next_mode`](Self::next_mode) transitions to the next fallback. +/// When the attempt is [`Subsequent`](LinkAttempt::Subsequent) and a file fails, it is +/// either a hard error (the strategy was confirmed to work, so this is a real failure) or, +/// for reflink, a transition to the next fallback. +#[derive(Debug, Clone, Copy)] +struct LinkState { + /// The linking strategy currently in use. + mode: LinkMode, + /// Whether the strategy has been confirmed to work. + attempt: LinkAttempt, +} + +impl LinkState { + /// Create a new state with the given mode, not yet confirmed to work. + fn new(mode: LinkMode) -> Self { + Self { + mode, + attempt: LinkAttempt::Initial, + } + } + + /// Mark the current strategy as confirmed working on this filesystem. + fn mode_working(self) -> Self { + Self { + attempt: LinkAttempt::Subsequent, + ..self + } + } + + /// Transition to the next fallback strategy in the chain. + /// + /// - `Clone` → `Hardlink` + /// - `Hardlink` → `Copy` + /// - `Symlink` → `Copy` + /// - `Copy` → Failure + 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, + }) + } } /// Error type for copy operations. @@ -269,9 +317,8 @@ pub enum LinkError { /// Clone a directory tree using copy-on-write. /// /// On macOS with APFS, tries to clone the entire directory in a single syscall. -/// -/// On all platforms, attempts to reflink individual files. If reflinking is not supported, -/// falls back to hard linking, then copying. +/// On all platforms, falls through to [`walk_and_link`] for per-file linking with +/// automatic fallback. fn clone_dir(src: &Path, dst: &Path, options: &LinkOptions<'_, F>) -> Result where F: Fn(&Path) -> bool, @@ -292,23 +339,23 @@ where } } - // Try per-file reflinking, with fallback to hardlink then copy - reflink_dir(src, dst, options) + walk_and_link(src, dst, LinkMode::Clone, options) } -/// Reflink individual files in a directory tree. +/// Walk a directory tree and link each file using the given starting [`LinkMode`]. /// -/// Attempts to reflink each file. If reflinking fails (e.g., unsupported filesystem), -/// falls back to hard linking, then copying. -fn reflink_dir( +/// The [`LinkState`] tracks the active strategy and automatically falls back via +/// [`LinkState::next_mode`] as needed. +fn walk_and_link( src: &Path, dst: &Path, + mode: LinkMode, options: &LinkOptions<'_, F>, ) -> Result where F: Fn(&Path) -> bool, { - let mut attempt = Attempt::Initial; + let mut state = LinkState::new(mode); for entry in WalkDir::new(src) { let entry = entry.map_err(|err| LinkError::WalkDir { @@ -328,92 +375,117 @@ where continue; } - match attempt { - Attempt::Initial => { - match reflink_copy::reflink(path, &target) { - Ok(()) => { - attempt = Attempt::Subsequent; - } - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { - if options.on_existing_directory == OnExistingDirectory::Merge { - // File exists, overwrite atomically via temp file - let parent = target.parent().unwrap(); - let tempdir = tempfile::tempdir_in(parent)?; - let tempfile = tempdir.path().join(target.file_name().unwrap()); - if reflink_copy::reflink(path, &tempfile).is_ok() { - fs_err::rename(&tempfile, &target)?; - attempt = Attempt::Subsequent; - } else { - // Reflink to temp failed, fallback to hardlink - debug!( - "Failed to reflink `{}` to temp location, falling back to hardlink", - path.display() - ); - return hardlink_dir(src, dst, options); - } - } else { - return Err(LinkError::Reflink { - from: path.to_path_buf(), - to: target, - err, - }); - } - } - Err(err) => { - debug!( - "Failed to reflink `{}` to `{}`: {}, falling back to hardlink", - path.display(), - target.display(), - err - ); - // Fallback to hardlinking (hardlink_dir handles AlreadyExists for - // files we already reflinked when in Merge mode) - return hardlink_dir(src, dst, options); - } - } - } - Attempt::Subsequent => match reflink_copy::reflink(path, &target) { - Ok(()) => {} - 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_copy::reflink(path, &tempfile).map_err(|err| { - LinkError::Reflink { - from: path.to_path_buf(), - to: tempfile.clone(), - err, - } - })?; - fs_err::rename(&tempfile, &target)?; - } else { - return Err(LinkError::Reflink { - from: path.to_path_buf(), - to: target, - err, - }); - } - } - Err(err) => { - return Err(LinkError::Reflink { - from: path.to_path_buf(), - to: target, - err, - }); - } - }, - Attempt::UseCopyFallback => { - // We've fallen back to hardlinking; this is handled by returning - // early to hardlink_dir, so this branch should not be reached. - unreachable!( - "reflink_dir should return to hardlink_dir before reaching UseCopyFallback" - ); - } - } + state = link_file(path, &target, state, options)?; } - Ok(LinkMode::Clone) + Ok(state.mode) +} + +/// Dispatch a single file to the appropriate linking strategy based on the current state. +/// +/// Returns the (possibly updated) state for the next file. When a strategy fails, it +/// transitions to [`LinkState::next_mode`] and re-dispatches through this function so the +/// fallback chain is followed automatically. +fn link_file( + path: &Path, + target: &Path, + state: LinkState, + options: &LinkOptions<'_, F>, +) -> Result +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) + } + } +} + +/// Attempt to reflink a single file, falling back via [`link_file`] on failure. +fn reflink_file_with_fallback( + path: &Path, + target: &Path, + state: LinkState, + options: &LinkOptions<'_, F>, +) -> Result +where + F: Fn(&Path) -> bool, +{ + match state.attempt { + LinkAttempt::Initial => match reflink_copy::reflink(path, target) { + Ok(()) => Ok(state.mode_working()), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => { + if options.on_existing_directory == OnExistingDirectory::Merge { + // File exists, overwrite atomically via temp file + let parent = target.parent().unwrap(); + let tempdir = tempfile::tempdir_in(parent)?; + let tempfile = tempdir.path().join(target.file_name().unwrap()); + if reflink_copy::reflink(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_copy::reflink(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_copy::reflink(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, + }), + }, + } } /// Try to clone a directory, handling `merge_directories` option. @@ -510,111 +582,139 @@ where Ok(()) } -/// Hard link or copy a directory tree from `src` to `dst`. +/// Attempt to hard link a single file, falling back via [`link_file`] on failure. /// -/// Tries hard linking first for efficiency, falling back to copying if hard links -/// are not supported (e.g., cross-filesystem operations). -fn hardlink_dir( - src: &Path, - dst: &Path, +/// Files matching the [`LinkOptions::needs_mutable_copy`] predicate are always copied +/// to avoid mutating the source through a hard link. +fn hardlink_file_with_fallback( + path: &Path, + target: &Path, + state: LinkState, options: &LinkOptions<'_, F>, -) -> Result +) -> Result where F: Fn(&Path) -> bool, { - let mut attempt = Attempt::Initial; + if (options.needs_mutable_copy)(path) { + copy_file(path, target, options)?; + return Ok(state); + } - for entry in WalkDir::new(src) { - let entry = entry.map_err(|err| LinkError::WalkDir { - path: src.to_path_buf(), + 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) + } + } + } +} + +/// Attempt to symlink a single file, falling back via [`link_file`] on failure. +/// +/// Files matching the [`LinkOptions::needs_mutable_copy`] predicate are always copied +/// to avoid mutating the source through a symlink. +fn symlink_file_with_fallback( + path: &Path, + target: &Path, + state: LinkState, + options: &LinkOptions<'_, F>, +) -> Result +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) + } + } + } +} + +/// Copy a single file, using synchronized copying if [`CopyLocks`] are configured. +fn copy_file(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, - })?; - - 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; - } - - if (options.needs_mutable_copy)(path) { - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - continue; - } - - match attempt { - Attempt::Initial => { - if let Err(err) = try_hardlink_file(path, &target) { - if err.kind() == io::ErrorKind::AlreadyExists - && options.on_existing_directory == OnExistingDirectory::Merge - { - // File exists, try atomic overwrite - atomic_hardlink_overwrite(path, &target, &mut attempt, options)?; - } else { - debug!( - "Failed to hard link `{}` to `{}`: {}; falling back to copy", - path.display(), - target.display(), - err - ); - attempt = Attempt::UseCopyFallback; - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - 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." - ); - } - } else { - attempt = Attempt::Subsequent; - } - } - Attempt::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, &mut attempt, options)?; - } else { - return Err(LinkError::Io(err)); - } - } - } - Attempt::UseCopyFallback => { - if options.on_existing_directory == OnExistingDirectory::Merge { - atomic_copy_overwrite(path, &target, options)?; - } else { - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - } - } - } - } - - if attempt == Attempt::UseCopyFallback { - Ok(LinkMode::Copy) - } else { - Ok(LinkMode::Hardlink) - } + }) } /// Try to create a hard link, returning the `io::Error` on failure. @@ -626,9 +726,9 @@ fn try_hardlink_file(src: &Path, dst: &Path) -> io::Result<()> { fn atomic_hardlink_overwrite( src: &Path, dst: &Path, - attempt: &mut Attempt, + state: LinkState, options: &LinkOptions<'_, F>, -) -> Result<(), LinkError> +) -> Result where F: Fn(&Path) -> bool, { @@ -640,21 +740,21 @@ where if fs_err::hard_link(src, &tempfile).is_ok() { fs_err::rename(&tempfile, dst)?; + Ok(state.mode_working()) } else { - // Hard link to temp failed, fallback to copy debug!( "Failed to hardlink `{}` to temp location, falling back to copy", src.display() ); - *attempt = Attempt::UseCopyFallback; - atomic_copy_overwrite(src, dst, options)?; 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) } - Ok(()) } /// Atomically overwrite an existing file with a copy. @@ -682,163 +782,13 @@ where Ok(()) } -/// Copy a directory tree from `src` to `dst`. -/// -/// Always copies files (no linking). Supports synchronized copying and -/// directory merging via options. -fn copy_dir(src: &Path, dst: &Path, options: &LinkOptions<'_, F>) -> Result -where - F: Fn(&Path) -> bool, -{ - 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; - } - - if options.on_existing_directory == OnExistingDirectory::Merge { - atomic_copy_overwrite(path, &target, options)?; - } else { - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - } - } - - Ok(LinkMode::Copy) -} - -/// Symbolically link a directory tree from `src` to `dst`. -/// -/// Tries creating symlinks first, falling back to copying if symlinks are not supported. -fn symlink_dir( - src: &Path, - dst: &Path, - options: &LinkOptions<'_, F>, -) -> Result -where - F: Fn(&Path) -> bool, -{ - let mut attempt = Attempt::Initial; - - 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; - } - - if (options.needs_mutable_copy)(path) { - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - continue; - } - - match attempt { - Attempt::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, &mut attempt, options)?; - } else { - debug!( - "Failed to symlink `{}` to `{}`: {}; falling back to copy", - path.display(), - target.display(), - err - ); - attempt = Attempt::UseCopyFallback; - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - 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." - ); - } - } else { - attempt = Attempt::Subsequent; - } - } - Attempt::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, &mut attempt, options)?; - } else { - return Err(LinkError::Symlink { - from: path.to_path_buf(), - to: target, - err, - }); - } - } - } - Attempt::UseCopyFallback => { - if options.on_existing_directory == OnExistingDirectory::Merge { - atomic_copy_overwrite(path, &target, options)?; - } else { - options - .copy_file(path, &target) - .map_err(|err| LinkError::Copy { - to: target.clone(), - err, - })?; - } - } - } - } - - if attempt == Attempt::UseCopyFallback { - Ok(LinkMode::Copy) - } else { - Ok(LinkMode::Symlink) - } -} - /// Atomically overwrite an existing file with a symlink. fn atomic_symlink_overwrite( src: &Path, dst: &Path, - attempt: &mut Attempt, + state: LinkState, options: &LinkOptions<'_, F>, -) -> Result<(), LinkError> +) -> Result where F: Fn(&Path) -> bool, { @@ -850,21 +800,21 @@ where if create_symlink(src, &tempfile).is_ok() { fs_err::rename(&tempfile, dst)?; + Ok(state.mode_working()) } else { - // Symlink to temp failed, fallback to copy debug!( "Failed to symlink `{}` to temp location, falling back to copy", src.display() ); - *attempt = Attempt::UseCopyFallback; - atomic_copy_overwrite(src, dst, options)?; 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) } - Ok(()) } /// Create a symbolic link. @@ -1702,4 +1652,107 @@ mod tests { } // If symlink failed (permissions), that's expected on Windows without elevation } + + #[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() { + // Clone → Hardlink → Copy + 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() { + // Confirming a mode as working, then transitioning resets to Initial + 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() { + // When hardlink succeeds through the atomic overwrite path during merge, + // the mode should be confirmed as working + let src_dir = TempDir::new().unwrap(); + let dst_dir = TempDir::new().unwrap(); + + create_test_tree(src_dir.path()); + + // Pre-create all destination files so every hardlink hits AlreadyExists + 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(); + + // Should succeed (hardlink or copy fallback) + 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; + } + + // When reflink is not supported, clone mode should fall back through + // hardlink before reaching copy + 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()); + } }