Initial commit
This commit is contained in:
161
src/fsops.rs
Normal file
161
src/fsops.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
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<PathBuf>,
|
||||
pub skipped_reason: Option<String>,
|
||||
}
|
||||
|
||||
pub fn execute(
|
||||
src: &Path,
|
||||
dest: &Path,
|
||||
mode: OpMode,
|
||||
policy: CollisionPolicy,
|
||||
sidecars: bool,
|
||||
) -> Result<OperationOutcome> {
|
||||
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<Option<PathBuf>> {
|
||||
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<PathBuf> {
|
||||
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<usize> {
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user