1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::Instant;
4
5use tokio_util::sync::CancellationToken;
6
7use crate::error::{Error, Result};
8use crate::planner::{BatchConfig, ErrorStrategy, OperationOutcome, StopReason};
9use crate::profiler::{probe_fs_caps, scan, DEFAULT_SMALL_FILE_THRESHOLD};
10use crate::progress::ProgressReporter;
11
12use super::default_concurrency;
13use super::move_path::{
14 is_cross_device, resolve_existing_dest_conflict, sweep, Renamer, TokioRenamer,
15};
16use super::pipeline::{merge_workloads, prefix_workload, run_workload_pipeline};
17
18pub struct MoveManyBuilder {
26 sources: Vec<PathBuf>,
27 dest: PathBuf,
28 overwrite: bool,
29 skip_if_identical: bool,
30 preserve_permissions: bool,
31 allow_filesystem_integrity_risk: bool,
32 small_file_threshold: Option<u64>,
33 batch_config: BatchConfig,
34 concurrency: Option<usize>,
35}
36
37impl MoveManyBuilder {
38 pub(crate) fn new(
39 sources: impl IntoIterator<Item = impl Into<PathBuf>>,
40 dest: impl Into<PathBuf>,
41 ) -> Self {
42 Self {
43 sources: sources.into_iter().map(Into::into).collect(),
44 dest: dest.into(),
45 overwrite: false,
46 skip_if_identical: false,
47 preserve_permissions: false,
48 allow_filesystem_integrity_risk: false,
49 small_file_threshold: None,
50 batch_config: BatchConfig::default(),
51 concurrency: None,
52 }
53 }
54
55 pub fn overwrite(mut self, overwrite: bool) -> Self {
56 self.overwrite = overwrite;
57 self
58 }
59
60 #[cfg(feature = "checksum")]
63 pub fn skip_if_identical(mut self, skip: bool) -> Self {
64 self.skip_if_identical = skip;
65 self
66 }
67
68 #[cfg(all(unix, feature = "permissions"))]
69 pub fn preserve_permissions(mut self, preserve: bool) -> Self {
70 self.preserve_permissions = preserve;
71 self
72 }
73
74 pub fn allow_filesystem_integrity_risk(mut self, allow: bool) -> Self {
75 self.allow_filesystem_integrity_risk = allow;
76 self
77 }
78
79 pub fn small_file_threshold(mut self, bytes: u64) -> Self {
80 self.small_file_threshold = Some(bytes);
81 self
82 }
83
84 pub fn on_error(mut self, strategy: ErrorStrategy) -> Self {
90 self.batch_config.error_strategy = strategy;
91 self
92 }
93
94 pub fn batch_concurrency(mut self, n: usize) -> Self {
95 self.concurrency = Some(n);
96 self
97 }
98
99 pub fn start(self) -> Result<crate::handle::Handle<OperationOutcome>> {
100 let cancel = CancellationToken::new();
101 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
102 let reporter = ProgressReporter::new(tx);
103
104 let concurrency = self.concurrency.unwrap_or_else(default_concurrency);
105 let threshold = self
106 .small_file_threshold
107 .unwrap_or(DEFAULT_SMALL_FILE_THRESHOLD);
108 let cancel_for_task = cancel.clone();
109
110 let join_handle = tokio::spawn(async move {
111 let started = Instant::now();
112 let mut outcome = move_many(
113 &self.sources,
114 &self.dest,
115 self.overwrite,
116 self.skip_if_identical,
117 self.preserve_permissions,
118 self.allow_filesystem_integrity_risk,
119 threshold,
120 &self.batch_config,
121 concurrency,
122 cancel_for_task,
123 reporter,
124 &TokioRenamer,
125 )
126 .await?;
127 outcome.duration = started.elapsed();
128 Ok(outcome)
129 });
130
131 Ok(crate::handle::Handle::new(join_handle, rx, cancel))
132 }
133}
134
135fn basenames(sources: &[PathBuf]) -> Result<Vec<PathBuf>> {
144 let mut seen: HashMap<PathBuf, &PathBuf> = HashMap::new();
145 let mut basenames = Vec::with_capacity(sources.len());
146
147 for source in sources {
148 let basename =
149 source
150 .file_name()
151 .map(PathBuf::from)
152 .ok_or_else(|| Error::InvalidSourceName {
153 path: source.clone(),
154 })?;
155
156 if let Some(other) = seen.get(&basename) {
157 return Err(Error::DuplicateSourceName {
158 path: source.clone(),
159 other: (*other).clone(),
160 });
161 }
162 seen.insert(basename.clone(), source);
163 basenames.push(basename);
164 }
165
166 Ok(basenames)
167}
168
169#[allow(clippy::too_many_arguments)]
194async fn move_many<R: Renamer>(
195 sources: &[PathBuf],
196 dest: &Path,
197 overwrite: bool,
198 skip_if_identical: bool,
199 preserve_permissions: bool,
200 allow_filesystem_integrity_risk: bool,
201 small_file_threshold: u64,
202 config: &BatchConfig,
203 concurrency: usize,
204 cancel: CancellationToken,
205 reporter: ProgressReporter,
206 renamer: &R,
207) -> Result<OperationOutcome> {
208 let basenames = basenames(sources)?;
209
210 if let Err(err) = tokio::fs::create_dir_all(dest).await {
211 return Err(crate::error::classify_io_error(err, dest.to_path_buf(), 0));
212 }
213
214 let mut sources_failed: Vec<(PathBuf, Error)> = Vec::new();
215 let mut pending_fallback: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
219 let mut renamed_ok: Vec<(PathBuf, PathBuf)> = Vec::new();
222 let mut stopped_early = None;
223
224 for (source, basename) in sources.iter().zip(basenames.iter()) {
225 if cancel.is_cancelled() {
226 stopped_early = Some(StopReason::Cancelled);
227 break;
228 }
229
230 let dest_target = dest.join(basename);
231 let result =
232 try_move_one_source(source, &dest_target, overwrite, skip_if_identical, renamer).await;
233
234 match result {
235 Ok(MoveOneOutcome::Resolved) => {}
236 Ok(MoveOneOutcome::Renamed) => {
237 renamed_ok.push((source.clone(), dest_target));
238 }
239 Ok(MoveOneOutcome::CrossDevice) => {
240 let is_file = tokio::fs::metadata(source)
241 .await
242 .map(|m| m.is_file())
243 .unwrap_or(false);
244 pending_fallback.push((source.clone(), basename.clone(), is_file));
245 }
246 Err(err) => {
247 let reason = if err.is_fatal() {
248 Some(StopReason::Fatal)
249 } else {
250 match config.error_strategy {
251 ErrorStrategy::ContinueAndCollect => None,
252 ErrorStrategy::AbortOnError => Some(StopReason::AbortOnError),
253 ErrorStrategy::Undo => Some(StopReason::Undo),
254 }
255 };
256
257 sources_failed.push((source.clone(), err));
258
259 if let Some(reason) = reason {
260 if matches!(reason, StopReason::Undo) {
261 for (src, dest_target) in renamed_ok.iter().rev() {
262 let _ = renamer.rename(dest_target, src).await;
263 }
264 }
265 stopped_early = Some(reason);
266 break;
267 }
268 }
269 }
270 }
271
272 if let Some(reason) = stopped_early {
273 return Ok(OperationOutcome {
274 sources_failed,
275 stopped_early: Some(reason),
276 ..OperationOutcome::default()
277 });
278 }
279
280 if pending_fallback.is_empty() {
281 return Ok(OperationOutcome {
282 sources_failed,
283 ..OperationOutcome::default()
284 });
285 }
286
287 let mut workloads = Vec::with_capacity(pending_fallback.len());
288 for (source, basename, is_file) in &pending_fallback {
289 let workload = scan(
290 source,
291 small_file_threshold,
292 crate::profiler::ScanOptions::default(),
293 )
294 .await?;
295 workloads.push(prefix_workload(workload, basename, *is_file));
296 }
297 let merged = merge_workloads(workloads);
298
299 let dest_caps = probe_fs_caps(dest).await?;
300 let mut outcome = run_workload_pipeline(
301 merged,
302 dest,
303 &dest_caps,
304 overwrite,
305 skip_if_identical,
306 preserve_permissions,
307 allow_filesystem_integrity_risk,
308 small_file_threshold,
309 config,
310 concurrency,
311 cancel,
312 reporter.clone(),
313 )
314 .await?;
315
316 sweep(&mut outcome, dest, config.error_strategy, reporter).await;
317
318 outcome.sources_failed = sources_failed;
319 Ok(outcome)
320}
321
322enum MoveOneOutcome {
323 Resolved,
327 Renamed,
328 CrossDevice,
329}
330
331async fn try_move_one_source<R: Renamer>(
332 source: &Path,
333 dest_target: &Path,
334 overwrite: bool,
335 skip_if_identical: bool,
336 renamer: &R,
337) -> Result<MoveOneOutcome> {
338 if !overwrite && resolve_existing_dest_conflict(source, dest_target, skip_if_identical).await? {
339 return Ok(MoveOneOutcome::Resolved);
340 }
341
342 match renamer.rename(source, dest_target).await {
343 Ok(()) => Ok(MoveOneOutcome::Renamed),
344 Err(err) if is_cross_device(&err) => Ok(MoveOneOutcome::CrossDevice),
345 Err(err) => Err(crate::error::classify_io_error(
346 err,
347 source.to_path_buf(),
348 0,
349 )),
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use std::fs;
356 use std::io;
357
358 use tempfile::tempdir;
359
360 use crate::planner::BatchConfig;
361
362 use super::*;
363
364 struct AlwaysCrossDevice;
365 impl Renamer for AlwaysCrossDevice {
366 async fn rename(&self, _source: &Path, _dest: &Path) -> io::Result<()> {
367 Err(io::Error::from(io::ErrorKind::CrossesDevices))
368 }
369 }
370
371 struct CrossDeviceFor(&'static str);
375 impl Renamer for CrossDeviceFor {
376 async fn rename(&self, source: &Path, dest: &Path) -> io::Result<()> {
377 if source.file_name().and_then(|n| n.to_str()) == Some(self.0) {
378 Err(io::Error::from(io::ErrorKind::CrossesDevices))
379 } else {
380 tokio::fs::rename(source, dest).await
381 }
382 }
383 }
384
385 #[tokio::test]
386 async fn same_device_sources_are_moved_via_rename_and_removed() {
387 let src_dir = tempdir().unwrap();
388 let dest_dir = tempdir().unwrap();
389 let a = src_dir.path().join("a.txt");
390 let b = src_dir.path().join("b.txt");
391 fs::write(&a, b"a").unwrap();
392 fs::write(&b, b"b").unwrap();
393
394 let outcome = move_many(
395 &[a.clone(), b.clone()],
396 dest_dir.path(),
397 false,
398 false,
399 false,
400 false,
401 256,
402 &BatchConfig::default(),
403 2,
404 CancellationToken::new(),
405 ProgressReporter::noop(),
406 &TokioRenamer,
407 )
408 .await
409 .unwrap();
410
411 assert!(outcome.succeeded.is_empty(), "fast path enumerates nothing");
412 assert!(outcome.sources_failed.is_empty());
413 assert!(!a.exists());
414 assert!(!b.exists());
415 assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"a");
416 assert_eq!(fs::read(dest_dir.path().join("b.txt")).unwrap(), b"b");
417 }
418
419 #[tokio::test]
420 async fn duplicate_basenames_are_rejected_before_any_source_is_touched() {
421 let root = tempdir().unwrap();
422 let a = root.path().join("one").join("shared.txt");
423 let b = root.path().join("two").join("shared.txt");
424 fs::create_dir_all(a.parent().unwrap()).unwrap();
425 fs::create_dir_all(b.parent().unwrap()).unwrap();
426 fs::write(&a, b"a").unwrap();
427 fs::write(&b, b"b").unwrap();
428 let dest_dir = tempdir().unwrap();
429
430 let result = move_many(
431 &[a.clone(), b.clone()],
432 dest_dir.path(),
433 false,
434 false,
435 false,
436 false,
437 256,
438 &BatchConfig::default(),
439 2,
440 CancellationToken::new(),
441 ProgressReporter::noop(),
442 &TokioRenamer,
443 )
444 .await;
445
446 assert!(matches!(result, Err(Error::DuplicateSourceName { .. })));
447 assert!(a.exists());
449 assert!(b.exists());
450 }
451
452 #[tokio::test]
453 async fn cross_device_sources_are_merged_into_one_pipeline_run() {
454 let src_dir = tempdir().unwrap();
455 let dest_dir = tempdir().unwrap();
456
457 let file_source = src_dir.path().join("notes.txt");
458 fs::write(&file_source, b"notes").unwrap();
459
460 let dir_source = src_dir.path().join("photos");
461 fs::create_dir(&dir_source).unwrap();
462 fs::write(dir_source.join("a.jpg"), b"a").unwrap();
463 fs::create_dir(dir_source.join("nested")).unwrap();
464 fs::write(dir_source.join("nested").join("b.jpg"), b"b").unwrap();
465
466 let outcome = move_many(
467 &[file_source.clone(), dir_source.clone()],
468 dest_dir.path(),
469 false,
470 false,
471 false,
472 false,
473 256,
474 &BatchConfig::default(),
475 2,
476 CancellationToken::new(),
477 ProgressReporter::noop(),
478 &AlwaysCrossDevice,
479 )
480 .await
481 .unwrap();
482
483 assert_eq!(outcome.succeeded.len(), 3);
484 assert!(outcome.failed.is_empty());
485 assert!(outcome.sources_failed.is_empty());
486
487 assert!(!file_source.exists());
488 assert!(!dir_source.join("a.jpg").exists());
492 assert!(!dir_source.join("nested").join("b.jpg").exists());
493 assert_eq!(
494 fs::read(dest_dir.path().join("notes.txt")).unwrap(),
495 b"notes"
496 );
497 assert_eq!(
498 fs::read(dest_dir.path().join("photos").join("a.jpg")).unwrap(),
499 b"a"
500 );
501 assert_eq!(
502 fs::read(dest_dir.path().join("photos").join("nested").join("b.jpg")).unwrap(),
503 b"b"
504 );
505 }
506
507 #[tokio::test]
508 async fn a_mixed_batch_fast_renames_some_and_falls_back_for_others() {
509 let src_dir = tempdir().unwrap();
510 let dest_dir = tempdir().unwrap();
511
512 let fast = src_dir.path().join("fast.txt");
513 let slow = src_dir.path().join("slow.txt");
514 fs::write(&fast, b"fast").unwrap();
515 fs::write(&slow, b"slow").unwrap();
516
517 let outcome = move_many(
518 &[fast.clone(), slow.clone()],
519 dest_dir.path(),
520 false,
521 false,
522 false,
523 false,
524 256,
525 &BatchConfig::default(),
526 2,
527 CancellationToken::new(),
528 ProgressReporter::noop(),
529 &CrossDeviceFor("slow.txt"),
530 )
531 .await
532 .unwrap();
533
534 assert_eq!(outcome.succeeded.len(), 1);
537 assert!(!fast.exists());
538 assert!(!slow.exists());
539 assert_eq!(fs::read(dest_dir.path().join("fast.txt")).unwrap(), b"fast");
540 assert_eq!(fs::read(dest_dir.path().join("slow.txt")).unwrap(), b"slow");
541 }
542
543 #[tokio::test]
544 async fn continue_and_collect_keeps_moving_other_sources_after_one_rename_failure() {
545 struct FailOn(&'static str);
546 impl Renamer for FailOn {
547 async fn rename(&self, source: &Path, dest: &Path) -> io::Result<()> {
548 if source.file_name().and_then(|n| n.to_str()) == Some(self.0) {
549 Err(io::Error::from(io::ErrorKind::PermissionDenied))
550 } else {
551 tokio::fs::rename(source, dest).await
552 }
553 }
554 }
555
556 let src_dir = tempdir().unwrap();
557 let dest_dir = tempdir().unwrap();
558 let a = src_dir.path().join("a.txt");
559 let b = src_dir.path().join("b.txt");
560 fs::write(&a, b"a").unwrap();
561 fs::write(&b, b"b").unwrap();
562
563 let outcome = move_many(
564 &[a.clone(), b.clone()],
565 dest_dir.path(),
566 false,
567 false,
568 false,
569 false,
570 256,
571 &BatchConfig::default(), 2,
573 CancellationToken::new(),
574 ProgressReporter::noop(),
575 &FailOn("a.txt"),
576 )
577 .await
578 .unwrap();
579
580 assert_eq!(outcome.sources_failed.len(), 1);
581 assert_eq!(outcome.sources_failed[0].0, a);
582 assert!(a.exists(), "a's move failed, so it should remain in place");
583 assert!(!b.exists(), "b should still have been moved");
584 assert_eq!(fs::read(dest_dir.path().join("b.txt")).unwrap(), b"b");
585 }
586
587 #[tokio::test]
588 async fn abort_on_error_stops_before_touching_later_sources() {
589 struct FailOn(&'static str);
590 impl Renamer for FailOn {
591 async fn rename(&self, source: &Path, dest: &Path) -> io::Result<()> {
592 if source.file_name().and_then(|n| n.to_str()) == Some(self.0) {
593 Err(io::Error::from(io::ErrorKind::PermissionDenied))
594 } else {
595 tokio::fs::rename(source, dest).await
596 }
597 }
598 }
599
600 let src_dir = tempdir().unwrap();
601 let dest_dir = tempdir().unwrap();
602 let a = src_dir.path().join("a.txt");
603 let b = src_dir.path().join("b.txt");
604 fs::write(&a, b"a").unwrap();
605 fs::write(&b, b"b").unwrap();
606
607 let config = BatchConfig {
608 error_strategy: ErrorStrategy::AbortOnError,
609 ..BatchConfig::default()
610 };
611
612 let outcome = move_many(
613 &[a.clone(), b.clone()],
614 dest_dir.path(),
615 false,
616 false,
617 false,
618 false,
619 256,
620 &config,
621 2,
622 CancellationToken::new(),
623 ProgressReporter::noop(),
624 &FailOn("a.txt"),
625 )
626 .await
627 .unwrap();
628
629 assert_eq!(outcome.stopped_early, Some(StopReason::AbortOnError));
630 assert_eq!(outcome.sources_failed.len(), 1);
631 assert!(a.exists());
632 assert!(
633 b.exists(),
634 "b comes after the triggering failure, so it should never be attempted"
635 );
636 }
637
638 #[cfg(feature = "checksum")]
639 #[tokio::test]
640 async fn skip_if_identical_applies_per_source_on_the_fast_path() {
641 let src_dir = tempdir().unwrap();
642 let dest_dir = tempdir().unwrap();
643
644 let a = src_dir.path().join("a.txt");
645 fs::write(&a, b"same").unwrap();
646 fs::write(dest_dir.path().join("a.txt"), b"same").unwrap();
647
648 let outcome = move_many(
649 std::slice::from_ref(&a),
650 dest_dir.path(),
651 false,
652 true, false,
654 false,
655 256,
656 &BatchConfig::default(),
657 2,
658 CancellationToken::new(),
659 ProgressReporter::noop(),
660 &TokioRenamer,
661 )
662 .await
663 .unwrap();
664
665 assert!(outcome.sources_failed.is_empty());
666 assert!(!a.exists(), "the redundant source should still be removed");
667 assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"same");
668 }
669}