use std::fs; use std::path::{Path, PathBuf}; use anyhow::{anyhow, Context, Result}; #[derive(Clone, Copy, Debug)] pub enum OpMode { Copy, Move, RenameInPlace, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CollisionPolicy { Skip, Overwrite, Suffix, } #[derive(Debug)] pub struct OperationOutcome { pub final_path: Option, pub skipped_reason: Option, } pub fn execute( src: &Path, dest: &Path, mode: OpMode, policy: CollisionPolicy, sidecars: bool, ) -> Result { let dest = resolve_collision(dest, policy)?; if dest.is_none() { return Ok(OperationOutcome { final_path: None, skipped_reason: Some("destination exists".to_string()), }); } let dest = dest.unwrap(); if let Some(parent) = dest.parent() { fs::create_dir_all(parent) .with_context(|| format!("failed to create output dir: {}", parent.display()))?; } match mode { OpMode::Copy => copy_file(src, &dest)?, OpMode::Move | OpMode::RenameInPlace => move_file(src, &dest)?, } if sidecars { process_sidecars(src, &dest, mode, policy)?; } Ok(OperationOutcome { final_path: Some(dest), skipped_reason: None, }) } fn resolve_collision(dest: &Path, policy: CollisionPolicy) -> Result> { if !dest.exists() { return Ok(Some(dest.to_path_buf())); } match policy { CollisionPolicy::Skip => Ok(None), CollisionPolicy::Overwrite => Ok(Some(dest.to_path_buf())), CollisionPolicy::Suffix => Ok(Some(append_suffix(dest)?)), } } fn append_suffix(dest: &Path) -> Result { let parent = dest.parent().ok_or_else(|| anyhow!("invalid destination path"))?; let stem = dest .file_stem() .ok_or_else(|| anyhow!("invalid destination filename"))? .to_string_lossy(); let ext = dest.extension().map(|e| e.to_string_lossy()); for idx in 1..=999 { let candidate_name = if let Some(ext) = ext.as_ref() { format!("{} ({}).{}", stem, idx, ext) } else { format!("{} ({})", stem, idx) }; let candidate = parent.join(candidate_name); if !candidate.exists() { return Ok(candidate); } } Err(anyhow!("unable to find available suffix for {}", dest.display())) } fn copy_file(src: &Path, dest: &Path) -> Result<()> { fs::copy(src, dest) .with_context(|| format!("failed to copy {} -> {}", src.display(), dest.display()))?; Ok(()) } fn move_file(src: &Path, dest: &Path) -> Result<()> { match fs::rename(src, dest) { Ok(()) => Ok(()), Err(err) if err.raw_os_error() == Some(libc::EXDEV) => { copy_file(src, dest)?; fs::remove_file(src) .with_context(|| format!("failed to remove source after copy: {}", src.display()))?; Ok(()) } Err(err) => Err(anyhow!("failed to move {} -> {}: {}", src.display(), dest.display(), err)), } } fn process_sidecars(src: &Path, dest: &Path, mode: OpMode, policy: CollisionPolicy) -> Result { let src_dir = src.parent().ok_or_else(|| anyhow!("source has no parent"))?; let src_stem = src.file_stem().ok_or_else(|| anyhow!("source has no stem"))?; let dest_dir = dest.parent().ok_or_else(|| anyhow!("destination has no parent"))?; let dest_stem = dest.file_stem().ok_or_else(|| anyhow!("destination has no stem"))?; let mut processed = 0; for entry in fs::read_dir(src_dir)? { let entry = entry?; let path = entry.path(); if path == src { continue; } if path.is_dir() { continue; } let stem = match path.file_stem() { Some(stem) => stem, None => continue, }; if stem != src_stem { continue; } let ext = path.extension().map(|e| e.to_string_lossy().to_string()); let mut dest_name = dest_stem.to_string_lossy().to_string(); if let Some(ext) = ext { dest_name.push('.'); dest_name.push_str(&ext); } let dest_path = dest_dir.join(dest_name); let dest_path = resolve_collision(&dest_path, policy)?; if let Some(dest_path) = dest_path { if let Some(parent) = dest_path.parent() { fs::create_dir_all(parent).with_context(|| { format!("failed to create sidecar output dir: {}", parent.display()) })?; } match mode { OpMode::Copy => copy_file(&path, &dest_path)?, OpMode::Move | OpMode::RenameInPlace => move_file(&path, &dest_path)?, } processed += 1; } } Ok(processed) }