use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, File};
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub const BATCH_RENAME_LIMIT: usize = 1_000;
const NAME_BYTES_LIMIT: usize = 255;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RenameMode {
#[default]
Replace,
AddText,
Number,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RenameOptions {
pub mode: RenameMode,
pub find: String,
pub replacement: String,
pub prefix: String,
pub suffix: String,
pub base: String,
pub start: u32,
pub digits: usize,
pub keep_extension: bool,
}
impl Default for RenameOptions {
fn default() -> Self {
Self {
mode: RenameMode::Replace,
find: String::new(),
replacement: String::new(),
prefix: String::new(),
suffix: String::new(),
base: "File".to_string(),
start: 1,
digits: 3,
keep_extension: true,
}
}
}
impl RenameOptions {
fn new_name(&self, name: &str, is_directory: bool, index: usize) -> Result<String, String> {
if [
&self.find,
&self.replacement,
&self.prefix,
&self.suffix,
&self.base,
]
.iter()
.any(|value| value.len() > NAME_BYTES_LIMIT)
{
return Err("Naming fields must be at most 255 bytes".to_string());
}
let (stem, extension) = name_parts(name, self.keep_extension && !is_directory);
let transformed = match self.mode {
RenameMode::Replace if self.find.is_empty() => stem.to_string(),
RenameMode::Replace => {
let matches = stem.matches(&self.find).count();
let length = stem.len() - matches * self.find.len()
+ matches * self.replacement.len()
+ extension.len();
if length > NAME_BYTES_LIMIT {
return Err("The new name exceeds 255 bytes".to_string());
}
stem.replace(&self.find, &self.replacement)
}
RenameMode::AddText => format!("{}{stem}{}", self.prefix, self.suffix),
RenameMode::Number => {
let number = u64::from(self.start) + index as u64;
format!(
"{} {number:0width$}",
self.base,
width = self.digits.clamp(1, 9)
)
}
};
let new_name = format!("{transformed}{extension}");
validate_name(&new_name)?;
Ok(new_name)
}
}
fn name_parts(name: &str, keep_extension: bool) -> (&str, &str) {
if !keep_extension {
return (name, "");
}
let lower = name.to_ascii_lowercase();
for suffix in [
".tar.gz",
".tar.bz2",
".tar.xz",
".tar.zst",
".tar.lz",
".tar.lzma",
] {
if lower.ends_with(suffix) && name.len() > suffix.len() {
return name.split_at(name.len() - suffix.len());
}
}
match name.rfind('.') {
Some(index) if index > 0 => name.split_at(index),
_ => (name, ""),
}
}
fn validate_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() || name == "." || name == ".." {
return Err("Enter a nonempty name other than . or ..".to_string());
}
if name.contains('/') || name.chars().any(char::is_control) {
return Err("Names cannot contain / or control characters".to_string());
}
if name.len() > NAME_BYTES_LIMIT {
return Err("The new name exceeds 255 bytes".to_string());
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct RenameRow {
pub original: PathBuf,
pub new_name: String,
pub changed: bool,
pub error: Option<String>,
}
#[derive(Clone, Debug)]
pub struct RenamePreview {
pub rows: Vec<RenameRow>,
pub plan: RenamePlan,
}
impl RenamePreview {
pub fn conflicts(&self) -> usize {
self.rows.iter().filter(|row| row.error.is_some()).count()
}
pub fn ready(&self) -> bool {
self.conflicts() == 0 && !self.plan.is_empty()
}
}
#[derive(Clone, Debug)]
struct RenameItem {
from: PathBuf,
to: PathBuf,
directory: Arc<File>,
source: Arc<File>,
}
#[derive(Clone, Debug, Default)]
pub struct RenamePlan {
items: Vec<RenameItem>,
}
impl RenamePlan {
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn destinations(&self) -> Vec<PathBuf> {
self.items.iter().map(|item| item.to.clone()).collect()
}
pub fn reversed(&self) -> Self {
Self {
items: self
.items
.iter()
.rev()
.map(|item| RenameItem {
from: item.to.clone(),
to: item.from.clone(),
directory: Arc::clone(&item.directory),
source: Arc::clone(&item.source),
})
.collect(),
}
}
}
#[derive(Clone, Debug)]
pub struct RenameResult {
pub completed: RenamePlan,
pub remaining: RenamePlan,
pub warning: Option<String>,
}
pub fn preview(paths: &[PathBuf], options: &RenameOptions) -> Result<RenamePreview, String> {
if paths.is_empty() || paths.len() > BATCH_RENAME_LIMIT {
return Err(format!(
"Select between 1 and {BATCH_RENAME_LIMIT} items to rename"
));
}
let mut directories = BTreeMap::<PathBuf, Arc<File>>::new();
let mut identities = BTreeSet::new();
let mut rows = Vec::with_capacity(paths.len());
let mut candidates = Vec::with_capacity(paths.len());
for (index, path) in paths.iter().enumerate() {
let mut row = RenameRow {
original: path.clone(),
new_name: String::new(),
changed: false,
error: None,
};
let candidate = (|| {
let parent = path
.parent()
.ok_or("The selected item has no parent folder")?;
let parent = fs::canonicalize(parent).map_err(|error| error.to_string())?;
let name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or("This name is not valid Unicode; rename it individually")?;
let from = parent.join(name);
if !identities.insert(from.clone()) {
return Err("The same item appears more than once".to_string());
}
let directory = if let Some(directory) = directories.get(&parent) {
Arc::clone(directory)
} else {
let directory =
Arc::new(open_directory(&parent).map_err(|error| error.to_string())?);
directories.insert(parent, Arc::clone(&directory));
directory
};
let source =
Arc::new(open_source(&directory, name).map_err(|error| error.to_string())?);
let metadata = source.metadata().map_err(|error| error.to_string())?;
row.new_name = options.new_name(name, metadata.is_dir(), index)?;
row.changed = row.new_name != name;
let to = from.with_file_name(&row.new_name);
Ok(RenameItem {
from,
to,
directory,
source,
})
})();
match candidate {
Ok(item) => candidates.push(Some(item)),
Err(error) => {
row.error = Some(error);
candidates.push(None);
}
}
rows.push(row);
}
let mut destinations = BTreeMap::<PathBuf, Vec<usize>>::new();
for (index, candidate) in candidates.iter().enumerate() {
if let Some(item) = candidate {
destinations.entry(item.to.clone()).or_default().push(index);
if item
.from
.ancestors()
.skip(1)
.any(|parent| identities.contains(parent))
{
rows[index].error = Some("Select a folder or its children, not both".to_string());
} else if rows[index].changed {
if let Err(error) = validate_item(item) {
rows[index].error = Some(error);
}
}
}
}
for indexes in destinations.values().filter(|indexes| indexes.len() > 1) {
for &index in indexes {
rows[index].error = Some("Another selected item would have this name".to_string());
}
}
let plan = RenamePlan {
items: if rows.iter().any(|row| row.error.is_some()) {
Vec::new()
} else {
candidates
.into_iter()
.enumerate()
.filter_map(|(i, item)| {
(rows[i].changed && rows[i].error.is_none())
.then_some(item)
.flatten()
})
.collect()
},
};
Ok(RenamePreview { rows, plan })
}
pub fn execute(plan: RenamePlan, mut progress: impl FnMut(usize, &Path)) -> RenameResult {
let mut completed = RenamePlan::default();
if let Some(error) = plan.items.iter().find_map(|item| {
validate_item(item)
.err()
.map(|error| format!("{}: {error}", item.from.display()))
}) {
return RenameResult {
completed,
remaining: plan,
warning: Some(error),
};
}
let mut pending = plan.items.into_iter();
while let Some(item) = pending.next() {
if let Err(error) = validate_item(&item).and_then(|()| rename_item(&item)) {
let warning = format!("{}: {error}", item.from.display());
return RenameResult {
completed,
remaining: RenamePlan {
items: std::iter::once(item).chain(pending).collect(),
},
warning: Some(warning),
};
}
completed.items.push(item);
progress(completed.len(), &completed.items.last().unwrap().to);
}
RenameResult {
completed,
remaining: RenamePlan::default(),
warning: None,
}
}
#[cfg(target_os = "linux")]
fn open_directory(path: &Path) -> std::io::Result<File> {
rustix::fs::open(
path,
rustix::fs::OFlags::PATH | rustix::fs::OFlags::DIRECTORY | rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)
.map(File::from)
.map_err(Into::into)
}
#[cfg(target_os = "linux")]
fn open_source(directory: &File, name: &str) -> std::io::Result<File> {
rustix::fs::openat(
directory,
name,
rustix::fs::OFlags::PATH | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)
.map(File::from)
.map_err(Into::into)
}
#[cfg(target_os = "linux")]
fn validate_item(item: &RenameItem) -> Result<(), String> {
use std::os::unix::fs::MetadataExt;
let parent = item
.from
.parent()
.ok_or("The source has no parent folder")?;
let pinned = item
.directory
.metadata()
.map_err(|error| error.to_string())?;
let current = fs::metadata(parent).map_err(|error| error.to_string())?;
if pinned.dev() != current.dev() || pinned.ino() != current.ino() {
return Err("The parent folder changed since the preview; reopen Rename".to_string());
}
let pinned = item.source.metadata().map_err(|error| error.to_string())?;
let current = rustix::fs::statat(
&*item.directory,
item.from.file_name().unwrap(),
rustix::fs::AtFlags::SYMLINK_NOFOLLOW,
)
.map_err(|error| error.to_string())?;
if pinned.dev() != current.st_dev || pinned.ino() != current.st_ino {
return Err("The selected item was replaced since the preview; reopen Rename".to_string());
}
match rustix::fs::statat(
&*item.directory,
item.to.file_name().unwrap(),
rustix::fs::AtFlags::SYMLINK_NOFOLLOW,
) {
Err(rustix::io::Errno::NOENT) => Ok(()),
Ok(_) => Err(format!(
"{} already exists",
item.to.file_name().unwrap().to_string_lossy()
)),
Err(error) => Err(format!("Cannot check the destination: {error}")),
}
}
#[cfg(target_os = "linux")]
fn rename_item(item: &RenameItem) -> Result<(), String> {
rustix::fs::renameat_with(
&*item.directory,
item.from.file_name().unwrap(),
&*item.directory,
item.to.file_name().unwrap(),
rustix::fs::RenameFlags::NOREPLACE,
)
.map_err(|error| error.to_string())
}
#[cfg(not(target_os = "linux"))]
fn open_directory(_path: &Path) -> std::io::Result<File> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Batch rename requires Linux",
))
}
#[cfg(not(target_os = "linux"))]
fn open_source(_directory: &File, _name: &str) -> std::io::Result<File> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"Batch rename requires Linux",
))
}
#[cfg(not(target_os = "linux"))]
fn validate_item(_item: &RenameItem) -> Result<(), String> {
Err("Batch rename requires Linux".to_string())
}
#[cfg(not(target_os = "linux"))]
fn rename_item(_item: &RenameItem) -> Result<(), String> {
Err("Batch rename requires Linux".to_string())
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"guth-batch-rename-{}-{}-{}",
std::process::id(),
NEXT.fetch_add(1, Ordering::Relaxed),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir(&root).unwrap();
Self(root)
}
fn file(&self, name: &str) -> PathBuf {
let path = self.0.join(name);
fs::write(&path, name.as_bytes()).unwrap();
path
}
}
impl Drop for Fixture {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).unwrap();
}
}
fn add_prefix() -> RenameOptions {
RenameOptions {
mode: RenameMode::AddText,
prefix: "new-".to_string(),
..Default::default()
}
}
#[test]
fn naming_modes_preserve_extensions_and_exact_unicode() {
let mut options = RenameOptions {
find: "夏".to_string(),
replacement: "冬".to_string(),
..Default::default()
};
assert_eq!(
options.new_name("夏 夏.TAR.GZ", false, 0).unwrap(),
"冬 冬.TAR.GZ"
);
assert_eq!(
options.new_name("summer.夏", false, 0).unwrap(),
"summer.夏"
);
assert_eq!(options.new_name("summer.夏", true, 0).unwrap(), "summer.冬");
options = add_prefix();
options.suffix = "-final".to_string();
assert_eq!(
options.new_name("photo.jpg", false, 0).unwrap(),
"new-photo-final.jpg"
);
assert_eq!(
options.new_name(".gitignore", false, 0).unwrap(),
"new-.gitignore-final"
);
assert_eq!(
options.new_name("folder.with.dots", true, 0).unwrap(),
"new-folder.with.dots-final"
);
options.mode = RenameMode::Number;
options.base = "Trip".to_string();
options.start = 9;
assert_eq!(
options.new_name("first.tar.gz", false, 1).unwrap(),
"Trip 010.tar.gz"
);
options.keep_extension = false;
assert_eq!(options.new_name("first.txt", false, 0).unwrap(), "Trip 009");
}
#[test]
fn name_validation_bounds_expansion_and_never_trims_the_preview() {
let mut options = add_prefix();
options.prefix = " ".to_string();
assert_eq!(options.new_name("name.txt", false, 0).unwrap(), " name.txt");
for prefix in ["/", "\0", "\n", "\t"] {
options.prefix = prefix.to_string();
assert!(options.new_name("name.txt", false, 0).is_err());
}
options.mode = RenameMode::Replace;
options.find = "a".to_string();
options.replacement = "x".repeat(255);
assert!(options.new_name(&"a".repeat(255), false, 0).is_err());
options.replacement = "".to_string();
assert!(options.new_name("a", false, 0).is_err());
options = add_prefix();
assert!(options.new_name(&"夏".repeat(85), false, 0).is_err());
assert!(preview(&[], &options).is_err());
assert!(preview(
&vec![PathBuf::from("/unused"); BATCH_RENAME_LIMIT + 1],
&options
)
.is_err());
}
#[test]
fn preview_marks_both_duplicate_targets_and_blocks_the_whole_plan() {
let root = Fixture::new();
let paths = [
root.file("a.txt"),
root.file("a-a.txt"),
root.file("a-b.txt"),
];
let options = RenameOptions {
find: "a-".to_string(),
..Default::default()
};
let preview = preview(&paths, &options).unwrap();
assert_eq!(preview.conflicts(), 2);
assert!(!preview.ready());
assert!(preview.plan.is_empty());
assert!(paths.iter().all(|path| path.exists()));
}
#[test]
fn hidden_and_dangling_entries_reserve_their_names() {
let root = Fixture::new();
let paths = [root.file("one"), root.file("two")];
root.file(".one");
std::os::unix::fs::symlink("missing", root.0.join(".two")).unwrap();
let options = RenameOptions {
mode: RenameMode::AddText,
prefix: ".".to_string(),
..Default::default()
};
let preview = preview(&paths, &options).unwrap();
assert_eq!(preview.conflicts(), 2);
assert!(preview.plan.is_empty());
}
#[test]
fn preview_rejects_nested_sources_aliases_and_non_unicode_names() {
use std::os::unix::ffi::OsStringExt;
let root = Fixture::new();
let parent = root.0.join("folder");
fs::create_dir(&parent).unwrap();
let child = root.file("folder/child.txt");
let nested = preview(&[parent.clone(), child.clone()], &add_prefix()).unwrap();
assert_eq!(nested.conflicts(), 1);
assert!(nested.plan.is_empty());
std::os::unix::fs::symlink(&parent, root.0.join("alias")).unwrap();
let aliases = preview(&[child, root.0.join("alias/child.txt")], &add_prefix()).unwrap();
assert!(!aliases.ready());
assert!(aliases.plan.is_empty());
let non_unicode = root.0.join(std::ffi::OsString::from_vec(vec![b'a', 0xff]));
fs::write(&non_unicode, b"preserved").unwrap();
let result = preview(std::slice::from_ref(&non_unicode), &add_prefix()).unwrap();
assert_eq!(result.conflicts(), 1);
assert_eq!(fs::read(non_unicode).unwrap(), b"preserved");
}
#[test]
fn numbering_uses_the_supplied_order_and_unchanged_names_are_skipped() {
let root = Fixture::new();
let paths = [root.file("z.txt"), root.file("a.txt")];
let options = RenameOptions {
mode: RenameMode::Number,
..Default::default()
};
let numbered = preview(&paths, &options).unwrap();
assert_eq!(numbered.rows[0].new_name, "File 001.txt");
assert_eq!(numbered.rows[1].new_name, "File 002.txt");
let result = execute(numbered.plan, |_, _| {});
assert!(result.warning.is_none());
assert_eq!(fs::read(root.0.join("File 001.txt")).unwrap(), b"z.txt");
let unchanged = preview(&result.completed.destinations(), &options).unwrap();
assert_eq!(unchanged.conflicts(), 0);
assert!(unchanged.plan.is_empty());
assert!(!unchanged.ready());
}
#[test]
fn renaming_files_folders_and_symlinks_is_undoable_without_touching_contents() {
let root = Fixture::new();
let folder = root.0.join("folder.data");
fs::create_dir(&folder).unwrap();
let child = root.file("folder.data/child.txt");
let link = root.0.join("link.txt");
std::os::unix::fs::symlink("missing", &link).unwrap();
let paths = [root.file("first.txt"), folder, link];
let preview = preview(&paths, &add_prefix()).unwrap();
assert!(preview.ready());
let result = execute(preview.plan, |_, _| {});
assert_eq!(result.completed.len(), 3);
assert!(result.warning.is_none());
assert_eq!(
fs::read(root.0.join("new-folder.data/child.txt")).unwrap(),
b"folder.data/child.txt"
);
assert_eq!(
fs::read_link(root.0.join("new-link.txt")).unwrap(),
PathBuf::from("missing")
);
fs::write(root.0.join("new-first.txt"), b"edited contents").unwrap();
let undone = execute(result.completed.reversed(), |_, _| {});
assert_eq!(undone.completed.len(), 3);
assert!(undone.warning.is_none());
assert_eq!(fs::read(&paths[0]).unwrap(), b"edited contents");
assert!(child.exists());
}
#[test]
fn a_destination_created_after_preview_prevents_every_rename() {
let root = Fixture::new();
let paths = [root.file("first.txt"), root.file("second.txt")];
let plan = preview(&paths, &add_prefix()).unwrap().plan;
root.file("new-second.txt");
let result = execute(plan, |_, _| {});
assert!(result.completed.is_empty());
assert_eq!(result.remaining.len(), 2);
assert!(result.warning.is_some());
assert!(paths.iter().all(|path| path.exists()));
assert_eq!(
fs::read(root.0.join("new-second.txt")).unwrap(),
b"new-second.txt"
);
}
#[test]
fn replacing_a_source_or_parent_invalidates_the_plan() {
let root = Fixture::new();
let source = root.file("original");
let plan = preview(std::slice::from_ref(&source), &add_prefix())
.unwrap()
.plan;
fs::rename(&source, root.0.join("moved-away")).unwrap();
root.file("original");
let result = execute(plan, |_, _| {});
assert!(result.completed.is_empty());
assert!(result.warning.unwrap().contains("replaced"));
let parent = root.0.join("folder");
fs::create_dir(&parent).unwrap();
let child = root.file("folder/file");
let plan = preview(&[child], &add_prefix()).unwrap().plan;
fs::rename(&parent, root.0.join("old-folder")).unwrap();
fs::create_dir(&parent).unwrap();
root.file("folder/file");
let result = execute(plan, |_, _| {});
assert!(result.completed.is_empty());
assert!(result.warning.unwrap().contains("parent folder changed"));
assert!(root.0.join("old-folder/file").exists());
assert!(root.0.join("folder/file").exists());
}
#[test]
fn partial_failure_and_undo_conflicts_preserve_completed_and_remaining_work() {
let root = Fixture::new();
let paths = [root.file("a"), root.file("b"), root.file("c")];
let plan = preview(&paths, &add_prefix()).unwrap().plan;
let result = execute(plan, |count, _| {
if count == 1 {
root.file("new-b");
}
});
assert_eq!(result.completed.len(), 1);
assert_eq!(result.remaining.len(), 2);
assert!(result.warning.is_some());
assert_eq!(fs::read(root.0.join("new-b")).unwrap(), b"new-b");
root.file("a");
let undo = execute(result.completed.reversed(), |_, _| {});
assert!(undo.completed.is_empty());
assert_eq!(undo.remaining.len(), 1);
assert!(undo.warning.is_some());
fs::rename(root.0.join("a"), root.0.join("a-created-later")).unwrap();
let retry = execute(undo.remaining, |_, _| {});
assert_eq!(retry.completed.len(), 1);
assert!(retry.warning.is_none());
assert_eq!(fs::read(&paths[0]).unwrap(), b"a");
assert_eq!(fs::read(&paths[1]).unwrap(), b"b");
assert_eq!(fs::read(&paths[2]).unwrap(), b"c");
}
#[test]
fn undo_refuses_to_rename_a_replacement_at_the_output_path() {
let root = Fixture::new();
let paths = [root.file("a"), root.file("b")];
let result = execute(preview(&paths, &add_prefix()).unwrap().plan, |_, _| {});
fs::rename(root.0.join("new-a"), root.0.join("saved-a")).unwrap();
root.file("new-a");
let undo = execute(result.completed.reversed(), |_, _| {});
assert!(undo.completed.is_empty());
assert_eq!(undo.remaining.len(), 2);
assert!(undo.warning.is_some());
assert_eq!(fs::read(root.0.join("new-a")).unwrap(), b"new-a");
assert_eq!(fs::read(root.0.join("saved-a")).unwrap(), b"a");
}
}