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(
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    /// Already fully handled (no conflict and renamed, or an
324    /// identical-destination skip) — nothing more to do for this
325    /// source.
326    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    /// Cross-device for one specific source (by its basename), the
372    /// atomic rename for everything else — exercises a batch where some
373    /// sources take the fast path and others fall back, in one run.
374    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        // Neither source was touched — validation runs before any work.
448        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        // The sweep deletes individual files, not directories (matching
489        // `move_path.rs`'s own cross-device sweep) — the now-empty
490        // directory tree is left behind, only its files are gone.
491        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        // Only the cross-device source is enumerated — the fast-renamed
535        // one never goes through the pipeline.
536        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(), // default: ContinueAndCollect
572            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, // skip_if_identical
653            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}