use std::{
collections::HashSet,
fmt, io,
path::{Path, PathBuf},
};
use crate::filesystem::FileSystem;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BulkRenameError {
LineCount {
expected: usize,
found: usize,
},
EmptyName {
line: usize,
},
InvalidName {
line: usize,
name: String,
},
Duplicate {
name: String,
},
Exists {
name: String,
},
}
impl fmt::Display for BulkRenameError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LineCount { expected, found } => {
write!(
f,
"expected {expected} lines, found {found} — don't add or remove lines"
)
}
Self::EmptyName { line } => write!(f, "line {line} is empty"),
Self::InvalidName { line, name } => {
write!(f, "line {line}: '{name}' is not a plain file name")
}
Self::Duplicate { name } => write!(f, "'{name}' would be used twice"),
Self::Exists { name } => write!(f, "'{name}' already exists"),
}
}
}
impl std::error::Error for BulkRenameError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenameStep {
pub from: PathBuf,
pub to: PathBuf,
}
pub fn names_file_content(sources: &[PathBuf]) -> Result<String, String> {
let mut out = String::new();
for p in sources {
let name = p.file_name().unwrap_or_default().to_string_lossy();
if name.contains('\n') || name.contains('\r') {
return Err(name.into_owned());
}
out.push_str(&name);
out.push('\n');
}
Ok(out)
}
pub fn plan(
sources: &[PathBuf],
edited: &str,
exists: impl Fn(&Path) -> bool,
) -> Result<Vec<RenameStep>, BulkRenameError> {
let lines: Vec<&str> = edited.lines().collect();
if lines.len() != sources.len() {
return Err(BulkRenameError::LineCount {
expected: sources.len(),
found: lines.len(),
});
}
let mut seen: HashSet<&str> = HashSet::new();
for (i, raw) in lines.iter().enumerate() {
let name = raw.trim_end_matches('\r').trim();
if name.is_empty() {
return Err(BulkRenameError::EmptyName { line: i + 1 });
}
if name.contains('/') || name.contains('\\') || name == "." || name == ".." {
return Err(BulkRenameError::InvalidName {
line: i + 1,
name: name.to_string(),
});
}
if !seen.insert(name) {
return Err(BulkRenameError::Duplicate {
name: name.to_string(),
});
}
}
let moving: HashSet<&Path> = sources.iter().map(PathBuf::as_path).collect();
let mut steps = Vec::new();
for (src, raw) in sources.iter().zip(&lines) {
let name = raw.trim_end_matches('\r').trim();
let old = src.file_name().unwrap_or_default().to_string_lossy();
if old == name {
continue;
}
let to = src.with_file_name(name);
if exists(&to) && !moving.contains(to.as_path()) {
return Err(BulkRenameError::Exists {
name: name.to_string(),
});
}
steps.push(RenameStep {
from: src.clone(),
to,
});
}
Ok(steps)
}
pub fn apply(fs: &dyn FileSystem, steps: &[RenameStep]) -> io::Result<Vec<RenameStep>> {
let tag = std::process::id();
let temp_for = |i: usize, s: &RenameStep| s.from.with_file_name(format!(".tfe-bulk-{tag}-{i}"));
let mut staged: Vec<(usize, PathBuf)> = Vec::new();
for (i, s) in steps.iter().enumerate() {
let tmp = temp_for(i, s);
if let Err(e) = fs.rename(&s.from, &tmp) {
rollback(fs, steps, &staged);
return Err(e);
}
staged.push((i, tmp));
}
let mut finished: Vec<usize> = Vec::new();
for (i, tmp) in &staged {
let s = &steps[*i];
if let Err(e) = fs.rename(tmp, &s.to) {
for &j in finished.iter().rev() {
let _ = fs.rename(&steps[j].to, &staged[j].1);
}
rollback(fs, steps, &staged);
return Err(e);
}
finished.push(*i);
}
Ok(steps.to_vec())
}
fn rollback(fs: &dyn FileSystem, steps: &[RenameStep], staged: &[(usize, PathBuf)]) {
for (i, tmp) in staged.iter().rev() {
let _ = fs.rename(tmp, &steps[*i].from);
}
}
#[cfg(test)]
mod tests;