Add rename history with undo

- New crates/renamr-core/src/history module: HistoryBatch/HistoryEntry
  records, revert_batch() moves files back to their original paths
  (skip-on-conflict, same policy as rename::execute_renames; recreates a
  deleted destination folder defensively), push_batch() maintains a
  most-recent-first log capped at 50 batches. A partial port of the idea
  behind net.filebot.HistoryDialog, as a flat undo stack rather than a full
  session/element log. 6 new tests.
- New src-tauri/src/history.rs: persists the log as history.json in the OS
  app-config directory (same location as settings.json). execute_rename now
  takes an AppHandle and calls history::record_batch after every rename,
  recording only the items that actually got renamed. Two new commands:
  list_rename_history and undo_rename_batch.
- Frontend: the History button (previously a no-op) now opens a panel
  listing past rename batches with a timestamp, file count, and the
  old-name/new-name pairs, each with an Undo button. Undoing reconciles any
  currently-loaded item back to its pre-rename path/name, same pattern as
  post-rename reconciliation.
- Verified via cargo test (45 total)/check + npm build + a full Playwright
  pass: loaded mocked files, renamed them, opened History, undid the batch,
  and confirmed both the UI and the (mocked) backend state matched at every
  step.
This commit is contained in:
Renamr Scaffold
2026-08-29 03:44:38 +00:00
parent 658a235e05
commit 93b5ac5e56
7 changed files with 643 additions and 28 deletions
+289
View File
@@ -0,0 +1,289 @@
//! Rename history: a persisted log of past rename batches (what changed, from what to
//! what) and the ability to revert one - Renamr's only safety net beyond the
//! confirmation-before-execute step, since a plain filesystem rename has no OS-level
//! undo. Loosely inspired by `net.filebot.HistoryDialog`/the original's rename-history
//! log, but as a flat JSON-backed undo stack (see [`push_batch`]) rather than a full
//! session/element log - simpler, and all this app actually needs.
//!
//! This module only implements the *logic* (recording, trimming, reverting); where the
//! log is persisted to disk is a Tauri-layer concern (`src-tauri/src/history.rs`), same
//! split as `rename` (pure logic here) vs. the Tauri command that calls it.
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
/// One file's old path -> new path, as recorded right after a successful rename.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryEntry {
pub source: String,
pub new_path: String,
}
/// One executed rename operation - everything renamed together in a single "Rename N
/// Files" confirmation - undoable as a unit.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HistoryBatch {
pub id: String,
/// Unix epoch milliseconds when the batch was executed. Kept as a plain number
/// (no date/time dependency needed in this crate) so the frontend can format it
/// however it likes, e.g. `new Date(timestamp).toLocaleString()`.
pub timestamp: u64,
pub entries: Vec<HistoryEntry>,
}
/// What happened when reverting one entry of a batch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum RevertOutcome {
/// Successfully moved back to its original path.
Reverted { restored_path: String },
/// A different file already exists at the original path; left untouched - the same
/// skip-never-overwrite policy `rename::execute_renames` uses.
SkippedConflict { existing_path: String },
/// The renamed file isn't where the history says it should be anymore (moved,
/// deleted, or already reverted some other way), or the OS reported an error.
Failed { error: String },
}
#[derive(Debug, Clone, Serialize)]
pub struct RevertResult {
/// The entry's `new_path` (i.e. where the file was expected to be found, before
/// reverting) - lets the caller map a result back to a specific history entry.
pub source: String,
pub outcome: RevertOutcome,
}
fn same_file(a: &Path, b: &Path) -> bool {
match (fs::canonicalize(a), fs::canonicalize(b)) {
(Ok(pa), Ok(pb)) => pa == pb,
_ => false,
}
}
fn revert_one(entry: &HistoryEntry) -> RevertOutcome {
let current = Path::new(&entry.new_path);
let original = Path::new(&entry.source);
if !current.exists() {
return RevertOutcome::Failed {
error: "the renamed file is no longer at its recorded location".to_string(),
};
}
if original.exists() && !same_file(original, current) {
return RevertOutcome::SkippedConflict {
existing_path: original.to_string_lossy().to_string(),
};
}
// The original's parent directory should still exist (nothing in Renamr deletes
// directories), but recreate it defensively in case it was removed by hand since -
// cheap, and `create_dir_all` is a no-op when it's already there.
if let Some(parent) = original.parent() {
if let Err(e) = fs::create_dir_all(parent) {
return RevertOutcome::Failed {
error: format!("couldn't recreate the original folder: {e}"),
};
}
}
match fs::rename(current, original) {
Ok(()) => RevertOutcome::Reverted {
restored_path: original.to_string_lossy().to_string(),
},
Err(e) => RevertOutcome::Failed { error: e.to_string() },
}
}
/// Reverts every entry in a batch, in order. Like [`crate::rename::execute_renames`],
/// entries are processed sequentially (not in parallel) so a collision between two
/// entries in the same batch resolves deterministically rather than racing.
pub fn revert_batch(batch: &HistoryBatch) -> Vec<RevertResult> {
batch
.entries
.iter()
.map(|entry| RevertResult {
source: entry.new_path.clone(),
outcome: revert_one(entry),
})
.collect()
}
/// Maximum number of batches kept in the persisted history - once the log grows past
/// this, the oldest batches are dropped so the history file can't grow unbounded over
/// a long-lived install.
pub const MAX_HISTORY_BATCHES: usize = 50;
/// Adds a new batch to the front of `history` (most-recent-first) and trims the log
/// back down to [`MAX_HISTORY_BATCHES`]. A batch with no entries is not recorded at
/// all - a rename operation where every item was unchanged/skipped/failed has nothing
/// to undo, so it shouldn't clutter the history.
pub fn push_batch(history: &mut Vec<HistoryBatch>, batch: HistoryBatch) {
if batch.entries.is_empty() {
return;
}
history.insert(0, batch);
history.truncate(MAX_HISTORY_BATCHES);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_dir() -> std::path::PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("renamr_history_test_{}_{}", std::process::id(), n));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reverts_a_renamed_file_back_to_its_original_path() {
let dir = temp_dir();
let original = dir.join("Breaking.Bad.S05E14.mkv");
let renamed = dir.join("Ozymandias.mkv");
fs::write(&renamed, "video").unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
match &results[0].outcome {
RevertOutcome::Reverted { restored_path } => {
assert_eq!(Path::new(restored_path), original.as_path());
assert!(original.exists());
assert!(!renamed.exists());
}
other => panic!("expected Reverted, got {other:?}"),
}
fs::remove_dir_all(&dir).ok();
}
#[test]
fn reverting_into_a_subfolder_target_recreates_a_deleted_original_folder() {
let dir = temp_dir();
let sub = dir.join("Breaking Bad");
fs::create_dir_all(&sub).unwrap();
let original = sub.join("Ozymandias.mkv");
let renamed = dir.join("flat.mkv");
fs::write(&renamed, "video").unwrap();
// Simulate the organized-into subfolder having been removed since the rename.
fs::remove_dir_all(&sub).unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(results[0].outcome, RevertOutcome::Reverted { .. }));
assert!(original.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn skips_revert_when_something_else_now_occupies_the_original_path() {
let dir = temp_dir();
let original = dir.join("original.mkv");
fs::write(&original, "a new unrelated file").unwrap();
let renamed = dir.join("renamed.mkv");
fs::write(&renamed, "video").unwrap();
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(
results[0].outcome,
RevertOutcome::SkippedConflict { .. }
));
// Neither file should have been touched.
assert_eq!(fs::read_to_string(&original).unwrap(), "a new unrelated file");
assert_eq!(fs::read_to_string(&renamed).unwrap(), "video");
fs::remove_dir_all(&dir).ok();
}
#[test]
fn fails_gracefully_when_the_renamed_file_is_no_longer_there() {
let dir = temp_dir();
let original = dir.join("original.mkv");
let renamed = dir.join("renamed.mkv"); // never created
let batch = HistoryBatch {
id: "b1".to_string(),
timestamp: 0,
entries: vec![HistoryEntry {
source: original.to_string_lossy().to_string(),
new_path: renamed.to_string_lossy().to_string(),
}],
};
let results = revert_batch(&batch);
assert!(matches!(results[0].outcome, RevertOutcome::Failed { .. }));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn push_batch_ignores_empty_batches() {
let mut history = Vec::new();
push_batch(
&mut history,
HistoryBatch {
id: "empty".to_string(),
timestamp: 0,
entries: vec![],
},
);
assert!(history.is_empty());
}
#[test]
fn push_batch_orders_most_recent_first_and_trims_to_the_cap() {
let mut history = Vec::new();
for i in 0..(MAX_HISTORY_BATCHES + 5) {
push_batch(
&mut history,
HistoryBatch {
id: format!("batch-{i}"),
timestamp: i as u64,
entries: vec![HistoryEntry {
source: "a".to_string(),
new_path: "b".to_string(),
}],
},
);
}
assert_eq!(history.len(), MAX_HISTORY_BATCHES);
// Most recently pushed batch is first.
assert_eq!(history[0].id, format!("batch-{}", MAX_HISTORY_BATCHES + 4));
// The oldest batches (0..5) were dropped to stay under the cap.
assert!(!history.iter().any(|b| b.id == "batch-0"));
}
}
+1
View File
@@ -6,6 +6,7 @@
//! parsing, web datasource clients, rename engine are not yet ported).
pub mod format;
pub mod history;
pub mod matching;
pub mod rename;
pub mod similarity;