Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions crates/ruf4/src/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,7 @@ fn draw_single_panel(
let ellipsis = "\u{2026}"; // …
let avail = name_w.saturating_sub(ext.len() + ellipsis.len());
let truncated_stem = &stem[..stem.floor_char_boundary(avail)];
let display_name =
arena_format!(ctx.arena(), "{truncated_stem}{ellipsis}{ext}");
let display_name = arena_format!(ctx.arena(), "{truncated_stem}{ellipsis}{ext}");
arena_format!(
ctx.arena(),
"{:<nw$} {:>7} {:>16}",
Expand Down
33 changes: 32 additions & 1 deletion crates/ruf4/src/fileops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,44 @@ pub fn ops_execute_one(src: &Path, target: &Path, is_copy: bool, errors: &mut Ve
fs::copy(src, target).map(|_| ())
}
} else {
fs::rename(src, target)
// fs::rename fails across filesystems/devices; fall back to copy + remove
// only for that specific error.
fs::rename(src, target).or_else(|e| {
if is_cross_device_error(&e) {
move_across_filesystems(src, target)
} else {
Err(e)
}
})
};
if let Err(e) = result {
errors.push(format!("{name}: {e}"));
}
}

fn is_cross_device_error(e: &std::io::Error) -> bool {
// Unix: EXDEV (18), Windows: ERROR_NOT_SAME_DEVICE (17)
#[cfg(unix)]
const CROSS_DEVICE: i32 = libc::EXDEV;
#[cfg(windows)]
const CROSS_DEVICE: i32 = 17; // ERROR_NOT_SAME_DEVICE
#[cfg(not(any(unix, windows)))]
const CROSS_DEVICE: i32 = -1;
e.raw_os_error() == Some(CROSS_DEVICE)
}

/// Move by copying then removing the source. Used when `fs::rename` fails
/// (e.g. across filesystem boundaries).
pub fn move_across_filesystems(src: &Path, dst: &Path) -> std::io::Result<()> {
if src.is_dir() {
copy_dir_recursive(src, dst)?;
fs::remove_dir_all(src)
} else {
fs::copy(src, dst)?;
fs::remove_file(src)
}
}

/// Execute all pairs, skipping same-file copies. No overwrite prompts.
pub fn ops_execute_all(pairs: &[(PathBuf, PathBuf)], is_copy: bool) -> Vec<String> {
let mut errors = Vec::new();
Expand Down
61 changes: 60 additions & 1 deletion crates/ruf4/tests/test_fileops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

use ruf4::fileops::{copy_dir_recursive, ops_build_pairs, ops_delete, ops_execute_all, ops_mkdir};
use ruf4::fileops::{
copy_dir_recursive, move_across_filesystems, ops_build_pairs, ops_delete, ops_execute_all,
ops_mkdir,
};
use ruf4::platform::run_command;

static COUNTER: AtomicU64 = AtomicU64::new(0);
Expand Down Expand Up @@ -196,6 +199,62 @@ fn test_ops_mkdir_empty_path_still_works() {
let _ = ops_mkdir(Path::new(""));
}

#[test]
fn test_move_across_filesystems_file() {
// Exercises the copy+remove fallback used when fs::rename fails
// (e.g. across filesystem boundaries). Uses separate temp dirs
// to simulate the scenario on all platforms.
let src_root = temp_dir();
let dst_root = temp_dir();
let src = src_root.join("data.txt");
let dst = dst_root.join("data.txt");
fs::write(&src, "cross-device content").unwrap();

move_across_filesystems(&src, &dst).unwrap();
assert!(!src.exists(), "source file should be removed");
assert_eq!(fs::read_to_string(&dst).unwrap(), "cross-device content");

cleanup(&src_root);
cleanup(&dst_root);
}

#[test]
fn test_move_across_filesystems_dir() {
let src_root = temp_dir();
let dst_root = temp_dir();
let src = src_root.join("mydir");
let dst = dst_root.join("mydir");
fs::create_dir_all(src.join("sub")).unwrap();
fs::write(src.join("a.txt"), "aaa").unwrap();
fs::write(src.join("sub/b.txt"), "bbb").unwrap();

move_across_filesystems(&src, &dst).unwrap();
assert!(!src.exists(), "source dir should be removed");
assert_eq!(fs::read_to_string(dst.join("a.txt")).unwrap(), "aaa");
assert_eq!(fs::read_to_string(dst.join("sub/b.txt")).unwrap(), "bbb");

cleanup(&src_root);
cleanup(&dst_root);
}

#[test]
fn test_ops_execute_all_move_uses_fallback() {
// Move between separate directories — exercises the rename-then-fallback path.
let src_root = temp_dir();
let dst_root = temp_dir();
let src_file = src_root.join("move_me.txt");
let dst_file = dst_root.join("move_me.txt");
fs::write(&src_file, "moved").unwrap();

let errors = ops_execute_all(&[(src_file.clone(), dst_file.clone())], false);
assert!(errors.is_empty(), "errors: {errors:?}");
assert!(!src_file.exists(), "source should be gone after move");
assert_eq!(fs::read_to_string(&dst_file).unwrap(), "moved");

cleanup(&src_root);
cleanup(&dst_root);
}

#[test]
fn test_overwrite_flow_no_conflict() {
let root = temp_dir();
Expand Down
Loading