Skip to main content

gravityfile_ops/
batch.rs

1//! Copy and move of multiple items into a destination directory.
2
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Duration;
8
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11
12use crate::conflict::{
13    Conflict, ConflictKind, ConflictRequest, ConflictResolution, auto_rename_path,
14    request_resolution,
15};
16use crate::fs::{RenameError, is_within_directory, remove_path, rename_noreplace, same_object};
17use crate::progress::{OperationComplete, OperationProgress, OperationType, publish_progress};
18use crate::transfer::{Preserve, ReplacePolicy, Transfer, dispose};
19use crate::{OPERATION_CHANNEL_SIZE, OperationError};
20
21/// How often live byte counts are published while an item is transferring.
22const PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
23
24/// Options for copy and move.
25#[derive(Debug, Clone, Copy)]
26pub struct TransferOptions {
27    /// Answer for every conflict; `None` asks through [`TransferResult::Conflict`].
28    pub conflict_resolution: Option<ConflictResolution>,
29    /// What happens to an existing item the user chose to overwrite.
30    pub replace_policy: ReplacePolicy,
31    /// Preserve access and modification times.
32    pub preserve_timestamps: bool,
33    /// Preserve extended attributes (Unix).
34    pub preserve_xattrs: bool,
35}
36
37impl Default for TransferOptions {
38    fn default() -> Self {
39        Self {
40            conflict_resolution: None,
41            replace_policy: ReplacePolicy::Trash,
42            preserve_timestamps: true,
43            preserve_xattrs: true,
44        }
45    }
46}
47
48/// Message sent while a copy or move runs.
49#[derive(Debug)]
50pub enum TransferResult {
51    /// Progress update; best-effort and may be coalesced.
52    Progress(OperationProgress),
53    /// The operation is paused until the request is answered (dropping it skips).
54    Conflict(ConflictRequest),
55    /// The operation finished.
56    Complete(TransferComplete),
57}
58
59/// Completion of a copy or move.
60#[derive(Debug)]
61pub struct TransferComplete {
62    pub inner: OperationComplete,
63    /// `(source, destination)` for every item published at a previously free
64    /// destination. Overwrites are excluded: they cannot be undone by deleting
65    /// or moving the new item back.
66    pub completed: Vec<(PathBuf, PathBuf)>,
67    /// `(source, destination)` for every item that replaced an existing one.
68    pub replaced: Vec<(PathBuf, PathBuf)>,
69}
70
71impl TransferComplete {
72    /// Every path whose contents the operation changed: destinations, and
73    /// sources that a move took away.
74    pub fn touched_paths(&self, moved: bool) -> Vec<PathBuf> {
75        let mut touched = Vec::new();
76        for (source, destination) in self.completed.iter().chain(&self.replaced) {
77            if moved {
78                touched.push(source.clone());
79            }
80            touched.push(destination.clone());
81        }
82        touched
83    }
84    /// Destinations created by the operation.
85    pub fn created_paths(&self) -> impl Iterator<Item = &Path> {
86        self.completed
87            .iter()
88            .map(|(_, destination)| destination.as_path())
89    }
90}
91
92/// Copy `sources` into the `destination` directory.
93pub fn start_copy(
94    sources: Vec<PathBuf>,
95    destination: PathBuf,
96    options: TransferOptions,
97    token: CancellationToken,
98) -> mpsc::Receiver<TransferResult> {
99    start(Kind::Copy, sources, destination, options, token)
100}
101
102/// Move `sources` into the `destination` directory. Same-filesystem moves are
103/// a single rename; cross-filesystem moves copy, then delete the source only
104/// when every entry arrived.
105pub fn start_move(
106    sources: Vec<PathBuf>,
107    destination: PathBuf,
108    options: TransferOptions,
109    token: CancellationToken,
110) -> mpsc::Receiver<TransferResult> {
111    start(Kind::Move, sources, destination, options, token)
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115enum Kind {
116    Copy,
117    Move,
118}
119
120impl Kind {
121    fn operation_type(self) -> OperationType {
122        match self {
123            Self::Copy => OperationType::Copy,
124            Self::Move => OperationType::Move,
125        }
126    }
127}
128
129fn start(
130    kind: Kind,
131    sources: Vec<PathBuf>,
132    destination: PathBuf,
133    options: TransferOptions,
134    token: CancellationToken,
135) -> mpsc::Receiver<TransferResult> {
136    let (tx, rx) = mpsc::channel(OPERATION_CHANNEL_SIZE);
137    tokio::spawn(async move {
138        let receiver = tx.clone();
139        let work = Batch::new(kind, options, token.clone(), tx).run(sources, destination);
140        tokio::pin!(work);
141        tokio::select! {
142            biased;
143            _ = receiver.closed() => {
144                token.cancel();
145                // Let in-flight blocking work observe cancellation and clean up.
146                work.await;
147            }
148            _ = &mut work => {}
149        }
150    });
151    rx
152}
153
154struct Batch {
155    kind: Kind,
156    options: TransferOptions,
157    token: CancellationToken,
158    tx: mpsc::Sender<TransferResult>,
159    progress: OperationProgress,
160    global: Option<ConflictResolution>,
161    succeeded: usize,
162    failed: usize,
163    skipped: usize,
164    completed: Vec<(PathBuf, PathBuf)>,
165    replaced: Vec<(PathBuf, PathBuf)>,
166}
167
168enum Plan {
169    Skip,
170    Abort,
171    Publish { destination: PathBuf, replace: bool },
172}
173
174impl Batch {
175    fn new(
176        kind: Kind,
177        options: TransferOptions,
178        token: CancellationToken,
179        tx: mpsc::Sender<TransferResult>,
180    ) -> Self {
181        Self {
182            kind,
183            global: options.conflict_resolution,
184            options,
185            token,
186            tx,
187            progress: OperationProgress::new(kind.operation_type(), 0, 0),
188            succeeded: 0,
189            failed: 0,
190            skipped: 0,
191            completed: Vec::new(),
192            replaced: Vec::new(),
193        }
194    }
195
196    async fn run(mut self, sources: Vec<PathBuf>, destination: PathBuf) {
197        if let Err(error) = self.prepare(&sources, &destination).await {
198            self.progress.add_error(error);
199            self.failed = sources.len();
200            return self.finish(false).await;
201        }
202        for source in sources {
203            if self.token.is_cancelled() {
204                break;
205            }
206            match self.plan(&source, &destination).await {
207                Plan::Skip => self.skipped += 1,
208                Plan::Abort => return self.finish(true).await,
209                Plan::Publish {
210                    destination,
211                    replace,
212                } => self.publish(source, destination, replace).await,
213            }
214        }
215        self.finish(false).await
216    }
217
218    /// Validate the batch and compute totals before any mutation.
219    async fn prepare(
220        &mut self,
221        sources: &[PathBuf],
222        destination: &Path,
223    ) -> Result<(), OperationError> {
224        if let Some(source) = sources
225            .iter()
226            .find(|source| is_within_directory(source, destination))
227        {
228            return Err(OperationError::new(
229                source.clone(),
230                format!(
231                    "cannot {} a directory into itself ('{}' is inside it)",
232                    if self.kind == Kind::Copy {
233                        "copy"
234                    } else {
235                        "move"
236                    },
237                    destination.display()
238                ),
239            ));
240        }
241        fs::create_dir_all(destination).map_err(|error| {
242            OperationError::new(
243                destination.to_path_buf(),
244                format!("cannot create destination: {error}"),
245            )
246        })?;
247        if !fs::metadata(destination).is_ok_and(|metadata| metadata.is_dir()) {
248            return Err(OperationError::new(
249                destination.to_path_buf(),
250                "destination is not a directory",
251            ));
252        }
253        self.progress.files_total = sources.len();
254        // Moves within a filesystem are renames; walking the sources first
255        // would cost far more than the move itself.
256        if self.kind == Kind::Copy {
257            let sources = sources.to_vec();
258            let token = self.token.clone();
259            self.progress.bytes_total =
260                tokio::task::spawn_blocking(move || total_bytes(&sources, &token))
261                    .await
262                    .unwrap_or(0);
263        }
264        Ok(())
265    }
266
267    async fn plan(&mut self, source: &Path, destination_dir: &Path) -> Plan {
268        let Some(name) = source.file_name() else {
269            self.fail(source, "path has no file name");
270            return Plan::Skip;
271        };
272        let destination = destination_dir.join(name);
273        if source == destination || same_object(source, &destination) {
274            return match self
275                .ask(Conflict::new(
276                    source.into(),
277                    destination,
278                    ConflictKind::SameFile,
279                ))
280                .await
281            {
282                ConflictResolution::Abort => Plan::Abort,
283                _ => Plan::Skip,
284            };
285        }
286        let Ok(existing) = fs::symlink_metadata(&destination) else {
287            return Plan::Publish {
288                destination,
289                replace: false,
290            };
291        };
292        let kind = if existing.is_dir() {
293            ConflictKind::DirectoryExists
294        } else {
295            ConflictKind::FileExists
296        };
297        let answer = match self.global {
298            Some(answer) => answer,
299            None => {
300                let answer = self
301                    .ask(Conflict::new(source.into(), destination.clone(), kind))
302                    .await;
303                if answer.is_global() {
304                    self.global = Some(answer);
305                }
306                answer
307            }
308        };
309        match answer.to_single() {
310            ConflictResolution::Abort => Plan::Abort,
311            ConflictResolution::Overwrite => Plan::Publish {
312                destination,
313                replace: true,
314            },
315            ConflictResolution::AutoRename => Plan::Publish {
316                destination: auto_rename_path(&destination),
317                replace: false,
318            },
319            _ => Plan::Skip,
320        }
321    }
322
323    async fn ask(&mut self, conflict: Conflict) -> ConflictResolution {
324        request_resolution(&self.tx, conflict, TransferResult::Conflict, &self.token).await
325    }
326
327    async fn publish(&mut self, source: PathBuf, destination: PathBuf, replace: bool) {
328        self.progress.set_current_file(Some(source.clone()));
329        self.report();
330        let kind = self.kind;
331        let options = self.options;
332        let token = self.token.clone();
333        let bytes = Arc::new(AtomicU64::new(0));
334        let job = {
335            let (source, destination, bytes) = (source.clone(), destination.clone(), bytes.clone());
336            tokio::task::spawn_blocking(move || {
337                transfer_item(
338                    kind,
339                    &source,
340                    &destination,
341                    replace,
342                    options,
343                    &token,
344                    &bytes,
345                )
346            })
347        };
348        let base = self.progress.bytes_processed;
349        let outcome = self.wait(job, base, &bytes).await;
350        self.progress.bytes_processed = base.saturating_add(bytes.load(Ordering::Relaxed));
351        self.progress.files_completed += 1;
352        match outcome {
353            Ok(ItemOutcome { errors }) => {
354                self.succeeded += 1;
355                for error in errors {
356                    self.progress.add_error(error);
357                }
358                if replace {
359                    self.replaced.push((source, destination));
360                } else {
361                    self.completed.push((source, destination));
362                }
363            }
364            Err(error) => {
365                self.failed += 1;
366                self.progress.add_error(error);
367            }
368        }
369        self.report();
370    }
371
372    /// Await blocking work, publishing live byte counts meanwhile.
373    async fn wait(
374        &mut self,
375        mut job: tokio::task::JoinHandle<Result<ItemOutcome, OperationError>>,
376        base: u64,
377        bytes: &AtomicU64,
378    ) -> Result<ItemOutcome, OperationError> {
379        let mut ticker = tokio::time::interval(PROGRESS_INTERVAL);
380        ticker.tick().await;
381        loop {
382            tokio::select! {
383                result = &mut job => {
384                    return result.unwrap_or_else(|error| {
385                        Err(OperationError::new(PathBuf::new(), format!("worker failed: {error}")))
386                    });
387                }
388                _ = ticker.tick() => {
389                    self.progress.bytes_processed = base.saturating_add(bytes.load(Ordering::Relaxed));
390                    self.report();
391                }
392            }
393        }
394    }
395
396    fn report(&self) {
397        publish_progress(
398            &self.tx,
399            || TransferResult::Progress(self.progress.clone()),
400            &self.token,
401        );
402    }
403
404    fn fail(&mut self, path: &Path, message: impl Into<String>) {
405        self.failed += 1;
406        self.progress
407            .add_error(OperationError::new(path.to_path_buf(), message));
408    }
409
410    async fn finish(self, aborted: bool) {
411        let complete = TransferComplete {
412            inner: OperationComplete {
413                cancelled: aborted || self.token.is_cancelled(),
414                operation_type: self.kind.operation_type(),
415                succeeded: self.succeeded,
416                failed: self.failed,
417                skipped: self.skipped,
418                bytes_processed: self.progress.bytes_processed,
419                errors: self.progress.errors,
420            },
421            completed: self.completed,
422            replaced: self.replaced,
423        };
424        let _ = self.tx.send(TransferResult::Complete(complete)).await;
425    }
426}
427
428struct ItemOutcome {
429    /// Non-fatal problems inside the item (skipped entries).
430    errors: Vec<OperationError>,
431}
432
433fn transfer_item(
434    kind: Kind,
435    source: &Path,
436    destination: &Path,
437    replace: bool,
438    options: TransferOptions,
439    token: &CancellationToken,
440    bytes: &AtomicU64,
441) -> Result<ItemOutcome, OperationError> {
442    let error = |message: String| OperationError::new(source.to_path_buf(), message);
443    let preserve = Preserve {
444        times: options.preserve_timestamps,
445        xattrs: options.preserve_xattrs,
446    };
447    let replace = replace.then_some(options.replace_policy);
448
449    if kind == Kind::Move {
450        if let Some(policy) = replace
451            && fs::symlink_metadata(destination).is_ok()
452            && !moves_across_devices(source, destination)
453        {
454            dispose(destination, policy).map_err(|e| error(e.to_string()))?;
455        }
456        match rename_noreplace(source, destination) {
457            Ok(()) => return Ok(ItemOutcome { errors: Vec::new() }),
458            Err(RenameError::CrossDevice) => {}
459            Err(RenameError::DestinationExists) => {
460                return Err(error(format!("'{}' already exists", destination.display())));
461            }
462            Err(RenameError::Io(e)) => return Err(error(format!("move failed: {e}"))),
463        }
464    }
465
466    let mut transfer = Transfer::new(token, bytes, preserve);
467    transfer
468        .copy_staged(source, destination, replace)
469        .map_err(|e| error(e.to_string()))?;
470    let errors = transfer.into_errors();
471    if kind == Kind::Move {
472        if !errors.is_empty() {
473            let mut errors = errors;
474            errors.push(error(
475                "some entries could not be copied; the source was kept".to_string(),
476            ));
477            return Ok(ItemOutcome { errors });
478        }
479        remove_path(source).map_err(|e| {
480            error(format!(
481                "copied to '{}', but removing the source failed: {e}",
482                destination.display()
483            ))
484        })?;
485    }
486    Ok(ItemOutcome { errors })
487}
488
489#[cfg(unix)]
490fn moves_across_devices(source: &Path, destination: &Path) -> bool {
491    use std::os::unix::fs::MetadataExt;
492    let parent = crate::fs::parent_or_current(destination);
493    match (fs::symlink_metadata(source), fs::metadata(parent)) {
494        (Ok(source), Ok(parent)) => source.dev() != parent.dev(),
495        _ => false,
496    }
497}
498
499#[cfg(not(unix))]
500fn moves_across_devices(_source: &Path, _destination: &Path) -> bool {
501    false
502}
503
504/// Total regular-file bytes beneath `sources`, without following symlinks.
505fn total_bytes(sources: &[PathBuf], token: &CancellationToken) -> u64 {
506    let mut total = 0u64;
507    let mut pending: Vec<PathBuf> = sources.to_vec();
508    while let Some(path) = pending.pop() {
509        if token.is_cancelled() {
510            break;
511        }
512        let Ok(metadata) = fs::symlink_metadata(&path) else {
513            continue;
514        };
515        if metadata.is_dir() {
516            if let Ok(entries) = fs::read_dir(&path) {
517                pending.extend(entries.flatten().map(|entry| entry.path()));
518            }
519        } else if metadata.is_file() {
520            total = total.saturating_add(metadata.len());
521        }
522    }
523    total
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    async fn drain(
531        mut rx: mpsc::Receiver<TransferResult>,
532        answer: ConflictResolution,
533    ) -> TransferComplete {
534        while let Some(message) = rx.recv().await {
535            match message {
536                TransferResult::Progress(_) => {}
537                TransferResult::Conflict(request) => request.resolve(answer),
538                TransferResult::Complete(complete) => return complete,
539            }
540        }
541        panic!("no completion");
542    }
543
544    fn options() -> TransferOptions {
545        TransferOptions {
546            replace_policy: ReplacePolicy::Permanent,
547            ..TransferOptions::default()
548        }
549    }
550
551    #[tokio::test]
552    async fn copy_reports_created_paths_and_bytes() {
553        let dir = tempfile::tempdir().unwrap();
554        let source = dir.path().join("a.txt");
555        fs::write(&source, "hello").unwrap();
556        let destination = dir.path().join("out");
557        let complete = drain(
558            start_copy(
559                vec![source.clone()],
560                destination.clone(),
561                options(),
562                CancellationToken::new(),
563            ),
564            ConflictResolution::Skip,
565        )
566        .await;
567        assert!(complete.inner.is_success(), "{:?}", complete.inner);
568        assert_eq!(complete.inner.bytes_processed, 5);
569        assert_eq!(
570            complete.completed,
571            vec![(source.clone(), destination.join("a.txt"))]
572        );
573        assert!(source.exists());
574    }
575
576    #[tokio::test]
577    async fn move_renames_and_skips_on_conflict() {
578        let dir = tempfile::tempdir().unwrap();
579        let destination = dir.path().join("out");
580        fs::create_dir(&destination).unwrap();
581        let (a, b) = (dir.path().join("a"), dir.path().join("b"));
582        fs::write(&a, "a").unwrap();
583        fs::write(&b, "b").unwrap();
584        fs::write(destination.join("b"), "existing").unwrap();
585        let complete = drain(
586            start_move(
587                vec![a.clone(), b.clone()],
588                destination.clone(),
589                options(),
590                CancellationToken::new(),
591            ),
592            ConflictResolution::Skip,
593        )
594        .await;
595        assert_eq!((complete.inner.succeeded, complete.inner.skipped), (1, 1));
596        assert!(!a.exists() && b.exists());
597        assert_eq!(
598            fs::read_to_string(destination.join("b")).unwrap(),
599            "existing"
600        );
601    }
602
603    #[tokio::test]
604    async fn overwrite_and_auto_rename() {
605        let dir = tempfile::tempdir().unwrap();
606        let destination = dir.path().join("out");
607        fs::create_dir(&destination).unwrap();
608        let source = dir.path().join("f");
609        fs::write(&source, "new").unwrap();
610        fs::write(destination.join("f"), "old").unwrap();
611        let complete = drain(
612            start_copy(
613                vec![source.clone()],
614                destination.clone(),
615                options(),
616                CancellationToken::new(),
617            ),
618            ConflictResolution::AutoRename,
619        )
620        .await;
621        assert_eq!(complete.completed[0].1, destination.join("f (1)"));
622        let complete = drain(
623            start_copy(
624                vec![source.clone()],
625                destination.clone(),
626                options(),
627                CancellationToken::new(),
628            ),
629            ConflictResolution::Overwrite,
630        )
631        .await;
632        assert!(complete.completed.is_empty());
633        assert_eq!(complete.replaced, [(source.clone(), destination.join("f"))]);
634        assert_eq!(complete.touched_paths(false), [destination.join("f")]);
635        assert_eq!(
636            complete.touched_paths(true),
637            [source, destination.join("f")]
638        );
639        assert_eq!(fs::read_to_string(destination.join("f")).unwrap(), "new");
640    }
641
642    #[tokio::test]
643    async fn refuses_to_copy_a_directory_into_itself() {
644        let dir = tempfile::tempdir().unwrap();
645        let source = dir.path().join("tree");
646        fs::create_dir(&source).unwrap();
647        let complete = drain(
648            start_copy(
649                vec![source.clone()],
650                source.join("inner"),
651                options(),
652                CancellationToken::new(),
653            ),
654            ConflictResolution::Skip,
655        )
656        .await;
657        assert_eq!(complete.inner.failed, 1);
658        assert!(!source.join("inner").exists());
659    }
660}