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(source, small_file_threshold).await?;
290 workloads.push(prefix_workload(workload, basename, *is_file));
291 }
292 let merged = merge_workloads(workloads);
293
294 let dest_caps = probe_fs_caps(dest).await?;
295 let mut outcome = run_workload_pipeline(
296 merged,
297 dest,
298 &dest_caps,
299 overwrite,
300 skip_if_identical,
301 preserve_permissions,
302 allow_filesystem_integrity_risk,
303 small_file_threshold,
304 config,
305 concurrency,
306 cancel,
307 reporter.clone(),
308 )
309 .await?;
310
311 sweep(&mut outcome, dest, config.error_strategy, reporter).await;
312
313 outcome.sources_failed = sources_failed;
314 Ok(outcome)
315}
316
317enum MoveOneOutcome {
318 Resolved,
322 Renamed,
323 CrossDevice,
324}
325
326async fn try_move_one_source<R: Renamer>(
327 source: &Path,
328 dest_target: &Path,
329 overwrite: bool,
330 skip_if_identical: bool,
331 renamer: &R,
332) -> Result<MoveOneOutcome> {
333 if !overwrite && resolve_existing_dest_conflict(source, dest_target, skip_if_identical).await? {
334 return Ok(MoveOneOutcome::Resolved);
335 }
336
337 match renamer.rename(source, dest_target).await {
338 Ok(()) => Ok(MoveOneOutcome::Renamed),
339 Err(err) if is_cross_device(&err) => Ok(MoveOneOutcome::CrossDevice),
340 Err(err) => Err(crate::error::classify_io_error(
341 err,
342 source.to_path_buf(),
343 0,
344 )),
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use std::fs;
351 use std::io;
352
353 use tempfile::tempdir;
354
355 use crate::planner::BatchConfig;
356
357 use super::*;
358
359 struct AlwaysCrossDevice;
360 impl Renamer for AlwaysCrossDevice {
361 async fn rename(&self, _source: &Path, _dest: &Path) -> io::Result<()> {
362 Err(io::Error::from(io::ErrorKind::CrossesDevices))
363 }
364 }
365
366 struct CrossDeviceFor(&'static str);
370 impl Renamer for CrossDeviceFor {
371 async fn rename(&self, source: &Path, dest: &Path) -> io::Result<()> {
372 if source.file_name().and_then(|n| n.to_str()) == Some(self.0) {
373 Err(io::Error::from(io::ErrorKind::CrossesDevices))
374 } else {
375 tokio::fs::rename(source, dest).await
376 }
377 }
378 }
379
380 #[tokio::test]
381 async fn same_device_sources_are_moved_via_rename_and_removed() {
382 let src_dir = tempdir().unwrap();
383 let dest_dir = tempdir().unwrap();
384 let a = src_dir.path().join("a.txt");
385 let b = src_dir.path().join("b.txt");
386 fs::write(&a, b"a").unwrap();
387 fs::write(&b, b"b").unwrap();
388
389 let outcome = move_many(
390 &[a.clone(), b.clone()],
391 dest_dir.path(),
392 false,
393 false,
394 false,
395 false,
396 256,
397 &BatchConfig::default(),
398 2,
399 CancellationToken::new(),
400 ProgressReporter::noop(),
401 &TokioRenamer,
402 )
403 .await
404 .unwrap();
405
406 assert!(outcome.succeeded.is_empty(), "fast path enumerates nothing");
407 assert!(outcome.sources_failed.is_empty());
408 assert!(!a.exists());
409 assert!(!b.exists());
410 assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"a");
411 assert_eq!(fs::read(dest_dir.path().join("b.txt")).unwrap(), b"b");
412 }
413
414 #[tokio::test]
415 async fn duplicate_basenames_are_rejected_before_any_source_is_touched() {
416 let root = tempdir().unwrap();
417 let a = root.path().join("one").join("shared.txt");
418 let b = root.path().join("two").join("shared.txt");
419 fs::create_dir_all(a.parent().unwrap()).unwrap();
420 fs::create_dir_all(b.parent().unwrap()).unwrap();
421 fs::write(&a, b"a").unwrap();
422 fs::write(&b, b"b").unwrap();
423 let dest_dir = tempdir().unwrap();
424
425 let result = move_many(
426 &[a.clone(), b.clone()],
427 dest_dir.path(),
428 false,
429 false,
430 false,
431 false,
432 256,
433 &BatchConfig::default(),
434 2,
435 CancellationToken::new(),
436 ProgressReporter::noop(),
437 &TokioRenamer,
438 )
439 .await;
440
441 assert!(matches!(result, Err(Error::DuplicateSourceName { .. })));
442 assert!(a.exists());
444 assert!(b.exists());
445 }
446
447 #[tokio::test]
448 async fn cross_device_sources_are_merged_into_one_pipeline_run() {
449 let src_dir = tempdir().unwrap();
450 let dest_dir = tempdir().unwrap();
451
452 let file_source = src_dir.path().join("notes.txt");
453 fs::write(&file_source, b"notes").unwrap();
454
455 let dir_source = src_dir.path().join("photos");
456 fs::create_dir(&dir_source).unwrap();
457 fs::write(dir_source.join("a.jpg"), b"a").unwrap();
458 fs::create_dir(dir_source.join("nested")).unwrap();
459 fs::write(dir_source.join("nested").join("b.jpg"), b"b").unwrap();
460
461 let outcome = move_many(
462 &[file_source.clone(), dir_source.clone()],
463 dest_dir.path(),
464 false,
465 false,
466 false,
467 false,
468 256,
469 &BatchConfig::default(),
470 2,
471 CancellationToken::new(),
472 ProgressReporter::noop(),
473 &AlwaysCrossDevice,
474 )
475 .await
476 .unwrap();
477
478 assert_eq!(outcome.succeeded.len(), 3);
479 assert!(outcome.failed.is_empty());
480 assert!(outcome.sources_failed.is_empty());
481
482 assert!(!file_source.exists());
483 assert!(!dir_source.join("a.jpg").exists());
487 assert!(!dir_source.join("nested").join("b.jpg").exists());
488 assert_eq!(
489 fs::read(dest_dir.path().join("notes.txt")).unwrap(),
490 b"notes"
491 );
492 assert_eq!(
493 fs::read(dest_dir.path().join("photos").join("a.jpg")).unwrap(),
494 b"a"
495 );
496 assert_eq!(
497 fs::read(dest_dir.path().join("photos").join("nested").join("b.jpg")).unwrap(),
498 b"b"
499 );
500 }
501
502 #[tokio::test]
503 async fn a_mixed_batch_fast_renames_some_and_falls_back_for_others() {
504 let src_dir = tempdir().unwrap();
505 let dest_dir = tempdir().unwrap();
506
507 let fast = src_dir.path().join("fast.txt");
508 let slow = src_dir.path().join("slow.txt");
509 fs::write(&fast, b"fast").unwrap();
510 fs::write(&slow, b"slow").unwrap();
511
512 let outcome = move_many(
513 &[fast.clone(), slow.clone()],
514 dest_dir.path(),
515 false,
516 false,
517 false,
518 false,
519 256,
520 &BatchConfig::default(),
521 2,
522 CancellationToken::new(),
523 ProgressReporter::noop(),
524 &CrossDeviceFor("slow.txt"),
525 )
526 .await
527 .unwrap();
528
529 assert_eq!(outcome.succeeded.len(), 1);
532 assert!(!fast.exists());
533 assert!(!slow.exists());
534 assert_eq!(fs::read(dest_dir.path().join("fast.txt")).unwrap(), b"fast");
535 assert_eq!(fs::read(dest_dir.path().join("slow.txt")).unwrap(), b"slow");
536 }
537
538 #[tokio::test]
539 async fn continue_and_collect_keeps_moving_other_sources_after_one_rename_failure() {
540 struct FailOn(&'static str);
541 impl Renamer for FailOn {
542 async fn rename(&self, source: &Path, dest: &Path) -> io::Result<()> {
543 if source.file_name().and_then(|n| n.to_str()) == Some(self.0) {
544 Err(io::Error::from(io::ErrorKind::PermissionDenied))
545 } else {
546 tokio::fs::rename(source, dest).await
547 }
548 }
549 }
550
551 let src_dir = tempdir().unwrap();
552 let dest_dir = tempdir().unwrap();
553 let a = src_dir.path().join("a.txt");
554 let b = src_dir.path().join("b.txt");
555 fs::write(&a, b"a").unwrap();
556 fs::write(&b, b"b").unwrap();
557
558 let outcome = move_many(
559 &[a.clone(), b.clone()],
560 dest_dir.path(),
561 false,
562 false,
563 false,
564 false,
565 256,
566 &BatchConfig::default(), 2,
568 CancellationToken::new(),
569 ProgressReporter::noop(),
570 &FailOn("a.txt"),
571 )
572 .await
573 .unwrap();
574
575 assert_eq!(outcome.sources_failed.len(), 1);
576 assert_eq!(outcome.sources_failed[0].0, a);
577 assert!(a.exists(), "a's move failed, so it should remain in place");
578 assert!(!b.exists(), "b should still have been moved");
579 assert_eq!(fs::read(dest_dir.path().join("b.txt")).unwrap(), b"b");
580 }
581
582 #[tokio::test]
583 async fn abort_on_error_stops_before_touching_later_sources() {
584 struct FailOn(&'static str);
585 impl Renamer for FailOn {
586 async fn rename(&self, source: &Path, dest: &Path) -> io::Result<()> {
587 if source.file_name().and_then(|n| n.to_str()) == Some(self.0) {
588 Err(io::Error::from(io::ErrorKind::PermissionDenied))
589 } else {
590 tokio::fs::rename(source, dest).await
591 }
592 }
593 }
594
595 let src_dir = tempdir().unwrap();
596 let dest_dir = tempdir().unwrap();
597 let a = src_dir.path().join("a.txt");
598 let b = src_dir.path().join("b.txt");
599 fs::write(&a, b"a").unwrap();
600 fs::write(&b, b"b").unwrap();
601
602 let config = BatchConfig {
603 error_strategy: ErrorStrategy::AbortOnError,
604 ..BatchConfig::default()
605 };
606
607 let outcome = move_many(
608 &[a.clone(), b.clone()],
609 dest_dir.path(),
610 false,
611 false,
612 false,
613 false,
614 256,
615 &config,
616 2,
617 CancellationToken::new(),
618 ProgressReporter::noop(),
619 &FailOn("a.txt"),
620 )
621 .await
622 .unwrap();
623
624 assert_eq!(outcome.stopped_early, Some(StopReason::AbortOnError));
625 assert_eq!(outcome.sources_failed.len(), 1);
626 assert!(a.exists());
627 assert!(
628 b.exists(),
629 "b comes after the triggering failure, so it should never be attempted"
630 );
631 }
632
633 #[cfg(feature = "checksum")]
634 #[tokio::test]
635 async fn skip_if_identical_applies_per_source_on_the_fast_path() {
636 let src_dir = tempdir().unwrap();
637 let dest_dir = tempdir().unwrap();
638
639 let a = src_dir.path().join("a.txt");
640 fs::write(&a, b"same").unwrap();
641 fs::write(dest_dir.path().join("a.txt"), b"same").unwrap();
642
643 let outcome = move_many(
644 std::slice::from_ref(&a),
645 dest_dir.path(),
646 false,
647 true, false,
649 false,
650 256,
651 &BatchConfig::default(),
652 2,
653 CancellationToken::new(),
654 ProgressReporter::noop(),
655 &TokioRenamer,
656 )
657 .await
658 .unwrap();
659
660 assert!(outcome.sources_failed.is_empty());
661 assert!(!a.exists(), "the redundant source should still be removed");
662 assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"same");
663 }
664}