Skip to main content

file_engine/operations/
copy.rs

1use std::path::{Path, PathBuf};
2
3use tokio::io::{AsyncReadExt, AsyncWriteExt};
4use tokio::sync::mpsc::UnboundedSender;
5use tokio_stream::wrappers::UnboundedReceiverStream;
6use tokio_util::sync::CancellationToken;
7
8use crate::error::{from_io, FileEngineError, Result};
9use crate::handle::Handle;
10use crate::progress::Progress;
11
12pub struct CopyBuilder {
13    src: PathBuf,
14    dst: PathBuf,
15    overwrite: bool,
16    buffer_size: usize,
17    follow_symlinks: bool,
18    #[cfg(feature = "permissions")]
19    pub(crate) preserve_permissions: bool,
20    cancel_token: Option<CancellationToken>,
21}
22
23impl CopyBuilder {
24    pub(crate) fn new(
25        src: PathBuf,
26        dst: PathBuf,
27        buffer_size: usize,
28        follow_symlinks: bool,
29    ) -> Self {
30        Self {
31            src,
32            dst,
33            overwrite: false,
34            buffer_size,
35            follow_symlinks,
36            #[cfg(feature = "permissions")]
37            preserve_permissions: false,
38            cancel_token: None,
39        }
40    }
41
42    pub fn overwrite(mut self, enabled: bool) -> Self {
43        self.overwrite = enabled;
44        self
45    }
46
47    pub fn cancellation_token(mut self, token: CancellationToken) -> Self {
48        self.cancel_token = Some(token);
49        self
50    }
51
52    pub fn start(self) -> Result<Handle<()>> {
53        let cancel_token = self.cancel_token.unwrap_or_default();
54        let (progress_tx, progress_rx) = tokio::sync::mpsc::unbounded_channel();
55
56        let task_cancel_token = cancel_token.clone();
57        let buffer_size = self.buffer_size;
58        let follow_symlinks = self.follow_symlinks;
59        let overwrite = self.overwrite;
60        let src = self.src;
61        let dst = self.dst;
62        #[cfg(feature = "permissions")]
63        let preserve_permissions = self.preserve_permissions;
64
65        let join = tokio::spawn(async move {
66            if !overwrite && tokio::fs::try_exists(&dst).await.unwrap_or(false) {
67                return Err(FileEngineError::DestinationExists(dst));
68            }
69
70            let src_metadata = tokio::fs::symlink_metadata(&src)
71                .await
72                .map_err(|e| from_io(src.clone(), e))?;
73
74            if src_metadata.is_dir() {
75                copy_dir(
76                    &src,
77                    &dst,
78                    buffer_size,
79                    follow_symlinks,
80                    &progress_tx,
81                    &task_cancel_token,
82                )
83                .await?;
84            } else if src_metadata.is_symlink() && !follow_symlinks {
85                copy_symlink(&src, &dst).await?;
86            } else {
87                copy_file(
88                    &src,
89                    &dst,
90                    buffer_size,
91                    0,
92                    1,
93                    &progress_tx,
94                    &task_cancel_token,
95                )
96                .await?;
97            }
98
99            #[cfg(feature = "permissions")]
100            if preserve_permissions {
101                preserve_permissions_recursive(&src, &dst).await?;
102            }
103
104            Ok(())
105        });
106
107        Ok(Handle {
108            join,
109            progress_rx: UnboundedReceiverStream::new(progress_rx),
110            cancel_token,
111        })
112    }
113}
114
115/// Copies a single regular file `src` -> `dst` in `buffer_size` chunks,
116/// emitting `Progress` after each chunk and checking `cancel_token` before
117/// each read. `files_done`/`files_total` are pass-through context for
118/// `Progress` — this function only knows about the one file, the caller
119/// (a directory walk, or `sync`) knows where it sits in a larger set.
120///
121/// Preserves `src`'s mtime on `dst` after writing (best-effort — a failure
122/// to set it is not a copy failure). This matters beyond cosmetics:
123/// `sync`'s default change-detection (§8.5) compares size+mtime, which
124/// would consider every file "changed" on every run if a plain copy always
125/// stamped `dst` with the current time instead of carrying `src`'s mtime
126/// forward.
127///
128/// `pub(crate)` so `move_op.rs` (cross-device fallback) and `sync.rs`
129/// (copying new/changed files) can reuse it — both features require
130/// `operations` at the manifest level (§4 of the design doc).
131pub(crate) async fn copy_file(
132    src: &Path,
133    dst: &Path,
134    buffer_size: usize,
135    files_done: u64,
136    files_total: u64,
137    progress_tx: &UnboundedSender<Progress>,
138    cancel_token: &CancellationToken,
139) -> Result<()> {
140    let src_metadata = tokio::fs::metadata(src)
141        .await
142        .map_err(|e| from_io(src.to_path_buf(), e))?;
143    let bytes_total = src_metadata.len();
144
145    if let Some(parent) = dst.parent() {
146        tokio::fs::create_dir_all(parent)
147            .await
148            .map_err(|e| from_io(parent.to_path_buf(), e))?;
149    }
150
151    let mut reader = tokio::fs::File::open(src)
152        .await
153        .map_err(|e| from_io(src.to_path_buf(), e))?;
154    let mut writer = tokio::fs::File::create(dst)
155        .await
156        .map_err(|e| from_io(dst.to_path_buf(), e))?;
157
158    let mut buf = vec![0u8; buffer_size.max(1)];
159    let mut bytes_done = 0u64;
160
161    loop {
162        if cancel_token.is_cancelled() {
163            return Err(FileEngineError::Cancelled);
164        }
165
166        let n = reader
167            .read(&mut buf)
168            .await
169            .map_err(|e| from_io(src.to_path_buf(), e))?;
170        if n == 0 {
171            break;
172        }
173
174        writer
175            .write_all(&buf[..n])
176            .await
177            .map_err(|e| from_io(dst.to_path_buf(), e))?;
178
179        bytes_done += n as u64;
180        let _ = progress_tx.send(Progress {
181            bytes_done,
182            bytes_total,
183            files_done,
184            files_total,
185            current_file: Some(src.to_path_buf()),
186        });
187    }
188
189    writer
190        .flush()
191        .await
192        .map_err(|e| from_io(dst.to_path_buf(), e))?;
193    drop(writer);
194
195    if let Ok(modified) = src_metadata.modified() {
196        let dst_owned = dst.to_path_buf();
197        let _ = tokio::task::spawn_blocking(move || {
198            std::fs::OpenOptions::new()
199                .write(true)
200                .open(&dst_owned)
201                .and_then(|f| f.set_modified(modified))
202        })
203        .await;
204    }
205
206    Ok(())
207}
208
209/// Recreates a symlink at `dst` pointing to the same target `src` points
210/// to, rather than copying through to the target's contents. Only called
211/// when `follow_symlinks` is false.
212pub(crate) async fn copy_symlink(src: &Path, dst: &Path) -> Result<()> {
213    let target = tokio::fs::read_link(src)
214        .await
215        .map_err(|e| from_io(src.to_path_buf(), e))?;
216
217    #[cfg(unix)]
218    {
219        tokio::fs::symlink(&target, dst)
220            .await
221            .map_err(|e| from_io(dst.to_path_buf(), e))?;
222    }
223    #[cfg(windows)]
224    {
225        let target_abs = src.parent().unwrap_or_else(|| Path::new(".")).join(&target);
226        let target_is_dir = tokio::fs::metadata(&target_abs)
227            .await
228            .map(|m| m.is_dir())
229            .unwrap_or(false);
230        let result = if target_is_dir {
231            tokio::fs::symlink_dir(&target, dst).await
232        } else {
233            tokio::fs::symlink_file(&target, dst).await
234        };
235        result.map_err(|e| from_io(dst.to_path_buf(), e))?;
236    }
237
238    Ok(())
239}
240
241/// Walks `src` (pre-pass to compute `files_total`, then a copy pass),
242/// recreating its structure at `dst`. Iterative (an explicit stack of
243/// relative directories), not recursive `async fn` calls, since Rust's
244/// async fns can't recurse without boxing.
245pub(crate) async fn copy_dir(
246    src: &Path,
247    dst: &Path,
248    buffer_size: usize,
249    follow_symlinks: bool,
250    progress_tx: &UnboundedSender<Progress>,
251    cancel_token: &CancellationToken,
252) -> Result<()> {
253    let mut entries: Vec<(PathBuf, bool)> = Vec::new();
254    let mut dirs = vec![PathBuf::new()];
255
256    while let Some(rel_dir) = dirs.pop() {
257        let abs_dir = src.join(&rel_dir);
258        let mut read_dir = tokio::fs::read_dir(&abs_dir)
259            .await
260            .map_err(|e| from_io(abs_dir.clone(), e))?;
261
262        while let Some(entry) = read_dir
263            .next_entry()
264            .await
265            .map_err(|e| from_io(abs_dir.clone(), e))?
266        {
267            let rel_path = rel_dir.join(entry.file_name());
268            let file_type = entry
269                .file_type()
270                .await
271                .map_err(|e| from_io(entry.path(), e))?;
272
273            if file_type.is_dir() {
274                dirs.push(rel_path);
275            } else if file_type.is_symlink() && !follow_symlinks {
276                entries.push((rel_path, true));
277            } else {
278                entries.push((rel_path, false));
279            }
280        }
281    }
282
283    let files_total = entries.len() as u64;
284    tokio::fs::create_dir_all(dst)
285        .await
286        .map_err(|e| from_io(dst.to_path_buf(), e))?;
287
288    for (files_done, (rel_path, is_symlink)) in entries.into_iter().enumerate() {
289        if cancel_token.is_cancelled() {
290            return Err(FileEngineError::Cancelled);
291        }
292
293        let entry_src = src.join(&rel_path);
294        let entry_dst = dst.join(&rel_path);
295
296        if is_symlink {
297            if let Some(parent) = entry_dst.parent() {
298                tokio::fs::create_dir_all(parent)
299                    .await
300                    .map_err(|e| from_io(parent.to_path_buf(), e))?;
301            }
302            copy_symlink(&entry_src, &entry_dst).await?;
303        } else {
304            copy_file(
305                &entry_src,
306                &entry_dst,
307                buffer_size,
308                files_done as u64,
309                files_total,
310                progress_tx,
311                cancel_token,
312            )
313            .await?;
314        }
315    }
316
317    Ok(())
318}
319
320#[cfg(feature = "permissions")]
321pub(crate) async fn preserve_permissions_recursive(src: &Path, dst: &Path) -> Result<()> {
322    let src_metadata = tokio::fs::symlink_metadata(src)
323        .await
324        .map_err(|e| from_io(src.to_path_buf(), e))?;
325
326    if !src_metadata.is_dir() {
327        let perms = tokio::fs::metadata(src)
328            .await
329            .map_err(|e| from_io(src.to_path_buf(), e))?
330            .permissions();
331        return tokio::fs::set_permissions(dst, perms)
332            .await
333            .map_err(|e| from_io(dst.to_path_buf(), e));
334    }
335
336    let mut dirs = vec![PathBuf::new()];
337    while let Some(rel_dir) = dirs.pop() {
338        let abs_src_dir = src.join(&rel_dir);
339        let abs_dst_dir = dst.join(&rel_dir);
340
341        let perms = tokio::fs::metadata(&abs_src_dir)
342            .await
343            .map_err(|e| from_io(abs_src_dir.clone(), e))?
344            .permissions();
345        tokio::fs::set_permissions(&abs_dst_dir, perms)
346            .await
347            .map_err(|e| from_io(abs_dst_dir.clone(), e))?;
348
349        let mut read_dir = tokio::fs::read_dir(&abs_src_dir)
350            .await
351            .map_err(|e| from_io(abs_src_dir.clone(), e))?;
352        while let Some(entry) = read_dir
353            .next_entry()
354            .await
355            .map_err(|e| from_io(abs_src_dir.clone(), e))?
356        {
357            let rel_path = rel_dir.join(entry.file_name());
358            let file_type = entry
359                .file_type()
360                .await
361                .map_err(|e| from_io(entry.path(), e))?;
362
363            if file_type.is_dir() {
364                dirs.push(rel_path);
365            } else if file_type.is_file() {
366                let entry_src = src.join(&rel_path);
367                let entry_dst = dst.join(&rel_path);
368                let perms = tokio::fs::metadata(&entry_src)
369                    .await
370                    .map_err(|e| from_io(entry_src.clone(), e))?
371                    .permissions();
372                tokio::fs::set_permissions(&entry_dst, perms)
373                    .await
374                    .map_err(|e| from_io(entry_dst.clone(), e))?;
375            }
376        }
377    }
378
379    Ok(())
380}