Skip to main content

ntfs_mac_core/
copy.rs

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