Skip to main content

agentmux_common/
copy_into_dir.rs

1//! The one "copy a file or folder into a directory" used by drag and drop,
2//! paste, and the attachment store's copy-to-workdir.
3//! docs/specs/SPEC_DRAG_AND_DROP_CONSOLIDATION_2026_09_27.md §5.5.
4//!
5//! Every destination is created exclusively (`create_new`, `create_dir`), so
6//! two copies of `report.pdf` land as `report.pdf` and `report_1.pdf` instead
7//! of racing for one name.
8
9use 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    /// Called with the bytes copied so far.
20    pub progress: Option<&'a dyn Fn(u64)>,
21    /// Checked between chunks and entries; the partial copy is removed.
22    pub cancel: Option<&'a AtomicBool>,
23}
24
25/// A source file name made valid as one path component on this platform,
26/// keeping everything that already is, dotfiles included.
27pub 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
57/// `report.pdf` → (`report`, `.pdf`); a leading dot is part of the stem, so
58/// `.env` → (`.env`, ``).
59fn 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
82/// Copy `src` (a file, or a folder recursively) into `dir` under `name` made
83/// valid by [`copy_name`] and de-conflicted. Returns the path it landed at.
84/// Symlinks inside a copied folder are skipped, so a link loop can't recurse.
85/// On error or cancel nothing is left behind.
86pub 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, &copy_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, &copy_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}