Skip to content

Add a move binary (mv replacement) reusing the copy engine#11

Merged
UnbreakableMJ merged 1 commit into
mainfrom
add-move-binary
Jun 22, 2026
Merged

Add a move binary (mv replacement) reusing the copy engine#11
UnbreakableMJ merged 1 commit into
mainfrom
add-move-binary

Conversation

@UnbreakableMJ

@UnbreakableMJ UnbreakableMJ commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Adds a second binary, move — a modern mv replacement that reuses the existing copy engine. It renames in place when possible and falls back to a copy + source removal only when the rename can't be atomic.

Engine (src/core/move_op.rs)

  • std::fs::rename first (atomic; preserves everything).
  • Fallback to the public copy() + source removal on EXDEV (cross-device) or when --exclude must leave part of a directory behind.
  • copy() always nests a directory under its destination, so the directory fallback stages the copy in a temp dir on the destination filesystem and cheap-renames it into place. The source is deleted only after a successful copy; an interrupt leaves it intact.
  • Exclude-aware deletion removes only entries that reached the target and prunes emptied dirs, keeping excluded files in the source.

CLI (src/cli/move_args.rs, src/bin/move.rs)

  • Rich mv surface: -t -i -f -n -u -b -v, plus -j --reflink -e -p on the cross-device fallback.
  • Optional-value flags use require_equals so they don't swallow a following positional.
  • Reuses the shared copyconfig.toml; thin main mirrors src/main.rs.

Bonus fix

utility/backup.rs::find_max_backup_number failed on bare relative filenames (read_dir("")); now falls back to the cwd. This affected copy -b too.

Tests

6 unit + 13 integration (tests/move_integration.rs): rename, into-dir, multi-source, -t, -i accept/decline, -f, -n, -b, -v, -e exclude, copy-fallback path; plus tests/gnu/move-*.sh.

Packaging (ships both binaries)

release.yml builds/uploads copy-* and move-* per target; install.sh installs both; AUR PKGBUILD/.SRCINFO install both + provides=move; guix.scm installs both; Nix buildRustPackage installs all bins automatically. READMEs + AGENTS.md/CLAUDE.md document move.

Verification (all green)

cargo build (copy + move), fmt --check, clippy --all-targets -D warnings, cargo test (75 unit + 68 + 13 integration), reuse lint (60/60), nix buildresult/bin/{copy,move}, and the GNU move-*.sh scripts.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a new move command as a modern mv replacement that reuses the copy engine with atomic rename behavior and automatic cross-filesystem copy-and-remove fallback.
    • Both copy and move binaries now ship and install together.
  • Documentation

    • Updated README, installation guides, and package metadata to reflect both utilities.
  • Tests

    • Added comprehensive integration and shell tests for move functionality.

`move` is a second binary in the crate that renames in place when possible and
falls back to a copy + source removal only when the rename can't be atomic.

Engine (src/core/move_op.rs):
- Try `std::fs::rename` first (atomic, preserves everything).
- Fall back to the public `copy()` + source removal on `EXDEV` (cross-device),
  or when `--exclude` means part of a directory must stay behind.
- `copy()` always nests a directory under its destination, so the directory
  fallback stages the copy in a temp dir on the destination filesystem and
  cheap-renames it into the final name. The source is never deleted unless the
  copy succeeded; an interrupt leaves the source in place.
- Exclude-aware deletion removes only the entries that reached the target and
  prunes emptied directories, leaving excluded files in the source.

CLI (src/cli/move_args.rs, src/bin/move.rs):
- Rich mv surface: -t, -i, -f, -n/--no-clobber, -u/--update, -b/--backup,
  -v/--verbose, plus -j, --reflink, -e/--exclude, -p/--preserve on the
  cross-device fallback. Optional-value flags use `require_equals` so they
  don't swallow a following positional (an issue the copy CLI still has).
- Reuses the shared copyconfig.toml for general defaults; thin main mirrors
  src/main.rs (parse -> validate -> signal abort -> dispatch).

Also fixes a pre-existing bug in utility/backup.rs: `find_max_backup_number`
treated a bare relative filename's empty parent as a directory and failed
`read_dir("")`; it now falls back to the cwd (affected `copy -b` too).

Tests: 6 unit + 13 integration (tests/move_integration.rs) covering rename,
into-dir, multi-source, -t, -i (accept/decline), -f, -n, -b, -v, exclude, and
the copy fallback path; plus tests/gnu/move-*.sh.

Packaging (ships both binaries): release.yml builds/uploads copy-* and move-*
per target; install.sh installs both; AUR PKGBUILD/.SRCINFO install both and
declare `provides=move`; guix.scm installs both; nix buildRustPackage installs
all bins automatically. READMEs and AGENTS.md/CLAUDE.md document `move`.

Verified: cargo build (copy + move), fmt, clippy --all-targets -D warnings,
cargo test (75 unit + 68 + 13 integration), reuse lint (60/60), nix build
(result/bin/{copy,move}), and the GNU move-*.sh scripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a move binary (a modern mv replacement) to the crate. The implementation spans a new CLI module (MoveArgs/MoveOptions), a move engine (move_op) that tries std::fs::rename first and falls back to copy-then-delete on EXDEV or with --exclude, a signal-handling entrypoint, and comprehensive integration/unit tests. All packaging artifacts, CI release workflow, and documentation are updated accordingly.

Changes

move binary feature

Layer / File(s) Summary
CLI contracts: MoveArgs and MoveOptions
Cargo.toml, src/cli/mod.rs, src/cli/move_args.rs
Registers the move binary target, adds move_args submodule, and defines the clap-driven MoveArgs struct, validated MoveOptions container, and to_copy_options() for cross-device fallback.
Move engine: move_path, move_multiple, and fallback logic
src/core/mod.rs, src/core/move_op.rs, src/utility/backup.rs
Implements move_path (overwrite policy, rename-first, EXDEV/exclude fallback via staging), move_multiple (abort-aware iteration), move_via_copy, and all helpers. Fixes find_max_backup_number for bare relative paths.
move binary entrypoint and signal handling
src/bin/move.rs
Parses and validates args, sets up an AtomicBool abort flag, handles SIGINT/SIGTERM via a spawned signal thread, dispatches to move_path or move_multiple, and exits with code 0/1/130.
Integration and unit tests
tests/move_integration.rs, tests/gnu/move-i.sh, tests/gnu/move-into-dir.sh, src/core/move_op.rs
Adds Rust integration tests for rename, move-into-dir, multi-source, -t, -n, -f, -i, -b, -v, -e, and failure modes. Adds shell tests for interactive-decline and move-into-dir. Adds move_op unit tests.
Packaging, installer, and CI release updates
.github/workflows/release.yml, install.sh, guix.scm, nix/package.nix, packaging/aur/...
Updates CI to build and upload per-binary tarballs; updates install.sh to loop over both binaries; updates AUR, Guix, and Nix definitions to install and describe both copy and move.
Docs and developer guidance
README.md, README_CRATES.md, AGENTS.md, CLAUDE.md
Adds move command sections to READMEs; updates AGENTS.md and CLAUDE.md with architectural invariants, where-to-look entries, and project identity text.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant main as src/bin/move.rs
  participant MoveArgs as MoveArgs::validate
  participant SignalThread
  participant move_op as src/core/move_op.rs

  User->>main: invoke move <sources> <dest>
  main->>MoveArgs: parse() + validate()
  MoveArgs-->>main: (sources, dest, MoveOptions)
  main->>SignalThread: spawn(SIGINT/SIGTERM → AtomicBool abort)
  alt single source
    main->>move_op: move_path(source, dest, options)
  else multiple sources
    main->>move_op: move_multiple(sources, dest, options)
  end
  move_op->>move_op: check overwrite policy (no-clobber/update/interactive)
  move_op->>move_op: try std::fs::rename
  alt rename succeeds
    move_op-->>main: Ok(())
  else EXDEV or exclude-dir
    move_op->>move_op: move_via_copy → stage_and_place_dir or copy file
    move_op->>move_op: check abort flag
    move_op->>move_op: remove source / exclude-aware prune
    move_op-->>main: Ok(()) or Err
  end
  main-->>User: exit 0 / 1 / 130
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • UnbreakableMJ/copy#1: Modifies the same .github/workflows/release.yml release asset naming and packaging logic (renaming cpxcopy), directly related to the per-binary tarball restructuring in this PR.

Poem

🐇 Hippity-hop, I renamed with flair,
std::fs::rename first — swift as a hare!
Cross the filesystem? No cause for alarm,
I'll copy then prune with atomic charm.
Two binaries ship, copy and move both run,
The warren is bigger — the work is all done! 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the main change: adding a move binary that functions as an mv replacement by reusing the existing copy engine.
Docstring Coverage ✅ Passed Docstring coverage is 87.23% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch add-move-binary

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Around line 37-46: The platform names published for aarch64 and armv7 targets
in the release workflow do not match what the installer is attempting to
download, causing 404 errors. Update the platform names for the
aarch64-unknown-linux-gnu and armv7-unknown-linux-gnueabihf targets to either
include the -musl suffix (linux-aarch64-musl and linux-armv7-musl) to match
installer expectations, or alternatively update the installer download URLs to
expect the current names (linux-aarch64 and linux-armv7). Choose one approach
and ensure consistency between both the workflow artifact publishing and the
installer download logic.
- Line 82: The `softprops/action-gh-release` action at line 82 (and also at line
24) is pinned to the mutable tag `@v3` instead of a specific commit SHA. Replace
both instances of `uses: softprops/action-gh-release@v3` with `uses:
softprops/action-gh-release@<specific-commit-sha>` where the commit SHA is the
full 40-character hash of a stable release commit. This prevents supply-chain
risks from upstream tag retargeting or compromise.

In `@src/core/move_op.rs`:
- Around line 28-36: The current check in the resolve_target and subsequent
error handling only catches cases where target equals source exactly, but fails
to prevent the case where the destination is a nested subdirectory within the
source tree. Before the copy operation fallback (between the current target
equality check and the actual copy logic that spans lines 61-84), add a
validation guard that checks if the target path is nested inside the source
directory tree and returns an appropriate error if it is. This prevents the
scenario where a staging directory created during copy can end up recursively
copying itself when the destination is a subdirectory of the source.
- Around line 124-138: The current implementation removes the target before the
copy operation completes, which leaves the original target destroyed if copy
fails or is interrupted. Remove the premature target deletion at line 127 (the
remove_path(target) call), and instead apply a staging pattern for both files
and directories: stage the copy to a temporary location first, and only after
the copy succeeds, remove the existing target and move the staged result into
the final target location. The directory path already uses stage_and_place_dir
for this pattern, so apply the same approach to the file copy branch in the else
block.
- Around line 170-173: The cleanup operation `std::fs::remove_dir_all(&staging)`
is currently ignoring its result with `let _`, which can mask failures in
staging directory cleanup. Instead of discarding the error, capture the result
from remove_dir_all and check if it failed. If the cleanup fails, propagate that
error rather than silently returning the original result from place_via_staging.
This ensures filesystem errors in the staging cleanup path are properly reported
instead of being hidden.
- Around line 254-258: The code unconditionally removes empty source directories
even when the corresponding target directory never existed, which deletes
excluded directories. In the directory handling block where file_type.is_dir()
is true, add a check to verify that tgt_child actually exists before attempting
to remove the empty src_child directory. This ensures only directories that
actually reached the target are pruned, preserving excluded empty source
directories.
- Around line 51-56: The current code uses `if let Some(mode) = options.backup
&& mode != BackupMode::None` syntax which requires Rust 1.88+ but the project
supports Rust 1.85 as the MSRV. Convert this into nested if statements for
compatibility: first check if options.backup is Some(mode) using if let, and
then inside that block add a separate if statement to check if mode is not equal
to BackupMode::None. Apply this same fix to both occurrences mentioned (around
lines 51-56 and 200-204) where the generate_backup_path and create_backup
functions are called, ensuring the backup creation logic remains intact within
the properly nested conditional blocks.

In `@tests/move_integration.rs`:
- Around line 45-64: In the moves_multiple_sources_into_a_directory test
function, add a missing assertion to verify that the second source file was also
removed after the move operation. After the existing assertion that checks
one.assert(predicate::path::missing()), add a similar assertion for the two
variable using the same predicate::path::missing() pattern to ensure both source
files were properly removed from their original locations.
- Around line 67-86: The test `target_directory_flag_moves_sources_into_dir`
verifies that files are moved to the destination directory but does not verify
that the source files were removed after the move operation. After the
`.assert().success()` call confirms the command succeeded, add assertions to
verify that the original source files `one` and `two` no longer exist in the
temp directory, ensuring the move operation fully completes by removing sources
from their original locations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6689c3f7-40b0-4aa0-b033-2df64f8997f5

📥 Commits

Reviewing files that changed from the base of the PR and between 0aad26e and 8aab20c.

📒 Files selected for processing (20)
  • .github/workflows/release.yml
  • AGENTS.md
  • CLAUDE.md
  • Cargo.toml
  • README.md
  • README_CRATES.md
  • guix.scm
  • install.sh
  • nix/package.nix
  • packaging/aur/.SRCINFO
  • packaging/aur/PKGBUILD
  • src/bin/move.rs
  • src/cli/mod.rs
  • src/cli/move_args.rs
  • src/core/mod.rs
  • src/core/move_op.rs
  • src/utility/backup.rs
  • tests/gnu/move-i.sh
  • tests/gnu/move-into-dir.sh
  • tests/move_integration.rs

Comment on lines +37 to +46
platform: linux-x86_64
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
artifact_name: copy
asset_name: copy-linux-x86_64-musl
platform: linux-x86_64-musl
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
artifact_name: copy
asset_name: copy-linux-aarch64
platform: linux-aarch64
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
artifact_name: copy
asset_name: copy-linux-armv7
platform: linux-armv7

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Artifact platform names no longer match installer expectations.

Line 37-46 publishes linux-aarch64 / linux-armv7, but installer downloads *-aarch64-musl / *-armv7-musl names. That causes release download 404s on those architectures.

Please align the naming contract between workflow artifacts and installer URLs (either side can be the source of truth).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 37 - 46, The platform names
published for aarch64 and armv7 targets in the release workflow do not match
what the installer is attempting to download, causing 404 errors. Update the
platform names for the aarch64-unknown-linux-gnu and
armv7-unknown-linux-gnueabihf targets to either include the -musl suffix
(linux-aarch64-musl and linux-armv7-musl) to match installer expectations, or
alternatively update the installer download URLs to expect the current names
(linux-aarch64 and linux-armv7). Choose one approach and ensure consistency
between both the workflow artifact publishing and the installer download logic.


- name: Upload Release Asset
- name: Upload Release Assets
uses: softprops/action-gh-release@v3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read-only check: list workflow actions that are referenced by tag instead of SHA.
rg -n '^\s*uses:\s*[^@]+@v?[0-9][^ ]*$' .github/workflows

Repository: UnbreakableMJ/copy

Length of output: 470


Pin softprops/action-gh-release to a commit SHA instead of the mutable tag.

Lines 24 and 82 use @v3, which is a mutable tag. Pinning to a specific commit SHA prevents supply-chain drift if the upstream tag is retargeted or compromised.

🧰 Tools
🪛 zizmor (1.26.1)

[error] 82-82: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)


[info] 82-82: action functionality is already included by the runner (superfluous-actions): use gh release in a script step

(superfluous-actions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml at line 82, The `softprops/action-gh-release`
action at line 82 (and also at line 24) is pinned to the mutable tag `@v3`
instead of a specific commit SHA. Replace both instances of `uses:
softprops/action-gh-release@v3` with `uses:
softprops/action-gh-release@<specific-commit-sha>` where the commit SHA is the
full 40-character hash of a stable release commit. This prevents supply-chain
risks from upstream tag retargeting or compromise.

Source: Linters/SAST tools

Comment thread src/core/move_op.rs
Comment on lines +28 to +36
let target = resolve_target(source, destination);

if target == source {
return Err(CopyError::CopyFailed {
source: source.to_path_buf(),
destination: target,
reason: "'source' and 'destination' are the same path".to_string(),
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Reject directory targets inside the source before copy fallback.

target == source only catches identical spelling. With --exclude, Line 63 skips rename, so a destination under the source tree can create the staging directory inside the tree being copied and recursively copy itself.

Proposed guard
     let target = resolve_target(source, destination);
+    let is_dir = source_metadata.is_dir();
 
     if target == source {
         return Err(CopyError::CopyFailed {
             source: source.to_path_buf(),
             destination: target,
             reason: "'source' and 'destination' are the same path".to_string(),
         });
     }
+
+    if is_dir && target_is_source_or_child(source, &target)? {
+        return Err(CopyError::CopyFailed {
+            source: source.to_path_buf(),
+            destination: target,
+            reason: "cannot move a directory into itself".to_string(),
+        });
+    }
 
     // Overwrite policy is decided before any rename, since rename overwrites
-    let is_dir = source_metadata.is_dir();
-
     // Excludes can't be honored by an atomic rename of a whole directory, so a
+fn target_is_source_or_child(source: &Path, target: &Path) -> CopyResult<bool> {
+    let source = std::fs::canonicalize(source).map_err(CopyError::Io)?;
+    let target_anchor = if target_exists(target) {
+        target
+    } else {
+        target.parent().unwrap_or_else(|| Path::new("."))
+    };
+    let target_anchor = std::fs::canonicalize(target_anchor).map_err(CopyError::Io)?;
+    Ok(target_anchor == source || target_anchor.starts_with(&source))
+}
+
 fn target_exists(path: &Path) -> bool {

Also applies to: 61-84

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/move_op.rs` around lines 28 - 36, The current check in the
resolve_target and subsequent error handling only catches cases where target
equals source exactly, but fails to prevent the case where the destination is a
nested subdirectory within the source tree. Before the copy operation fallback
(between the current target equality check and the actual copy logic that spans
lines 61-84), add a validation guard that checks if the target path is nested
inside the source directory tree and returns an appropriate error if it is. This
prevents the scenario where a staging directory created during copy can end up
recursively copying itself when the destination is a subdirectory of the source.

Comment thread src/core/move_op.rs
Comment on lines +51 to +56
if let Some(mode) = options.backup
&& mode != BackupMode::None
{
let backup_path = generate_backup_path(&target, mode)?;
create_backup(&target, &backup_path)?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm the documented Rust floor and locate let-chain syntax.

rg -n 'Rust 1\.85|1\.85|rust-version|edition' README.md Cargo.toml
rg -n 'if let Some\(mode\) = options\.backup|&& let Some\(name\)' src/core/move_op.rs

Repository: UnbreakableMJ/copy

Length of output: 250


🌐 Web query:

Rust 1.80.0 if-let chains stabilization release date

💡 Result:

If-let chains (also known as "let chains") were not stabilized in Rust 1.80.0 [1][2]. This feature was stabilized in Rust 1.88.0, but only for use in the 2024 Edition [3][4][5]. If you are working in a codebase using an older Rust edition, let chains remain unavailable without using a nightly compiler and the associated feature gate [3][6][7].

Citations:


Use nested if statements instead of if let chains to maintain MSRV compatibility.

The if let ... && ... syntax at lines 51–56 and 200–204 requires Rust 1.88 or later with the 2024 Edition, but the README documents a minimum Rust version of 1.85. Rewrite using nested if statements:

Proposed fixes

Line 51:

-        if let Some(mode) = options.backup
-            && mode != BackupMode::None
-        {
-            let backup_path = generate_backup_path(&target, mode)?;
-            create_backup(&target, &backup_path)?;
+        if let Some(mode) = options.backup {
+            if mode != BackupMode::None {
+                let backup_path = generate_backup_path(&target, mode)?;
+                create_backup(&target, &backup_path)?;
+            }
         }

Line 200:

-    if destination.is_dir()
-        && let Some(name) = source.file_name()
-    {
-        return destination.join(name);
+    if destination.is_dir() {
+        if let Some(name) = source.file_name() {
+            return destination.join(name);
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if let Some(mode) = options.backup
&& mode != BackupMode::None
{
let backup_path = generate_backup_path(&target, mode)?;
create_backup(&target, &backup_path)?;
}
if let Some(mode) = options.backup {
if mode != BackupMode::None {
let backup_path = generate_backup_path(&target, mode)?;
create_backup(&target, &backup_path)?;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/move_op.rs` around lines 51 - 56, The current code uses `if let
Some(mode) = options.backup && mode != BackupMode::None` syntax which requires
Rust 1.88+ but the project supports Rust 1.85 as the MSRV. Convert this into
nested if statements for compatibility: first check if options.backup is
Some(mode) using if let, and then inside that block add a separate if statement
to check if mode is not equal to BackupMode::None. Apply this same fix to both
occurrences mentioned (around lines 51-56 and 200-204) where the
generate_backup_path and create_backup functions are called, ensuring the backup
creation logic remains intact within the properly nested conditional blocks.

Source: Learnings

Comment thread src/core/move_op.rs
Comment on lines +124 to +138
// The overwrite policy has already been applied, so clear any existing
// target first to reproduce the source exactly at `target`.
if target_exists(target) {
remove_path(target)?;
}

if is_dir {
// `copy` always nests a directory under its destination (dest/name),
// so it can't rename. Stage the copy on the destination filesystem,
// then cheap-rename the nested result into the final target.
stage_and_place_dir(source, target, options)?;
} else {
// A file copies directly to the target path.
copy(source, target, &options.to_copy_options())?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Do not remove the existing target before fallback copy succeeds.

On EXDEV fallback, Line 127 deletes the current destination before copy() completes. A copy error or interrupt then leaves the source intact but destroys the existing target; stage the file/directory first, then swap into place after a successful copy.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/move_op.rs` around lines 124 - 138, The current implementation
removes the target before the copy operation completes, which leaves the
original target destroyed if copy fails or is interrupted. Remove the premature
target deletion at line 127 (the remove_path(target) call), and instead apply a
staging pattern for both files and directories: stage the copy to a temporary
location first, and only after the copy succeeds, remove the existing target and
move the staged result into the final target location. The directory path
already uses stage_and_place_dir for this pattern, so apply the same approach to
the file copy branch in the else block.

Comment thread src/core/move_op.rs
Comment on lines +170 to +173
let result = place_via_staging(source, target, &staging, options);

let _ = std::fs::remove_dir_all(&staging);
result

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Propagate staging cleanup failures.

Ignoring remove_dir_all can report success while leaving partial copied data in .copy-move-staging-*. As per coding guidelines, src/core/**/*.rs must not ignore filesystem errors in production copy paths.

Proposed cleanup handling
     let result = place_via_staging(source, target, &staging, options);
 
-    let _ = std::fs::remove_dir_all(&staging);
-    result
+    let cleanup = std::fs::remove_dir_all(&staging).map_err(CopyError::Io);
+    match (result, cleanup) {
+        (Ok(()), Ok(())) => Ok(()),
+        (Ok(()), Err(error)) => Err(error),
+        (Err(error), Ok(())) => Err(error),
+        (Err(error), Err(cleanup_error)) => Err(CopyError::CopyFailed {
+            source: staging,
+            destination: target.to_path_buf(),
+            reason: format!(
+                "{error}; additionally failed to clean staging directory: {cleanup_error}"
+            ),
+        }),
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let result = place_via_staging(source, target, &staging, options);
let _ = std::fs::remove_dir_all(&staging);
result
let result = place_via_staging(source, target, &staging, options);
let cleanup = std::fs::remove_dir_all(&staging).map_err(CopyError::Io);
match (result, cleanup) {
(Ok(()), Ok(())) => Ok(()),
(Ok(()), Err(error)) => Err(error),
(Err(error), Ok(())) => Err(error),
(Err(error), Err(cleanup_error)) => Err(CopyError::CopyFailed {
source: staging,
destination: target.to_path_buf(),
reason: format!(
"{error}; additionally failed to clean staging directory: {cleanup_error}"
),
}),
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/move_op.rs` around lines 170 - 173, The cleanup operation
`std::fs::remove_dir_all(&staging)` is currently ignoring its result with `let
_`, which can mask failures in staging directory cleanup. Instead of discarding
the error, capture the result from remove_dir_all and check if it failed. If the
cleanup fails, propagate that error rather than silently returning the original
result from place_via_staging. This ensures filesystem errors in the staging
cleanup path are properly reported instead of being hidden.

Source: Coding guidelines

Comment thread src/core/move_op.rs
Comment on lines +254 to +258
if file_type.is_dir() {
remove_copied_entries(&src_child, &tgt_child)?;
if is_empty_dir(&src_child)? {
std::fs::remove_dir(&src_child)?;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve excluded empty directories.

This recurses into every source directory and removes it when empty even if tgt_child never existed, so an empty excluded directory is deleted from the source. Only prune directories that actually reached the target.

Proposed target-existence check
         if file_type.is_dir() {
+            let target_metadata = match std::fs::symlink_metadata(&tgt_child) {
+                Ok(metadata) => metadata,
+                Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
+                Err(error) => return Err(error),
+            };
+            if !target_metadata.is_dir() {
+                continue;
+            }
             remove_copied_entries(&src_child, &tgt_child)?;
             if is_empty_dir(&src_child)? {
                 std::fs::remove_dir(&src_child)?;
             }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if file_type.is_dir() {
remove_copied_entries(&src_child, &tgt_child)?;
if is_empty_dir(&src_child)? {
std::fs::remove_dir(&src_child)?;
}
if file_type.is_dir() {
let target_metadata = match std::fs::symlink_metadata(&tgt_child) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => return Err(error),
};
if !target_metadata.is_dir() {
continue;
}
remove_copied_entries(&src_child, &tgt_child)?;
if is_empty_dir(&src_child)? {
std::fs::remove_dir(&src_child)?;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/move_op.rs` around lines 254 - 258, The code unconditionally removes
empty source directories even when the corresponding target directory never
existed, which deletes excluded directories. In the directory handling block
where file_type.is_dir() is true, add a check to verify that tgt_child actually
exists before attempting to remove the empty src_child directory. This ensures
only directories that actually reached the target are pruned, preserving
excluded empty source directories.

Comment thread tests/move_integration.rs
Comment on lines +45 to +64
fn moves_multiple_sources_into_a_directory() {
let temp = assert_fs::TempDir::new().unwrap();
let one = temp.child("one.txt");
let two = temp.child("two.txt");
let dest_dir = temp.child("dest");
one.write_str("1").unwrap();
two.write_str("2").unwrap();
dest_dir.create_dir_all().unwrap();

move_cmd()
.arg(one.path())
.arg(two.path())
.arg(dest_dir.path())
.assert()
.success();

dest_dir.child("one.txt").assert("1");
dest_dir.child("two.txt").assert("2");
one.assert(predicate::path::missing());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Verify both sources were removed.

The test checks that dest_dir contains both files (lines 61-62) and that one was removed (line 63), but doesn't verify that two was also removed.

✅ Proposed fix to add the missing assertion
     dest_dir.child("one.txt").assert("1");
     dest_dir.child("two.txt").assert("2");
     one.assert(predicate::path::missing());
+    two.assert(predicate::path::missing());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/move_integration.rs` around lines 45 - 64, In the
moves_multiple_sources_into_a_directory test function, add a missing assertion
to verify that the second source file was also removed after the move operation.
After the existing assertion that checks one.assert(predicate::path::missing()),
add a similar assertion for the two variable using the same
predicate::path::missing() pattern to ensure both source files were properly
removed from their original locations.

Comment thread tests/move_integration.rs
Comment on lines +67 to +86
fn target_directory_flag_moves_sources_into_dir() {
let temp = assert_fs::TempDir::new().unwrap();
let one = temp.child("one.txt");
let two = temp.child("two.txt");
let dest_dir = temp.child("dest");
one.write_str("1").unwrap();
two.write_str("2").unwrap();
dest_dir.create_dir_all().unwrap();

move_cmd()
.arg("-t")
.arg(dest_dir.path())
.arg(one.path())
.arg(two.path())
.assert()
.success();

dest_dir.child("one.txt").assert("1");
dest_dir.child("two.txt").assert("2");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Verify sources were removed after -t move.

The test confirms both files reached the destination (lines 84-85), but doesn't verify the sources were removed. For consistency with other move tests, add assertions that one and two are missing after the move.

✅ Proposed fix to add source removal checks
     dest_dir.child("one.txt").assert("1");
     dest_dir.child("two.txt").assert("2");
+    one.assert(predicate::path::missing());
+    two.assert(predicate::path::missing());
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn target_directory_flag_moves_sources_into_dir() {
let temp = assert_fs::TempDir::new().unwrap();
let one = temp.child("one.txt");
let two = temp.child("two.txt");
let dest_dir = temp.child("dest");
one.write_str("1").unwrap();
two.write_str("2").unwrap();
dest_dir.create_dir_all().unwrap();
move_cmd()
.arg("-t")
.arg(dest_dir.path())
.arg(one.path())
.arg(two.path())
.assert()
.success();
dest_dir.child("one.txt").assert("1");
dest_dir.child("two.txt").assert("2");
}
fn target_directory_flag_moves_sources_into_dir() {
let temp = assert_fs::TempDir::new().unwrap();
let one = temp.child("one.txt");
let two = temp.child("two.txt");
let dest_dir = temp.child("dest");
one.write_str("1").unwrap();
two.write_str("2").unwrap();
dest_dir.create_dir_all().unwrap();
move_cmd()
.arg("-t")
.arg(dest_dir.path())
.arg(one.path())
.arg(two.path())
.assert()
.success();
dest_dir.child("one.txt").assert("1");
dest_dir.child("two.txt").assert("2");
one.assert(predicate::path::missing());
two.assert(predicate::path::missing());
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/move_integration.rs` around lines 67 - 86, The test
`target_directory_flag_moves_sources_into_dir` verifies that files are moved to
the destination directory but does not verify that the source files were removed
after the move operation. After the `.assert().success()` call confirms the
command succeeded, add assertions to verify that the original source files `one`
and `two` no longer exist in the temp directory, ensuring the move operation
fully completes by removing sources from their original locations.

@UnbreakableMJ UnbreakableMJ merged commit ea9e453 into main Jun 22, 2026
3 checks passed
@UnbreakableMJ UnbreakableMJ deleted the add-move-binary branch June 22, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant