fn batch_targets_unique(ops: &[BatchOp]) -> bool {
let mut seen = HashSet::with_capacity(ops.len());
for op in ops {
let key = op
.target
.as_deref()
.or(op.path.as_deref())
.or(op.source.as_deref())
.unwrap_or("");
if key.is_empty() {
return false;
}
if !seen.insert(key.to_string()) {
return false;
}
if matches!(op.op.as_str(), "move" | "copy") {
if let Some(src) = op.source.as_deref().or(op.path.as_deref()) {
if !seen.insert(src.to_string()) {
return false;
}
}
}
}
true
}
fn collect_target_paths(ops: &[BatchOp], workspace: &std::path::Path) -> Vec<PathBuf> {
let mut paths = Vec::with_capacity(ops.len());
for op in ops {
for candidate in [op.target.as_ref(), op.path.as_ref(), op.source.as_ref()]
.iter()
.flatten()
{
let p = std::path::Path::new(candidate.as_str());
if let Ok(validated) = crate::path_safety::validate_path(p, workspace) {
if validated.is_file() {
paths.push(validated);
}
}
}
}
paths.sort();
paths.dedup();
paths
}
fn rollback_transaction(
backups: &[(PathBuf, PathBuf)],
created_files: &[PathBuf],
moves_to_reverse: &[(PathBuf, PathBuf)],
workspace: &std::path::Path,
) -> Result<(u64, u64)> {
let mut restored = 0u64;
for (source, target) in moves_to_reverse.iter().rev() {
if target.exists() && !source.exists() {
std::fs::rename(target, source).with_context(|| {
format!(
"cannot reverse move {} → {}",
target.display(),
source.display()
)
})?;
restored += 1;
}
}
for (original, backup) in backups {
if backup.exists() {
let content = crate::file_io::read_file_bytes(
backup,
crate::constants::DEFAULT_MAX_FILESIZE,
)
.with_context(|| format!("cannot read backup {}", backup.display()))?;
let opts = AtomicWriteOptions::default();
atomic_write(original, &content, &opts, workspace)
.with_context(|| format!("cannot restore {}", original.display()))?;
restored += 1;
}
}
let mut removed = 0u64;
for path in created_files {
if path.exists() {
std::fs::remove_file(path)
.with_context(|| format!("cannot remove created file {}", path.display()))?;
removed += 1;
}
}
Ok((restored, removed))
}