use std::collections::HashSet;
use std::fs;
use std::path::Path;
use crate::fs_ops;
#[derive(Debug, PartialEq, Eq)]
pub struct Plan {
pub renames: Vec<(String, String)>,
pub overwrites: Vec<String>,
}
pub fn list_text(names: &[String]) -> String {
let mut text = names.join("\n");
text.push('\n');
text
}
pub fn parse_edit(old_names: &[String], edited: &str) -> Result<Vec<(String, String)>, String> {
let edited = edited.strip_prefix('\u{feff}').unwrap_or(edited);
let mut lines: Vec<&str> = edited
.split('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line))
.collect();
while lines.last().is_some_and(|line| line.is_empty()) && lines.len() > old_names.len() {
lines.pop();
}
if lines.len() != old_names.len() {
return Err(format!(
"Expected {} lines, found {} — lines must not be added or removed",
old_names.len(),
lines.len()
));
}
let case_insensitive = cfg!(any(windows, target_os = "macos"));
let fold = |name: &str| {
if case_insensitive {
name.to_lowercase()
} else {
name.to_string()
}
};
let mut seen = HashSet::new();
for line in &lines {
validate_name(line)?;
if !seen.insert(fold(line)) {
return Err(format!("\"{line}\" appears more than once"));
}
}
Ok(old_names
.iter()
.zip(lines)
.filter(|(old, new)| old.as_str() != *new)
.map(|(old, new)| (old.clone(), new.to_string()))
.collect())
}
fn validate_name(name: &str) -> Result<(), String> {
if name.trim().is_empty() {
return Err("A name cannot be empty".to_string());
}
if name == "." || name == ".." {
return Err(format!("\"{name}\" is not a valid name"));
}
let forbidden: &[char] = if cfg!(windows) {
&['/', '\\', ':', '*', '?', '"', '<', '>', '|', '\0']
} else {
&['/', '\0']
};
if let Some(c) = name.chars().find(|c| forbidden.contains(c)) {
return Err(format!("\"{name}\" contains the character '{c}'"));
}
Ok(())
}
pub fn plan(dir: &Path, old_names: &[String], edited: &str) -> Result<Plan, String> {
let renames = parse_edit(old_names, edited)?;
let overwrites = renames
.iter()
.filter(|(_, new)| {
let target = dir.join(new);
renames
.iter()
.all(|(old, _)| fs_ops::rename_target_taken(&dir.join(old), &target))
})
.map(|(_, new)| new.clone())
.collect();
Ok(Plan {
renames,
overwrites,
})
}
pub fn execute(dir: &Path, plan: &Plan) -> (usize, Vec<String>) {
let mut errors = Vec::new();
let pid = std::process::id();
let mut parked = Vec::new();
for (idx, (old, new)) in plan.renames.iter().enumerate() {
let Some(temp) = (0..)
.map(|n| format!(".pc-rename-{pid}-{idx}-{n}"))
.find(|name| fs::symlink_metadata(dir.join(name)).is_err())
else {
continue;
};
match fs::rename(dir.join(old), dir.join(&temp)) {
Ok(()) => parked.push((temp, old, new)),
Err(err) => errors.push(format!("{old}: {err}")),
}
}
let mut done = 0;
for (temp, old, new) in parked {
let target = dir.join(new);
let allowed = plan.overwrites.contains(new);
let result = if !allowed && fs::symlink_metadata(&target).is_ok() {
Err(format!("{old}: \"{new}\" already exists"))
} else {
fs::rename(dir.join(&temp), &target).map_err(|err| format!("{old} → {new}: {err}"))
};
match result {
Ok(()) => done += 1,
Err(message) => errors.push(restore(dir, &temp, old, message)),
}
}
(done, errors)
}
fn restore(dir: &Path, temp: &str, old: &str, message: String) -> String {
if fs::symlink_metadata(dir.join(old)).is_err()
&& fs::rename(dir.join(temp), dir.join(old)).is_ok()
{
message
} else {
format!("{message} (left as \"{temp}\")")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn names(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn unchanged_lines_are_skipped_and_crlf_bom_tolerated() {
let old = names(&["a.txt", "b.txt"]);
let edited = "\u{feff}a.txt\r\nc.txt\r\n\r\n";
assert_eq!(
parse_edit(&old, edited).unwrap(),
vec![("b.txt".to_string(), "c.txt".to_string())]
);
}
#[test]
fn rejects_bad_edits() {
let old = names(&["a", "b"]);
assert!(parse_edit(&old, "a\n").is_err(), "line removed");
assert!(parse_edit(&old, "a\nb\nc\n").is_err(), "line added");
assert!(parse_edit(&old, "a\n\n").is_err(), "empty name");
assert!(parse_edit(&old, "a\n..\n").is_err(), "dot-dot");
assert!(parse_edit(&old, "a\nx/y\n").is_err(), "slash");
assert!(parse_edit(&old, "c\nc\n").is_err(), "duplicate");
assert!(parse_edit(&old, "b\na\n").is_ok(), "swap");
}
fn test_dir(name: &str, files: &[&str]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(name);
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
for file in files {
fs::write(dir.join(file), file).unwrap();
}
dir
}
#[test]
fn swap_is_not_an_overwrite_and_works() {
let dir = test_dir("pc_test_bulk_swap", &["a", "b"]);
let plan = plan(&dir, &names(&["a", "b"]), "b\na\n").unwrap();
assert!(plan.overwrites.is_empty());
let (done, errors) = execute(&dir, &plan);
assert_eq!((done, errors.len()), (2, 0));
assert_eq!(fs::read_to_string(dir.join("a")).unwrap(), "b");
assert_eq!(fs::read_to_string(dir.join("b")).unwrap(), "a");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn existing_file_outside_the_batch_is_flagged_and_only_replaced_when_planned() {
let dir = test_dir("pc_test_bulk_overwrite", &["a", "keep"]);
let old = names(&["a"]);
let plan_it = plan(&dir, &old, "keep\n").unwrap();
assert_eq!(plan_it.overwrites, vec!["keep".to_string()]);
let unconfirmed = Plan {
renames: plan_it.renames.clone(),
overwrites: Vec::new(),
};
let (done, errors) = execute(&dir, &unconfirmed);
assert_eq!((done, errors.len()), (0, 1));
assert_eq!(fs::read_to_string(dir.join("keep")).unwrap(), "keep");
assert_eq!(fs::read_to_string(dir.join("a")).unwrap(), "a");
let (done, errors) = execute(&dir, &plan_it);
assert_eq!((done, errors.len()), (1, 0));
assert_eq!(fs::read_to_string(dir.join("keep")).unwrap(), "a");
assert!(!dir.join("a").exists());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn failed_restore_never_replaces_the_new_occupant_of_the_old_name() {
let dir = test_dir("pc_test_bulk_restore", &["b", ".pc-parked"]);
let message = restore(&dir, ".pc-parked", "b", "boom".to_string());
assert!(message.contains("left as"));
assert_eq!(fs::read_to_string(dir.join("b")).unwrap(), "b");
assert!(dir.join(".pc-parked").exists());
fs::remove_dir_all(&dir).unwrap();
}
}