Add Move/Copy/Symlink rename actions, with undo support for all three

- renamr-core::rename: new RenameAction enum (Move/Copy/Symlink, default Move,
  mirroring net.filebot.cli.CmdlineOperations's --action modes). execute_renames() now
  takes an action applied to the whole batch. Copy uses fs::copy (source untouched);
  Symlink uses std::os::unix::fs::symlink / std::os::windows::fs::symlink_file behind a
  cfg(unix)/cfg(windows) split. Same conflict policy (skip, never overwrite) and
  auto-created destination folders apply to all three actions. 8 new tests.
- renamr-core::history: HistoryBatch now records which action produced it
  (#[serde(default)] so old history.json entries - all implicitly Move - still
  deserialize). revert_one() branches on it: Move still moves the file back; Copy/
  Symlink undo by deleting the copy/symlink (using symlink_metadata so a broken link is
  still recognized as present and removable) - the original was never touched by those
  actions, so there's nothing to move back. 3 new tests.
- src-tauri: execute_rename command takes the chosen action and threads it through to
  both execute_renames and record_batch.
- Frontend: a Move/Copy/Symlink segmented selector in the "Confirm Rename" modal
  (remembered across modal opens, defaults to Move), action-aware subtitle/button
  copy, and a status message using the right verb (Renamed/Copied/Symlinked). Only Move
  reconciles a loaded item's id/name/bindings to the new path afterward, since Copy/
  Symlink leave the source file exactly where it was. History panel now shows a Copy/
  Symlink badge on non-Move batches and explains what Undo does for each.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Renamr Scaffold
2026-08-29 04:31:16 +00:00
co-authored by Claude Sonnet 5
parent aab8e28246
commit fd4e6f66f0
7 changed files with 546 additions and 127 deletions
+166 -34
View File
@@ -9,6 +9,7 @@
//! log is persisted to disk is a Tauri-layer concern (`src-tauri/src/history.rs`), same
//! split as `rename` (pure logic here) vs. the Tauri command that calls it.
use crate::rename::RenameAction;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
@@ -22,7 +23,8 @@ pub struct HistoryEntry {
}
/// One executed rename operation - everything renamed together in a single "Rename N
/// Files" confirmation - undoable as a unit.
/// Files" confirmation - undoable as a unit. All entries in a batch share one
/// [`RenameAction`], since the UI picks a single action per confirmation, not per file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryBatch {
@@ -31,6 +33,11 @@ pub struct HistoryBatch {
/// (no date/time dependency needed in this crate) so the frontend can format it
/// however it likes, e.g. `new Date(timestamp).toLocaleString()`.
pub timestamp: u64,
/// What was actually done to produce `entries.new_path` from `entries.source`.
/// `#[serde(default)]` so a `history.json` written before this field existed (every
/// batch back then was necessarily a Move) still deserializes cleanly.
#[serde(default)]
pub action: RenameAction,
pub entries: Vec<HistoryEntry>,
}
@@ -38,13 +45,18 @@ pub struct HistoryBatch {
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum RevertOutcome {
/// Successfully moved back to its original path.
/// For a Move batch: successfully moved back to its original path. For a Copy or
/// Symlink batch: the copy/symlink that was created has been removed - the original
/// file was never touched in the first place, so `restored_path` here just names it
/// for the caller rather than describing something that was actually moved.
Reverted { restored_path: String },
/// A different file already exists at the original path; left untouched - the same
/// skip-never-overwrite policy `rename::execute_renames` uses.
/// skip-never-overwrite policy `rename::execute_renames` uses. Only possible for a
/// Move revert; Copy/Symlink reverts never touch the original path at all.
SkippedConflict { existing_path: String },
/// The renamed file isn't where the history says it should be anymore (moved,
/// deleted, or already reverted some other way), or the OS reported an error.
/// The renamed/copied/linked file isn't where the history says it should be
/// anymore (moved, deleted, or already reverted some other way), or the OS reported
/// an error.
Failed { error: String },
}
@@ -63,51 +75,76 @@ fn same_file(a: &Path, b: &Path) -> bool {
}
}
fn revert_one(entry: &HistoryEntry) -> RevertOutcome {
fn revert_one(entry: &HistoryEntry, action: RenameAction) -> RevertOutcome {
let current = Path::new(&entry.new_path);
let original = Path::new(&entry.source);
if !current.exists() {
return RevertOutcome::Failed {
error: "the renamed file is no longer at its recorded location".to_string(),
};
}
match action {
RenameAction::Move => {
if !current.exists() {
return RevertOutcome::Failed {
error: "the renamed file is no longer at its recorded location".to_string(),
};
}
if original.exists() && !same_file(original, current) {
return RevertOutcome::SkippedConflict {
existing_path: original.to_string_lossy().to_string(),
};
}
if original.exists() && !same_file(original, current) {
return RevertOutcome::SkippedConflict {
existing_path: original.to_string_lossy().to_string(),
};
}
// The original's parent directory should still exist (nothing in Renamr deletes
// directories), but recreate it defensively in case it was removed by hand since -
// cheap, and `create_dir_all` is a no-op when it's already there.
if let Some(parent) = original.parent() {
if let Err(e) = fs::create_dir_all(parent) {
return RevertOutcome::Failed {
error: format!("couldn't recreate the original folder: {e}"),
};
// The original's parent directory should still exist (nothing in Renamr
// deletes directories), but recreate it defensively in case it was removed
// by hand since - cheap, and `create_dir_all` is a no-op when it's already
// there.
if let Some(parent) = original.parent() {
if let Err(e) = fs::create_dir_all(parent) {
return RevertOutcome::Failed {
error: format!("couldn't recreate the original folder: {e}"),
};
}
}
match fs::rename(current, original) {
Ok(()) => RevertOutcome::Reverted {
restored_path: original.to_string_lossy().to_string(),
},
Err(e) => RevertOutcome::Failed { error: e.to_string() },
}
}
}
RenameAction::Copy | RenameAction::Symlink => {
// Copy/Symlink never touched `original` in the first place - the file that
// was created lives only at `current`, so undoing is just removing it.
// `symlink_metadata` (rather than `exists`, which follows links) is used so
// a broken symlink - its target moved or deleted separately - still counts
// as present and removable, instead of being reported as already gone.
if fs::symlink_metadata(current).is_err() {
return RevertOutcome::Failed {
error: "the copy/symlink is no longer at its recorded location".to_string(),
};
}
match fs::rename(current, original) {
Ok(()) => RevertOutcome::Reverted {
restored_path: original.to_string_lossy().to_string(),
},
Err(e) => RevertOutcome::Failed { error: e.to_string() },
match fs::remove_file(current) {
Ok(()) => RevertOutcome::Reverted {
restored_path: original.to_string_lossy().to_string(),
},
Err(e) => RevertOutcome::Failed { error: e.to_string() },
}
}
}
}
/// Reverts every entry in a batch, in order. Like [`crate::rename::execute_renames`],
/// entries are processed sequentially (not in parallel) so a collision between two
/// entries in the same batch resolves deterministically rather than racing.
/// Reverts every entry in a batch, in order, all under the batch's recorded
/// [`RenameAction`]. Like [`crate::rename::execute_renames`], entries are processed
/// sequentially (not in parallel) so a collision between two entries in the same batch
/// resolves deterministically rather than racing.
pub fn revert_batch(batch: &HistoryBatch) -> Vec<RevertResult> {
batch
.entries
.iter()
.map(|entry| RevertResult {
source: entry.new_path.clone(),
outcome: revert_one(entry),
outcome: revert_one(entry, batch.action),
})
.collect()
}
@@ -152,6 +189,7 @@ mod tests {
let batch = HistoryBatch {
id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
@@ -185,6 +223,7 @@ mod tests {
let batch = HistoryBatch {
id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
@@ -209,6 +248,7 @@ mod tests {
let batch = HistoryBatch {
id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
@@ -236,6 +276,7 @@ mod tests {
let batch = HistoryBatch {
id: "b1".to_string(),
action: RenameAction::Move,
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
@@ -257,6 +298,7 @@ mod tests {
HistoryBatch {
id: "empty".to_string(),
timestamp: 0,
action: RenameAction::Move,
entries: vec![],
},
);
@@ -272,6 +314,7 @@ mod tests {
HistoryBatch {
id: format!("batch-{i}"),
timestamp: i as u64,
action: RenameAction::Move,
entries: vec![HistoryEntry {
source: "a".to_string(),
new_path: "b".to_string(),
@@ -286,4 +329,93 @@ mod tests {
// The oldest batches (0..5) were dropped to stay under the cap.
assert!(!history.iter().any(|b| b.id == "batch-0"));
}
#[test]
fn reverting_a_copy_batch_deletes_the_copy_and_leaves_the_original_untouched() {
let dir = temp_dir();
let original = dir.join("original.txt");
fs::write(&original, "hello").unwrap();
let copy = dir.join("copied.txt");
fs::write(&copy, "hello").unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
action: RenameAction::Copy,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: copy.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
match &results[0].outcome {
RevertOutcome::Reverted { restored_path } => {
assert_eq!(Path::new(restored_path), original.as_path());
}
other => panic!("expected Reverted, got {other:?}"),
}
// The copy is gone...
assert!(!copy.exists());
// ...but the original was never touched in the first place.
assert!(original.exists());
assert_eq!(fs::read_to_string(&original).unwrap(), "hello");
fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn reverting_a_symlink_batch_removes_the_link_not_the_file_it_points_to() {
let dir = temp_dir();
let original = dir.join("original.txt");
fs::write(&original, "hello").unwrap();
let link = dir.join("linked.txt");
std::os::unix::fs::symlink(&original, &link).unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
action: RenameAction::Symlink,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: link.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(results[0].outcome, RevertOutcome::Reverted { .. }));
// The symlink is gone...
assert!(fs::symlink_metadata(&link).is_err());
// ...but the real file it pointed at is completely unaffected.
assert!(original.exists());
assert_eq!(fs::read_to_string(&original).unwrap(), "hello");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn reverting_a_copy_batch_fails_gracefully_when_the_copy_is_already_gone() {
let dir = temp_dir();
let original = dir.join("original.txt");
fs::write(&original, "hello").unwrap();
let copy = dir.join("copied.txt"); // never created
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
action: RenameAction::Copy,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: copy.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(results[0].outcome, RevertOutcome::Failed { .. }));
// The original should still be completely untouched.
assert!(original.exists());
fs::remove_dir_all(&dir).ok();
}
}
+198 -22
View File
@@ -12,9 +12,11 @@
//! 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. There is no
//! undo yet, so the UI is expected to show the caller a confirmation of the full plan
//! before calling [`execute_renames`].
//! 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.
use serde::{Deserialize, Serialize};
use std::fs;
@@ -32,6 +34,33 @@ pub struct RenamePlanItem {
pub new_name: String,
}
/// What to actually do at the target path once it's been resolved and cleared of
/// conflicts - a port of the handful of `--action` modes `net.filebot.cli
/// .CmdlineOperations` supports (`move`, the default, plus `copy`/`symlink`; `keeplink`
/// and `test` are not ported). Applies to a whole batch at once - the UI picks one
/// action per "Rename" confirmation, not per file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RenameAction {
/// Renames/moves the file in place - the only action Renamr had before this. The
/// source file no longer exists afterward; the item now lives at the target path.
Move,
/// Copies the file's contents to the target path, leaving the source untouched.
Copy,
/// Creates a symlink at the target path pointing back at the (untouched) source
/// file. Unix: `std::os::unix::fs::symlink`. Windows: `symlink_file` (creating a
/// file symlink there normally requires Developer Mode or an elevated process -
/// surfaced to the caller as an ordinary `Failed` outcome if it's rejected, not a
/// special case).
Symlink,
}
impl Default for RenameAction {
fn default() -> Self {
RenameAction::Move
}
}
/// What happened to one planned rename.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
@@ -90,7 +119,25 @@ fn same_file(a: &Path, b: &Path) -> bool {
}
}
fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
#[cfg(unix)]
fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(source, target)
}
#[cfg(windows)]
fn create_symlink(source: &Path, target: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_file(source, target)
}
#[cfg(not(any(unix, windows)))]
fn create_symlink(_source: &Path, _target: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"symlinks aren't supported on this platform",
))
}
fn rename_one(source: &Path, new_name: &str, action: RenameAction) -> RenameOutcome {
if let Err(reason) = validate_relative_target(new_name) {
return RenameOutcome::Failed {
error: format!("\"{new_name}\" is not a valid target: {reason}"),
@@ -108,6 +155,9 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
let target = parent.join(new_name);
if target == source {
// Nothing meaningful to do for any action - Move has nothing to move, and
// Copy/Symlink onto the source's own path would just be a no-op self-reference
// at best (or an outright error at worst, e.g. `fs::copy` onto the same path).
return RenameOutcome::Unchanged;
}
@@ -119,7 +169,7 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
// `new_name` may include subdirectory components (organizing files into folders) -
// make sure the destination directory tree exists before attempting the actual
// rename/move. `create_dir_all` is a no-op (Ok) when the directory already exists.
// operation. `create_dir_all` is a no-op (Ok) when the directory already exists.
if let Some(target_parent) = target.parent() {
if let Err(e) = fs::create_dir_all(target_parent) {
return RenameOutcome::Failed {
@@ -128,7 +178,13 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
}
}
match fs::rename(source, &target) {
let result = match action {
RenameAction::Move => fs::rename(source, &target),
RenameAction::Copy => fs::copy(source, &target).map(|_bytes| ()),
RenameAction::Symlink => create_symlink(source, &target),
};
match result {
Ok(()) => RenameOutcome::Renamed {
new_path: target.to_string_lossy().to_string(),
},
@@ -138,16 +194,17 @@ fn rename_one(source: &Path, new_name: &str) -> RenameOutcome {
}
}
/// Executes a batch of planned renames in order. 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]) -> Vec<RenameResult> {
/// 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> {
items
.iter()
.map(|item| RenameResult {
source: item.source.clone(),
outcome: rename_one(Path::new(&item.source), &item.new_name),
outcome: rename_one(Path::new(&item.source), &item.new_name, action),
})
.collect()
}
@@ -178,7 +235,7 @@ mod tests {
new_name: "new.txt".to_string(),
}];
let results = execute_renames(&items);
let results = execute_renames(&items, RenameAction::Move);
assert_eq!(results.len(), 1);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
@@ -200,7 +257,7 @@ mod tests {
new_name: "same.txt".to_string(),
}];
let results = execute_renames(&items);
let results = execute_renames(&items, RenameAction::Move);
assert_eq!(results[0].outcome, RenameOutcome::Unchanged);
assert!(file.exists());
@@ -217,7 +274,7 @@ mod tests {
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items);
let results = execute_renames(&items, RenameAction::Move);
match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt"));
@@ -248,7 +305,7 @@ mod tests {
},
];
let results = execute_renames(&items);
let results = execute_renames(&items, RenameAction::Move);
assert!(matches!(results[0].outcome, RenameOutcome::Renamed { .. }));
assert!(matches!(
results[1].outcome,
@@ -268,7 +325,7 @@ mod tests {
new_name: "../escaped.txt".to_string(),
}];
let results = execute_renames(&items);
let results = execute_renames(&items, RenameAction::Move);
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists());
@@ -283,7 +340,7 @@ mod tests {
new_name: "Breaking Bad/Ozymandias.mkv".to_string(),
}];
let results = execute_renames(&items);
let results = execute_renames(&items, RenameAction::Move);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
let expected = dir.join("Breaking Bad").join("Ozymandias.mkv");
@@ -305,7 +362,7 @@ mod tests {
new_name: "Breaking Bad/Season 05/S05E14.mkv".to_string(),
}];
let results = execute_renames(&items);
let results = execute_renames(&items, RenameAction::Move);
let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv");
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
@@ -329,7 +386,7 @@ mod tests {
new_name: "Sub/b.txt".to_string(),
}];
let results = execute_renames(&items);
let results = execute_renames(&items, RenameAction::Move);
assert!(matches!(
results[0].outcome,
RenameOutcome::SkippedConflict { .. }
@@ -348,7 +405,7 @@ mod tests {
new_name: "Sub\\b.txt".to_string(),
}];
let results = execute_renames(&items);
let results = execute_renames(&items, RenameAction::Move);
assert!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists());
@@ -364,7 +421,7 @@ mod tests {
source: file.to_string_lossy().to_string(),
new_name: bad_name.to_string(),
}];
let results = execute_renames(&items);
let results = execute_renames(&items, RenameAction::Move);
assert!(
matches!(results[0].outcome, RenameOutcome::Failed { .. }),
"expected {bad_name:?} to be rejected"
@@ -374,4 +431,123 @@ mod tests {
fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_action_leaves_the_source_file_in_place() {
let (dir, file) = temp_file_setup("original.txt", "hello");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "copied.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Copy);
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert!(new_path.ends_with("copied.txt"));
assert!(Path::new(new_path).exists());
assert_eq!(fs::read_to_string(new_path).unwrap(), "hello");
// The whole point of Copy: the source is untouched.
assert!(file.exists());
assert_eq!(fs::read_to_string(&file).unwrap(), "hello");
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_action_can_also_organize_into_a_new_subfolder() {
let (dir, file) = temp_file_setup("original.txt", "hello");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "Sub/copied.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Copy);
let expected = dir.join("Sub").join("copied.txt");
match &results[0].outcome {
RenameOutcome::Renamed { new_path } => {
assert_eq!(Path::new(new_path), expected.as_path());
assert!(expected.exists());
assert!(file.exists());
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn symlink_action_leaves_the_source_file_in_place_and_points_back_at_it() {
let (dir, file) = temp_file_setup("original.txt", "hello");
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "linked.txt".to_string(),
}];
let results = execute_renames(&items, RenameAction::Symlink);
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_link(link).unwrap(), file);
// Reading through the link gets the source's real content...
assert_eq!(fs::read_to_string(link).unwrap(), "hello");
// ...and the source itself is completely untouched.
assert!(file.exists());
assert!(!fs::symlink_metadata(&file).unwrap().file_type().is_symlink());
}
other => panic!("expected Renamed, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn conflict_policy_applies_the_same_way_to_copy_and_symlink() {
let (dir, file) = temp_file_setup("a.txt", "aaa");
fs::write(dir.join("b.txt"), "bbb").unwrap();
for action in [RenameAction::Copy, RenameAction::Symlink] {
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "b.txt".to_string(),
}];
let results = execute_renames(&items, action);
match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt"));
}
other => panic!("expected SkippedConflict for {action:?}, got {other:?}"),
}
}
// Neither original file nor the pre-existing conflicting file should be touched.
assert!(file.exists());
assert_eq!(fs::read_to_string(dir.join("b.txt")).unwrap(), "bbb");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn copy_and_symlink_report_unchanged_when_the_computed_name_is_already_the_source() {
let (dir, file) = temp_file_setup("same.txt", "hello");
for action in [RenameAction::Copy, RenameAction::Symlink] {
let items = vec![RenamePlanItem {
source: file.to_string_lossy().to_string(),
new_name: "same.txt".to_string(),
}];
let results = execute_renames(&items, action);
assert_eq!(
results[0].outcome,
RenameOutcome::Unchanged,
"expected Unchanged for {action:?}"
);
}
assert!(file.exists());
fs::remove_dir_all(&dir).ok();
}
}