Skip to main content

file_engine/operations/
move_path.rs

1use std::collections::HashSet;
2use std::io;
3use std::path::{Path, PathBuf};
4
5use tokio_util::sync::CancellationToken;
6
7use crate::error::{Error, Result};
8use crate::planner::{BatchConfig, CopyAction, EntryAction, ErrorStrategy, OperationOutcome, StopReason};
9use crate::profiler::{Entry, DEFAULT_SMALL_FILE_THRESHOLD};
10use crate::progress::{Progress, ProgressReporter};
11
12use super::default_concurrency;
13use super::pipeline::run_copy_pipeline;
14
15pub struct MoveBuilder {
16    source: PathBuf,
17    dest: PathBuf,
18    overwrite: bool,
19    preserve_permissions: bool,
20    allow_filesystem_integrity_risk: bool,
21    small_file_threshold: Option<u64>,
22    batch_config: BatchConfig,
23    concurrency: Option<usize>,
24}
25
26impl MoveBuilder {
27    pub(crate) fn new(source: impl Into<PathBuf>, dest: impl Into<PathBuf>) -> Self {
28        Self {
29            source: source.into(),
30            dest: dest.into(),
31            overwrite: false,
32            preserve_permissions: false,
33            allow_filesystem_integrity_risk: false,
34            small_file_threshold: None,
35            batch_config: BatchConfig::default(),
36            concurrency: None,
37        }
38    }
39
40    pub fn overwrite(mut self, overwrite: bool) -> Self {
41        self.overwrite = overwrite;
42        self
43    }
44
45    /// Only meaningful for the cross-device fallback (which reuses
46    /// `CopyAction`) — the atomic-rename fast path already preserves
47    /// everything about the source, permissions included, for free.
48    #[cfg(all(unix, feature = "permissions"))]
49    pub fn preserve_permissions(mut self, preserve: bool) -> Self {
50        self.preserve_permissions = preserve;
51        self
52    }
53
54    /// Only meaningful for the cross-device fallback, for the same
55    /// reason `.preserve_permissions()` is — the atomic-rename fast path
56    /// never touches `dest`'s filesystem capabilities at all. See
57    /// `CopyBuilder::allow_filesystem_integrity_risk` and
58    /// dev-docs/design/filesystem-detection.md.
59    pub fn allow_filesystem_integrity_risk(mut self, allow: bool) -> Self {
60        self.allow_filesystem_integrity_risk = allow;
61        self
62    }
63
64    pub fn small_file_threshold(mut self, bytes: u64) -> Self {
65        self.small_file_threshold = Some(bytes);
66        self
67    }
68
69    pub fn on_error(mut self, strategy: ErrorStrategy) -> Self {
70        self.batch_config.error_strategy = strategy;
71        self
72    }
73
74    pub fn batch_concurrency(mut self, n: usize) -> Self {
75        self.concurrency = Some(n);
76        self
77    }
78
79    pub fn start(self) -> Result<crate::handle::Handle<OperationOutcome>> {
80        let cancel = CancellationToken::new();
81        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
82        let reporter = ProgressReporter::new(tx);
83
84        let concurrency = self.concurrency.unwrap_or_else(default_concurrency);
85        let threshold = self.small_file_threshold.unwrap_or(DEFAULT_SMALL_FILE_THRESHOLD);
86        let cancel_for_task = cancel.clone();
87
88        let join_handle = tokio::spawn(async move {
89            move_path(
90                &self.source,
91                &self.dest,
92                self.overwrite,
93                self.preserve_permissions,
94                self.allow_filesystem_integrity_risk,
95                threshold,
96                &self.batch_config,
97                concurrency,
98                cancel_for_task,
99                reporter,
100                &TokioRenamer,
101            )
102            .await
103        });
104
105        Ok(crate::handle::Handle::new(join_handle, rx, cancel))
106    }
107}
108
109/// Pure classification, unit-testable with synthetic `io::Error` values —
110/// no real cross-device filesystem needed. See
111/// dev-docs/design/batching-engine.md, "Move" / "EXDEV test seam".
112pub(crate) fn is_cross_device(err: &io::Error) -> bool {
113    err.kind() == io::ErrorKind::CrossesDevices
114}
115
116/// Injectable rename seam: production code uses `TokioRenamer`, tests
117/// inject a fake that deterministically returns a synthetic cross-device
118/// error to exercise the fallback wiring without a second filesystem.
119pub(crate) trait Renamer {
120    async fn rename(&self, source: &Path, dest: &Path) -> io::Result<()>;
121}
122
123pub(crate) struct TokioRenamer;
124
125impl Renamer for TokioRenamer {
126    async fn rename(&self, source: &Path, dest: &Path) -> io::Result<()> {
127        tokio::fs::rename(source, dest).await
128    }
129}
130
131/// 1. Attempt a single atomic rename, deferring entirely to the OS's
132/// native rename semantics — no synthesized top-level overwrite check
133/// here, since for a directory move `dest` legitimately pre-exists as the
134/// directory being moved *into* (matching `CopyAction`'s
135/// `dest_root.join(relative_path)` placement, which mirrors contents into
136/// an existing directory rather than nesting a new one under it); a
137/// pre-check for "dest exists" would reject that normal case. Per-file
138/// overwrite conflicts are still caught correctly, just per-entry, by
139/// `CopyAction` in the fallback path below. 2. On cross-device failure,
140/// fall back to `pipeline::run_copy_pipeline` (unmodified — no dedicated
141/// `EntryAction` for move). 3. Any other rename error surfaces directly.
142/// 4. Once the copy phase resolves, run the deferred deletion sweep over
143/// `succeeded`, governed by the same `ErrorStrategy`. See
144/// dev-docs/design/batching-engine.md, "Move".
145#[allow(clippy::too_many_arguments)]
146async fn move_path<R: Renamer>(
147    source: &Path,
148    dest: &Path,
149    overwrite: bool,
150    preserve_permissions: bool,
151    allow_filesystem_integrity_risk: bool,
152    small_file_threshold: u64,
153    config: &BatchConfig,
154    concurrency: usize,
155    cancel: CancellationToken,
156    reporter: ProgressReporter,
157    renamer: &R,
158) -> Result<OperationOutcome> {
159    match renamer.rename(source, dest).await {
160        // Trivially "everything succeeded" without ever enumerating
161        // individual entries, so no progress events are emitted either
162        // — see dev-docs/design/batching-engine.md, "Move".
163        Ok(()) => return Ok(OperationOutcome::default()),
164        Err(err) if is_cross_device(&err) => {}
165        Err(err) => return Err(classify_error(err, source)),
166    }
167
168    let mut outcome = run_copy_pipeline(
169        source,
170        dest,
171        overwrite,
172        preserve_permissions,
173        allow_filesystem_integrity_risk,
174        small_file_threshold,
175        config,
176        concurrency,
177        cancel,
178        reporter.clone(),
179    )
180    .await?;
181
182    sweep(&mut outcome, dest, config.error_strategy, reporter).await;
183
184    Ok(outcome)
185}
186
187/// Deletes each `succeeded` entry's original source. Sequential — no
188/// batching/concurrency of its own, since deletions are cheap metadata
189/// operations, not data transfer.
190async fn sweep(
191    outcome: &mut OperationOutcome,
192    dest_root: &Path,
193    error_strategy: ErrorStrategy,
194    reporter: ProgressReporter,
195) {
196    if outcome.succeeded.is_empty() {
197        return;
198    }
199
200    let entries = outcome.succeeded.clone();
201    let mut deleted_paths: HashSet<PathBuf> = HashSet::new();
202
203    reporter.send(Progress::Started { bytes_total: None, entries_total: entries.len() });
204
205    for entry in &entries {
206        reporter.send(Progress::EntryStarted { entry: entry.clone() });
207
208        match remove_source(entry).await {
209            Ok(()) => {
210                reporter.send(Progress::EntryCompleted { entry: entry.clone() });
211                deleted_paths.insert(entry.path.clone());
212            }
213            Err(err) => {
214                reporter.send(Progress::EntryFailed { entry: entry.clone() });
215                let fatal = err.is_fatal();
216                let reason = if fatal {
217                    Some(StopReason::Fatal)
218                } else {
219                    match error_strategy {
220                        ErrorStrategy::ContinueAndCollect => None,
221                        ErrorStrategy::AbortOnError => Some(StopReason::AbortOnError),
222                        ErrorStrategy::Undo => Some(StopReason::Undo),
223                    }
224                };
225
226                outcome.cleanup_failed.push((entry.clone(), err));
227
228                if let Some(reason) = reason {
229                    if outcome.stopped_early.is_none() {
230                        outcome.stopped_early = Some(reason);
231                    }
232
233                    if matches!(error_strategy, ErrorStrategy::Undo) {
234                        rollback(&entries, &deleted_paths, dest_root).await;
235                        outcome.succeeded.clear();
236                        outcome.cleanup_failed.clear();
237                    }
238
239                    break;
240                }
241            }
242        }
243    }
244}
245
246/// Restores every entry to its pre-operation state: already-deleted
247/// sources are restored from the destination copy then that copy is
248/// removed; sources that were never touched just have their destination
249/// copy removed (identical to `CopyAction::undo`, reused directly rather
250/// than reimplemented).
251async fn rollback(entries: &[Entry], deleted_paths: &HashSet<PathBuf>, dest_root: &Path) {
252    let copy_action = CopyAction { overwrite: true };
253    for entry in entries.iter().rev() {
254        if deleted_paths.contains(&entry.path) {
255            let _ = restore_source(entry, dest_root).await;
256        } else {
257            let _ = copy_action.undo(entry, dest_root).await;
258        }
259    }
260}
261
262async fn remove_source(entry: &Entry) -> Result<()> {
263    match tokio::fs::remove_file(&entry.path).await {
264        Ok(()) => Ok(()),
265        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
266        Err(e) => Err(classify_error(e, &entry.path)),
267    }
268}
269
270async fn restore_source(entry: &Entry, dest_root: &Path) -> Result<()> {
271    let dest_path = dest_root.join(&entry.relative_path);
272    tokio::fs::copy(&dest_path, &entry.path)
273        .await
274        .map_err(|e| classify_error(e, &entry.path))?;
275    tokio::fs::remove_file(&dest_path)
276        .await
277        .map_err(|e| classify_error(e, &dest_path))?;
278    Ok(())
279}
280
281fn classify_error(err: io::Error, path: &Path) -> Error {
282    match err.kind() {
283        io::ErrorKind::NotFound => Error::SourceNotFound { path: path.to_path_buf() },
284        io::ErrorKind::PermissionDenied => Error::PermissionDenied { path: path.to_path_buf() },
285        io::ErrorKind::StorageFull => Error::NoSpace { needed: 0, available: 0 },
286        _ => Error::Io { path: path.to_path_buf(), source: err },
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use std::fs;
293
294    use tempfile::tempdir;
295
296    use super::*;
297
298    #[test]
299    fn is_cross_device_true_for_crosses_devices_kind() {
300        let err = io::Error::from(io::ErrorKind::CrossesDevices);
301        assert!(is_cross_device(&err));
302    }
303
304    #[test]
305    fn is_cross_device_false_for_other_kinds() {
306        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::NotFound, io::ErrorKind::Other] {
307            let err = io::Error::from(kind);
308            assert!(!is_cross_device(&err), "{kind:?} should not be classified as cross-device");
309        }
310    }
311
312    struct AlwaysCrossDevice;
313    impl Renamer for AlwaysCrossDevice {
314        async fn rename(&self, _source: &Path, _dest: &Path) -> io::Result<()> {
315            Err(io::Error::from(io::ErrorKind::CrossesDevices))
316        }
317    }
318
319    struct AlwaysPermissionDenied;
320    impl Renamer for AlwaysPermissionDenied {
321        async fn rename(&self, _source: &Path, _dest: &Path) -> io::Result<()> {
322            Err(io::Error::from(io::ErrorKind::PermissionDenied))
323        }
324    }
325
326    // Only referenced by the `#[cfg(unix)]` tests below (they need a
327    // deferred-deletion sweep to exercise, which only exists on the
328    // cross-device fallback path) — undetected until a Windows
329    // cross-compile of `--tests` was actually run.
330    #[cfg(unix)]
331    fn entry(path: PathBuf, relative_path: PathBuf, size: u64) -> Entry {
332        Entry { path, relative_path, size, modified: None }
333    }
334
335    #[tokio::test]
336    async fn same_filesystem_move_uses_rename_and_skips_pipeline() {
337        let root = tempdir().unwrap();
338        let source = root.path().join("src.txt");
339        let dest = root.path().join("dst.txt");
340        fs::write(&source, b"hello").unwrap();
341
342        let outcome = move_path(
343            &source,
344            &dest,
345            false,
346            false,
347            false,
348            256,
349            &BatchConfig::default(),
350            2,
351            CancellationToken::new(),
352            ProgressReporter::noop(),
353            &TokioRenamer,
354        )
355        .await
356        .unwrap();
357
358        assert!(outcome.succeeded.is_empty(), "fast path doesn't enumerate entries");
359        assert!(!source.exists());
360        assert_eq!(fs::read(&dest).unwrap(), b"hello");
361    }
362
363    #[tokio::test]
364    async fn cross_device_fallback_copies_then_deletes_sources() {
365        let src_dir = tempdir().unwrap();
366        let dest_dir = tempdir().unwrap();
367        fs::write(src_dir.path().join("a.txt"), b"a").unwrap();
368        fs::write(src_dir.path().join("b.txt"), b"b").unwrap();
369
370        let outcome = move_path(
371            src_dir.path(),
372            dest_dir.path(),
373            false,
374            false,
375            false,
376            256,
377            &BatchConfig::default(),
378            2,
379            CancellationToken::new(),
380            ProgressReporter::noop(),
381            &AlwaysCrossDevice,
382        )
383        .await
384        .unwrap();
385
386        assert_eq!(outcome.succeeded.len(), 2);
387        assert!(outcome.failed.is_empty());
388        assert!(outcome.cleanup_failed.is_empty());
389
390        assert!(!src_dir.path().join("a.txt").exists());
391        assert!(!src_dir.path().join("b.txt").exists());
392        assert_eq!(fs::read(dest_dir.path().join("a.txt")).unwrap(), b"a");
393        assert_eq!(fs::read(dest_dir.path().join("b.txt")).unwrap(), b"b");
394    }
395
396    #[tokio::test]
397    async fn non_cross_device_rename_error_surfaces_directly_without_fallback() {
398        let src_dir = tempdir().unwrap();
399        let dest_dir = tempdir().unwrap();
400        fs::write(src_dir.path().join("a.txt"), b"a").unwrap();
401
402        let result = move_path(
403            src_dir.path(),
404            dest_dir.path(),
405            false,
406            false,
407            false,
408            256,
409            &BatchConfig::default(),
410            2,
411            CancellationToken::new(),
412            ProgressReporter::noop(),
413            &AlwaysPermissionDenied,
414        )
415        .await;
416
417        assert!(matches!(result, Err(Error::PermissionDenied { .. })));
418        assert!(!dest_dir.path().join("a.txt").exists());
419        assert!(src_dir.path().join("a.txt").exists());
420    }
421
422    #[cfg(unix)]
423    #[tokio::test]
424    async fn continue_and_collect_sweep_keeps_deleting_after_one_failure() {
425        use std::os::unix::fs::PermissionsExt;
426
427        let src_dir = tempdir().unwrap();
428        let dest_dir = tempdir().unwrap();
429
430        let locked_dir = src_dir.path().join("locked");
431        fs::create_dir(&locked_dir).unwrap();
432        let a = locked_dir.join("a.txt");
433        fs::write(&a, b"a").unwrap();
434
435        let b = src_dir.path().join("b.txt");
436        fs::write(&b, b"b").unwrap();
437
438        fs::create_dir_all(dest_dir.path().join("locked")).unwrap();
439        fs::write(dest_dir.path().join("locked").join("a.txt"), b"a").unwrap();
440        fs::write(dest_dir.path().join("b.txt"), b"b").unwrap();
441
442        let entry_a = entry(a.clone(), PathBuf::from("locked/a.txt"), 1);
443        let entry_b = entry(b.clone(), PathBuf::from("b.txt"), 1);
444
445        let mut outcome = OperationOutcome::default();
446        outcome.succeeded = vec![entry_a.clone(), entry_b.clone()];
447
448        // unlink needs write+execute on the containing directory, so
449        // locking it down makes deleting `a` fail with permission denied.
450        fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap();
451        sweep(&mut outcome, dest_dir.path(), ErrorStrategy::ContinueAndCollect, ProgressReporter::noop()).await;
452        fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap();
453
454        assert_eq!(outcome.cleanup_failed.len(), 1);
455        assert_eq!(outcome.cleanup_failed[0].0.path, a);
456        assert!(a.exists(), "a's deletion should have failed, leaving it in place");
457        assert!(!b.exists(), "b's deletion should still have succeeded");
458        assert_eq!(outcome.stopped_early, None);
459    }
460
461    #[cfg(unix)]
462    #[tokio::test]
463    async fn abort_on_error_sweep_stops_after_first_deletion_failure() {
464        use std::os::unix::fs::PermissionsExt;
465
466        let src_dir = tempdir().unwrap();
467        let dest_dir = tempdir().unwrap();
468
469        let locked_dir = src_dir.path().join("locked");
470        fs::create_dir(&locked_dir).unwrap();
471        let a = locked_dir.join("a.txt");
472        fs::write(&a, b"a").unwrap();
473
474        let b = src_dir.path().join("b.txt");
475        fs::write(&b, b"b").unwrap();
476        let c = src_dir.path().join("c.txt");
477        fs::write(&c, b"c").unwrap();
478
479        fs::write(dest_dir.path().join("b.txt"), b"b").unwrap();
480        fs::write(dest_dir.path().join("c.txt"), b"c").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        let entry_c = entry(c.clone(), PathBuf::from("c.txt"), 1);
485
486        let mut outcome = OperationOutcome::default();
487        outcome.succeeded = vec![entry_a, entry_b, entry_c];
488
489        fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap();
490        sweep(&mut outcome, dest_dir.path(), ErrorStrategy::AbortOnError, ProgressReporter::noop()).await;
491        fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap();
492
493        assert_eq!(outcome.stopped_early, Some(StopReason::AbortOnError));
494        assert!(a.exists());
495        assert!(b.exists(), "b comes after the triggering failure, so it should never be attempted");
496        assert!(c.exists(), "c comes after the triggering failure, so it should never be attempted");
497    }
498
499    #[cfg(unix)]
500    #[tokio::test]
501    async fn undo_sweep_restores_everything_on_deletion_failure() {
502        use std::os::unix::fs::PermissionsExt;
503
504        let src_dir = tempdir().unwrap();
505        let dest_dir = tempdir().unwrap();
506
507        let locked_dir = src_dir.path().join("locked");
508        fs::create_dir(&locked_dir).unwrap();
509        let a = locked_dir.join("a.txt");
510        fs::write(&a, b"a").unwrap();
511
512        let b = src_dir.path().join("b.txt");
513        fs::write(&b, b"b").unwrap();
514
515        fs::create_dir_all(dest_dir.path().join("locked")).unwrap();
516        fs::write(dest_dir.path().join("locked").join("a.txt"), b"a").unwrap();
517        fs::write(dest_dir.path().join("b.txt"), b"b").unwrap();
518
519        let entry_a = entry(a.clone(), PathBuf::from("locked/a.txt"), 1);
520        let entry_b = entry(b.clone(), PathBuf::from("b.txt"), 1);
521
522        // b first (its deletion succeeds), then a (its deletion fails and
523        // triggers rollback of everything, including b).
524        let mut outcome = OperationOutcome::default();
525        outcome.succeeded = vec![entry_b, entry_a];
526
527        fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap();
528        sweep(&mut outcome, dest_dir.path(), ErrorStrategy::Undo, ProgressReporter::noop()).await;
529        fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap();
530
531        assert!(outcome.succeeded.is_empty());
532        assert!(outcome.cleanup_failed.is_empty());
533        assert_eq!(outcome.stopped_early, Some(StopReason::Undo));
534
535        assert!(a.exists(), "a's source was never removed, since its deletion failed");
536        assert!(b.exists(), "b's source should have been restored from dest");
537        assert_eq!(fs::read(&b).unwrap(), b"b");
538
539        assert!(!dest_dir.path().join("locked").join("a.txt").exists());
540        assert!(!dest_dir.path().join("b.txt").exists());
541    }
542}