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
+36 -24
View File
@@ -9,14 +9,19 @@ native file/folder loading and drag-and-drop, local filename-based season/episod
movie title/year* detection ("Match") is working, real online metadata lookups against
TMDB ("Fetch Data") are working for both TV episodes and movies — including fuzzy
title disambiguation, so a search that returns several similarly-named results doesn't
just take TMDB's top-ranked hit — and the actual on-disk operation is wired up
(skip-on-conflict, with a confirmation step showing the full old-path/new-path plan
before anything touches disk, a per-row ✕ to drop an individual file from that plan at
the last step, and a Move/Copy/Symlink selector for what to actually do at each
target). It can also organize files into subfolders — including ones that don't exist
yet — when the naming scheme includes a `/`. Every completed batch is logged to a
history panel that can undo it (moving files back for Move, or just removing what was
created for Copy/Symlink). A single loaded batch can freely mix TV episodes and movies
just take TMDB's top-ranked hit — and the actual on-disk operation is wired up (with a
confirmation step showing the full old-path/new-path plan before anything touches disk,
a per-row ✕ to drop an individual file from that plan at the last step, a Move/Copy/
Symlink selector for what to actually do at each target, and — independently — a Skip/
Auto-number/Override selector for what happens when the computed name already exists:
leave it alone (the original, and still default, behavior), save alongside it as
"Name (1).ext", or replace it outright). It can also organize files into subfolders —
including ones that don't exist yet — when the naming scheme includes a `/`. Every
completed batch is logged to a history panel that can undo it (moving files back for
Move, or just removing what was created for Copy/Symlink — undoing always uses its own
skip-on-conflict policy regardless of what the original rename used, so an undo can
never overwrite something else as a side effect). A single loaded batch can freely mix
TV episodes and movies
— each file only fills in the fields that apply to it. "Match" also parses release-tag
text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…),
video/audio codec, release group — so naming schemes can reference
@@ -110,9 +115,10 @@ to the source file's parent directory, never as an absolute path, and every path
component is validated (`validate_relative_target`) to reject empty segments (a
leading, trailing, or doubled `/` — which also catches an absolute-looking
`/etc/passwd`-style path), `.`/`..` segments, and backslashes (Renamr always uses `/`
for the naming scheme's folder separator regardless of OS). Conflict policy is
unchanged: if the resolved target already exists and isn't the same underlying file,
that item is skipped rather than overwritten.
for the naming scheme's folder separator regardless of OS). If the resolved target
already exists and isn't the same underlying file, what happens next is up to the
chosen conflict policy — Skip (default), Auto-number, or Override; see item 6 of the
porting roadmap below for the full behavior of each.
## Project layout
@@ -127,8 +133,9 @@ crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
search + episode lookup
and movie search + lookup, both disambiguated via `similarity/`,
tested against a mocked HTTP server via `mockito`), the on-disk
rename engine (`rename/` — skip-on-conflict, Move/Copy/Symlink
actions, can also organize into (auto-created) subfolders when
rename engine (`rename/` — Move/Copy/Symlink actions,
Skip/Auto-number/Override conflict policies, can also organize
into (auto-created) subfolders when
the naming scheme includes `/`) — and a rename history/undo log
(`history/`, a partial `HistoryDialog` port, undo-aware of which
action produced each batch). Not yet ported: full media metadata
@@ -244,17 +251,22 @@ ported from, for anyone picking this up:
6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
done (`crates/renamr-core/src/rename`, fixed skip-on-conflict policy, wired to the
UI's Rename button behind a confirmation step) including the `move`-into-subfolder
`--action` variant — a naming scheme like `{n}/{s00e00}` organizes files into
subfolders, creating any missing folders automatically, always scoped to the file's
own directory tree. The `copy`/`symlink` `--action` variants are done too — an action
selector in the confirmation modal picks Move/Copy/Symlink for the whole batch; Copy
and Symlink both leave the source file untouched and produce a new file/link at the
computed target, and both are undoable from the History panel the same as Move (undo
for Copy/Symlink just removes what was created, since there's nothing to move back).
Not yet done: the `--conflict` variants beyond skip (auto-number, override), and the
CLI flag surface itself.
done (`crates/renamr-core/src/rename`, wired to the UI's Rename button behind a
confirmation step) including the `move`-into-subfolder `--action` variant — a naming
scheme like `{n}/{s00e00}` organizes files into subfolders, creating any missing
folders automatically, always scoped to the file's own directory tree. The
`copy`/`symlink` `--action` variants are done too — an action selector in the
confirmation modal picks Move/Copy/Symlink for the whole batch; Copy and Symlink both
leave the source file untouched and produce a new file/link at the computed target,
and both are undoable from the History panel the same as Move (undo for Copy/Symlink
just removes what was created, since there's nothing to move back). The `--conflict`
variants are done too — a second selector in the same modal picks Skip (default,
leaves the conflicting file alone), Auto-number (`"Name (1).ext"`, counting up to the
first free slot), or Override (replaces the conflicting file outright — the one
irreversible option, called out with red styling in the UI; undoing that batch later
restores the *source*, never what got overwritten, since undo always uses its own
fixed skip-on-conflict policy no matter which policy the original rename used). Not
yet done: the CLI flag surface itself.
7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview,
local TV/movie matching, confirmed rename with per-item exclude, rename history/undo,
Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built; the
+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();
}
}
+16 -9
View File
@@ -6,7 +6,7 @@ mod settings;
use renamr_core::format::ExpressionFormat;
use renamr_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo};
use renamr_core::rename::{self, RenameAction, RenamePlanItem, RenameResult};
use renamr_core::rename::{self, ConflictPolicy, RenameAction, RenamePlanItem, RenameResult};
use renamr_core::string_bindings;
use std::collections::HashMap;
use tauri::AppHandle;
@@ -53,19 +53,26 @@ fn guess_release_info(filename: String) -> Option<ReleaseInfo> {
}
/// Executes a batch of planned on-disk rename/copy/symlink operations (optionally
/// moving/copying files into subfolders - see `renamr_core::rename`; conflicting
/// targets are always skipped, never overwritten). `action` picks which of the three
/// happens - the UI is expected to have already shown the user the full old-path/
/// new-path plan, the chosen action, and gotten explicit confirmation before calling
/// this. Runs off the main thread since real filesystem I/O shouldn't block the UI.
/// moving/copying files into subfolders - see `renamr_core::rename`). `action` picks
/// which of the three happens; `conflict` picks what happens when a computed target
/// already exists (skip it, save alongside it with an auto-numbered name, or replace
/// it) - the UI is expected to have already shown the user the full old-path/new-path
/// plan and both choices, and gotten explicit confirmation before calling this. Runs
/// off the main thread since real filesystem I/O shouldn't block the UI.
///
/// Every successfully-produced item is recorded to the rename history log afterward
/// (see `history::record_batch`), tagged with `action`, so it can be undone later from
/// the History panel - a Move undo moves the file back, a Copy/Symlink undo just
/// removes what was created.
/// removes what was created. `conflict` is NOT recorded - undoing always uses its own
/// fixed skip-on-conflict policy regardless of what the original rename used.
#[tauri::command]
async fn execute_rename(app: AppHandle, items: Vec<RenamePlanItem>, action: RenameAction) -> Vec<RenameResult> {
let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items, action))
async fn execute_rename(
app: AppHandle,
items: Vec<RenamePlanItem>,
action: RenameAction,
conflict: ConflictPolicy,
) -> Vec<RenameResult> {
let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items, action, conflict))
.await
.unwrap_or_default();
history::record_batch(&app, &results, action);
+13
View File
@@ -497,6 +497,19 @@
pointer-events: none;
}
.action-segmented-option-danger.selected {
background: #ff8a8a;
color: var(--dock-deep);
}
.action-segmented-option-danger:not(.selected):hover {
color: #ff8a8a;
}
.modal-note-danger {
color: #ff8a8a;
}
.modal-field-label {
display: block;
font-size: 11px;
+52 -2
View File
@@ -98,6 +98,12 @@ interface RenameResult {
// confirmRename below).
type RenameAction = "move" | "copy" | "symlink";
// Mirrors renamr-core's `ConflictPolicy` - what happens when a computed target already
// exists. "skip" (the default, and the only behavior Renamr had before) leaves the
// conflicting file alone; "autoNumber" saves alongside it as "Name (1).ext" instead;
// "override" replaces it outright (irreversible for whatever gets overwritten).
type ConflictPolicy = "skip" | "autoNumber" | "override";
// Sent to `fetch_metadata`: one file's local guess (from Match) to resolve against
// real TMDB data. Needs a series name + season/episode to search with - exactly what
// Match already produces offline, which is why Fetch Data requires Match to have run
@@ -287,6 +293,25 @@ const RENAME_ACTION_LABELS: Record<
},
};
// Copy for each ConflictPolicy, used in the confirmation modal's second segmented
// selector and the modal's conflict-behavior note.
const CONFLICT_POLICY_LABELS: Record<ConflictPolicy, { button: string; description: string }> = {
skip: {
button: "Skip",
description: "If a computed name already exists on disk, that file is left alone and skipped.",
},
autoNumber: {
button: "Auto-number",
description:
'If a computed name already exists on disk, the new one is saved alongside it as "Name (1).ext" instead (counting up until a free name is found).',
},
override: {
button: "Override",
description:
"If a computed name already exists on disk, it's replaced outright. This can't be undone — undoing this batch later can restore the source file, but not whatever got overwritten.",
},
};
function realFileToItem(entry: FileEntry): Item {
return {
id: entry.path,
@@ -372,6 +397,10 @@ export default function RenamePanel() {
// remembered across modal opens within the session (defaults to "move", matching
// Renamr's original and only behavior before copy/symlink existed).
const [renameAction, setRenameAction] = useState<RenameAction>("move");
// What to do when a computed target already exists - same remembered-across-opens
// pattern as renameAction, defaults to "skip" (Renamr's original and only behavior
// before auto-number/override existed).
const [conflictPolicy, setConflictPolicy] = useState<ConflictPolicy>("skip");
// TMDB token state for "Fetch Data". `null` = not checked yet, `false` = checked and
// nothing saved, `true` = a token is on file (the value itself never comes back to
// the frontend after saving - see Settings modal below).
@@ -707,6 +736,7 @@ export default function RenamePanel() {
const results = await invoke<RenameResult[]>("execute_rename", {
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
action: renameAction,
conflict: conflictPolicy,
});
const bySource = new Map(results.map((r) => [r.source, r] as const));
// Compute counts from `results` directly, not inside the setItems updater below:
@@ -1316,6 +1346,26 @@ export default function RenamePanel() {
</button>
))}
</div>
<span className="modal-field-label">On conflict</span>
<div
className="action-segmented"
role="radiogroup"
aria-label="What to do when the computed name already exists"
>
{(["skip", "autoNumber", "override"] as const).map((c) => (
<button
key={c}
type="button"
className={`action-segmented-option${conflictPolicy === c ? " selected" : ""}${
c === "override" ? " action-segmented-option-danger" : ""
}`}
onClick={() => setConflictPolicy(c)}
disabled={renaming}
>
{CONFLICT_POLICY_LABELS[c].button}
</button>
))}
</div>
<p className="modal-subtitle">
This will {RENAME_ACTION_LABELS[renameAction].verb} {renamePlan.length} file
{renamePlan.length === 1 ? "" : "s"} on disk right now. Review the list below before continuing — use
@@ -1355,8 +1405,8 @@ export default function RenamePanel() {
))
)}
</div>
<p className="modal-note">
If a computed name already exists on disk, that file is skipped rather than overwritten.
<p className={`modal-note${conflictPolicy === "override" ? " modal-note-danger" : ""}`}>
{CONFLICT_POLICY_LABELS[conflictPolicy].description}
</p>
<div className="modal-actions">
<button className="action-button" onClick={cancelRename} disabled={renaming}>