Skip to main content

file_engine/operations/
move_many.rs

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
18/// Moves several independent sources into one destination directory in
19/// a single operation, rather than requiring the caller to run
20/// `MoveBuilder` once per source (and lose the ability to batch them
21/// under one `ErrorStrategy`/concurrency pool/progress stream). `dest`
22/// is always a directory sources land *inside* — each source keeps its
23/// own basename under it — never a rename target the way `MoveBuilder`'s
24/// `dest` can be.
25pub 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    /// See `MoveBuilder::skip_if_identical` — identical semantics,
61    /// applied per source.
62    #[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    /// Governs the batch as a whole, not source-by-source: under
85    /// `AbortOnError`/`Undo`, the first failure — whether a whole
86    /// source's rename or a single file inside one of the cross-device
87    /// fallback sources — stops every source not yet finished, not just
88    /// the one that failed.
89    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
135/// Every source's basename, validated up front: no two sources may
136/// share one (ambiguous — "moved into `dest`" would mean two different
137/// things), and every source must have one at all (rules out `/`, `.`,
138/// `..`, and similar). Both are whole-batch, caller-input problems, so
139/// they're reported as a fatal `Err` before any source is touched,
140/// rather than as a per-source failure discovered partway through —
141/// same reasoning as `allow_filesystem_integrity_risk`'s pre-flight
142/// check in `pipeline::run_workload_pipeline`.
143fn 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/// 1. Validate every source's basename (see `basenames`).
170/// 2. `create_dir_all(dest)` — a not-yet-created destination directory
171///    is normal, same reasoning as `move_path`'s equivalent fix.
172/// 3. For each source in order, resolve any existing-destination
173///    conflict and attempt an atomic rename (see
174///    `move_path::resolve_existing_dest_conflict`, reused verbatim —
175///    each source is independent, so the same per-source logic
176///    `MoveBuilder` uses applies unchanged here). A same-device source
177///    is fully done at this point, contributing nothing to `succeeded`
178///    (matching `MoveBuilder`'s "trivial success" fast path). A
179///    cross-device source is queued for step 4. Any other failure is a
180///    whole-source failure: recorded in `sources_failed` under
181///    `ContinueAndCollect`, or stops the entire batch under
182///    `AbortOnError`/`Undo` (rolling back every source already renamed
183///    in this loop, under `Undo`) — see `stop_reason_for`.
184/// 4. Every cross-device source is scanned, its `Workload` re-rooted
185///    under its own basename (`prefix_workload`), and all of them
186///    merged (`merge_workloads`) into one combined `Workload` — planned
187///    and dispatched in a single `run_workload_pipeline` call, sharing
188///    one concurrency pool and one `ErrorStrategy` scope across every
189///    remaining source instead of one pass per source.
190/// 5. The merged phase's `succeeded` entries go through the same
191///    deferred-deletion sweep `MoveBuilder` uses (`move_path::sweep`,
192///    reused directly).
193#[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    // (source, basename, is_file) — `is_file` decides how
216    // `prefix_workload` needs to treat that source's scan result, see
217    // its doc comment.
218    let mut pending_fallback: Vec<(PathBuf, PathBuf, bool)> = Vec::new();
219    // (source, dest_target) for every source already renamed in this
220    // loop — only ever consulted to roll back under `Undo`.
221    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    /// Already fully handled (no conflict and renamed, or an
319    /// identical-destination skip) — nothing more to do for this
320    /// source.
321    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    /// Cross-device for one specific source (by its basename), the
367    /// atomic rename for everything else — exercises a batch where some
368    /// sources take the fast path and others fall back, in one run.
369    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        // Neither source was touched — validation runs before any work.
443        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        // The sweep deletes individual files, not directories (matching
484        // `move_path.rs`'s own cross-device sweep) — the now-empty
485        // directory tree is left behind, only its files are gone.
486        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        // Only the cross-device source is enumerated — the fast-renamed
530        // one never goes through the pipeline.
531        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(), // default: ContinueAndCollect
567            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, // skip_if_identical
648            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}