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 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 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 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 just take TMDB's top-ranked hit — and the actual on-disk operation is wired up (with a
(skip-on-conflict, with a confirmation step showing the full old-path/new-path plan confirmation step showing the full old-path/new-path plan before anything touches disk,
before anything touches disk, a per-row ✕ to drop an individual file from that plan at a per-row ✕ to drop an individual file from that plan at the last step, a Move/Copy/
the last step, and a Move/Copy/Symlink selector for what to actually do at each Symlink selector for what to actually do at each target, and — independently — a Skip/
target). It can also organize files into subfolders — including ones that don't exist Auto-number/Override selector for what happens when the computed name already exists:
yet — when the naming scheme includes a `/`. Every completed batch is logged to a leave it alone (the original, and still default, behavior), save alongside it as
history panel that can undo it (moving files back for Move, or just removing what was "Name (1).ext", or replace it outright). It can also organize files into subfolders —
created for Copy/Symlink). A single loaded batch can freely mix TV episodes and movies 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 — 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/…), text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…),
video/audio codec, release group — so naming schemes can reference 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 component is validated (`validate_relative_target`) to reject empty segments (a
leading, trailing, or doubled `/` — which also catches an absolute-looking leading, trailing, or doubled `/` — which also catches an absolute-looking
`/etc/passwd`-style path), `.`/`..` segments, and backslashes (Renamr always uses `/` `/etc/passwd`-style path), `.`/`..` segments, and backslashes (Renamr always uses `/`
for the naming scheme's folder separator regardless of OS). Conflict policy is for the naming scheme's folder separator regardless of OS). If the resolved target
unchanged: if the resolved target already exists and isn't the same underlying file, already exists and isn't the same underlying file, what happens next is up to the
that item is skipped rather than overwritten. 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 ## Project layout
@@ -127,8 +133,9 @@ crates/renamr-core/ Pure Rust core: naming-expression engine (ported), local
search + episode lookup search + episode lookup
and movie search + lookup, both disambiguated via `similarity/`, and movie search + lookup, both disambiguated via `similarity/`,
tested against a mocked HTTP server via `mockito`), the on-disk tested against a mocked HTTP server via `mockito`), the on-disk
rename engine (`rename/` — skip-on-conflict, Move/Copy/Symlink rename engine (`rename/` — Move/Copy/Symlink actions,
actions, can also organize into (auto-created) subfolders when Skip/Auto-number/Override conflict policies, can also organize
into (auto-created) subfolders when
the naming scheme includes `/`) — and a rename history/undo log the naming scheme includes `/`) — and a rename history/undo log
(`history/`, a partial `HistoryDialog` port, undo-aware of which (`history/`, a partial `HistoryDialog` port, undo-aware of which
action produced each batch). Not yet ported: full media metadata 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 6. **Rename engine + CLI** — `net.filebot.cli.CmdlineOperations` (1235 lines) and
`ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`, `ArgumentBean` (415 lines, the full flag surface: `-rename`, `--db`, `--format`,
`--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is `--action`, `--conflict`, `-get-subtitles`, `-script`, etc.). The rename operation is
done (`crates/renamr-core/src/rename`, fixed skip-on-conflict policy, wired to the done (`crates/renamr-core/src/rename`, wired to the UI's Rename button behind a
UI's Rename button behind a confirmation step) including the `move`-into-subfolder confirmation step) including the `move`-into-subfolder `--action` variant — a naming
`--action` variant — a naming scheme like `{n}/{s00e00}` organizes files into scheme like `{n}/{s00e00}` organizes files into subfolders, creating any missing
subfolders, creating any missing folders automatically, always scoped to the file's folders automatically, always scoped to the file's own directory tree. The
own directory tree. The `copy`/`symlink` `--action` variants are done too — an action `copy`/`symlink` `--action` variants are done too — an action selector in the
selector in the confirmation modal picks Move/Copy/Symlink for the whole batch; Copy confirmation modal picks Move/Copy/Symlink for the whole batch; Copy and Symlink both
and Symlink both leave the source file untouched and produce a new file/link at the leave the source file untouched and produce a new file/link at the computed target,
computed target, and both are undoable from the History panel the same as Move (undo and both are undoable from the History panel the same as Move (undo for Copy/Symlink
for Copy/Symlink just removes what was created, since there's nothing to move back). just removes what was created, since there's nothing to move back). The `--conflict`
Not yet done: the `--conflict` variants beyond skip (auto-number, override), and the variants are done too — a second selector in the same modal picks Skip (default,
CLI flag surface itself. 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, 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, 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 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 //! 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 //! 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 //! 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 //! intermediate directories are created automatically. What happens when the computed
//! **skip**: if the computed target already exists and isn't the same underlying file, //! target already exists is chosen per batch via [`ConflictPolicy`] - skip (the
//! that item is left alone and reported as skipped rather than overwritten. The UI is //! original, and still default, behavior), auto-number, or override - a port of
//! expected to show the caller a confirmation of the full plan (and let them choose a //! `net.filebot.cli.CmdlineOperations`'s `--conflict` flag. The UI is expected to show
//! [`RenameAction`]) before calling [`execute_renames`]; every batch that action //! the caller a confirmation of the full plan (and let them choose both a
//! actually touches a file for is also recorded to the rename-history log one layer up //! [`RenameAction`] and a [`ConflictPolicy`]) before calling [`execute_renames`]; every
//! (`renamr_core::history`), so it can be undone from there. //! 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 serde::{Deserialize, Serialize};
use std::fs; 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 /// 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 - /// 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. /// What happened to one planned rename.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")] #[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) { if let Err(reason) = validate_relative_target(new_name) {
return RenameOutcome::Failed { return RenameOutcome::Failed {
error: format!("\"{new_name}\" is not a valid target: {reason}"), 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; return RenameOutcome::Unchanged;
} }
if target.exists() && !same_file(&target, source) { let target = if target.exists() && !same_file(&target, source) {
return RenameOutcome::SkippedConflict { match resolve_conflict(&target, action, conflict) {
existing_path: target.to_string_lossy().to_string(), Ok(resolved) => resolved,
}; Err(outcome) => return outcome,
} }
} else {
target
};
// `new_name` may include subdirectory components (organizing files into folders) - // `new_name` may include subdirectory components (organizing files into folders) -
// make sure the destination directory tree exists before attempting the actual // 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`]. /// 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 /// and [`ConflictPolicy`]. Items are processed sequentially (not in parallel) so that
/// names collide with each other resolve the same way an on-disk collision would: the /// two items whose computed names collide with each other resolve the same way an
/// first one through wins, the second is reported as a skipped conflict rather than /// on-disk collision would: under Skip, the first one through wins and the second is
/// silently overwriting it. /// reported as a skipped conflict; under AutoNumber, the second gets bumped to " (1)";
pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction) -> Vec<RenameResult> { /// under Override, the second replaces what the first just wrote.
pub fn execute_renames(items: &[RenamePlanItem], action: RenameAction, conflict: ConflictPolicy) -> Vec<RenameResult> {
items items
.iter() .iter()
.map(|item| RenameResult { .map(|item| RenameResult {
source: item.source.clone(), 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() .collect()
} }
@@ -235,7 +337,7 @@ mod tests {
new_name: "new.txt".to_string(), 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); assert_eq!(results.len(), 1);
match &results[0].outcome { match &results[0].outcome {
RenameOutcome::Renamed { new_path } => { RenameOutcome::Renamed { new_path } => {
@@ -257,7 +359,7 @@ mod tests {
new_name: "same.txt".to_string(), 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_eq!(results[0].outcome, RenameOutcome::Unchanged);
assert!(file.exists()); assert!(file.exists());
@@ -274,7 +376,7 @@ mod tests {
new_name: "b.txt".to_string(), 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 { match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => { RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt")); 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[0].outcome, RenameOutcome::Renamed { .. }));
assert!(matches!( assert!(matches!(
results[1].outcome, results[1].outcome,
@@ -325,7 +427,7 @@ mod tests {
new_name: "../escaped.txt".to_string(), 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!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists()); assert!(file.exists());
@@ -340,7 +442,7 @@ mod tests {
new_name: "Breaking Bad/Ozymandias.mkv".to_string(), 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 { match &results[0].outcome {
RenameOutcome::Renamed { new_path } => { RenameOutcome::Renamed { new_path } => {
let expected = dir.join("Breaking Bad").join("Ozymandias.mkv"); 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(), 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"); let expected = dir.join("Breaking Bad").join("Season 05").join("S05E14.mkv");
match &results[0].outcome { match &results[0].outcome {
RenameOutcome::Renamed { new_path } => { RenameOutcome::Renamed { new_path } => {
@@ -386,7 +488,7 @@ mod tests {
new_name: "Sub/b.txt".to_string(), 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!( assert!(matches!(
results[0].outcome, results[0].outcome,
RenameOutcome::SkippedConflict { .. } RenameOutcome::SkippedConflict { .. }
@@ -405,7 +507,7 @@ mod tests {
new_name: "Sub\\b.txt".to_string(), 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!(matches!(results[0].outcome, RenameOutcome::Failed { .. }));
assert!(file.exists()); assert!(file.exists());
@@ -421,7 +523,7 @@ mod tests {
source: file.to_string_lossy().to_string(), source: file.to_string_lossy().to_string(),
new_name: bad_name.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!( assert!(
matches!(results[0].outcome, RenameOutcome::Failed { .. }), matches!(results[0].outcome, RenameOutcome::Failed { .. }),
"expected {bad_name:?} to be rejected" "expected {bad_name:?} to be rejected"
@@ -440,7 +542,7 @@ mod tests {
new_name: "copied.txt".to_string(), 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 { match &results[0].outcome {
RenameOutcome::Renamed { new_path } => { RenameOutcome::Renamed { new_path } => {
assert!(new_path.ends_with("copied.txt")); assert!(new_path.ends_with("copied.txt"));
@@ -464,7 +566,7 @@ mod tests {
new_name: "Sub/copied.txt".to_string(), 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"); let expected = dir.join("Sub").join("copied.txt");
match &results[0].outcome { match &results[0].outcome {
RenameOutcome::Renamed { new_path } => { RenameOutcome::Renamed { new_path } => {
@@ -487,7 +589,7 @@ mod tests {
new_name: "linked.txt".to_string(), 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 { match &results[0].outcome {
RenameOutcome::Renamed { new_path } => { RenameOutcome::Renamed { new_path } => {
let link = Path::new(new_path); let link = Path::new(new_path);
@@ -515,7 +617,7 @@ mod tests {
source: file.to_string_lossy().to_string(), source: file.to_string_lossy().to_string(),
new_name: "b.txt".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 { match &results[0].outcome {
RenameOutcome::SkippedConflict { existing_path } => { RenameOutcome::SkippedConflict { existing_path } => {
assert!(existing_path.ends_with("b.txt")); assert!(existing_path.ends_with("b.txt"));
@@ -539,7 +641,7 @@ mod tests {
source: file.to_string_lossy().to_string(), source: file.to_string_lossy().to_string(),
new_name: "same.txt".to_string(), new_name: "same.txt".to_string(),
}]; }];
let results = execute_renames(&items, action); let results = execute_renames(&items, action, ConflictPolicy::Skip);
assert_eq!( assert_eq!(
results[0].outcome, results[0].outcome,
RenameOutcome::Unchanged, RenameOutcome::Unchanged,
@@ -550,4 +652,198 @@ mod tests {
fs::remove_dir_all(&dir).ok(); 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::format::ExpressionFormat;
use renamr_core::matching::{self, EpisodeGuess, MovieGuess, ReleaseInfo}; 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 renamr_core::string_bindings;
use std::collections::HashMap; use std::collections::HashMap;
use tauri::AppHandle; 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 /// Executes a batch of planned on-disk rename/copy/symlink operations (optionally
/// moving/copying files into subfolders - see `renamr_core::rename`; conflicting /// moving/copying files into subfolders - see `renamr_core::rename`). `action` picks
/// targets are always skipped, never overwritten). `action` picks which of the three /// which of the three happens; `conflict` picks what happens when a computed target
/// happens - the UI is expected to have already shown the user the full old-path/ /// already exists (skip it, save alongside it with an auto-numbered name, or replace
/// new-path plan, the chosen action, and gotten explicit confirmation before calling /// it) - the UI is expected to have already shown the user the full old-path/new-path
/// this. Runs off the main thread since real filesystem I/O shouldn't block the UI. /// 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 /// 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 /// (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 /// 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] #[tauri::command]
async fn execute_rename(app: AppHandle, items: Vec<RenamePlanItem>, action: RenameAction) -> Vec<RenameResult> { async fn execute_rename(
let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items, action)) 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 .await
.unwrap_or_default(); .unwrap_or_default();
history::record_batch(&app, &results, action); history::record_batch(&app, &results, action);
+13
View File
@@ -497,6 +497,19 @@
pointer-events: none; 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 { .modal-field-label {
display: block; display: block;
font-size: 11px; font-size: 11px;
+52 -2
View File
@@ -98,6 +98,12 @@ interface RenameResult {
// confirmRename below). // confirmRename below).
type RenameAction = "move" | "copy" | "symlink"; 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 // 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 // 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 // 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 { function realFileToItem(entry: FileEntry): Item {
return { return {
id: entry.path, id: entry.path,
@@ -372,6 +397,10 @@ export default function RenamePanel() {
// remembered across modal opens within the session (defaults to "move", matching // remembered across modal opens within the session (defaults to "move", matching
// Renamr's original and only behavior before copy/symlink existed). // Renamr's original and only behavior before copy/symlink existed).
const [renameAction, setRenameAction] = useState<RenameAction>("move"); 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 // 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 // nothing saved, `true` = a token is on file (the value itself never comes back to
// the frontend after saving - see Settings modal below). // the frontend after saving - see Settings modal below).
@@ -707,6 +736,7 @@ export default function RenamePanel() {
const results = await invoke<RenameResult[]>("execute_rename", { const results = await invoke<RenameResult[]>("execute_rename", {
items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })), items: renamePlan.map((p) => ({ source: p.source, newName: p.newName })),
action: renameAction, action: renameAction,
conflict: conflictPolicy,
}); });
const bySource = new Map(results.map((r) => [r.source, r] as const)); const bySource = new Map(results.map((r) => [r.source, r] as const));
// Compute counts from `results` directly, not inside the setItems updater below: // Compute counts from `results` directly, not inside the setItems updater below:
@@ -1316,6 +1346,26 @@ export default function RenamePanel() {
</button> </button>
))} ))}
</div> </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"> <p className="modal-subtitle">
This will {RENAME_ACTION_LABELS[renameAction].verb} {renamePlan.length} file 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 {renamePlan.length === 1 ? "" : "s"} on disk right now. Review the list below before continuing — use
@@ -1355,8 +1405,8 @@ export default function RenamePanel() {
)) ))
)} )}
</div> </div>
<p className="modal-note"> <p className={`modal-note${conflictPolicy === "override" ? " modal-note-danger" : ""}`}>
If a computed name already exists on disk, that file is skipped rather than overwritten. {CONFLICT_POLICY_LABELS[conflictPolicy].description}
</p> </p>
<div className="modal-actions"> <div className="modal-actions">
<button className="action-button" onClick={cancelRename} disabled={renaming}> <button className="action-button" onClick={cancelRename} disabled={renaming}>