Skip to main content

file_engine/operations/
move_path.rs

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    /// Only meaningful for the cross-device fallback (which reuses
49    /// `CopyAction`) — the atomic-rename fast path already preserves
50    /// everything about the source, permissions included, for free.
51    #[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    /// Only meaningful for the cross-device fallback, for the same
58    /// reason `.preserve_permissions()` is — the atomic-rename fast path
59    /// never touches `dest`'s filesystem capabilities at all. See
60    /// `CopyBuilder::allow_filesystem_integrity_risk`.
61    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
116/// Pure classification, unit-testable with synthetic `io::Error` values —
117/// no real cross-device filesystem needed.
118pub(crate) fn is_cross_device(err: &io::Error) -> bool {
119    err.kind() == io::ErrorKind::CrossesDevices
120}
121
122/// Injectable rename seam: production code uses `TokioRenamer`, tests
123/// inject a fake that deterministically returns a synthetic cross-device
124/// error to exercise the fallback wiring without a second filesystem.
125pub(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/// 1. Attempt a single atomic rename, deferring entirely to the OS's
138///    native rename semantics — no synthesized top-level overwrite check
139///    here, since for a directory move `dest` legitimately pre-exists as
140///    the directory being moved *into* (matching `CopyAction`'s
141///    `dest_root.join(relative_path)` placement, which mirrors contents
142///    into an existing directory rather than nesting a new one under
143///    it); a pre-check for "dest exists" would reject that normal case.
144///    Per-file overwrite conflicts are still caught correctly, just
145///    per-entry, by `CopyAction` in the fallback path below.
146/// 2. On cross-device failure, fall back to
147///    `pipeline::run_copy_pipeline` (unmodified — no dedicated
148///    `EntryAction` for move).
149/// 3. Any other rename error surfaces directly.
150/// 4. Once the copy phase resolves, run the deferred deletion sweep over
151///    `succeeded`, governed by the same `ErrorStrategy`.
152#[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        // Trivially "everything succeeded" without ever enumerating
168        // individual entries, so no progress events are emitted either.
169        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
193/// Deletes each `succeeded` entry's original source. Sequential — no
194/// batching/concurrency of its own, since deletions are cheap metadata
195/// operations, not data transfer.
196async 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
261/// Restores every entry to its pre-operation state: already-deleted
262/// sources are restored from the destination copy then that copy is
263/// removed; sources that were never touched just have their destination
264/// copy removed (identical to `CopyAction::undo`, reused directly rather
265/// than reimplemented).
266async 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    // Only referenced by the `#[cfg(unix)]` tests below (they need a
359    // deferred-deletion sweep to exercise, which only exists on the
360    // cross-device fallback path) — undetected until a Windows
361    // cross-compile of `--tests` was actually run.
362    #[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        // unlink needs write+execute on the containing directory, so
491        // locking it down makes deleting `a` fail with permission denied.
492        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        // b first (its deletion succeeds), then a (its deletion fails and
588        // triggers rollback of everything, including b).
589        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}