Skip to main content

ntfs_mac_core/
copy.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 kodephp contributors
3
4//! File copy between macOS and an NTFS volume. Uses `rsync` when
5//! available for progress reporting + resume semantics; falls back
6//! to `cp -a` when rsync is not installed.
7//!
8//! `src` and `dst` are paths (either local filesystem paths or
9//! `/Volumes/<label>/<file>` paths on a mounted NTFS volume). The
10//! caller is responsible for ensuring the target is mounted.
11
12use std::path::Path;
13use std::time::Duration;
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::Result;
18use crate::runner::{RunOptions, run_expect_success, which};
19
20/// Options for `copy`.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CopyOptions {
23    /// Delete files in destination that are not in source (rsync `--delete`).
24    #[serde(default)]
25    pub delete: bool,
26    /// Preserve timestamps, ownership, permissions.
27    #[serde(default = "default_true")]
28    pub preserve: bool,
29    /// Human-readable progress output.
30    #[serde(default = "default_true")]
31    pub progress: bool,
32    /// Dry-run: print what would be copied, but don't touch files.
33    #[serde(default)]
34    pub dry_run: bool,
35}
36
37impl Default for CopyOptions {
38    fn default() -> Self {
39        Self {
40            delete: false,
41            preserve: true,
42            progress: true,
43            dry_run: false,
44        }
45    }
46}
47
48fn default_true() -> bool {
49    true
50}
51
52/// Result of a copy operation.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct CopyResult {
55    pub bytes_transferred: Option<u64>,
56    pub files_transferred: Option<u64>,
57    pub duration_ms: u64,
58    pub tool_used: String,
59    pub dry_run: bool,
60}
61
62/// Copy `src` to `dst` using rsync (preferred) or cp (fallback).
63pub fn copy(src: &Path, dst: &Path, opts: &CopyOptions) -> Result<CopyResult> {
64    let started = std::time::Instant::now();
65    let result = if which("rsync").is_ok() {
66        copy_with_rsync(src, dst, opts)?
67    } else {
68        copy_with_cp(src, dst, opts)?
69    };
70    Ok(CopyResult {
71        bytes_transferred: result.bytes_transferred,
72        files_transferred: result.files_transferred,
73        duration_ms: started.elapsed().as_millis() as u64,
74        tool_used: result.tool_used,
75        dry_run: opts.dry_run,
76    })
77}
78
79struct CopyOutcome {
80    bytes_transferred: Option<u64>,
81    files_transferred: Option<u64>,
82    tool_used: String,
83}
84
85fn copy_with_rsync(src: &Path, dst: &Path, opts: &CopyOptions) -> Result<CopyOutcome> {
86    let mut args: Vec<String> = Vec::new();
87    if opts.delete {
88        args.push("--delete".into());
89    }
90    if opts.preserve {
91        args.push("-a".into());
92    } else {
93        args.push("-rt".into());
94    }
95    if opts.progress {
96        // macOS ships rsync 2.6.9 which lacks --info=progress2;
97        // use --progress (universally supported).
98        args.push("--progress".into());
99    }
100    if opts.dry_run {
101        args.push("--dry-run".into());
102    }
103    args.push(src.to_string_lossy().to_string());
104    args.push(dst.to_string_lossy().to_string());
105
106    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
107    // With `--progress` the transfer output goes to rsync's stdout;
108    // capturing it would swallow the progress display entirely. Inherit
109    // the parent's stdio so the user actually sees it. (dry-run output
110    // is small and useful to capture.)
111    let capture = !(opts.progress && !opts.dry_run);
112    run_expect_success(
113        "rsync",
114        &refs,
115        &RunOptions {
116            timeout: Some(Duration::from_secs(3600)),
117            capture,
118            ..Default::default()
119        },
120    )?;
121    Ok(CopyOutcome {
122        bytes_transferred: None,
123        files_transferred: None,
124        tool_used: "rsync".into(),
125    })
126}
127
128fn copy_with_cp(src: &Path, dst: &Path, opts: &CopyOptions) -> Result<CopyOutcome> {
129    let mut args: Vec<String> = Vec::new();
130    args.push("-a".into());
131    if opts.dry_run {
132        args.push("-n".into());
133    }
134    args.push(src.to_string_lossy().to_string());
135    args.push(dst.to_string_lossy().to_string());
136
137    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
138    run_expect_success(
139        "cp",
140        &refs,
141        &RunOptions {
142            timeout: Some(Duration::from_secs(3600)),
143            ..Default::default()
144        },
145    )?;
146    let _ = opts;
147    Ok(CopyOutcome {
148        bytes_transferred: None,
149        files_transferred: None,
150        tool_used: "cp".into(),
151    })
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn copy_options_defaults() {
160        let opts = CopyOptions::default();
161        assert!(!opts.delete);
162        assert!(opts.preserve);
163        assert!(opts.progress);
164        assert!(!opts.dry_run);
165    }
166
167    #[test]
168    fn dry_run_does_not_panic() {
169        let tmp = tempfile::tempdir().unwrap();
170        let src = tmp.path().join("src");
171        let dst = tmp.path().join("dst");
172        std::fs::create_dir_all(&src).unwrap();
173        std::fs::write(src.join("a.txt"), "hello").unwrap();
174
175        let opts = CopyOptions {
176            dry_run: true,
177            ..Default::default()
178        };
179        let _r = copy(&src, &dst, &opts);
180    }
181}