use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct Conflict {
pub source: PathBuf,
pub destination: PathBuf,
pub kind: ConflictKind,
}
impl Conflict {
pub fn new(source: PathBuf, destination: PathBuf, kind: ConflictKind) -> Self {
Self {
source,
destination,
kind,
}
}
pub fn file_exists(source: PathBuf, destination: PathBuf) -> Self {
Self::new(source, destination, ConflictKind::FileExists)
}
pub fn directory_exists(source: PathBuf, destination: PathBuf) -> Self {
Self::new(source, destination, ConflictKind::DirectoryExists)
}
pub fn source_is_ancestor(source: PathBuf, destination: PathBuf) -> Self {
Self::new(source, destination, ConflictKind::SourceIsAncestor)
}
pub fn permission_denied(source: PathBuf, destination: PathBuf) -> Self {
Self::new(source, destination, ConflictKind::PermissionDenied)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConflictKind {
FileExists,
DirectoryExists,
SourceIsAncestor,
PermissionDenied,
SameFile,
}
impl std::fmt::Display for ConflictKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FileExists => write!(f, "File already exists"),
Self::DirectoryExists => write!(f, "Directory already exists"),
Self::SourceIsAncestor => write!(f, "Cannot copy/move a directory into itself"),
Self::PermissionDenied => write!(f, "Permission denied"),
Self::SameFile => write!(f, "Source and destination are the same file"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ConflictResolution {
#[default]
Skip,
Overwrite,
AutoRename,
SkipAll,
OverwriteAll,
Abort,
}
impl ConflictResolution {
pub fn is_global(&self) -> bool {
matches!(self, Self::SkipAll | Self::OverwriteAll | Self::Abort)
}
pub fn to_single(&self) -> Self {
match self {
Self::SkipAll => Self::Skip,
Self::OverwriteAll => Self::Overwrite,
_ => *self,
}
}
}
#[derive(Debug)]
pub struct ConflictRequest {
conflict: Conflict,
responder: tokio::sync::oneshot::Sender<ConflictResolution>,
}
impl ConflictRequest {
pub(crate) fn new(
conflict: Conflict,
responder: tokio::sync::oneshot::Sender<ConflictResolution>,
) -> Self {
Self {
conflict,
responder,
}
}
pub fn conflict(&self) -> &Conflict {
&self.conflict
}
pub fn kind(&self) -> ConflictKind {
self.conflict.kind
}
pub fn resolve(self, resolution: ConflictResolution) {
let _ = self.responder.send(resolution);
}
}
pub(crate) async fn request_resolution<T, F>(
tx: &tokio::sync::mpsc::Sender<T>,
conflict: Conflict,
wrap: F,
token: &tokio_util::sync::CancellationToken,
) -> ConflictResolution
where
F: FnOnce(ConflictRequest) -> T,
{
let (responder, reply) = tokio::sync::oneshot::channel();
tokio::select! {
biased;
_ = token.cancelled() => return ConflictResolution::Abort,
result = tx.send(wrap(ConflictRequest::new(conflict, responder))) => {
if result.is_err() {
return ConflictResolution::Abort;
}
}
}
tokio::select! {
biased;
_ = token.cancelled() => ConflictResolution::Abort,
_ = tx.closed() => ConflictResolution::Abort,
result = reply => result.unwrap_or(ConflictResolution::Skip),
}
}
fn path_is_free(path: &Path) -> bool {
path.symlink_metadata().is_err()
}
pub fn auto_rename_path(path: &Path) -> PathBuf {
let parent = path.parent().unwrap_or(std::path::Path::new(""));
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let extension = path.extension().and_then(|e| e.to_str());
for i in 1..1000 {
let new_name = if let Some(ext) = extension {
format!("{} ({}).{}", stem, i, ext)
} else {
format!("{} ({})", stem, i)
};
let new_path = parent.join(&new_name);
if path_is_free(&new_path) {
return new_path;
}
}
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for suffix in 0.. {
let base = if suffix == 0 {
format!("{}_{}", stem, timestamp)
} else {
format!("{}_{}_{}", stem, timestamp, suffix)
};
let new_name = match extension {
Some(ext) => format!("{}.{}", base, ext),
None => base,
};
let new_path = parent.join(&new_name);
if path_is_free(&new_path) {
return new_path;
}
}
parent.join(format!("{}_{}", stem, timestamp))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn cancellation_interrupts_unanswered_conflict() {
let token = tokio_util::sync::CancellationToken::new();
let (tx, mut rx) = tokio::sync::mpsc::channel(1);
let worker_token = token.clone();
let task = tokio::spawn(async move {
request_resolution(
&tx,
Conflict::file_exists("a".into(), "b".into()),
|r| r,
&worker_token,
)
.await
});
let request = rx.recv().await.unwrap();
token.cancel();
let result = tokio::time::timeout(std::time::Duration::from_secs(2), task)
.await
.unwrap()
.unwrap();
assert_eq!(result, ConflictResolution::Abort);
request.resolve(ConflictResolution::Overwrite);
}
#[tokio::test]
async fn cancellation_interrupts_full_result_channel() {
let token = tokio_util::sync::CancellationToken::new();
let (tx, _rx) = tokio::sync::mpsc::channel(1);
tx.send(()).await.unwrap();
let pending = request_resolution(
&tx,
Conflict::file_exists("a".into(), "b".into()),
|_| (),
&token,
);
token.cancel();
assert_eq!(
tokio::time::timeout(std::time::Duration::from_secs(2), pending)
.await
.unwrap(),
ConflictResolution::Abort
);
}
#[test]
fn test_auto_rename_path() {
let path = PathBuf::from("/tmp/test.txt");
let renamed = auto_rename_path(&path);
assert!(renamed.to_string_lossy().contains("test (1).txt"));
}
#[test]
fn test_auto_rename_no_extension() {
let path = PathBuf::from("/tmp/testfile");
let renamed = auto_rename_path(&path);
assert!(renamed.to_string_lossy().contains("testfile (1)"));
}
}