1use std::collections::HashSet;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use tokio_util::sync::CancellationToken;
6
7use crate::error::{Error, Result};
8use crate::planner::{BatchConfig, CopyAction, EntryAction, ErrorStrategy, OperationOutcome, StopReason};
9use crate::profiler::{Entry, DEFAULT_SMALL_FILE_THRESHOLD};
10use crate::progress::{Progress, ProgressReporter};
11
12use super::default_concurrency;
13use super::pipeline::run_copy_pipeline;
14
15pub struct MoveBuilder {
16 source: PathBuf,
17 dest: PathBuf,
18 overwrite: bool,
19 preserve_permissions: bool,
20 allow_filesystem_integrity_risk: bool,
21 small_file_threshold: Option<u64>,
22 batch_config: BatchConfig,
23 concurrency: Option<usize>,
24}
25
26impl MoveBuilder {
27 pub(crate) fn new(source: impl Into<PathBuf>, dest: impl Into<PathBuf>) -> Self {
28 Self {
29 source: source.into(),
30 dest: dest.into(),
31 overwrite: false,
32 preserve_permissions: false,
33 allow_filesystem_integrity_risk: false,
34 small_file_threshold: None,
35 batch_config: BatchConfig::default(),
36 concurrency: None,
37 }
38 }
39
40 pub fn overwrite(mut self, overwrite: bool) -> Self {
41 self.overwrite = overwrite;
42 self
43 }
44
45 #[cfg(all(unix, feature = "permissions"))]
49 pub fn preserve_permissions(mut self, preserve: bool) -> Self {
50 self.preserve_permissions = preserve;
51 self
52 }
53
54 pub fn allow_filesystem_integrity_risk(mut self, allow: bool) -> Self {
60 self.allow_filesystem_integrity_risk = allow;
61 self
62 }
63
64 pub fn small_file_threshold(mut self, bytes: u64) -> Self {
65 self.small_file_threshold = Some(bytes);
66 self
67 }
68
69 pub fn on_error(mut self, strategy: ErrorStrategy) -> Self {
70 self.batch_config.error_strategy = strategy;
71 self
72 }
73
74 pub fn batch_concurrency(mut self, n: usize) -> Self {
75 self.concurrency = Some(n);
76 self
77 }
78
79 pub fn start(self) -> Result<crate::handle::Handle<OperationOutcome>> {
80 let cancel = CancellationToken::new();
81 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
82 let reporter = ProgressReporter::new(tx);
83
84 let concurrency = self.concurrency.unwrap_or_else(default_concurrency);
85 let threshold = self.small_file_threshold.unwrap_or(DEFAULT_SMALL_FILE_THRESHOLD);
86 let cancel_for_task = cancel.clone();
87
88 let join_handle = tokio::spawn(async move {
89 move_path(
90 &self.source,
91 &self.dest,
92 self.overwrite,
93 self.preserve_permissions,
94 self.allow_filesystem_integrity_risk,
95 threshold,
96 &self.batch_config,
97 concurrency,
98 cancel_for_task,
99 reporter,
100 &TokioRenamer,
101 )
102 .await
103 });
104
105 Ok(crate::handle::Handle::new(join_handle, rx, cancel))
106 }
107}
108
109pub(crate) fn is_cross_device(err: &io::Error) -> bool {
113 err.kind() == io::ErrorKind::CrossesDevices
114}
115
116pub(crate) trait Renamer {
120 async fn rename(&self, source: &Path, dest: &Path) -> io::Result<()>;
121}
122
123pub(crate) struct TokioRenamer;
124
125impl Renamer for TokioRenamer {
126 async fn rename(&self, source: &Path, dest: &Path) -> io::Result<()> {
127 tokio::fs::rename(source, dest).await
128 }
129}
130
131#[allow(clippy::too_many_arguments)]
146async fn move_path<R: Renamer>(
147 source: &Path,
148 dest: &Path,
149 overwrite: bool,
150 preserve_permissions: bool,
151 allow_filesystem_integrity_risk: bool,
152 small_file_threshold: u64,
153 config: &BatchConfig,
154 concurrency: usize,
155 cancel: CancellationToken,
156 reporter: ProgressReporter,
157 renamer: &R,
158) -> Result<OperationOutcome> {
159 match renamer.rename(source, dest).await {
160 Ok(()) => return Ok(OperationOutcome::default()),
164 Err(err) if is_cross_device(&err) => {}
165 Err(err) => return Err(classify_error(err, source)),
166 }
167
168 let mut outcome = run_copy_pipeline(
169 source,
170 dest,
171 overwrite,
172 preserve_permissions,
173 allow_filesystem_integrity_risk,
174 small_file_threshold,
175 config,
176 concurrency,
177 cancel,
178 reporter.clone(),
179 )
180 .await?;
181
182 sweep(&mut outcome, dest, config.error_strategy, reporter).await;
183
184 Ok(outcome)
185}
186
187async fn sweep(
191 outcome: &mut OperationOutcome,
192 dest_root: &Path,
193 error_strategy: ErrorStrategy,
194 reporter: ProgressReporter,
195) {
196 if outcome.succeeded.is_empty() {
197 return;
198 }
199
200 let entries = outcome.succeeded.clone();
201 let mut deleted_paths: HashSet<PathBuf> = HashSet::new();
202
203 reporter.send(Progress::Started { bytes_total: None, entries_total: entries.len() });
204
205 for entry in &entries {
206 reporter.send(Progress::EntryStarted { entry: entry.clone() });
207
208 match remove_source(entry).await {
209 Ok(()) => {
210 reporter.send(Progress::EntryCompleted { entry: entry.clone() });
211 deleted_paths.insert(entry.path.clone());
212 }
213 Err(err) => {
214 reporter.send(Progress::EntryFailed { entry: entry.clone() });
215 let fatal = err.is_fatal();
216 let reason = if fatal {
217 Some(StopReason::Fatal)
218 } else {
219 match error_strategy {
220 ErrorStrategy::ContinueAndCollect => None,
221 ErrorStrategy::AbortOnError => Some(StopReason::AbortOnError),
222 ErrorStrategy::Undo => Some(StopReason::Undo),
223 }
224 };
225
226 outcome.cleanup_failed.push((entry.clone(), err));
227
228 if let Some(reason) = reason {
229 if outcome.stopped_early.is_none() {
230 outcome.stopped_early = Some(reason);
231 }
232
233 if matches!(error_strategy, ErrorStrategy::Undo) {
234 rollback(&entries, &deleted_paths, dest_root).await;
235 outcome.succeeded.clear();
236 outcome.cleanup_failed.clear();
237 }
238
239 break;
240 }
241 }
242 }
243 }
244}
245
246async fn rollback(entries: &[Entry], deleted_paths: &HashSet<PathBuf>, dest_root: &Path) {
252 let copy_action = CopyAction { overwrite: true };
253 for entry in entries.iter().rev() {
254 if deleted_paths.contains(&entry.path) {
255 let _ = restore_source(entry, dest_root).await;
256 } else {
257 let _ = copy_action.undo(entry, dest_root).await;
258 }
259 }
260}
261
262async fn remove_source(entry: &Entry) -> Result<()> {
263 match tokio::fs::remove_file(&entry.path).await {
264 Ok(()) => Ok(()),
265 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
266 Err(e) => Err(classify_error(e, &entry.path)),
267 }
268}
269
270async fn restore_source(entry: &Entry, dest_root: &Path) -> Result<()> {
271 let dest_path = dest_root.join(&entry.relative_path);
272 tokio::fs::copy(&dest_path, &entry.path)
273 .await
274 .map_err(|e| classify_error(e, &entry.path))?;
275 tokio::fs::remove_file(&dest_path)
276 .await
277 .map_err(|e| classify_error(e, &dest_path))?;
278 Ok(())
279}
280
281fn classify_error(err: io::Error, path: &Path) -> Error {
282 match err.kind() {
283 io::ErrorKind::NotFound => Error::SourceNotFound { path: path.to_path_buf() },
284 io::ErrorKind::PermissionDenied => Error::PermissionDenied { path: path.to_path_buf() },
285 io::ErrorKind::StorageFull => Error::NoSpace { needed: 0, available: 0 },
286 _ => Error::Io { path: path.to_path_buf(), source: err },
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use std::fs;
293
294 use tempfile::tempdir;
295
296 use super::*;
297
298 #[test]
299 fn is_cross_device_true_for_crosses_devices_kind() {
300 let err = io::Error::from(io::ErrorKind::CrossesDevices);
301 assert!(is_cross_device(&err));
302 }
303
304 #[test]
305 fn is_cross_device_false_for_other_kinds() {
306 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::NotFound, io::ErrorKind::Other] {
307 let err = io::Error::from(kind);
308 assert!(!is_cross_device(&err), "{kind:?} should not be classified as cross-device");
309 }
310 }
311
312 struct AlwaysCrossDevice;
313 impl Renamer for AlwaysCrossDevice {
314 async fn rename(&self, _source: &Path, _dest: &Path) -> io::Result<()> {
315 Err(io::Error::from(io::ErrorKind::CrossesDevices))
316 }
317 }
318
319 struct AlwaysPermissionDenied;
320 impl Renamer for AlwaysPermissionDenied {
321 async fn rename(&self, _source: &Path, _dest: &Path) -> io::Result<()> {
322 Err(io::Error::from(io::ErrorKind::PermissionDenied))
323 }
324 }
325
326 #[cfg(unix)]
331 fn entry(path: PathBuf, relative_path: PathBuf, size: u64) -> Entry {
332 Entry { path, relative_path, size, modified: None }
333 }
334
335 #[tokio::test]
336 async fn same_filesystem_move_uses_rename_and_skips_pipeline() {
337 let root = tempdir().unwrap();
338 let source = root.path().join("src.txt");
339 let dest = root.path().join("dst.txt");
340 fs::write(&source, b"hello").unwrap();
341
342 let outcome = move_path(
343 &source,
344 &dest,
345 false,
346 false,
347 false,
348 256,
349 &BatchConfig::default(),
350 2,
351 CancellationToken::new(),
352 ProgressReporter::noop(),
353 &TokioRenamer,
354 )
355 .await
356 .unwrap();
357
358 assert!(outcome.succeeded.is_empty(), "fast path doesn't enumerate entries");
359 assert!(!source.exists());
360 assert_eq!(fs::read(&dest).unwrap(), b"hello");
361 }
362
363 #[tokio::test]
364 async fn cross_device_fallback_copies_then_deletes_sources() {
365 let src_dir = tempdir().unwrap();
366 let dest_dir = tempdir().unwrap();
367 fs::write(src_dir.path().join("a.txt"), b"a").unwrap();
368 fs::write(src_dir.path().join("b.txt"), b"b").unwrap();
369
370 let outcome = move_path(
371 src_dir.path(),
372 dest_dir.path(),
373 false,
374 false,
375 false,
376 256,
377 &BatchConfig::default(),
378 2,
379 CancellationToken::new(),
380 ProgressReporter::noop(),
381 &AlwaysCrossDevice,
382 )
383 .await
384 .unwrap();
385
386 assert_eq!(outcome.succeeded.len(), 2);
387 assert!(outcome.failed.is_empty());
388 assert!(outcome.cleanup_failed.is_empty());
389
390 assert!(!src_dir.path().join("a.txt").exists());
391 assert!(!src_dir.path().join("b.txt").exists());
392 assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"a");
393 assert_eq!(fs::read(dest_dir.path().join("b.txt")).unwrap(), b"b");
394 }
395
396 #[tokio::test]
397 async fn non_cross_device_rename_error_surfaces_directly_without_fallback() {
398 let src_dir = tempdir().unwrap();
399 let dest_dir = tempdir().unwrap();
400 fs::write(src_dir.path().join("a.txt"), b"a").unwrap();
401
402 let result = move_path(
403 src_dir.path(),
404 dest_dir.path(),
405 false,
406 false,
407 false,
408 256,
409 &BatchConfig::default(),
410 2,
411 CancellationToken::new(),
412 ProgressReporter::noop(),
413 &AlwaysPermissionDenied,
414 )
415 .await;
416
417 assert!(matches!(result, Err(Error::PermissionDenied { .. })));
418 assert!(!dest_dir.path().join("a.txt").exists());
419 assert!(src_dir.path().join("a.txt").exists());
420 }
421
422 #[cfg(unix)]
423 #[tokio::test]
424 async fn continue_and_collect_sweep_keeps_deleting_after_one_failure() {
425 use std::os::unix::fs::PermissionsExt;
426
427 let src_dir = tempdir().unwrap();
428 let dest_dir = tempdir().unwrap();
429
430 let locked_dir = src_dir.path().join("locked");
431 fs::create_dir(&locked_dir).unwrap();
432 let a = locked_dir.join("a.txt");
433 fs::write(&a, b"a").unwrap();
434
435 let b = src_dir.path().join("b.txt");
436 fs::write(&b, b"b").unwrap();
437
438 fs::create_dir_all(dest_dir.path().join("locked")).unwrap();
439 fs::write(dest_dir.path().join("locked").join("a.txt"), b"a").unwrap();
440 fs::write(dest_dir.path().join("b.txt"), b"b").unwrap();
441
442 let entry_a = entry(a.clone(), PathBuf::from("locked/a.txt"), 1);
443 let entry_b = entry(b.clone(), PathBuf::from("b.txt"), 1);
444
445 let mut outcome = OperationOutcome::default();
446 outcome.succeeded = vec![entry_a.clone(), entry_b.clone()];
447
448 fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap();
451 sweep(&mut outcome, dest_dir.path(), ErrorStrategy::ContinueAndCollect, ProgressReporter::noop()).await;
452 fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap();
453
454 assert_eq!(outcome.cleanup_failed.len(), 1);
455 assert_eq!(outcome.cleanup_failed[0].0.path, a);
456 assert!(a.exists(), "a's deletion should have failed, leaving it in place");
457 assert!(!b.exists(), "b's deletion should still have succeeded");
458 assert_eq!(outcome.stopped_early, None);
459 }
460
461 #[cfg(unix)]
462 #[tokio::test]
463 async fn abort_on_error_sweep_stops_after_first_deletion_failure() {
464 use std::os::unix::fs::PermissionsExt;
465
466 let src_dir = tempdir().unwrap();
467 let dest_dir = tempdir().unwrap();
468
469 let locked_dir = src_dir.path().join("locked");
470 fs::create_dir(&locked_dir).unwrap();
471 let a = locked_dir.join("a.txt");
472 fs::write(&a, b"a").unwrap();
473
474 let b = src_dir.path().join("b.txt");
475 fs::write(&b, b"b").unwrap();
476 let c = src_dir.path().join("c.txt");
477 fs::write(&c, b"c").unwrap();
478
479 fs::write(dest_dir.path().join("b.txt"), b"b").unwrap();
480 fs::write(dest_dir.path().join("c.txt"), b"c").unwrap();
481
482 let entry_a = entry(a.clone(), PathBuf::from("locked/a.txt"), 1);
483 let entry_b = entry(b.clone(), PathBuf::from("b.txt"), 1);
484 let entry_c = entry(c.clone(), PathBuf::from("c.txt"), 1);
485
486 let mut outcome = OperationOutcome::default();
487 outcome.succeeded = vec![entry_a, entry_b, entry_c];
488
489 fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap();
490 sweep(&mut outcome, dest_dir.path(), ErrorStrategy::AbortOnError, ProgressReporter::noop()).await;
491 fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap();
492
493 assert_eq!(outcome.stopped_early, Some(StopReason::AbortOnError));
494 assert!(a.exists());
495 assert!(b.exists(), "b comes after the triggering failure, so it should never be attempted");
496 assert!(c.exists(), "c comes after the triggering failure, so it should never be attempted");
497 }
498
499 #[cfg(unix)]
500 #[tokio::test]
501 async fn undo_sweep_restores_everything_on_deletion_failure() {
502 use std::os::unix::fs::PermissionsExt;
503
504 let src_dir = tempdir().unwrap();
505 let dest_dir = tempdir().unwrap();
506
507 let locked_dir = src_dir.path().join("locked");
508 fs::create_dir(&locked_dir).unwrap();
509 let a = locked_dir.join("a.txt");
510 fs::write(&a, b"a").unwrap();
511
512 let b = src_dir.path().join("b.txt");
513 fs::write(&b, b"b").unwrap();
514
515 fs::create_dir_all(dest_dir.path().join("locked")).unwrap();
516 fs::write(dest_dir.path().join("locked").join("a.txt"), b"a").unwrap();
517 fs::write(dest_dir.path().join("b.txt"), b"b").unwrap();
518
519 let entry_a = entry(a.clone(), PathBuf::from("locked/a.txt"), 1);
520 let entry_b = entry(b.clone(), PathBuf::from("b.txt"), 1);
521
522 let mut outcome = OperationOutcome::default();
525 outcome.succeeded = vec![entry_b, entry_a];
526
527 fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap();
528 sweep(&mut outcome, dest_dir.path(), ErrorStrategy::Undo, ProgressReporter::noop()).await;
529 fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap();
530
531 assert!(outcome.succeeded.is_empty());
532 assert!(outcome.cleanup_failed.is_empty());
533 assert_eq!(outcome.stopped_early, Some(StopReason::Undo));
534
535 assert!(a.exists(), "a's source was never removed, since its deletion failed");
536 assert!(b.exists(), "b's source should have been restored from dest");
537 assert_eq!(fs::read(&b).unwrap(), b"b");
538
539 assert!(!dest_dir.path().join("locked").join("a.txt").exists());
540 assert!(!dest_dir.path().join("b.txt").exists());
541 }
542}