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