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 collect_snapshot_paths(ops: &[BatchOp], workspace: &std::path::Path) -> Vec<PathBuf> {
let mut paths = Vec::with_capacity(ops.len());
for op in ops {
let candidate = match op.op.as_str() {
"write" | "replace" | "edit" | "delete" => {
op.target.as_ref().or(op.path.as_ref())
}
"move" | "copy" => op.target.as_ref(),
_ => None,
};
let Some(candidate) = candidate else {
continue;
};
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 destructive_paths(
ops: &[BatchOp],
workspace: &std::path::Path,
keep_backup: bool,
no_backup: bool,
) -> HashSet<PathBuf> {
let mut paths = HashSet::new();
for op in ops {
let candidates: [Option<&String>; 2] = match op.op.as_str() {
"delete" => [op.target.as_ref(), op.path.as_ref()],
"move" | "copy" => [op.target.as_ref(), None],
"write" | "replace" | "edit" if keep_backup && !no_backup => {
[op.target.as_ref().or(op.path.as_ref()), None]
}
_ => continue,
};
for candidate in candidates.into_iter().flatten() {
if let Ok(validated) = crate::path_safety::validate_path(
std::path::Path::new(candidate.as_str()),
workspace,
) {
paths.insert(validated);
}
}
}
paths
}
#[derive(Debug, Default)]
pub(crate) struct RollbackOutcome {
pub restored: u64,
pub removed: u64,
pub failed_path: Option<String>,
pub reason: String,
}
fn rollback_transaction(
backups: &[(PathBuf, PathBuf)],
created_files: &[PathBuf],
moves_to_reverse: &[(PathBuf, PathBuf)],
workspace: &std::path::Path,
) -> RollbackOutcome {
let mut outcome = RollbackOutcome::default();
for (source, target) in moves_to_reverse.iter().rev() {
if target.exists() && !source.exists() {
if let Err(error) = std::fs::rename(target, source) {
outcome.failed_path = Some(target.display().to_string());
outcome.reason = format!(
"cannot reverse move {} → {}: {error}",
target.display(),
source.display()
);
return outcome;
}
outcome.restored += 1;
}
}
for (original, backup) in backups {
if !backup.exists() {
continue;
}
let restored = (|| -> Result<()> {
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();
let expected = crate::checksum::hash_file(original, u64::MAX).ok();
atomic_write_based(original, &content, &opts, workspace, expected.as_deref())
.with_context(|| format!("cannot restore {}", original.display()))?;
Ok(())
})();
if let Err(error) = restored {
outcome.failed_path = Some(original.display().to_string());
outcome.reason = format!("{error:#}");
return outcome;
}
outcome.restored += 1;
}
for path in created_files {
if !path.exists() {
continue;
}
if let Err(error) = std::fs::remove_file(path) {
outcome.failed_path = Some(path.display().to_string());
outcome.reason = format!("cannot remove created file {}: {error}", path.display());
return outcome;
}
outcome.removed += 1;
}
outcome
}