From 8aab20c245485f83759a6d24aef41c2110fed8c6 Mon Sep 17 00:00:00 2001 From: UnbreakableMJ Date: Mon, 22 Jun 2026 23:17:17 +0300 Subject: [PATCH] Add a `move` binary (a `mv` replacement) reusing the copy engine `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 --- .github/workflows/release.yml | 47 ++-- AGENTS.md | 10 +- CLAUDE.md | 10 +- Cargo.toml | 6 +- README.md | 24 ++ README_CRATES.md | 14 ++ guix.scm | 12 +- install.sh | 63 +++--- nix/package.nix | 5 +- packaging/aur/.SRCINFO | 5 +- packaging/aur/PKGBUILD | 6 +- src/bin/move.rs | 63 ++++++ src/cli/mod.rs | 1 + src/cli/move_args.rs | 233 ++++++++++++++++++++ src/core/mod.rs | 1 + src/core/move_op.rs | 398 ++++++++++++++++++++++++++++++++++ src/utility/backup.rs | 7 +- tests/gnu/move-i.sh | 21 ++ tests/gnu/move-into-dir.sh | 24 ++ tests/move_integration.rs | 251 +++++++++++++++++++++ 20 files changed, 1124 insertions(+), 77 deletions(-) create mode 100644 src/bin/move.rs create mode 100644 src/cli/move_args.rs create mode 100644 src/core/move_op.rs create mode 100644 tests/gnu/move-i.sh create mode 100644 tests/gnu/move-into-dir.sh create mode 100644 tests/move_integration.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03d7559..dcd3b8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,20 +34,16 @@ jobs: include: - os: ubuntu-latest target: x86_64-unknown-linux-gnu - artifact_name: copy - asset_name: copy-linux-x86_64 + 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 steps: - uses: actions/checkout@v6 @@ -68,26 +64,23 @@ jobs: sudo apt-get update -q sudo apt-get install -y binutils-arm-linux-gnueabihf binutils-aarch64-linux-gnu - - name: Strip binary + - name: Strip and archive binaries run: | - if [[ "${{ matrix.target }}" == *"musl"* ]]; then - strip target/${{ matrix.target }}/release/${{ matrix.artifact_name }} || true - elif [[ "${{ matrix.target }}" == "armv7-unknown-linux-gnueabihf" ]]; then - arm-linux-gnueabihf-strip target/${{ matrix.target }}/release/${{ matrix.artifact_name }} || true - elif [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then - aarch64-linux-gnu-strip target/${{ matrix.target }}/release/${{ matrix.artifact_name }} || true - else - strip target/${{ matrix.target }}/release/${{ matrix.artifact_name }} - fi + case "${{ matrix.target }}" in + *musl*) strip_cmd=strip ;; + armv7-unknown-linux-gnueabihf) strip_cmd=arm-linux-gnueabihf-strip ;; + aarch64-unknown-linux-gnu) strip_cmd=aarch64-linux-gnu-strip ;; + *) strip_cmd=strip ;; + esac + rel="target/${{ matrix.target }}/release" + for bin in copy move; do + "$strip_cmd" "$rel/$bin" || true + tar czf "$bin-${{ matrix.platform }}.tar.gz" -C "$rel" "$bin" + done - - name: Create archive - run: | - cd target/${{ matrix.target }}/release - tar czf ${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }} - cd - - mv target/${{ matrix.target }}/release/${{ matrix.asset_name }}.tar.gz . - - - name: Upload Release Asset + - name: Upload Release Assets uses: softprops/action-gh-release@v3 with: - files: ./${{ matrix.asset_name }}.tar.gz + files: | + copy-${{ matrix.platform }}.tar.gz + move-${{ matrix.platform }}.tar.gz diff --git a/AGENTS.md b/AGENTS.md index 6d3bf65..bb7a3ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,9 +9,11 @@ SPDX-License-Identifier: GPL-3.0-or-later `copy` is a Linux-first Rust 2024 CLI that replaces `cp` with parallel copying, resume support, reflinks, symlink/hardlink modes, and configurable preservation -behavior. It is a Spacecraft Software-maintained fork of upstream `cpx`; upstream -MIT attribution is preserved in `LICENSES/MIT.txt`, and fork modifications are -distributed under GPL-3.0-or-later. +behavior. The crate also ships a sibling `move` binary (a `mv` replacement) that +reuses the copy engine: it renames in place when possible and otherwise copies +then removes the source. It is a Spacecraft Software-maintained fork of upstream +`cpx`; upstream MIT attribution is preserved in `LICENSES/MIT.txt`, and fork +modifications are distributed under GPL-3.0-or-later. ## Build, Test, Lint @@ -41,6 +43,7 @@ green before handing work back. - `copy_core()` tries hardlink preservation, reflink, Linux `copy_file_range`, then buffered fallback. - Worker failures request cooperative cancellation for remaining parallel work. User SIGINT/SIGTERM uses the separate `options.abort` flag and maps to exit code 130 in `main.rs`. - `README.md` and `README_CRATES.md` must stay in sync for user-facing behavior, install instructions, licensing, and release references. +- `move` (`src/bin/move.rs`, `src/core/move_op.rs`, `src/cli/move_args.rs`) is a thin second binary over the same library. It tries `std::fs::rename` first and only falls back to `copy()` + source removal on `EXDEV` (cross-device) or when `--exclude` must leave part of a directory behind. Because `copy()` always nests a directory under its destination, the directory fallback stages the copy in a temp dir on the destination filesystem and renames it into place. Never delete the source unless the copy succeeded. ## Forbidden Patterns @@ -71,6 +74,7 @@ green before handing work back. - Error types: `src/error.rs` - Integration tests: `tests/intergration.rs` - Standalone GNU compatibility scripts: `tests/gnu/` +- Move binary, engine, and CLI: `src/bin/move.rs`, `src/core/move_op.rs`, `src/cli/move_args.rs` - Packaging: `nix/package.nix`, `guix.scm`, `packaging/aur/PKGBUILD` ## Release Notes For Agents diff --git a/CLAUDE.md b/CLAUDE.md index 0cd8e13..a61761d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,9 +12,11 @@ when project invariants, commands, packaging, or release workflow change. `copy` is a Linux-first Rust 2024 CLI that replaces `cp` with parallel copying, resume support, reflinks, symlink/hardlink modes, and configurable preservation -behavior. It is a Spacecraft Software-maintained fork of upstream `cpx`; upstream -MIT attribution is preserved in `LICENSES/MIT.txt`, and fork modifications are -distributed under GPL-3.0-or-later. +behavior. The crate also ships a sibling `move` binary (a `mv` replacement) that +reuses the copy engine: it renames in place when possible and otherwise copies +then removes the source. It is a Spacecraft Software-maintained fork of upstream +`cpx`; upstream MIT attribution is preserved in `LICENSES/MIT.txt`, and fork +modifications are distributed under GPL-3.0-or-later. ## Build, Test, Lint @@ -44,6 +46,7 @@ green before handing work back. - `copy_core()` tries hardlink preservation, reflink, Linux `copy_file_range`, then buffered fallback. - Worker failures request cooperative cancellation for remaining parallel work. User SIGINT/SIGTERM uses the separate `options.abort` flag and maps to exit code 130 in `main.rs`. - `README.md` and `README_CRATES.md` must stay in sync for user-facing behavior, install instructions, licensing, and release references. +- `move` (`src/bin/move.rs`, `src/core/move_op.rs`, `src/cli/move_args.rs`) is a thin second binary over the same library. It tries `std::fs::rename` first and only falls back to `copy()` + source removal on `EXDEV` (cross-device) or when `--exclude` must leave part of a directory behind. Because `copy()` always nests a directory under its destination, the directory fallback stages the copy in a temp dir on the destination filesystem and renames it into place. Never delete the source unless the copy succeeded. ## Forbidden Patterns @@ -74,6 +77,7 @@ green before handing work back. - Error types: `src/error.rs` - Integration tests: `tests/intergration.rs` - Standalone GNU compatibility scripts: `tests/gnu/` +- Move binary, engine, and CLI: `src/bin/move.rs`, `src/core/move_op.rs`, `src/cli/move_args.rs` - Packaging: `nix/package.nix`, `guix.scm`, `packaging/aur/PKGBUILD` ## Release Notes For Agents diff --git a/Cargo.toml b/Cargo.toml index 6009355..5bbff92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "copy" -description = "A modern, fast file copying tool" +description = "Modern, fast file copy and move tools" repository = "https://github.com/UnbreakableMJ/copy" readme = "README_CRATES.md" keywords = ["copy", "file", "cli", "backup", "utility"] @@ -19,6 +19,10 @@ edition = "2024" name = "copy" path = "src/main.rs" +[[bin]] +name = "move" +path = "src/bin/move.rs" + [dependencies] clap = { version = "4.5.53", features = ["derive"] } filetime = "0.2.26" diff --git a/README.md b/README.md index ff70f58..eea689f 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,30 @@ mode = "auto" **See [configuration.md](docs/configuration.md) for all options and use cases.** +## The `move` command + +This project also ships a `move` binary — a modern `mv` replacement that reuses the same engine. It renames in place when possible (atomic, instant) and falls back to a copy + source removal only when the rename can't be done atomically (across filesystems, or when `--exclude` leaves part of a directory behind). + +```bash +# Rename / move a file +move old.txt new.txt + +# Move into a directory +move a.txt b.txt dest/ +move -t dest/ a.txt b.txt + +# Don't overwrite, prompt, or only move newer files +move -n src dst # never overwrite +move -i src dst # prompt before overwrite +move -u src dst # only if source is newer +move -b src dst # back up an existing destination + +# Cross-filesystem move that skips logs and preserves attributes +move -e '*.log' -p project/ /mnt/backup/project/ +``` + +Key options: `-t/--target-directory`, `-i/--interactive`, `-f/--force`, `-n/--no-clobber`, `-u/--update`, `-b/--backup`, `-v/--verbose`, and on the cross-device fallback `-j`, `--reflink`, `-e/--exclude`, `-p/--preserve`. Run `move --help` for the full reference. Both `copy` and `move` are installed together by every install method above. + ## Performance `copy` is built for speed. Quick comparison: diff --git a/README_CRATES.md b/README_CRATES.md index 9d4e2f1..a3e331b 100644 --- a/README_CRATES.md +++ b/README_CRATES.md @@ -236,6 +236,20 @@ mode = "auto" **See [configuration.md](docs/configuration.md) for all options and use cases.** +## The `move` command + +This crate also ships a `move` binary — a modern `mv` replacement that reuses the same engine. It renames in place when possible and falls back to a copy + source removal only across filesystems (or when `--exclude` leaves part of a directory behind). + +```bash +move old.txt new.txt # rename +move a.txt b.txt dest/ # move into a directory +move -t dest/ a.txt b.txt # target-directory form +move -n / -i / -u / -b src dst +move -e '*.log' -p project/ /mnt/backup/project/ # cross-fs, skip logs, preserve attrs +``` + +Options: `-t`, `-i`, `-f`, `-n`, `-u`, `-b`, `-v`, plus `-j`, `--reflink`, `-e`, `-p` on the cross-device fallback. Run `move --help` for details. `copy` and `move` install together. + ## Performance `copy` is built for speed. Quick comparison: diff --git a/guix.scm b/guix.scm index f932dda..daa9b1a 100644 --- a/guix.scm +++ b/guix.scm @@ -66,16 +66,20 @@ directory = ~s~%" #$%vendor))) (lambda _ (install-file "target/release/copy" (string-append #$output "/bin")) + (install-file "target/release/move" + (string-append #$output "/bin")) (install-file "LICENSE" (string-append #$output "/share/licenses/copy")) (install-file "LICENSES/MIT.txt" (string-append #$output "/share/licenses/copy"))))))) (native-inputs (list rust (list rust "cargo"))) (home-page "https://github.com/UnbreakableMJ/copy") - (synopsis "A modern, fast file copying tool") + (synopsis "Modern, fast file copy and move tools") (description - "copy is a modern, parallel replacement for the @command{cp} command on -Linux. It copies files and directories in parallel with progress bars, can + "This package provides @command{copy} and @command{move}, modern parallel +replacements for the @command{cp} and @command{mv} commands on Linux. +@command{copy} copies files and directories in parallel with progress bars, can resume interrupted transfers, preserves attributes, and supports reflink and -gitignore-style exclude patterns.") +gitignore-style exclude patterns. @command{move} renames in place when possible +and otherwise falls back to a copy plus source removal.") (license license:gpl3+)) diff --git a/install.sh b/install.sh index dca026e..1112023 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,11 @@ #!/bin/bash set -e -# copy installer script +# copy + move installer script # Usage: curl -fsSL https://raw.githubusercontent.com/UnbreakableMJ/copy/main/install.sh | bash REPO="UnbreakableMJ/copy" INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}" -BINARY_NAME="copy" +BINARIES="copy move" # Colors RED='\033[0;31m' @@ -60,8 +60,8 @@ get_latest_version() { } # Download and install -install_copy() { - local platform version download_url tarball_name +install_binaries() { + local platform version platform=$(detect_platform) info "Detected platform: $platform" @@ -72,36 +72,32 @@ install_copy() { fi info "Latest version: v$version" - tarball_name="copy-${platform}.tar.gz" - download_url="https://github.com/$REPO/releases/download/v${version}/${tarball_name}" - - info "Downloading from: $download_url" - # Create temporary directory tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT + mkdir -p "$INSTALL_DIR" - # Download - if command -v curl &> /dev/null; then - if ! curl -fsSL "$download_url" -o "$tmp_dir/$tarball_name"; then - error "Failed to download from: $download_url" - fi - elif command -v wget &> /dev/null; then - if ! wget -q "$download_url" -O "$tmp_dir/$tarball_name"; then - error "Failed to download from: $download_url" + local bin tarball_name download_url + for bin in $BINARIES; do + tarball_name="${bin}-${platform}.tar.gz" + download_url="https://github.com/$REPO/releases/download/v${version}/${tarball_name}" + info "Downloading $bin from: $download_url" + + if command -v curl &> /dev/null; then + if ! curl -fsSL "$download_url" -o "$tmp_dir/$tarball_name"; then + error "Failed to download from: $download_url" + fi + elif command -v wget &> /dev/null; then + if ! wget -q "$download_url" -O "$tmp_dir/$tarball_name"; then + error "Failed to download from: $download_url" + fi fi - fi - - # Extract - info "Extracting to $INSTALL_DIR..." - tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir" - - # Install - mkdir -p "$INSTALL_DIR" - cp "$tmp_dir/$BINARY_NAME" "$INSTALL_DIR/$BINARY_NAME" - chmod +x "$INSTALL_DIR/$BINARY_NAME" - info "✓ copy v$version installed to $INSTALL_DIR/$BINARY_NAME" + tar xzf "$tmp_dir/$tarball_name" -C "$tmp_dir" + cp "$tmp_dir/$bin" "$INSTALL_DIR/$bin" + chmod +x "$INSTALL_DIR/$bin" + info "✓ $bin v$version installed to $INSTALL_DIR/$bin" + done # Check if in PATH if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then @@ -113,9 +109,10 @@ install_copy() { fi # Verify installation - if "$INSTALL_DIR/$BINARY_NAME" --version &> /dev/null; then + if "$INSTALL_DIR/copy" --version &> /dev/null && "$INSTALL_DIR/move" --version &> /dev/null; then info "Installation verified successfully!" - "$INSTALL_DIR/$BINARY_NAME" --version + "$INSTALL_DIR/copy" --version + "$INSTALL_DIR/move" --version else warn "Installation completed but verification failed" fi @@ -124,14 +121,14 @@ install_copy() { # Main main() { echo "╔═══════════════════════════════════╗" - echo "║ copy - Modern File Copy Tool ║" + echo "║ copy & move file utilities ║" echo "╚═══════════════════════════════════╝" echo "" - install_copy + install_binaries echo "" - echo "To get started, run: copy --help" + echo "To get started, run: copy --help or move --help" } main diff --git a/nix/package.nix b/nix/package.nix index 17ddbe8..5c664d4 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -29,13 +29,16 @@ rustPlatform.buildRustPackage { cargoLock.lockFile = ../Cargo.lock; # committed lockfile -> no vendor hash + # buildRustPackage installs every [[bin]] in the crate, so both `copy` and + # `move` land in result/bin. + # Default features need no system libraries. SELinux xattr preservation is # opt-in and requires libselinux development headers; to enable it, uncomment: # buildFeatures = [ "selinux-support" ]; # buildInputs = [ libselinux ]; meta = { - description = "A modern, fast file copying tool"; + description = "Modern, fast file copy and move tools"; homepage = "https://github.com/UnbreakableMJ/copy"; license = lib.licenses.gpl3Plus; mainProgram = "copy"; diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index 3d0a72c..e30cc8d 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -1,13 +1,14 @@ pkgbase = copy - pkgdesc = A modern, fast file copying tool + pkgdesc = Modern, fast file copy and move tools pkgver = 0.1.5 pkgrel = 1 url = https://github.com/UnbreakableMJ/copy arch = x86_64 arch = aarch64 - license = MIT + license = GPL-3.0-or-later makedepends = cargo depends = gcc-libs + provides = move source = copy-0.1.5.tar.gz::https://github.com/UnbreakableMJ/copy/archive/refs/tags/v0.1.5.tar.gz sha256sums = 5f9e42b24b63cfd4071a6160ed45603f34553fe48bc22fd514dce740026af614 diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index dcb8b0f..2bbb7d2 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -5,12 +5,13 @@ pkgname=copy pkgver=0.1.5 pkgrel=1 -pkgdesc="A modern, fast file copying tool" +pkgdesc="Modern, fast file copy and move tools" arch=('x86_64' 'aarch64') url="https://github.com/UnbreakableMJ/copy" license=('GPL-3.0-or-later') depends=('gcc-libs') makedepends=('cargo') +provides=('move') source=("$pkgname-$pkgver.tar.gz::$url/archive/refs/tags/v$pkgver.tar.gz") sha256sums=('5f9e42b24b63cfd4071a6160ed45603f34553fe48bc22fd514dce740026af614') @@ -38,7 +39,8 @@ check() { package() { cd "$pkgname-$pkgver" - install -Dm0755 "target/release/$pkgname" "$pkgdir/usr/bin/$pkgname" + install -Dm0755 "target/release/copy" "$pkgdir/usr/bin/copy" + install -Dm0755 "target/release/move" "$pkgdir/usr/bin/move" install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" install -Dm0644 LICENSES/MIT.txt "$pkgdir/usr/share/licenses/$pkgname/MIT.txt" } diff --git a/src/bin/move.rs b/src/bin/move.rs new file mode 100644 index 0000000..b43b21c --- /dev/null +++ b/src/bin/move.rs @@ -0,0 +1,63 @@ +use copy::cli::move_args::MoveArgs; +use copy::core::move_op::{move_multiple, move_path}; +use copy::error::CliError; +use signal_hook::consts::signal::*; +use signal_hook::iterator::Signals; +use std::process; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +fn main() { + let args = MoveArgs::parse(); + + let (sources, destination, mut options) = match args.validate() { + Ok(validated) => validated, + Err(e) => { + eprintln!("Error: {}", e); + process::exit(1); + } + }; + + let abort = Arc::new(AtomicBool::new(false)); + options.abort = abort.clone(); + + let mut signals = Signals::new([SIGINT, SIGTERM]) + .map_err(CliError::Io) + .unwrap_or_else(|e| { + eprintln!("Failed to setup signal handler: {}", e); + process::exit(1); + }); + + std::thread::spawn({ + let abort = abort.clone(); + move || { + for sig in signals.forever() { + match sig { + SIGINT | SIGTERM => { + abort.store(true, Ordering::Relaxed); + } + _ => unreachable!(), + } + } + } + }); + + let result = if sources.len() == 1 { + move_path(&sources[0], &destination, &options) + } else { + move_multiple(sources, destination, &options) + }; + + if let Err(e) = result { + if abort.load(Ordering::Relaxed) { + eprintln!("\nOperation interrupted"); + eprintln!( + "Already-moved files remain at their destination; sources of in-progress moves are left in place" + ); + process::exit(130); // SIGINT + } else { + eprintln!("Error moving file: {}", e); + process::exit(1); + } + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 6e10f4a..c631ac1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1 +1,2 @@ pub mod args; +pub mod move_args; diff --git a/src/cli/move_args.rs b/src/cli/move_args.rs new file mode 100644 index 0000000..f915a8e --- /dev/null +++ b/src/cli/move_args.rs @@ -0,0 +1,233 @@ +use crate::cli::args::{BackupMode, CopyOptions, ReflinkMode}; +use crate::config::loader::{load_config, load_config_file}; +use crate::error::{CliError, CliResult}; +use crate::utility::exclude::{ + ExcludePattern, ExcludeRules, build_exclude_rules, parse_exclude_pattern_list, +}; +use crate::utility::helper::parse_backup_mode; +use crate::utility::preserve::PreserveAttr; +use clap::Parser; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +/// Default number of parallel operations on the cross-device copy fallback. +const DEFAULT_PARALLEL: usize = 4; + +/// Command-line surface for the `move` binary (a `mv` replacement). +/// +/// `move` renames in place when possible and falls back to a copy + source +/// removal only when the rename cannot be done atomically (cross-device, or +/// when `--exclude` means part of a directory must be left behind). +#[derive(Parser, Debug)] +#[command(name = "move", version = env!("CARGO_PKG_VERSION"))] +pub struct MoveArgs { + #[arg(help = "Source file(s) or directory(ies)", required = true)] + pub sources: Vec, + + #[arg(help = "Destination file or directory", required = true)] + pub destination: PathBuf, + + #[arg( + short = 't', + long = "target-directory", + value_name = "DIRECTORY", + help = "move all SOURCE arguments into DIRECTORY" + )] + pub target_directory: Option, + + #[arg(short = 'i', long, help = "prompt before overwrite")] + pub interactive: bool, + + #[arg(short = 'f', long, help = "do not prompt before overwriting")] + pub force: bool, + + #[arg( + short = 'n', + long = "no-clobber", + help = "do not overwrite an existing file" + )] + pub no_clobber: bool, + + #[arg( + short = 'u', + long, + help = "move only when the SOURCE is newer than the destination, or the destination is missing" + )] + pub update: bool, + + #[arg( + short = 'b', + long = "backup", + value_name = "CONTROL", + default_missing_value = "existing", + num_args = 0..=1, + require_equals = true, + help = "make a backup of each existing destination file (none, numbered, existing, simple)" + )] + pub backup: Option, + + #[arg(short = 'v', long, help = "explain what is being done")] + pub verbose: bool, + + #[arg( + short = 'j', + value_name = "N", + help = "number of parallel operations on the cross-device copy fallback [default: 4]" + )] + pub parallel: Option, + + #[arg( + long = "reflink", + value_name = "WHEN", + default_missing_value = "auto", + num_args = 0..=1, + require_equals = true, + help = "on the cross-device fallback, control clone/CoW copies (auto, always, never)" + )] + pub reflink: Option, + + #[arg( + short = 'e', + long = "exclude", + value_name = "PATTERN", + help = "exclude files matching pattern on a directory move (can be repeated, comma-separated)" + )] + pub exclude: Vec, + + #[arg( + short = 'p', + long = "preserve", + value_name = "ATTR_LIST", + default_missing_value = "", + num_args = 0..=1, + require_equals = true, + help = "attributes to preserve on the cross-device fallback [default: all]" + )] + pub preserve: Option, + + #[arg(long, value_name = "PATH", help = "use custom config file")] + pub config: Option, + + #[arg(long, help = "ignore all config files")] + pub no_config: bool, +} + +/// Validated, merged options driving a move operation. +/// +/// The `force`/`interactive`/`no_clobber`/`update`/`backup` fields govern the +/// overwrite policy applied before any rename; the remaining fields seed the +/// [`CopyOptions`] used only on the cross-device copy fallback. +#[derive(Debug, Clone)] +pub struct MoveOptions { + pub force: bool, + pub interactive: bool, + pub no_clobber: bool, + pub update: bool, + pub backup: Option, + pub verbose: bool, + pub parallel: usize, + pub reflink: Option, + pub preserve: PreserveAttr, + pub exclude_rules: Option, + pub abort: Arc, +} + +impl MoveOptions { + /// Build the [`CopyOptions`] used for the cross-device / exclude copy + /// fallback. The overwrite policy has already been applied at the move + /// layer, so the copy runs with `force` and preserves attributes. + pub fn to_copy_options(&self) -> CopyOptions { + let mut options = CopyOptions::none(); + options.recursive = true; + options.force = true; + options.preserve = self.preserve; + options.parallel = self.parallel; + options.reflink = self.reflink; + options.exclude_rules = self.exclude_rules.clone(); + options.abort = self.abort.clone(); + options + } +} + +impl MoveArgs { + /// Parse command-line arguments for the `move` binary. + pub fn parse() -> Self { + ::parse() + } + + /// Validate the parsed arguments, merge config defaults, and produce the + /// `(sources, destination, options)` triple the binary dispatches on. + pub fn validate(self) -> CliResult<(Vec, PathBuf, MoveOptions)> { + // Load the shared `copyconfig.toml` for the general defaults `move` + // honors (parallel, backup, exclude patterns). `--no-config` skips it; + // `--config PATH` forces a specific file. + let config = if self.no_config { + None + } else if let Some(ref path) = self.config { + Some(load_config_file(path).map_err(CliError::Config)?) + } else { + Some(load_config().map_err(CliError::Config)?) + }; + + let mut config_parallel = None; + let mut config_backup = None; + let mut patterns: Vec = Vec::new(); + if let Some(cfg) = &config { + config_parallel = Some(cfg.copy.parallel); + config_backup = parse_backup_mode(&cfg.backup.mode); + for pattern in &cfg.exclude.patterns { + patterns.extend(parse_exclude_pattern_list(pattern).map_err(CliError::Exclude)?); + } + } + + for pattern in &self.exclude { + patterns.extend(parse_exclude_pattern_list(pattern).map_err(CliError::Exclude)?); + } + let exclude_rules = build_exclude_rules(patterns).map_err(CliError::Exclude)?; + + // Default is to preserve everything on a cross-device move (mv keeps + // all attributes); `-p` narrows that selection. + let preserve = match &self.preserve { + Some(spec) if !spec.is_empty() => { + PreserveAttr::from_string(spec).map_err(CliError::Preserve)? + } + _ => PreserveAttr::all(), + }; + + // CLI `-j` overrides config; otherwise fall back to the config value + // or the built-in default. + let parallel = self + .parallel + .or(config_parallel) + .unwrap_or(DEFAULT_PARALLEL); + + let backup = self.backup.or(config_backup); + + // `-t DIR` moves every SOURCE (including the positional "destination") + // into DIR, mirroring the copy binary. + let (sources, destination) = if let Some(target) = self.target_directory { + let mut sources = self.sources; + sources.push(self.destination); + (sources, target) + } else { + (self.sources, self.destination) + }; + + let options = MoveOptions { + force: self.force, + interactive: self.interactive, + no_clobber: self.no_clobber, + update: self.update, + backup, + verbose: self.verbose, + parallel, + reflink: self.reflink, + preserve, + exclude_rules, + abort: Arc::new(AtomicBool::new(false)), + }; + + Ok((sources, destination, options)) + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index d68e033..a3f6445 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,2 +1,3 @@ pub mod copy; pub mod fast_copy; +pub mod move_op; diff --git a/src/core/move_op.rs b/src/core/move_op.rs new file mode 100644 index 0000000..94b8a10 --- /dev/null +++ b/src/core/move_op.rs @@ -0,0 +1,398 @@ +//! Move engine for the `move` binary. +//! +//! A move is "rename in place when possible, otherwise copy and delete the +//! source." `std::fs::rename` is attempted first — it is atomic and preserves +//! everything. When the rename cannot work atomically (the source and target +//! live on different filesystems, signalled by `EXDEV`) or when `--exclude` +//! means part of a directory must be left behind, the operation falls back to +//! the shared copy engine and then removes whatever was successfully copied. + +use crate::cli::args::BackupMode; +use crate::cli::move_args::MoveOptions; +use crate::core::copy::copy; +use crate::error::{CopyError, CopyResult}; +use crate::utility::backup::{create_backup, generate_backup_path}; +use crate::utility::helper::prompt_overwrite; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Disambiguates concurrent staging directories within a single process. +static STAGING_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Move a single `source` to `destination`, resolving "into directory" targets. +pub fn move_path(source: &Path, destination: &Path, options: &MoveOptions) -> CopyResult<()> { + let source_metadata = std::fs::symlink_metadata(source) + .map_err(|_| CopyError::InvalidSource(source.to_path_buf()))?; + + 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(), + }); + } + + // Overwrite policy is decided before any rename, since rename overwrites + // silently. Precedence: no-clobber, then update, then interactive. + if target_exists(&target) { + if options.no_clobber { + report_verbose(options, &format!("not overwriting '{}'", target.display())); + return Ok(()); + } + if options.update && !source_is_newer(source, &target)? { + return Ok(()); + } + if options.interactive && !prompt_overwrite(&target).map_err(CopyError::Io)? { + return Ok(()); + } + if let Some(mode) = options.backup + && mode != BackupMode::None + { + let backup_path = generate_backup_path(&target, mode)?; + create_backup(&target, &backup_path)?; + } + } + + let is_dir = source_metadata.is_dir(); + + // Excludes can't be honored by an atomic rename of a whole directory, so a + // directory move with excludes always takes the copy fallback. + let force_fallback = is_dir && options.exclude_rules.is_some(); + + if !force_fallback { + match std::fs::rename(source, &target) { + Ok(()) => { + report_renamed(options, source, &target); + return Ok(()); + } + Err(error) if is_cross_device(&error) => { + // Different filesystem: fall through to copy + delete. + } + Err(error) => { + return Err(CopyError::CopyFailed { + source: source.to_path_buf(), + destination: target, + reason: error.to_string(), + }); + } + } + } + + move_via_copy(source, &target, is_dir, options) +} + +/// Move every `source` into the directory `destination`. +pub fn move_multiple( + sources: Vec, + destination: PathBuf, + options: &MoveOptions, +) -> CopyResult<()> { + if !destination.is_dir() { + return Err(CopyError::InvalidDestination(destination)); + } + + let mut failures = 0usize; + for source in &sources { + if options.abort.load(Ordering::Relaxed) { + return Err(aborted()); + } + if let Err(error) = move_path(source, &destination, options) { + eprintln!("Failed to move '{}': {}", source.display(), error); + failures += 1; + } + } + + if failures > 0 { + return Err(CopyError::Io(io::Error::other(format!( + "{failures} item(s) failed to move" + )))); + } + Ok(()) +} + +/// Cross-device / exclude fallback: copy the source to the target, then remove +/// the source. The source is left untouched if the copy fails or is interrupted. +fn move_via_copy( + source: &Path, + target: &Path, + is_dir: bool, + options: &MoveOptions, +) -> CopyResult<()> { + // 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())?; + } + + // A user interrupt during the copy must not delete the source. + if options.abort.load(Ordering::Relaxed) { + return Err(aborted()); + } + + if is_dir { + if options.exclude_rules.is_some() { + // Excluded entries were never copied; delete only what reached the + // target and prune directories that became empty, leaving the rest. + remove_moved_sources(source, target)?; + } else { + std::fs::remove_dir_all(source).map_err(CopyError::Io)?; + } + } else { + std::fs::remove_file(source).map_err(CopyError::Io)?; + } + + report_renamed(options, source, target); + Ok(()) +} + +/// Copy a directory across filesystems and place it at `target` under its final +/// name. The copy lands in a staging directory on the destination filesystem so +/// the concluding rename to `target` is cheap and atomic; staging is always +/// cleaned up, even on failure. +fn stage_and_place_dir(source: &Path, target: &Path, options: &MoveOptions) -> CopyResult<()> { + let dest_parent = target.parent().unwrap_or_else(|| Path::new(".")); + let staging = unique_staging_dir(dest_parent); + std::fs::create_dir(&staging).map_err(CopyError::Io)?; + + let result = place_via_staging(source, target, &staging, options); + + let _ = std::fs::remove_dir_all(&staging); + result +} + +fn place_via_staging( + source: &Path, + target: &Path, + staging: &Path, + options: &MoveOptions, +) -> CopyResult<()> { + copy(source, staging, &options.to_copy_options())?; + let name = source + .file_name() + .ok_or_else(|| CopyError::InvalidSource(source.to_path_buf()))?; + std::fs::rename(staging.join(name), target).map_err(CopyError::Io) +} + +fn unique_staging_dir(parent: &Path) -> PathBuf { + let counter = STAGING_COUNTER.fetch_add(1, Ordering::Relaxed); + parent.join(format!( + ".copy-move-staging-{}-{}", + std::process::id(), + counter + )) +} + +/// Resolve the final target path, moving `source` *into* `destination` when the +/// destination is an existing directory. +fn resolve_target(source: &Path, destination: &Path) -> PathBuf { + if destination.is_dir() + && let Some(name) = source.file_name() + { + return destination.join(name); + } + destination.to_path_buf() +} + +fn target_exists(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok() +} + +fn source_is_newer(source: &Path, target: &Path) -> CopyResult { + let source_time = std::fs::metadata(source) + .and_then(|metadata| metadata.modified()) + .map_err(CopyError::Io)?; + let target_time = std::fs::metadata(target) + .and_then(|metadata| metadata.modified()) + .map_err(CopyError::Io)?; + Ok(source_time > target_time) +} + +fn is_cross_device(error: &io::Error) -> bool { + error.raw_os_error() == Some(libc::EXDEV) +} + +fn remove_path(path: &Path) -> CopyResult<()> { + let metadata = std::fs::symlink_metadata(path).map_err(CopyError::Io)?; + if metadata.is_dir() { + std::fs::remove_dir_all(path).map_err(CopyError::Io) + } else { + std::fs::remove_file(path).map_err(CopyError::Io) + } +} + +/// After an exclude-aware directory copy, delete the source entries that reached +/// the target (proof they were moved) and prune directories left empty, while +/// preserving excluded files that were never copied. +fn remove_moved_sources(source: &Path, target: &Path) -> CopyResult<()> { + remove_copied_entries(source, target).map_err(CopyError::Io)?; + if is_empty_dir(source).map_err(CopyError::Io)? { + std::fs::remove_dir(source).map_err(CopyError::Io)?; + } + Ok(()) +} + +fn remove_copied_entries(src: &Path, tgt: &Path) -> io::Result<()> { + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let src_child = entry.path(); + let tgt_child = tgt.join(entry.file_name()); + + if file_type.is_dir() { + remove_copied_entries(&src_child, &tgt_child)?; + if is_empty_dir(&src_child)? { + std::fs::remove_dir(&src_child)?; + } + } else if std::fs::symlink_metadata(&tgt_child).is_ok() { + std::fs::remove_file(&src_child)?; + } + } + Ok(()) +} + +fn is_empty_dir(path: &Path) -> io::Result { + Ok(std::fs::read_dir(path)?.next().is_none()) +} + +fn aborted() -> CopyError { + CopyError::Io(io::Error::new( + io::ErrorKind::Interrupted, + "Operation aborted by user", + )) +} + +fn report_renamed(options: &MoveOptions, source: &Path, target: &Path) { + if options.verbose { + println!("renamed '{}' -> '{}'", source.display(), target.display()); + } +} + +fn report_verbose(options: &MoveOptions, message: &str) { + if options.verbose { + println!("{message}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utility::preserve::PreserveAttr; + use std::fs; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + use tempfile::TempDir; + + fn options() -> MoveOptions { + MoveOptions { + force: false, + interactive: false, + no_clobber: false, + update: false, + backup: None, + verbose: false, + parallel: 1, + reflink: None, + preserve: PreserveAttr::none(), + exclude_rules: None, + abort: Arc::new(AtomicBool::new(false)), + } + } + + #[test] + fn moves_a_file_by_rename() { + let dir = TempDir::new().unwrap(); + let source = dir.path().join("a.txt"); + let target = dir.path().join("b.txt"); + fs::write(&source, b"data").unwrap(); + + move_path(&source, &target, &options()).unwrap(); + + assert!(!source.exists()); + assert_eq!(fs::read(&target).unwrap(), b"data"); + } + + #[test] + fn moves_into_an_existing_directory() { + let dir = TempDir::new().unwrap(); + let source = dir.path().join("a.txt"); + let dest_dir = dir.path().join("dest"); + fs::write(&source, b"data").unwrap(); + fs::create_dir(&dest_dir).unwrap(); + + move_path(&source, &dest_dir, &options()).unwrap(); + + assert!(!source.exists()); + assert_eq!(fs::read(dest_dir.join("a.txt")).unwrap(), b"data"); + } + + #[test] + fn renames_a_directory() { + let dir = TempDir::new().unwrap(); + let source = dir.path().join("src"); + let target = dir.path().join("dst"); + fs::create_dir(&source).unwrap(); + fs::write(source.join("f.txt"), b"x").unwrap(); + + move_path(&source, &target, &options()).unwrap(); + + assert!(!source.exists()); + assert_eq!(fs::read(target.join("f.txt")).unwrap(), b"x"); + } + + #[test] + fn no_clobber_keeps_existing_destination() { + let dir = TempDir::new().unwrap(); + let source = dir.path().join("a.txt"); + let target = dir.path().join("b.txt"); + fs::write(&source, b"new").unwrap(); + fs::write(&target, b"old").unwrap(); + + let mut opts = options(); + opts.no_clobber = true; + move_path(&source, &target, &opts).unwrap(); + + assert!(source.exists()); + assert_eq!(fs::read(&target).unwrap(), b"old"); + } + + #[test] + fn copy_fallback_moves_a_file() { + // Exercises the cross-device path directly (CI can't span filesystems). + let dir = TempDir::new().unwrap(); + let source = dir.path().join("a.txt"); + let target = dir.path().join("b.txt"); + fs::write(&source, b"payload").unwrap(); + + move_via_copy(&source, &target, false, &options()).unwrap(); + + assert!(!source.exists()); + assert_eq!(fs::read(&target).unwrap(), b"payload"); + } + + #[test] + fn copy_fallback_moves_a_directory() { + let dir = TempDir::new().unwrap(); + let source = dir.path().join("src"); + let target = dir.path().join("dst"); + fs::create_dir_all(source.join("sub")).unwrap(); + fs::write(source.join("sub/f.txt"), b"deep").unwrap(); + + move_via_copy(&source, &target, true, &options()).unwrap(); + + assert!(!source.exists()); + assert_eq!(fs::read(target.join("sub/f.txt")).unwrap(), b"deep"); + } +} diff --git a/src/utility/backup.rs b/src/utility/backup.rs index 90f3759..b0c3f8c 100644 --- a/src/utility/backup.rs +++ b/src/utility/backup.rs @@ -35,7 +35,12 @@ pub fn generate_backup_path(destination: &Path, mode: BackupMode) -> CopyResult< } fn find_max_backup_number(path: &Path) -> io::Result { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); + // `Path::parent` returns `Some("")` for a bare relative filename, which is + // not a valid directory to read; treat that (and `None`) as the cwd. + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); let file_name = path .file_name() .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "Invalid file name"))? diff --git a/tests/gnu/move-i.sh b/tests/gnu/move-i.sh new file mode 100644 index 0000000..64a21bb --- /dev/null +++ b/tests/gnu/move-i.sh @@ -0,0 +1,21 @@ +# Ensure `move -i` prompts and a declined prompt preserves the destination. +# +# Independent reimplementation in the spirit of the GNU coreutils mv tests. +# Requires a `move` binary on PATH. + +set -eu + +fail=0 +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +cd "$tmp" + +echo new > a.txt +echo old > b.txt + +printf 'n\n' | move -i a.txt b.txt + +test "$(cat b.txt)" = old || { echo "ERROR: a declined prompt overwrote the destination"; fail=1; } +test -e a.txt || { echo "ERROR: a declined prompt removed the source"; fail=1; } + +exit $fail diff --git a/tests/gnu/move-into-dir.sh b/tests/gnu/move-into-dir.sh new file mode 100644 index 0000000..1936c2d --- /dev/null +++ b/tests/gnu/move-into-dir.sh @@ -0,0 +1,24 @@ +# Ensure `move` relocates files into a directory and removes the sources. +# +# Independent reimplementation in the spirit of the GNU coreutils mv tests. +# Requires a `move` binary on PATH. + +set -eu + +fail=0 +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +cd "$tmp" + +mkdir dest +echo one > a.txt +echo two > b.txt + +move a.txt b.txt dest/ + +test ! -e a.txt || { echo "ERROR: source a.txt was not removed"; fail=1; } +test ! -e b.txt || { echo "ERROR: source b.txt was not removed"; fail=1; } +test "$(cat dest/a.txt)" = one || { echo "ERROR: dest/a.txt has wrong contents"; fail=1; } +test "$(cat dest/b.txt)" = two || { echo "ERROR: dest/b.txt has wrong contents"; fail=1; } + +exit $fail diff --git a/tests/move_integration.rs b/tests/move_integration.rs new file mode 100644 index 0000000..e4fc414 --- /dev/null +++ b/tests/move_integration.rs @@ -0,0 +1,251 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use assert_fs::prelude::*; +use predicates::prelude::*; + +fn move_cmd() -> assert_cmd::Command { + cargo_bin_cmd!("move") +} + +#[test] +fn renames_a_file_and_removes_the_source() { + let temp = assert_fs::TempDir::new().unwrap(); + let source = temp.child("a.txt"); + let dest = temp.child("b.txt"); + source.write_str("hello").unwrap(); + + move_cmd() + .arg(source.path()) + .arg(dest.path()) + .assert() + .success(); + + source.assert(predicate::path::missing()); + dest.assert("hello"); +} + +#[test] +fn moves_into_an_existing_directory() { + let temp = assert_fs::TempDir::new().unwrap(); + let source = temp.child("a.txt"); + let dest_dir = temp.child("dest"); + source.write_str("hello").unwrap(); + dest_dir.create_dir_all().unwrap(); + + move_cmd() + .arg(source.path()) + .arg(dest_dir.path()) + .assert() + .success(); + + source.assert(predicate::path::missing()); + dest_dir.child("a.txt").assert("hello"); +} + +#[test] +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()); +} + +#[test] +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"); +} + +#[test] +fn renames_a_directory() { + let temp = assert_fs::TempDir::new().unwrap(); + let source = temp.child("src"); + let dest = temp.child("dst"); + source.child("inner/f.txt").write_str("deep").unwrap(); + + move_cmd() + .arg(source.path()) + .arg(dest.path()) + .assert() + .success(); + + source.assert(predicate::path::missing()); + dest.child("inner/f.txt").assert("deep"); +} + +#[test] +fn no_clobber_leaves_the_destination_untouched() { + let temp = assert_fs::TempDir::new().unwrap(); + let source = temp.child("a.txt"); + let dest = temp.child("b.txt"); + source.write_str("new").unwrap(); + dest.write_str("old").unwrap(); + + move_cmd() + .arg("-n") + .arg(source.path()) + .arg(dest.path()) + .assert() + .success(); + + dest.assert("old"); + source.assert(predicate::path::exists()); +} + +#[test] +fn force_overwrites_the_destination() { + let temp = assert_fs::TempDir::new().unwrap(); + let source = temp.child("a.txt"); + let dest = temp.child("b.txt"); + source.write_str("new").unwrap(); + dest.write_str("old").unwrap(); + + move_cmd() + .arg("-f") + .arg(source.path()) + .arg(dest.path()) + .assert() + .success(); + + dest.assert("new"); + source.assert(predicate::path::missing()); +} + +#[test] +fn interactive_declined_keeps_the_destination() { + let temp = assert_fs::TempDir::new().unwrap(); + let source = temp.child("a.txt"); + let dest = temp.child("b.txt"); + source.write_str("new").unwrap(); + dest.write_str("old").unwrap(); + + move_cmd() + .arg("-i") + .arg(source.path()) + .arg(dest.path()) + .write_stdin("n\n") + .assert() + .success(); + + dest.assert("old"); + source.assert(predicate::path::exists()); +} + +#[test] +fn interactive_accepted_overwrites_the_destination() { + let temp = assert_fs::TempDir::new().unwrap(); + let source = temp.child("a.txt"); + let dest = temp.child("b.txt"); + source.write_str("new").unwrap(); + dest.write_str("old").unwrap(); + + move_cmd() + .arg("-i") + .arg(source.path()) + .arg(dest.path()) + .write_stdin("y\n") + .assert() + .success(); + + dest.assert("new"); + source.assert(predicate::path::missing()); +} + +#[test] +fn backup_preserves_the_old_destination() { + let temp = assert_fs::TempDir::new().unwrap(); + let source = temp.child("a.txt"); + let dest = temp.child("b.txt"); + source.write_str("new").unwrap(); + dest.write_str("old").unwrap(); + + move_cmd() + .arg("-b") + .arg(source.path()) + .arg(dest.path()) + .assert() + .success(); + + dest.assert("new"); + temp.child("b.txt~").assert("old"); +} + +#[test] +fn verbose_reports_the_rename() { + let temp = assert_fs::TempDir::new().unwrap(); + let source = temp.child("a.txt"); + let dest = temp.child("b.txt"); + source.write_str("x").unwrap(); + + move_cmd() + .arg("-v") + .arg(source.path()) + .arg(dest.path()) + .assert() + .success() + .stdout(predicate::str::contains("renamed")); +} + +#[test] +fn exclude_keeps_excluded_files_in_the_source() { + let temp = assert_fs::TempDir::new().unwrap(); + let source = temp.child("src"); + source.child("keep.log").write_str("keep").unwrap(); + source.child("data.txt").write_str("move").unwrap(); + let dest = temp.child("dst"); + + move_cmd() + .arg("-e") + .arg("*.log") + .arg(source.path()) + .arg(dest.path()) + .assert() + .success(); + + // The excluded file is left behind in the source; everything else moved. + source.child("keep.log").assert("keep"); + source.child("data.txt").assert(predicate::path::missing()); + dest.child("data.txt").assert("move"); + dest.child("keep.log").assert(predicate::path::missing()); +} + +#[test] +fn missing_source_fails() { + let temp = assert_fs::TempDir::new().unwrap(); + let dest = temp.child("b.txt"); + + move_cmd() + .arg(temp.child("does-not-exist.txt").path()) + .arg(dest.path()) + .assert() + .failure(); +}