use std::{
fmt, fs,
path::{Path, PathBuf},
str::FromStr,
};
use clap::ValueEnum;
use crate::{
error::{MtagError, MtagResult},
planner::CopyPlan,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum ConflictStrategy {
Fail,
Skip,
Overwrite,
Rename,
}
impl fmt::Display for ConflictStrategy {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let value = match self {
Self::Fail => "fail",
Self::Skip => "skip",
Self::Overwrite => "overwrite",
Self::Rename => "rename",
};
formatter.write_str(value)
}
}
impl FromStr for ConflictStrategy {
type Err = MtagError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"fail" => Ok(Self::Fail),
"skip" => Ok(Self::Skip),
"overwrite" => Ok(Self::Overwrite),
"rename" => Ok(Self::Rename),
_ => Err(MtagError::InvalidConflictStrategy {
value: value.to_string(),
}),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExecutionMode {
Copy,
Move,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ExecutionOptions {
pub conflict_strategy: ConflictStrategy,
pub mode: ExecutionMode,
pub dry_run: bool,
}
impl Default for ExecutionOptions {
fn default() -> Self {
Self {
conflict_strategy: ConflictStrategy::Fail,
mode: ExecutionMode::Copy,
dry_run: false,
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct ExecutionSummary {
pub planned: usize,
pub copied: usize,
pub moved: usize,
pub skipped: usize,
pub renamed: usize,
}
pub fn execute_plan(plan: &CopyPlan, options: &ExecutionOptions) -> MtagResult<ExecutionSummary> {
let mut summary = ExecutionSummary {
planned: plan.tasks.len(),
..ExecutionSummary::default()
};
for task in &plan.tasks {
let destination = resolve_destination(&task.to, options.conflict_strategy, &mut summary)?;
let Some(destination) = destination else {
summary.skipped += 1;
continue;
};
if options.dry_run {
continue;
}
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent).map_err(|source| MtagError::FileOperation {
operation: "create directory for",
from: task.from.clone(),
to: destination.clone(),
source,
})?;
}
match options.mode {
ExecutionMode::Copy => {
copy_file(&task.from, &destination)?;
summary.copied += 1;
}
ExecutionMode::Move => {
copy_file(&task.from, &destination)?;
fs::remove_file(&task.from).map_err(|source| MtagError::FileOperation {
operation: "remove source after moving",
from: task.from.clone(),
to: destination.clone(),
source,
})?;
summary.moved += 1;
}
}
}
Ok(summary)
}
fn copy_file(from: &Path, to: &Path) -> MtagResult<()> {
fs::copy(from, to).map_err(|source| MtagError::FileOperation {
operation: "copy",
from: from.to_path_buf(),
to: to.to_path_buf(),
source,
})?;
Ok(())
}
fn resolve_destination(
destination: &Path,
conflict_strategy: ConflictStrategy,
summary: &mut ExecutionSummary,
) -> MtagResult<Option<PathBuf>> {
if !destination.exists() {
return Ok(Some(destination.to_path_buf()));
}
match conflict_strategy {
ConflictStrategy::Fail => Err(MtagError::DestinationExists {
path: destination.to_path_buf(),
}),
ConflictStrategy::Skip => Ok(None),
ConflictStrategy::Overwrite => Ok(Some(destination.to_path_buf())),
ConflictStrategy::Rename => {
summary.renamed += 1;
Ok(Some(next_available_path(destination)?))
}
}
}
fn next_available_path(destination: &Path) -> MtagResult<PathBuf> {
let parent = destination.parent().unwrap_or_else(|| Path::new(""));
let file_stem = destination
.file_stem()
.ok_or_else(|| MtagError::MissingFileName {
path: destination.to_path_buf(),
})?
.to_string_lossy();
let extension = destination.extension().map(|extension| {
let mut value = String::from(".");
value.push_str(&extension.to_string_lossy());
value
});
for index in 1.. {
let mut file_name = format!("{file_stem} ({index})");
if let Some(extension) = &extension {
file_name.push_str(extension);
}
let candidate = parent.join(file_name);
if !candidate.exists() {
return Ok(candidate);
}
}
unreachable!("unbounded rename search always returns or loops")
}