Add conflict policy options: Skip / Auto-number / Override

New ConflictPolicy enum in rename/ (Skip = original/default behavior,
AutoNumber = save as "Name (1).ext" counting up to the first free slot,
Override = replace the conflicting file outright), threaded through
execute_renames/rename_one alongside RenameAction. Override never deletes
a conflicting directory (falls back to Skip for that case); AutoNumber
sidesteps directories the same way by just trying the next number.
execute_rename now takes a conflict argument; undo/history are
deliberately NOT conflict-aware and always use their own fixed
skip-on-conflict policy regardless of what the original rename used.

Frontend: a second segmented selector in the rename confirmation modal
("On conflict: Skip / Auto-number / Override"), with Override styled in
red and its warning copy calling out that it can't be undone.
This commit is contained in:
Renamr Scaffold
2026-08-29 09:50:36 +00:00
parent 21d78b8f3a
commit 7dd9ad2078
5 changed files with 449 additions and 71 deletions
+332 -36
View File
@@ -10,17 +10,22 @@
//! treats `/` between two `{ }` scriptlets as ordinary literal text, so no changes were
//! needed there; this is purely about the rename engine trusting a `/` in the computed
//! name to mean "put it in a subfolder" instead of rejecting it outright). Any missing
//! intermediate directories are created automatically. Conflict policy is fixed at
//! **skip**: if the computed target already exists and isn't the same underlying file,
//! that item is left alone and reported as skipped rather than overwritten. The UI is
//! expected to show the caller a confirmation of the full plan (and let them choose a
//! [`RenameAction`]) before calling [`execute_renames`]; every batch that action
//! actually touches a file for is also recorded to the rename-history log one layer up
//! (`renamr_core::history`), so it can be undone from there.
//! intermediate directories are created automatically. What happens when the computed
//! target already exists is chosen per batch via [`ConflictPolicy`] - skip (the
//! original, and still default, behavior), auto-number, or override - a port of
//! `net.filebot.cli.CmdlineOperations`'s `--conflict` flag. The UI is expected to show
//! the caller a confirmation of the full plan (and let them choose both a
//! [`RenameAction`] and a [`ConflictPolicy`]) before calling [`execute_renames`]; every
//! batch that action actually touches a file for is also recorded to the rename-history
//! log one layer up (`renamr_core::history`), so it can be undone from there. Undoing
//! always uses skip-on-conflict regardless of what policy the original rename used
//! (see `history::revert_one`) - overwriting an unrelated file as a side effect of an
//! *undo* would be a much worse surprise than leaving it for the user to sort out by
//! hand.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
/// One planned rename: `source` is the file's current full path, `new_name` is the
/// target it should have afterward, resolved relative to `source`'s own directory -
@@ -61,6 +66,31 @@ impl Default for RenameAction {
}
}
/// What to do when a computed target already exists (and isn't just the source file
/// itself under a different case) - a port of `CmdlineOperations`'s `--conflict` flag.
/// Applies to a whole batch at once, same as [`RenameAction`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ConflictPolicy {
/// Leave the existing file alone and report the item as skipped. The original (and
/// still default) behavior - never destroys anything.
Skip,
/// Save alongside the conflicting file instead, as `"Name (1).ext"`, `"Name
/// (2).ext"`, etc. - the first name in that sequence that isn't already taken in
/// the target directory (tries up to 999 before giving up).
AutoNumber,
/// Replace whatever's at the target path. Irreversible for the file that gets
/// overwritten - undo can restore what the *source* becomes, never what conflict
/// resolution destroyed at the target.
Override,
}
impl Default for ConflictPolicy {
fn default() -> Self {
ConflictPolicy::Skip
}
}
/// What happened to one planned rename.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
@@ -137,7 +167,75 @@ fn create_symlink(_source: &Path, _target: &Path) -> std::io::Result<()> {
))
}
fn rename_one(source: &Path, new_name: &str, action: RenameAction) -> RenameOutcome {
/// Finds the first `"<stem> (N).<ext>"` (or `"<stem> (N)"` with no extension) in
/// `target`'s own directory that isn't already taken, starting at N=1. Tries up to 999
/// before giving up (`None`) - a directory with 999+ same-named conflicts is almost
/// certainly a sign something else is wrong, not a case to loop on forever.
fn next_available_name(target: &Path) -> Option<PathBuf> {
let parent = target.parent()?;
let stem = target.file_stem()?.to_string_lossy().to_string();
let ext = target.extension().map(|e| e.to_string_lossy().to_string());
for n in 1..=999 {
let candidate_name = match &ext {
Some(e) => format!("{stem} ({n}).{e}"),
None => format!("{stem} ({n})"),
};
let candidate = parent.join(candidate_name);
if !candidate.exists() {
return Some(candidate);
}
}
None
}
/// Resolves a conflict at `target` (which the caller has already confirmed exists and
/// isn't `source` under a different case) according to `conflict`. Returns the target
/// path to actually use, or the terminal outcome to report instead (skip, or a failure
/// - e.g. auto-numbering exhausted its attempts, or removing an existing file ahead of
/// an Override-Symlink failed).
///
/// A conflicting target that's a *directory* is only ever a special case for Override
/// (which falls back to skipping it) - Override is for replacing one file with
/// another, never for deleting a whole directory tree out from under the caller. Skip
/// treats it like any other conflict already, and AutoNumber doesn't need to touch it
/// at all - it just tries the next number instead.
fn resolve_conflict(target: &Path, action: RenameAction, conflict: ConflictPolicy) -> Result<PathBuf, RenameOutcome> {
match conflict {
ConflictPolicy::Skip => Err(RenameOutcome::SkippedConflict {
existing_path: target.to_string_lossy().to_string(),
}),
// Doesn't matter whether the conflicting entry is a file or a directory here -
// auto-numbering never touches it either way, it just tries the next name.
ConflictPolicy::AutoNumber => next_available_name(target).ok_or_else(|| RenameOutcome::Failed {
error: "couldn't find a free auto-numbered name (tried up to 999)".to_string(),
}),
ConflictPolicy::Override => {
// Override is for replacing one file with another - never for deleting a
// whole directory tree out from under the caller. Falls back to Skip's
// behavior for that one case.
if target.is_dir() {
return Err(RenameOutcome::SkippedConflict {
existing_path: target.to_string_lossy().to_string(),
});
}
// fs::rename and fs::copy both already replace an existing regular file at
// the destination on their own - only Symlink's underlying syscall fails
// outright when the target path already exists, so that's the one case that
// needs the old file removed first.
if action == RenameAction::Symlink {
if let Err(e) = fs::remove_file(target) {
return Err(RenameOutcome::Failed {
error: format!("couldn't remove the existing file before overriding it: {e}"),
});
}
}
Ok(target.to_path_buf())
}
}
}
fn rename_one(source: &Path, new_name: &str, action: RenameAction, conflict: ConflictPolicy) -> RenameOutcome {
if let Err(reason) = validate_relative_target(new_name) {
return RenameOutcome::Failed {
error: format!("\"{new_name}\" is not a valid target: {reason}"),
@@ -161,11 +259,14 @@ fn rename_one(source: &Path, new_name: &str, action: RenameAction) -> RenameOutc
return RenameOutcome::Unchanged;
}
if target.exists() && !same_file(&target, source) {
return RenameOutcome::SkippedConflict {
existing_path: target.to_string_lossy().to_string(),
};
}
let target = if target.exists() && !same_file(&target, source) {
match resolve_conflict(&target, action, conflict) {
Ok(resolved) => resolved,
Err(outcome) => return outcome,
}
} else {
target
};
// `new_name` may include subdirectory components (organizing files into folders) -
// make sure the destination directory tree exists before attempting the actual
@@ -194,17 +295,18 @@ fn rename_one(source: &Path, new_name: &str, action: RenameAction) -> RenameOutc
}
}
/// Executes a batch of planned renames in order, all under the same [`RenameAction`].
/// Items are processed sequentially (not in parallel) so that two items whose computed
/// names collide with each other resolve the same way an on-disk collision would: the
/// first one through wins, the second is reported as a skipped conflict rather than
/// silently overwriting it.
pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction) -> Vec<RenameResult> {
/// Executes a batch of planned renames in order, all under the same [`RenameAction`]
/// and [`ConflictPolicy`]. Items are processed sequentially (not in parallel) so that
/// two items whose computed names collide with each other resolve the same way an
/// on-disk collision would: under Skip, the first one through wins and the second is
/// reported as a skipped conflict; under AutoNumber, the second gets bumped to " (1)";
/// under Override, the second replaces what the first just wrote.
pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction, conflict: ConflictPolicy) -> Vec<RenameResult> {
items
.iter()
.map(|item| RenameResult {
source: item.source.clone(),
outcome: rename_one(Path::new(&item.source), &item.new_name, action),
outcome: rename_one(Path::new(&item.source), &item.new_name, action, conflict),
})
.collect()
}
@@ -235,7 +337,7 @@ mod tests {
new_name: "new.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move);
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert_eq!(results.len(), 1);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
@@ -257,7 +359,7 @@ mod tests {
new_name: "same.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move);
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert_eq!(results[0].outcome, RenameOutcome::Unchanged);
assert!(file.exists());
@@ -274,7 +376,7 @@ mod tests {
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move);
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt"));
@@ -305,7 +407,7 @@ mod tests {
},
];
let results = execute_renames(&items, RenameAction::Move);
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert!(matches!(results[0].outcome, RenameOutcome::Renamed { .. }));
assert!(matches!(
results[1].outcome,
@@ -325,7 +427,7 @@ mod tests {
new_name: "../escaped.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move);
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists());
@@ -340,7 +442,7 @@ mod tests {
new_name: "Breaking Bad/Ozymandias.mkv".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move);
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
let expected = dir.join("Breaking Bad").join("Ozymandias.mkv");
@@ -362,7 +464,7 @@ mod tests {
new_name: "Breaking Bad/Season 05/S05E14.mkv".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move);
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv");
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
@@ -386,7 +488,7 @@ mod tests {
new_name: "Sub/b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move);
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert!(matches!(
results[0].outcome,
RenameOutcome::SkippedConflict { .. }
@@ -405,7 +507,7 @@ mod tests {
new_name: "Sub\\b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move);
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists());
@@ -421,7 +523,7 @@ mod tests {
source: file.to_string_lossy().to_string(),
new_name: bad_name.to_string(),
}];
let results = execute_renames(&items, RenameAction::Move);
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Skip);
assert!(
matches!(results[0].outcome, RenameOutcome::Failed { .. }),
"expected {bad_name:?} to be rejected"
@@ -440,7 +542,7 @@ mod tests {
new_name: "copied.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Copy);
let results = execute_renames(&items, RenameAction::Copy, ConflictPolicy::Skip);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert!(new_path.ends_with("copied.txt"));
@@ -464,7 +566,7 @@ mod tests {
new_name: "Sub/copied.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Copy);
let results = execute_renames(&items, RenameAction::Copy, ConflictPolicy::Skip);
let expected = dir.join("Sub").join("copied.txt");
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
@@ -487,7 +589,7 @@ mod tests {
new_name: "linked.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Symlink);
let results = execute_renames(&items, RenameAction::Symlink, ConflictPolicy::Skip);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
let link = Path::new(new_path);
@@ -515,7 +617,7 @@ mod tests {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, action);
let results = execute_renames(&items, action, ConflictPolicy::Skip);
match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt"));
@@ -539,7 +641,7 @@ mod tests {
source: file.to_string_lossy().to_string(),
new_name: "same.txt".to_string(),
}];
let results = execute_renames(&items, action);
let results = execute_renames(&items, action, ConflictPolicy::Skip);
assert_eq!(
results[0].outcome,
RenameOutcome::Unchanged,
@@ -550,4 +652,198 @@ mod tests {
fs::remove_dir_all(&dir).ok();
}
#[test]
fn auto_number_saves_alongside_the_conflicting_file_instead_of_skipping() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::write(dir.join("b.txt"), "bbb").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), dir.join("b (1).txt"));
assert!(Path::new(new_path).exists());
}
other => panic!("expected Renamed, got {other:?}"),
}
// The original conflicting file must still be exactly as it was.
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
assert!(!file.exists()); // Move: the source is gone, relocated to "b (1).txt"
fs::remove_dir_all(&dir).ok();
}
#[test]
fn auto_number_keeps_counting_past_names_that_are_also_taken() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::write(dir.join("b.txt"), "bbb").unwrap();
fs::write(dir.join("b (1).txt"), "existing 1").unwrap();
fs::write(dir.join("b (2).txt"), "existing 2").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), dir.join("b (3).txt"));
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn auto_number_handles_extensionless_names_and_organized_subfolders() {
let (dir, file) = temp_file_setup("a", "aaa");
fs::write(dir.join("README"), "existing").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "README".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), dir.join("README (1)"));
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn second_item_in_batch_auto_numbers_when_it_collides_with_the_first() {
let (dir, file_a) = temp_file_setup("a.txt", "aaa");
let file_b = dir.join("c.txt");
fs::write(&file_b, "ccc").unwrap();
let items = vec![
RenamePlanItem {
source: file_a.to_string_lossy().to_string(),
new_name: "target.txt".to_string(),
},
RenamePlanItem {
source: file_b.to_string_lossy().to_string(),
new_name: "target.txt".to_string(),
},
];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
assert_eq!(
results[0].outcome,
RenameOutcome::Renamed {
new_path: dir.join("target.txt").to_string_lossy().to_string()
}
);
assert_eq!(
results[1].outcome,
RenameOutcome::Renamed {
new_path: dir.join("target (1).txt").to_string_lossy().to_string()
}
);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn override_replaces_the_conflicting_file_for_move_and_copy() {
for action in [RenameAction::Move, RenameAction::Copy] {
let (dir, file) = temp_file_setup("a.txt", "new content");
fs::write(dir.join("b.txt"), "old content").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, action, ConflictPolicy::Override);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), dir.join("b.txt"));
assert_eq!(
fs::read_to_string(new_path).unwrap(),
"new content",
"expected b.txt to now hold the source's content for {action:?}"
);
}
other => panic!("expected Renamed for {action:?}, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
}
#[cfg(unix)]
#[test]
fn override_removes_an_existing_file_before_creating_a_symlink_there() {
let (dir, file) = temp_file_setup("a.txt", "hello");
fs::write(dir.join("b.txt"), "old content").unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Symlink, ConflictPolicy::Override);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
let link = Path::new(new_path);
assert!(fs::symlink_metadata(link).unwrap().file_type().is_symlink());
assert_eq!(fs::read_to_string(link).unwrap(), "hello");
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn override_never_deletes_a_conflicting_directory() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::create_dir_all(dir.join("b.txt")).unwrap(); // a DIRECTORY named "b.txt"
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::Override);
match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt"));
}
other => panic!("expected SkippedConflict (never delete a directory), got {other:?}"),
}
assert!(dir.join("b.txt").is_dir());
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn auto_number_also_never_deletes_a_conflicting_directory() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::create_dir_all(dir.join("b.txt")).unwrap();
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
// AutoNumber should sidestep the directory entirely by picking "b (1).txt"
// rather than touching "b.txt" at all.
let results = execute_renames(&items, RenameAction::Move, ConflictPolicy::AutoNumber);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), dir.join("b (1).txt"));
}
other => panic!("expected Renamed, got {other:?}"),
}
assert!(dir.join("b.txt").is_dir());
fs::remove_dir_all(&dir).ok();
}
}