1use std::io::{Read, Write};
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicBool, Ordering};
12
13const MAX_NAME_CHARS: usize = 200;
14const MAX_DECONFLICT: u32 = 1000;
15const BUF_BYTES: usize = 1 << 20;
16
17#[derive(Default)]
18pub struct CopyControl<'a> {
19 pub progress: Option<&'a dyn Fn(u64)>,
21 pub cancel: Option<&'a AtomicBool>,
23}
24
25pub fn copy_name(name: &str) -> String {
28 let cleaned: String = name
29 .chars()
30 .map(|c| if c.is_control() || c == '/' || c == '\\' || invalid_on_windows(c) { '_' } else { c })
31 .collect();
32 let trimmed = if cfg!(windows) { cleaned.trim_end_matches(['.', ' ']) } else { cleaned.as_str() };
33 let short: String = trimmed.chars().take(MAX_NAME_CHARS).collect();
34 if short.is_empty() || short.chars().all(|c| c == '.') {
35 return "file".to_string();
36 }
37 if cfg!(windows) && is_reserved_on_windows(&short) {
38 let (stem, ext) = split_ext(&short);
39 return format!("{stem}_{ext}");
40 }
41 short
42}
43
44fn invalid_on_windows(c: char) -> bool {
45 cfg!(windows) && matches!(c, ':' | '*' | '?' | '"' | '<' | '>' | '|')
46}
47
48fn is_reserved_on_windows(name: &str) -> bool {
49 let base = name.split('.').next().unwrap_or("").trim_end().to_ascii_uppercase();
50 matches!(base.as_str(), "CON" | "PRN" | "AUX" | "NUL")
51 || ((base.starts_with("COM") || base.starts_with("LPT"))
52 && base.len() == 4
53 && base.as_bytes()[3].is_ascii_digit()
54 && base.as_bytes()[3] != b'0')
55}
56
57fn split_ext(name: &str) -> (&str, &str) {
60 match name.rfind('.') {
61 Some(dot) if dot > 0 => (&name[..dot], &name[dot..]),
62 _ => (name, ""),
63 }
64}
65
66fn candidate(dir: &Path, name: &str, n: u32) -> PathBuf {
67 if n == 0 {
68 return dir.join(name);
69 }
70 let (stem, ext) = split_ext(name);
71 dir.join(format!("{stem}_{n}{ext}"))
72}
73
74fn cancelled(ctl: &CopyControl) -> bool {
75 ctl.cancel.is_some_and(|c| c.load(Ordering::Relaxed))
76}
77
78fn interrupted() -> std::io::Error {
79 std::io::Error::new(std::io::ErrorKind::Interrupted, "copy cancelled")
80}
81
82pub fn copy_into_dir(src: &Path, dir: &Path, name: &str, ctl: &CopyControl) -> std::io::Result<PathBuf> {
87 if !dir.is_dir() {
88 return Err(std::io::Error::new(std::io::ErrorKind::NotFound, format!("not a folder: {}", dir.display())));
89 }
90 let meta = std::fs::metadata(src)?;
91 let name = copy_name(name);
92 let mut copied = 0u64;
93 if meta.is_dir() {
94 let dest = create_unique(dir, &name, |p| std::fs::create_dir(p).map(|_| ()))?;
95 match copy_dir_contents(src, &dest, ctl, &mut copied) {
96 Ok(()) => Ok(dest),
97 Err(e) => {
98 let _ = std::fs::remove_dir_all(&dest);
99 Err(e)
100 }
101 }
102 } else {
103 copy_file_unique(src, dir, &name, ctl, &mut copied)
104 }
105}
106
107fn create_unique(dir: &Path, name: &str, create: impl Fn(&Path) -> std::io::Result<()>) -> std::io::Result<PathBuf> {
108 for n in 0..MAX_DECONFLICT {
109 let path = candidate(dir, name, n);
110 match create(&path) {
111 Ok(()) => return Ok(path),
112 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
113 Err(e) => return Err(e),
114 }
115 }
116 Err(std::io::Error::new(
117 std::io::ErrorKind::AlreadyExists,
118 format!("no free name for {name} in {}", dir.display()),
119 ))
120}
121
122fn copy_file_unique(src: &Path, dir: &Path, name: &str, ctl: &CopyControl, copied: &mut u64) -> std::io::Result<PathBuf> {
123 let mut input = std::fs::File::open(src)?;
124 for n in 0..MAX_DECONFLICT {
125 let path = candidate(dir, name, n);
126 match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
127 Ok(mut out) => {
128 let result = stream(&mut input, &mut out, ctl, copied);
129 drop(out);
130 return match result {
131 Ok(()) => Ok(path),
132 Err(e) => {
133 let _ = std::fs::remove_file(&path);
134 Err(e)
135 }
136 };
137 }
138 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
139 Err(e) => return Err(e),
140 }
141 }
142 Err(std::io::Error::new(
143 std::io::ErrorKind::AlreadyExists,
144 format!("no free name for {name} in {}", dir.display()),
145 ))
146}
147
148fn stream(input: &mut impl Read, out: &mut impl Write, ctl: &CopyControl, copied: &mut u64) -> std::io::Result<()> {
149 let mut buf = vec![0u8; BUF_BYTES];
150 loop {
151 if cancelled(ctl) {
152 return Err(interrupted());
153 }
154 let n = input.read(&mut buf)?;
155 if n == 0 {
156 return out.flush();
157 }
158 out.write_all(&buf[..n])?;
159 *copied += n as u64;
160 if let Some(progress) = ctl.progress {
161 progress(*copied);
162 }
163 }
164}
165
166fn copy_dir_contents(src: &Path, dest: &Path, ctl: &CopyControl, copied: &mut u64) -> std::io::Result<()> {
167 for entry in std::fs::read_dir(src)? {
168 if cancelled(ctl) {
169 return Err(interrupted());
170 }
171 let entry = entry?;
172 let kind = entry.file_type()?;
173 let name = entry.file_name().to_string_lossy().into_owned();
174 if kind.is_dir() {
175 let sub = create_unique(dest, ©_name(&name), |p| std::fs::create_dir(p).map(|_| ()))?;
176 copy_dir_contents(&entry.path(), &sub, ctl, copied)?;
177 } else if kind.is_file() {
178 copy_file_unique(&entry.path(), dest, ©_name(&name), ctl, copied)?;
179 }
180 }
181 Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use std::sync::Arc;
188
189 fn dirs() -> (tempfile::TempDir, tempfile::TempDir) {
190 (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap())
191 }
192
193 fn file(dir: &Path, name: &str, body: &str) -> PathBuf {
194 let p = dir.join(name);
195 std::fs::write(&p, body).unwrap();
196 p
197 }
198
199 fn names(dir: &Path) -> Vec<String> {
200 let mut v: Vec<String> = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect();
201 v.sort();
202 v
203 }
204
205 #[test]
206 fn a_dotfile_keeps_its_name_and_deconflicts_after_it() {
207 let (src_tmp, dst_tmp) = dirs();
208 let (src, dst) = (src_tmp.path().to_path_buf(), dst_tmp.path().to_path_buf());
209 let env = file(&src, ".env", "A=1");
210 let first = copy_into_dir(&env, &dst, ".env", &CopyControl::default()).unwrap();
211 let second = copy_into_dir(&env, &dst, ".env", &CopyControl::default()).unwrap();
212 assert_eq!(first.file_name().unwrap(), ".env");
213 assert_eq!(second.file_name().unwrap(), ".env_1");
214 assert_eq!(std::fs::read_to_string(second).unwrap(), "A=1");
215 }
216
217 #[test]
218 fn extensions_stay_last_when_deconflicting() {
219 let (src_tmp, dst_tmp) = dirs();
220 let (src, dst) = (src_tmp.path().to_path_buf(), dst_tmp.path().to_path_buf());
221 let f = file(&src, "report.pdf", "x");
222 for _ in 0..3 {
223 copy_into_dir(&f, &dst, "report.pdf", &CopyControl::default()).unwrap();
224 }
225 assert_eq!(names(&dst), vec!["report.pdf", "report_1.pdf", "report_2.pdf"]);
226 }
227
228 #[test]
229 fn racing_copies_never_share_a_name() {
230 let (src_tmp, dst_tmp) = dirs();
231 let (src, dst) = (src_tmp.path().to_path_buf(), dst_tmp.path().to_path_buf());
232 let f = file(&src, "same.txt", "x");
233 let (f, dst2) = (Arc::new(f), Arc::new(dst.clone()));
234 let handles: Vec<_> = (0..8)
235 .map(|_| {
236 let (f, d) = (Arc::clone(&f), Arc::clone(&dst2));
237 std::thread::spawn(move || copy_into_dir(&f, &d, "same.txt", &CopyControl::default()).unwrap())
238 })
239 .collect();
240 let mut landed: Vec<PathBuf> = handles.into_iter().map(|h| h.join().unwrap()).collect();
241 landed.sort();
242 landed.dedup();
243 assert_eq!(landed.len(), 8);
244 assert_eq!(names(&dst).len(), 8);
245 }
246
247 #[test]
248 fn folders_copy_recursively_and_deconflict_as_a_whole() {
249 let (src_tmp, dst_tmp) = dirs();
250 let (src, dst) = (src_tmp.path().to_path_buf(), dst_tmp.path().to_path_buf());
251 let proj = src.join("proj");
252 std::fs::create_dir_all(proj.join("sub")).unwrap();
253 file(&proj, "a.txt", "a");
254 file(&proj.join("sub"), ".hidden", "h");
255 let first = copy_into_dir(&proj, &dst, "proj", &CopyControl::default()).unwrap();
256 let second = copy_into_dir(&proj, &dst, "proj", &CopyControl::default()).unwrap();
257 assert_eq!(names(&dst), vec!["proj", "proj_1"]);
258 assert_eq!(std::fs::read_to_string(first.join("sub").join(".hidden")).unwrap(), "h");
259 assert_eq!(std::fs::read_to_string(second.join("a.txt")).unwrap(), "a");
260 }
261
262 #[test]
263 fn a_cancelled_copy_leaves_nothing_behind() {
264 let (src_tmp, dst_tmp) = dirs();
265 let (src, dst) = (src_tmp.path().to_path_buf(), dst_tmp.path().to_path_buf());
266 let f = file(&src, "big.bin", &"x".repeat(3 * BUF_BYTES));
267 let cancel = AtomicBool::new(false);
268 let trip = |n: u64| {
269 if n >= BUF_BYTES as u64 {
270 cancel.store(true, Ordering::Relaxed);
271 }
272 };
273 let ctl = CopyControl { progress: Some(&trip), cancel: Some(&cancel) };
274 let err = copy_into_dir(&f, &dst, "big.bin", &ctl).unwrap_err();
275 assert_eq!(err.kind(), std::io::ErrorKind::Interrupted);
276 assert!(names(&dst).is_empty());
277
278 let proj = src.join("proj");
279 std::fs::create_dir_all(&proj).unwrap();
280 file(&proj, "a.txt", "a");
281 let stopped = AtomicBool::new(true);
282 let ctl = CopyControl { progress: None, cancel: Some(&stopped) };
283 assert!(copy_into_dir(&proj, &dst, "proj", &ctl).is_err());
284 assert!(names(&dst).is_empty(), "the partial folder is removed");
285 }
286
287 #[test]
288 fn progress_reports_bytes_copied() {
289 let (src_tmp, dst_tmp) = dirs();
290 let (src, dst) = (src_tmp.path().to_path_buf(), dst_tmp.path().to_path_buf());
291 let f = file(&src, "p.bin", &"y".repeat(BUF_BYTES + 10));
292 let last = std::cell::Cell::new(0u64);
293 let see = |n: u64| last.set(n);
294 copy_into_dir(&f, &dst, "p.bin", &CopyControl { progress: Some(&see), cancel: None }).unwrap();
295 assert_eq!(last.get(), (BUF_BYTES + 10) as u64);
296 }
297
298 #[test]
299 fn a_missing_folder_is_an_error() {
300 let src_tmp = tempfile::tempdir().unwrap();
301 let src = src_tmp.path().to_path_buf();
302 let f = file(&src, "a.txt", "a");
303 assert!(copy_into_dir(&f, &src.join("nope"), "a.txt", &CopyControl::default()).is_err());
304 }
305
306 #[test]
307 fn copy_name_keeps_valid_names_and_fixes_invalid_ones() {
308 assert_eq!(copy_name(".env"), ".env");
309 assert_eq!(copy_name("notes.md"), "notes.md");
310 assert_eq!(copy_name("a/b\\c"), "a_b_c");
311 assert_eq!(copy_name("tab\there"), "tab_here");
312 assert_eq!(copy_name(""), "file");
313 assert_eq!(copy_name(".."), "file");
314 assert_eq!(copy_name("."), "file");
315 assert_eq!(copy_name(&"n".repeat(500)).chars().count(), MAX_NAME_CHARS);
316 if cfg!(windows) {
317 assert_eq!(copy_name("what?.txt"), "what_.txt");
318 assert_eq!(copy_name("a:b*c|d"), "a_b_c_d");
319 assert_eq!(copy_name("trailing. "), "trailing");
320 assert_eq!(copy_name("CON"), "CON_");
321 assert_eq!(copy_name("nul.txt"), "nul_.txt");
322 assert_eq!(copy_name("com1.log"), "com1_.log");
323 assert_eq!(copy_name("COM0.log"), "COM0.log");
324 assert_eq!(copy_name("console.txt"), "console.txt");
325 } else {
326 assert_eq!(copy_name("what?.txt"), "what?.txt");
327 assert_eq!(copy_name("trailing. "), "trailing. ");
328 }
329 }
330}