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:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user