1use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone)]
9pub struct Conflict {
10 pub source: PathBuf,
12 pub destination: PathBuf,
14 pub kind: ConflictKind,
16}
17
18impl Conflict {
19 pub fn new(source: PathBuf, destination: PathBuf, kind: ConflictKind) -> Self {
21 Self {
22 source,
23 destination,
24 kind,
25 }
26 }
27
28 pub fn file_exists(source: PathBuf, destination: PathBuf) -> Self {
30 Self::new(source, destination, ConflictKind::FileExists)
31 }
32
33 pub fn directory_exists(source: PathBuf, destination: PathBuf) -> Self {
35 Self::new(source, destination, ConflictKind::DirectoryExists)
36 }
37
38 pub fn source_is_ancestor(source: PathBuf, destination: PathBuf) -> Self {
40 Self::new(source, destination, ConflictKind::SourceIsAncestor)
41 }
42
43 pub fn permission_denied(source: PathBuf, destination: PathBuf) -> Self {
45 Self::new(source, destination, ConflictKind::PermissionDenied)
46 }
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum ConflictKind {
52 FileExists,
54 DirectoryExists,
56 SourceIsAncestor,
58 PermissionDenied,
60 SameFile,
62}
63
64impl std::fmt::Display for ConflictKind {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 Self::FileExists => write!(f, "File already exists"),
68 Self::DirectoryExists => write!(f, "Directory already exists"),
69 Self::SourceIsAncestor => write!(f, "Cannot copy/move a directory into itself"),
70 Self::PermissionDenied => write!(f, "Permission denied"),
71 Self::SameFile => write!(f, "Source and destination are the same file"),
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
78pub enum ConflictResolution {
79 #[default]
81 Skip,
82 Overwrite,
84 AutoRename,
86 SkipAll,
88 OverwriteAll,
90 Abort,
92}
93
94impl ConflictResolution {
95 pub fn is_global(&self) -> bool {
97 matches!(self, Self::SkipAll | Self::OverwriteAll | Self::Abort)
98 }
99
100 pub fn to_single(&self) -> Self {
102 match self {
103 Self::SkipAll => Self::Skip,
104 Self::OverwriteAll => Self::Overwrite,
105 _ => *self,
106 }
107 }
108}
109
110#[derive(Debug)]
116pub struct ConflictRequest {
117 conflict: Conflict,
118 responder: tokio::sync::oneshot::Sender<ConflictResolution>,
119}
120
121impl ConflictRequest {
122 pub(crate) fn new(
123 conflict: Conflict,
124 responder: tokio::sync::oneshot::Sender<ConflictResolution>,
125 ) -> Self {
126 Self {
127 conflict,
128 responder,
129 }
130 }
131
132 pub fn conflict(&self) -> &Conflict {
134 &self.conflict
135 }
136
137 pub fn kind(&self) -> ConflictKind {
139 self.conflict.kind
140 }
141
142 pub fn resolve(self, resolution: ConflictResolution) {
144 let _ = self.responder.send(resolution);
147 }
148}
149
150pub(crate) async fn request_resolution<T, F>(
155 tx: &tokio::sync::mpsc::Sender<T>,
156 conflict: Conflict,
157 wrap: F,
158 token: &tokio_util::sync::CancellationToken,
159) -> ConflictResolution
160where
161 F: FnOnce(ConflictRequest) -> T,
162{
163 let (responder, reply) = tokio::sync::oneshot::channel();
164 tokio::select! {
165 biased;
166 _ = token.cancelled() => return ConflictResolution::Abort,
167 result = tx.send(wrap(ConflictRequest::new(conflict, responder))) => {
168 if result.is_err() {
169 return ConflictResolution::Abort;
170 }
171 }
172 }
173 tokio::select! {
174 biased;
175 _ = token.cancelled() => ConflictResolution::Abort,
176 _ = tx.closed() => ConflictResolution::Abort,
177 result = reply => result.unwrap_or(ConflictResolution::Skip),
178 }
179}
180
181fn path_is_free(path: &Path) -> bool {
187 path.symlink_metadata().is_err()
188}
189
190pub fn auto_rename_path(path: &Path) -> PathBuf {
198 let parent = path.parent().unwrap_or(std::path::Path::new(""));
199 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
200 let extension = path.extension().and_then(|e| e.to_str());
201
202 for i in 1..1000 {
203 let new_name = if let Some(ext) = extension {
204 format!("{} ({}).{}", stem, i, ext)
205 } else {
206 format!("{} ({})", stem, i)
207 };
208
209 let new_path = parent.join(&new_name);
210 if path_is_free(&new_path) {
211 return new_path;
212 }
213 }
214
215 let timestamp = std::time::SystemTime::now()
217 .duration_since(std::time::UNIX_EPOCH)
218 .map(|d| d.as_secs())
219 .unwrap_or(0);
220
221 for suffix in 0.. {
222 let base = if suffix == 0 {
223 format!("{}_{}", stem, timestamp)
224 } else {
225 format!("{}_{}_{}", stem, timestamp, suffix)
226 };
227 let new_name = match extension {
228 Some(ext) => format!("{}.{}", base, ext),
229 None => base,
230 };
231 let new_path = parent.join(&new_name);
232 if path_is_free(&new_path) {
233 return new_path;
234 }
235 }
236
237 parent.join(format!("{}_{}", stem, timestamp))
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[tokio::test]
246 async fn cancellation_interrupts_unanswered_conflict() {
247 let token = tokio_util::sync::CancellationToken::new();
248 let (tx, mut rx) = tokio::sync::mpsc::channel(1);
249 let worker_token = token.clone();
250 let task = tokio::spawn(async move {
251 request_resolution(
252 &tx,
253 Conflict::file_exists("a".into(), "b".into()),
254 |r| r,
255 &worker_token,
256 )
257 .await
258 });
259 let request = rx.recv().await.unwrap();
261 token.cancel();
262 let result = tokio::time::timeout(std::time::Duration::from_secs(2), task)
263 .await
264 .unwrap()
265 .unwrap();
266 assert_eq!(result, ConflictResolution::Abort);
267 request.resolve(ConflictResolution::Overwrite);
268 }
269
270 #[tokio::test]
271 async fn cancellation_interrupts_full_result_channel() {
272 let token = tokio_util::sync::CancellationToken::new();
273 let (tx, _rx) = tokio::sync::mpsc::channel(1);
274 tx.send(()).await.unwrap();
275 let pending = request_resolution(
276 &tx,
277 Conflict::file_exists("a".into(), "b".into()),
278 |_| (),
279 &token,
280 );
281 token.cancel();
282 assert_eq!(
283 tokio::time::timeout(std::time::Duration::from_secs(2), pending)
284 .await
285 .unwrap(),
286 ConflictResolution::Abort
287 );
288 }
289
290 #[test]
291 fn test_auto_rename_path() {
292 let path = PathBuf::from("/tmp/test.txt");
293 let renamed = auto_rename_path(&path);
294 assert!(renamed.to_string_lossy().contains("test (1).txt"));
295 }
296
297 #[test]
298 fn test_auto_rename_no_extension() {
299 let path = PathBuf::from("/tmp/testfile");
300 let renamed = auto_rename_path(&path);
301 assert!(renamed.to_string_lossy().contains("testfile (1)"));
302 }
303}