use crate::cli::global::GlobalFlags;
use crate::cmd::output::execute_via_engine;
use crate::cmd::write_dispatch::{WriteMessages, execute_write};
use crate::exit;
use crate::plan::Operation;
use anyhow::Context;
use clap::Args;
use serde::Serialize;
use std::fs;
#[derive(Debug, Args)]
#[command(after_help = "\
EXAMPLES:
patchloom rename old_config.json config.json --apply
patchloom rename src/utils.rs src/helpers.rs --apply --force")]
pub struct RenameArgs {
pub from: String,
pub to: String,
#[arg(long)]
pub force: bool,
#[command(flatten)]
pub write: crate::cli::global::WriteFlags,
}
#[derive(Debug, Serialize)]
struct RenameOutput {
ok: bool,
from: String,
#[serde(skip_serializing_if = "Option::is_none")]
to: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
diff: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
applied: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
backup_session: Option<String>,
}
pub fn run(mut args: RenameArgs, global: &GlobalFlags) -> anyhow::Result<u8> {
crate::verbose!(
"rename: from={}, to={}, force={}",
args.from,
args.to,
args.force
);
let cwd = global.resolve_cwd()?;
args.from = global.rewrite_user_path_arg(&cwd, &args.from)?;
args.to = global.rewrite_user_path_arg(&cwd, &args.to)?;
let src = cwd.join(&args.from);
let dst = cwd.join(&args.to);
if !src.exists() {
let msg = format!("source file not found: {}", args.from);
global.emit_error_json_kind(Some("not_found"), &msg)?;
return Ok(crate::exit::FAILURE);
}
if !src.is_file() {
let msg = format!("source is not a file: {}", args.from);
global.emit_error_json_kind(Some("invalid_input"), &msg)?;
return Ok(crate::exit::FAILURE);
}
if let Err(e) = crate::ops::file::ensure_parent_components_are_directories(&dst) {
global.emit_error_json_kind(Some("invalid_input"), &e.msg)?;
return Ok(crate::exit::FAILURE);
}
if dst.exists() && !dst.is_file() {
let msg = format!("destination is not a file: {}", args.to);
global.emit_error_json_kind(Some("invalid_input"), &msg)?;
return Ok(crate::exit::FAILURE);
}
let is_case_only_change = src != dst
&& src.parent() == dst.parent()
&& src.file_name().map(|n| n.to_ascii_lowercase())
== dst.file_name().map(|n| n.to_ascii_lowercase());
if !is_case_only_change
&& (src == dst
|| matches!(
(
crate::containment::safe_canonicalize(&src),
crate::containment::safe_canonicalize(&dst)
),
(Ok(ref s), Ok(ref d)) if s == d
))
{
let output = RenameOutput {
ok: true,
from: args.from.clone(),
to: Some(args.to.clone()),
diff: None,
applied: Some(false),
backup_session: None,
};
if !global.emit_json(&output)? && !global.quiet {
println!("source and destination are the same: {}", args.from);
}
return Ok(exit::SUCCESS);
}
if !args.force && !is_case_only_change && dst.exists() {
let msg = format!("destination already exists: {}", args.to);
global.emit_error_json_kind(Some("already_exists"), &msg)?;
return Ok(crate::exit::FAILURE);
}
let is_binary = {
use std::io::Read;
let mut file = fs::File::open(&src)?;
let mut buf = [0u8; 8192];
let n = file.read(&mut buf)?;
crate::files::is_binary(&buf[..n])
};
if is_binary {
return run_direct_rename(&args, global, &cwd, &src, &dst, DirectRenameKind::Binary);
}
if is_case_only_change {
return run_direct_rename(&args, global, &cwd, &src, &dst, DirectRenameKind::CaseOnly);
}
let policy = crate::write::policy_from_flags(global, None);
if (global.apply || global.confirm) && policy.is_noop() && !global.respect_editorconfig {
return run_direct_rename(&args, global, &cwd, &src, &dst, DirectRenameKind::Plain);
}
let op = Operation::FileRename {
from: args.from.clone(),
to: args.to.clone(),
force: args.force,
};
let check_msg = format!("would rename {} -> {}", args.from, args.to);
let apply_msg = format!("renamed {} -> {}", args.from, args.to);
execute_via_engine(
op,
global,
|phase, diff, _backup| RenameOutput {
ok: true,
from: args.from.clone(),
to: Some(args.to.clone()),
diff,
applied: phase.applied_flag(),
backup_session: _backup,
},
&check_msg,
&apply_msg,
)
}
#[derive(Debug, Clone, Copy)]
enum DirectRenameKind {
Binary,
CaseOnly,
Plain,
}
impl DirectRenameKind {
fn label(self) -> &'static str {
match self {
DirectRenameKind::Binary => "binary",
DirectRenameKind::CaseOnly => "case-only",
DirectRenameKind::Plain => "plain",
}
}
}
fn run_direct_rename(
args: &RenameArgs,
global: &GlobalFlags,
cwd: &std::path::Path,
src: &std::path::Path,
dst: &std::path::Path,
kind: DirectRenameKind,
) -> anyhow::Result<u8> {
if global.trim_trailing_whitespace
|| global.ensure_final_newline
|| global.normalize_eol.is_some()
|| global.collapse_blanks
{
let msg = match kind {
DirectRenameKind::Binary => {
format!(
"cannot apply write policy to binary file: {}",
src.display()
)
}
DirectRenameKind::CaseOnly => format!(
"cannot apply write policy on case-only rename (content is unchanged): {}",
src.display()
),
DirectRenameKind::Plain => format!(
"cannot apply write policy on plain rename (content is unchanged): {}",
src.display()
),
};
global.emit_error_json_kind(Some("invalid_input"), &msg)?;
return Ok(exit::FAILURE);
}
let label = kind.label();
let check_msg = format!("would rename {} -> {} ({label})", args.from, args.to);
let apply_msg = format!("renamed {} -> {} ({label})", args.from, args.to);
let post_msg = format!("renamed {} -> {}", args.from, args.to);
let from_disp = args.from.clone();
let to_disp = args.to.clone();
let content_for_diff = if matches!(kind, DirectRenameKind::Binary) {
None
} else {
fs::read_to_string(src).ok()
};
execute_write(
global,
cwd,
|phase, diff, _backup| RenameOutput {
ok: true,
from: args.from.clone(),
to: Some(args.to.clone()),
diff,
applied: phase.applied_flag(),
backup_session: _backup,
},
Some(&|_| {
if let Some(ref body) = content_for_diff {
let create = crate::diff::unified_diff(&to_disp, "", body);
let delete = crate::diff::unified_diff(&from_disp, body, "");
let mut diffs = Vec::new();
if create.has_changes {
diffs.push(create);
}
if delete.has_changes {
diffs.push(delete);
}
crate::diff::format_diff_result(&crate::diff::DiffResult { diffs })
} else {
String::new()
}
}),
|| {
let mut backup = crate::backup::BackupSession::new(cwd)?;
backup.save_before_delete(src)?;
backup.save_before_write(dst)?;
if let Some(parent) = dst.parent()
&& !parent.as_os_str().is_empty()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
let session = backup.finalize()?;
rename_or_copy(src, dst)?;
Ok(session)
},
WriteMessages {
check: &check_msg,
apply: &apply_msg,
post_confirm: Some(&post_msg),
},
)
}
fn rename_or_copy(src: &std::path::Path, dst: &std::path::Path) -> anyhow::Result<()> {
match fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(e) if is_cross_device(&e) => {
fs::copy(src, dst).with_context(|| {
format!("cross-device copy {} -> {}", src.display(), dst.display())
})?;
fs::remove_file(src).with_context(|| {
format!("removing source after cross-device copy: {}", src.display())
})?;
Ok(())
}
Err(e) => Err(e.into()),
}
}
fn is_cross_device(e: &std::io::Error) -> bool {
#[cfg(unix)]
{
e.raw_os_error() == Some(libc::EXDEV)
}
#[cfg(windows)]
{
e.raw_os_error() == Some(17)
}
#[cfg(not(any(unix, windows)))]
{
let _ = e;
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn rename_moves_file() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("old.txt");
let dst = dir.path().join("new.txt");
fs::write(&src, "hello\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: src.to_string_lossy().into_owned(),
to: dst.to_string_lossy().into_owned(),
force: false,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!src.exists());
assert!(dst.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "hello\n");
}
#[cfg(unix)]
#[test]
fn rename_preserves_hardlinks() {
use std::os::unix::fs::MetadataExt;
let dir = TempDir::new().unwrap();
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
let c = dir.path().join("c.txt");
fs::write(&a, "shared\n").unwrap();
fs::hard_link(&a, &b).unwrap();
let before_ino = fs::metadata(&a).unwrap().ino();
assert_eq!(fs::metadata(&a).unwrap().nlink(), 2);
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: a.to_string_lossy().into_owned(),
to: c.to_string_lossy().into_owned(),
force: false,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!a.exists());
assert!(b.exists());
assert!(c.exists());
assert_eq!(fs::read_to_string(&c).unwrap(), "shared\n");
assert_eq!(fs::read_to_string(&b).unwrap(), "shared\n");
assert_eq!(
fs::metadata(&c).unwrap().ino(),
before_ino,
"renamed path must keep the multi-linked inode"
);
assert_eq!(
fs::metadata(&b).unwrap().ino(),
before_ino,
"sibling hardlink must still share the inode"
);
assert!(
fs::metadata(&c).unwrap().nlink() > 1,
"nlink must stay > 1 after rename"
);
}
#[cfg(unix)]
#[test]
fn rename_force_preserves_hardlinks() {
use std::os::unix::fs::MetadataExt;
let dir = TempDir::new().unwrap();
let a = dir.path().join("a.txt");
let b = dir.path().join("b.txt");
let dest = dir.path().join("existing.txt");
fs::write(&a, "source body\n").unwrap();
fs::hard_link(&a, &b).unwrap();
fs::write(&dest, "old dest\n").unwrap();
let before_ino = fs::metadata(&a).unwrap().ino();
assert_eq!(fs::metadata(&a).unwrap().nlink(), 2);
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: a.to_string_lossy().into_owned(),
to: dest.to_string_lossy().into_owned(),
force: true,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!a.exists());
assert_eq!(fs::read_to_string(&dest).unwrap(), "source body\n");
assert_eq!(
fs::read_to_string(&b).unwrap(),
"source body\n",
"sibling hardlink must still share the moved inode"
);
assert_eq!(fs::metadata(&dest).unwrap().ino(), before_ino);
assert_eq!(fs::metadata(&b).unwrap().ino(), before_ino);
assert!(
fs::metadata(&dest).unwrap().nlink() > 1,
"nlink must stay > 1 after force rename"
);
}
#[test]
fn rename_fails_if_dst_exists() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("old.txt");
let dst = dir.path().join("new.txt");
fs::write(&src, "hello\n").unwrap();
fs::write(&dst, "existing\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: src.to_string_lossy().into_owned(),
to: dst.to_string_lossy().into_owned(),
force: false,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::FAILURE);
}
#[test]
fn rename_force_rejects_directory_destination() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("old.txt");
let dst = dir.path().join("folder");
fs::write(&src, "hello\n").unwrap();
fs::create_dir(&dst).unwrap();
let args = RenameArgs {
from: src.to_string_lossy().into_owned(),
to: dst.to_string_lossy().into_owned(),
force: true,
write: Default::default(),
};
let code = run(args, &GlobalFlags::test_with_cwd(dir.path())).unwrap();
assert_eq!(code, exit::FAILURE);
}
#[test]
fn rename_force_overwrites_dst() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("old.txt");
let dst = dir.path().join("new.txt");
fs::write(&src, "new content\n").unwrap();
fs::write(&dst, "old content\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: src.to_string_lossy().into_owned(),
to: dst.to_string_lossy().into_owned(),
force: true,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!src.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "new content\n");
}
#[test]
fn rename_check_reports_changes() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("old.txt");
let dst = dir.path().join("new.txt");
fs::write(&src, "hello\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.check = true;
let args = RenameArgs {
from: src.to_string_lossy().into_owned(),
to: dst.to_string_lossy().into_owned(),
force: false,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::CHANGES_DETECTED);
assert!(src.exists());
assert_eq!(
fs::read_to_string(&src).unwrap(),
"hello\n",
"--check must not modify source content"
);
assert!(!dst.exists(), "--check must not create destination file");
}
#[test]
fn rename_fails_if_src_missing() {
let dir = TempDir::new().unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: dir.path().join("nope.txt").to_string_lossy().into_owned(),
to: dir.path().join("dst.txt").to_string_lossy().into_owned(),
force: false,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::FAILURE);
}
#[test]
fn rename_same_path_is_noop() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("same.txt");
fs::write(&file, "hello\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: file.to_string_lossy().into_owned(),
to: file.to_string_lossy().into_owned(),
force: true,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(file.exists(), "file should still exist");
assert_eq!(fs::read_to_string(&file).unwrap(), "hello\n");
}
#[test]
fn rename_same_file_via_different_path_is_noop() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("same.txt");
fs::write(&file, "hello\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: file.to_string_lossy().into_owned(),
to: dir.path().join("./same.txt").to_string_lossy().into_owned(),
force: true,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(file.exists(), "file must not be deleted");
assert_eq!(fs::read_to_string(&file).unwrap(), "hello\n");
}
#[test]
fn rename_binary_file() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("image.bin");
let dst = dir.path().join("moved.bin");
fs::write(&src, b"\x00\x01\x02\xff\xfe").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: src.to_string_lossy().into_owned(),
to: dst.to_string_lossy().into_owned(),
force: false,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!src.exists());
assert_eq!(fs::read(&dst).unwrap(), b"\x00\x01\x02\xff\xfe");
}
#[test]
fn rename_with_contain_rejects_parent_escape_on_to() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("inside.txt"), "keep\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
global.contain = true;
let args = RenameArgs {
from: "inside.txt".into(),
to: "../escape-renamed.txt".into(),
force: false,
write: Default::default(),
};
let err = run(args, &global).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("escapes") || msg.contains("rejected"),
"expected containment error, got: {msg}"
);
assert!(
dir.path().join("inside.txt").exists(),
"source must remain when containment rejects destination"
);
assert!(!dir.path().join("../escape-renamed.txt").exists());
}
#[test]
fn rename_with_contain_rejects_parent_escape_on_from() {
let dir = TempDir::new().unwrap();
let outside = dir.path().parent().unwrap().join(format!(
"patchloom-rename-from-escape-{}.txt",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
));
fs::write(&outside, "outside\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
global.contain = true;
let args = RenameArgs {
from: format!("../{}", outside.file_name().unwrap().to_string_lossy()),
to: "pulled-in.txt".into(),
force: false,
write: Default::default(),
};
let err = run(args, &global).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("escapes") || msg.contains("rejected"),
"expected containment error, got: {msg}"
);
assert!(outside.exists(), "outside source must not be moved");
assert!(!dir.path().join("pulled-in.txt").exists());
let _ = fs::remove_file(&outside);
}
#[test]
fn rename_binary_with_contain_rejects_parent_escape() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("image.bin"), b"\x00\x01\x02\xff\xfe").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
global.contain = true;
let args = RenameArgs {
from: "image.bin".into(),
to: "../escaped.bin".into(),
force: false,
write: Default::default(),
};
let err = run(args, &global).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("escapes") || msg.contains("rejected"),
"expected containment error on binary rename escape, got: {msg}"
);
assert!(dir.path().join("image.bin").exists());
assert!(!dir.path().join("../escaped.bin").exists());
}
#[test]
fn rename_with_contain_allows_in_workspace_relative_path() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("old.txt"), "hello\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
global.contain = true;
let args = RenameArgs {
from: "old.txt".into(),
to: "new.txt".into(),
force: false,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!dir.path().join("old.txt").exists());
assert_eq!(
fs::read_to_string(dir.path().join("new.txt")).unwrap(),
"hello\n"
);
}
#[test]
fn rename_without_contain_allows_parent_escape() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("inside.txt"), "move me\n").unwrap();
let name = format!(
"patchloom-rename-escape-{}.txt",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
);
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: "inside.txt".into(),
to: format!("../{name}"),
force: false,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
let outside = dir.path().parent().unwrap().join(&name);
assert_eq!(fs::read_to_string(&outside).unwrap(), "move me\n");
let _ = fs::remove_file(&outside);
}
#[test]
fn rename_creates_parent_dirs() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("old.txt");
let dst = dir.path().join("sub").join("dir").join("new.txt");
fs::write(&src, "hello\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: src.to_string_lossy().into_owned(),
to: dst.to_string_lossy().into_owned(),
force: false,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!src.exists());
assert_eq!(fs::read_to_string(&dst).unwrap(), "hello\n");
}
#[test]
fn rename_fails_if_src_is_directory() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("folder");
let dst = dir.path().join("new.txt");
fs::create_dir(&src).unwrap();
let args = RenameArgs {
from: src.to_string_lossy().into_owned(),
to: dst.to_string_lossy().into_owned(),
force: false,
write: Default::default(),
};
let code = run(args, &GlobalFlags::test_with_cwd(dir.path())).unwrap();
assert_eq!(code, exit::FAILURE);
}
#[test]
fn rename_creates_backup_before_mutation() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("original.txt");
let dst = dir.path().join("moved.txt");
fs::write(&src, "backup me\n").unwrap();
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: src.to_string_lossy().into_owned(),
to: dst.to_string_lossy().into_owned(),
force: false,
write: Default::default(),
};
let code = run(args, &global).unwrap();
assert_eq!(code, exit::SUCCESS);
assert!(!src.exists(), "source should be gone after rename");
assert_eq!(fs::read_to_string(&dst).unwrap(), "backup me\n");
let backup_dir = dir.path().join(".patchloom/backups");
assert!(
backup_dir.exists(),
"backup directory should exist after rename --apply"
);
let sessions: Vec<_> = fs::read_dir(&backup_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.collect();
assert!(
!sessions.is_empty(),
"at least one backup session should be created"
);
}
#[cfg(unix)]
#[test]
fn rename_unreadable_file_propagates_io_error() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let src = dir.path().join("unreadable.txt");
let dst = dir.path().join("dest.txt");
fs::write(&src, "hello\n").unwrap();
fs::set_permissions(&src, fs::Permissions::from_mode(0o000)).unwrap();
if fs::read_to_string(&src).is_ok() {
fs::set_permissions(&src, fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let mut global = GlobalFlags::test_with_cwd(dir.path());
global.apply = true;
let args = RenameArgs {
from: src.to_string_lossy().into_owned(),
to: dst.to_string_lossy().into_owned(),
force: false,
write: Default::default(),
};
let result = run(args, &global);
assert!(result.is_err(), "expected error, got Ok: {result:?}");
let err_msg = result.unwrap_err().to_string();
assert!(
!err_msg.contains("binary"),
"should not misclassify permission error as binary: {err_msg}"
);
fs::set_permissions(&src, fs::Permissions::from_mode(0o644)).unwrap();
}
}