Initial commit

This commit is contained in:
2025-12-30 10:51:50 -05:00
parent 12315c4925
commit ee7e765181
22 changed files with 6714 additions and 1 deletions

161
src/fsops.rs Normal file
View 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)
}