From 7dd9ad2078c81c7b148e1453d7e562ec11edb4f6 Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 09:50:36 +0000 Subject: [PATCH] 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. --- README.md | 60 +++-- crates/renamr-core/src/rename/mod.rs | 368 ++++++++++++++++++++++++--- src-tauri/src/lib.rs | 25 +- src/components/RenamePanel.css | 13 + src/components/RenamePanel.tsx | 54 +++- 5 files changed, 449 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index 82b5d53..7a0d82a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crates/renamr-core/src/rename/mod.rs b/crates/renamr-core/src/rename/mod.rs index 3e4c8e6..362fa20 100644 --- a/crates/renamr-core/src/rename/mod.rs +++ b/crates/renamr-core/src/rename/mod.rs @@ -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 `" (N)."` (or `" (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 { + 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 { + 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 { +/// 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 { 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(); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 03d2ecd..e29ef61 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { } /// 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, action: RenameAction) -> Vec { - let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items, action)) +async fn execute_rename( + app: AppHandle, + items: Vec, + action: RenameAction, + conflict: ConflictPolicy, +) -> Vec { + let results = tauri::async_runtime::spawn_blocking(move || rename::execute_renames(&items, action, conflict)) .await .unwrap_or_default(); history::record_batch(&app, &results, action); diff --git a/src/components/RenamePanel.css b/src/components/RenamePanel.css index 9a7ec86..1a55c9c 100644 --- a/src/components/RenamePanel.css +++ b/src/components/RenamePanel.css @@ -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; diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index 87f29ed..b5788ab 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -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 = { + 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("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("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("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() { ))} + On conflict +
+ {(["skip", "autoNumber", "override"] as const).map((c) => ( + + ))} +

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() { )) )} -

- If a computed name already exists on disk, that file is skipped rather than overwritten. +

+ {CONFLICT_POLICY_LABELS[conflictPolicy].description}