1use std::fs;
10use std::io;
11use std::path::{Path, PathBuf};
12
13use relm4::gtk::gio::prelude::FileExt;
14
15#[derive(Debug)]
18pub enum OpsError {
19 Io(io::Error),
20 Conflict(PathBuf),
22 NotFound(String),
24 NotADirectory(String),
26 Trash(String),
28}
29
30impl std::fmt::Display for OpsError {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 match self {
33 OpsError::Io(err) => write!(f, "{err}"),
34 OpsError::Conflict(path) => write!(f, "'{}' already exists", path.display()),
35 OpsError::NotFound(name) => write!(f, "no such file or directory: {name}"),
36 OpsError::NotADirectory(name) => write!(f, "not a directory: {name}"),
37 OpsError::Trash(reason) => write!(f, "could not move to trash: {reason}"),
38 }
39 }
40}
41
42impl std::error::Error for OpsError {
43 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
44 match self {
45 OpsError::Io(err) => Some(err),
46 _ => None,
47 }
48 }
49}
50
51impl From<io::Error> for OpsError {
52 fn from(err: io::Error) -> Self {
53 OpsError::Io(err)
54 }
55}
56
57const EXDEV: i32 = 18;
60
61const EINVAL: i32 = 22;
63
64#[cfg(target_os = "linux")]
69fn rename_noreplace(from: &Path, dest: &Path) -> io::Result<()> {
70 use std::ffi::CString;
71 use std::os::unix::ffi::OsStrExt;
72
73 const AT_FDCWD: i32 = -100;
76 const RENAME_NOREPLACE: u32 = 1;
77
78 let from_c = CString::new(from.as_os_str().as_bytes())
79 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
80 let dest_c = CString::new(dest.as_os_str().as_bytes())
81 .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
82 let rc = unsafe {
85 libc_syscall::renameat2(
86 AT_FDCWD,
87 from_c.as_ptr(),
88 AT_FDCWD,
89 dest_c.as_ptr(),
90 RENAME_NOREPLACE,
91 )
92 };
93 if rc == 0 {
94 Ok(())
95 } else {
96 Err(io::Error::last_os_error())
97 }
98}
99
100#[cfg(not(target_os = "linux"))]
103fn rename_noreplace(_from: &Path, _dest: &Path) -> io::Result<()> {
104 Err(io::Error::from_raw_os_error(EINVAL))
105}
106
107mod libc_syscall {
110 use std::os::raw::{c_char, c_int};
111
112 pub type Syscall = i64;
113
114 unsafe extern "C" {
115 fn syscall(number: Syscall, ...) -> c_int;
118 }
119
120 pub unsafe fn renameat2(
125 olddirfd: c_int,
126 oldpath: *const c_char,
127 newdirfd: c_int,
128 newpath: *const c_char,
129 flags: u32,
130 ) -> c_int {
131 const SYS_RENAMEAT2: Syscall = 316;
135 unsafe { syscall(SYS_RENAMEAT2, olddirfd, oldpath, newdirfd, newpath, flags) }
136 }
137}
138
139fn is_exdev(err: &io::Error) -> bool {
140 err.raw_os_error() == Some(EXDEV)
141}
142
143pub trait TrashService: Send + Sync + std::fmt::Debug {
145 fn trash(&self, path: &Path) -> Result<(), OpsError>;
146}
147
148#[derive(Debug, Default, Clone, Copy)]
151pub struct GioTrash;
152
153impl TrashService for GioTrash {
154 fn trash(&self, path: &Path) -> Result<(), OpsError> {
155 let file = relm4::gtk::gio::File::for_path(path);
156 file.trash(None::<&relm4::gtk::gio::Cancellable>)
157 .map_err(|err| OpsError::Trash(format!("{}: {err}", path.display())))
158 }
159}
160
161#[derive(Debug)]
163pub struct FileOps {
164 trash: Box<dyn TrashService>,
165}
166
167impl FileOps {
168 pub fn new() -> Self {
170 Self {
171 trash: Box::new(GioTrash),
172 }
173 }
174
175 pub fn with_trash(trash: Box<dyn TrashService>) -> Self {
177 Self { trash }
178 }
179
180 pub fn create_file(&self, dir: &Path, name: &str) -> Result<PathBuf, OpsError> {
185 if name.trim().is_empty() {
186 return Err(OpsError::NotFound("empty name".into()));
187 }
188 let dest = dir.join(name);
189 fs::OpenOptions::new()
190 .write(true)
191 .create_new(true)
192 .open(&dest)
193 .map_err(|err| match err.kind() {
194 io::ErrorKind::AlreadyExists => OpsError::Conflict(dest.clone()),
195 _ => OpsError::Io(err),
196 })?;
197 Ok(dest)
198 }
199
200 pub fn create_dir(&self, dir: &Path, name: &str) -> Result<PathBuf, OpsError> {
202 if name.trim().is_empty() {
203 return Err(OpsError::NotFound("empty name".into()));
204 }
205 let dest = dir.join(name);
206 fs::create_dir(&dest).map_err(|err| match err.kind() {
207 io::ErrorKind::AlreadyExists => OpsError::Conflict(dest.clone()),
208 _ => OpsError::Io(err),
209 })?;
210 Ok(dest)
211 }
212
213 pub fn rename(&self, from: &Path, new_name: &str) -> Result<PathBuf, OpsError> {
223 if new_name.trim().is_empty() {
224 return Err(OpsError::NotFound("empty name".into()));
225 }
226 let dest = from.with_file_name(new_name);
227 match rename_noreplace(from, &dest) {
228 Ok(()) => Ok(dest),
229 Err(err) if err.raw_os_error() == Some(EINVAL) => {
232 if dest.exists() {
233 return Err(OpsError::Conflict(dest.clone()));
234 }
235 self.move_path(from, &dest)?;
236 Ok(dest)
237 }
238 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
239 Err(OpsError::Conflict(dest.clone()))
240 }
241 Err(err) if err.kind() == io::ErrorKind::NotFound => {
242 Err(OpsError::NotFound(from.display().to_string()))
243 }
244 Err(err) => Err(OpsError::from(err)),
245 }
246 }
247
248 pub fn copy(&self, from: &Path, dest_dir: &Path) -> Result<PathBuf, OpsError> {
251 if !from.exists() {
252 return Err(OpsError::NotFound(from.display().to_string()));
253 }
254 let dest = self.unique_dest(dest_dir, from.file_name().unwrap_or_default());
255 self.copy_path(from, &dest)?;
256 Ok(dest)
257 }
258
259 pub fn move_(&self, from: &Path, dest_dir: &Path) -> Result<PathBuf, OpsError> {
263 if !from.exists() {
264 return Err(OpsError::NotFound(from.display().to_string()));
265 }
266 let dest = self.unique_dest(dest_dir, from.file_name().unwrap_or_default());
267 self.move_or_copy(from, &dest)?;
268 Ok(dest)
269 }
270
271 pub fn duplicate(&self, from: &Path) -> Result<PathBuf, OpsError> {
273 let dir = from.parent().unwrap_or_else(|| Path::new("."));
274 self.copy(from, dir)
275 }
276
277 pub fn create_link(&self, from: &Path) -> Result<PathBuf, OpsError> {
281 if !from.exists() {
282 return Err(OpsError::NotFound(from.display().to_string()));
283 }
284 let dir = from.parent().unwrap_or_else(|| Path::new("."));
285 let name = from.file_name().unwrap_or_default();
286 let link_name = format!("Link to {}", name.to_string_lossy());
287 let dest = self.unique_dest(dir, std::ffi::OsStr::new(&link_name));
288 std::os::unix::fs::symlink(from, &dest).map_err(OpsError::from)?;
289 Ok(dest)
290 }
291
292 pub fn trash(&self, paths: &[PathBuf]) -> Result<(), OpsError> {
294 for path in paths {
295 self.trash.trash(path)?;
296 }
297 Ok(())
298 }
299
300 pub fn delete_permanently(&self, paths: &[PathBuf]) -> Result<(), OpsError> {
304 for path in paths {
305 self.remove_path(path)?;
306 }
307 Ok(())
308 }
309
310 fn move_or_copy(&self, from: &Path, dest: &Path) -> Result<(), OpsError> {
314 self.rename_with_fallback(from, dest, |a, b| std::fs::rename(a, b))
315 }
316
317 fn rename_with_fallback(
320 &self,
321 from: &Path,
322 dest: &Path,
323 rename: impl for<'a, 'b> Fn(&'a Path, &'b Path) -> io::Result<()>,
324 ) -> Result<(), OpsError> {
325 match rename(from, dest) {
326 Err(err) if is_exdev(&err) => {
327 self.copy_path(from, dest)?;
328 self.remove_path(from)?;
329 Ok(())
330 }
331 Err(err) => Err(OpsError::from(err)),
332 Ok(()) => Ok(()),
333 }
334 }
335
336 fn move_path(&self, from: &Path, dest: &Path) -> Result<(), OpsError> {
338 std::fs::rename(from, dest).map_err(OpsError::from)
339 }
340
341 fn copy_path(&self, from: &Path, dest: &Path) -> Result<(), OpsError> {
344 let meta = fs::symlink_metadata(from)?;
345 if meta.is_dir() {
346 fs::create_dir_all(dest)?;
347 for entry in fs::read_dir(from)? {
348 let entry = entry?;
349 self.copy_path(&entry.path(), &dest.join(entry.file_name()))?;
350 }
351 } else if meta.file_type().is_symlink() {
352 let target = fs::read_link(from)?;
353 std::os::unix::fs::symlink(target, dest).map_err(OpsError::from)?;
354 } else {
355 fs::copy(from, dest)?;
356 }
357 Ok(())
358 }
359
360 fn remove_path(&self, path: &Path) -> Result<(), OpsError> {
363 let meta = fs::symlink_metadata(path)?;
364 if meta.is_dir() {
365 fs::remove_dir_all(path)?;
366 } else {
367 fs::remove_file(path)?;
368 }
369 Ok(())
370 }
371
372 fn unique_dest(&self, dir: &Path, name: &std::ffi::OsStr) -> PathBuf {
375 let candidate = dir.join(name);
376 if !candidate.exists() {
377 return candidate;
378 }
379 let text = name.to_string_lossy();
380 let (stem, ext) = split_extension(&text);
381 for n in 1.. {
382 let candidate_text = format!("{stem} ({n}){ext}");
383 let candidate = dir.join(&candidate_text);
384 if !candidate.exists() {
385 return candidate;
386 }
387 }
388 unreachable!("the loop above never terminates without a free name")
389 }
390}
391
392impl Default for FileOps {
393 fn default() -> Self {
394 Self::new()
395 }
396}
397
398fn split_extension(name: &str) -> (String, String) {
401 match name.rsplit_once('.') {
402 Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => {
403 (stem.to_owned(), format!(".{ext}"))
404 }
405 _ => (name.to_owned(), String::new()),
406 }
407}
408
409#[cfg(test)]
412mod tests {
413 use super::*;
414 use std::sync::{Arc, Mutex};
415
416 #[derive(Debug, Clone, Default)]
418 struct FakeTrash(Arc<Mutex<Vec<PathBuf>>>);
419
420 impl TrashService for FakeTrash {
421 fn trash(&self, path: &Path) -> Result<(), OpsError> {
422 self.0.lock().unwrap().push(path.to_path_buf());
423 Ok(())
424 }
425 }
426
427 fn fops() -> (FileOps, FakeTrash) {
428 let trash = FakeTrash::default();
429 let ops = FileOps::with_trash(Box::new(trash.clone()));
430 (ops, trash)
431 }
432
433 fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
434 let p = dir.join(name);
435 fs::write(&p, body).unwrap();
436 p
437 }
438
439 #[test]
440 fn create_file_creates_and_conflicts() {
441 let dir = tempfile::tempdir().unwrap();
442 let ops = FileOps::new();
443 let p = ops.create_file(dir.path(), "hi.txt").unwrap();
444 assert_eq!(fs::read_to_string(&p).unwrap(), "");
445 assert!(matches!(ops.create_file(dir.path(), "hi.txt"), Err(OpsError::Conflict(_))));
446 }
447
448 #[test]
449 fn create_dir_and_conflict() {
450 let dir = tempfile::tempdir().unwrap();
451 let ops = FileOps::new();
452 let p = ops.create_dir(dir.path(), "sub").unwrap();
453 assert!(p.is_dir());
454 assert!(matches!(ops.create_dir(dir.path(), "sub"), Err(OpsError::Conflict(_))));
455 }
456
457 #[test]
458 fn create_with_bad_names() {
459 let dir = tempfile::tempdir().unwrap();
460 let ops = FileOps::new();
461 assert!(ops.create_file(dir.path(), " ").is_err());
462 assert!(ops.create_dir(dir.path(), "\t").is_err());
463 }
464
465 #[test]
466 fn rename_changes_name_and_rejects_conflict() {
467 let dir = tempfile::tempdir().unwrap();
468 let ops = FileOps::new();
469 let from = write(dir.path(), "a.txt", "data");
470 let to = ops.rename(&from, "b.txt").unwrap();
471 assert_eq!(to.file_name().unwrap(), "b.txt");
472 assert!(!from.exists());
473 write(dir.path(), "c.txt", "x");
475 assert!(matches!(
476 ops.rename(&from, "c.txt"),
477 Err(OpsError::Conflict(_)) | Err(OpsError::NotFound(_))
478 ));
479 }
480
481 #[test]
482 fn rename_dir_works() {
483 let dir = tempfile::tempdir().unwrap();
484 let ops = FileOps::new();
485 let from = ops.create_dir(dir.path(), "folder").unwrap();
486 let dest = ops.rename(&from, "folder2").unwrap();
487 assert!(dest.is_dir());
488 assert!(!from.exists());
489 }
490
491 #[test]
492 fn copy_file_into_dir_with_unique_names() {
493 let src = tempfile::tempdir().unwrap();
494 let dst = tempfile::tempdir().unwrap();
495 let ops = FileOps::new();
496 let from = write(src.path(), "photo.png", "bytes");
497
498 let first = ops.copy(&from, dst.path()).unwrap();
499 assert_eq!(first.file_name().unwrap(), "photo.png");
500 assert_eq!(fs::read(&first).unwrap(), b"bytes");
501
502 let second = ops.copy(&from, dst.path()).unwrap();
503 assert_eq!(second.file_name().unwrap(), "photo (1).png");
504 let third = ops.copy(&from, dst.path()).unwrap();
505 assert_eq!(third.file_name().unwrap(), "photo (2).png");
506 }
507
508 #[test]
509 fn copy_directory_tree_preserves_structure_and_symlinks() {
510 let src = tempfile::tempdir().unwrap();
511 let dst = tempfile::tempdir().unwrap();
512 let ops = FileOps::new();
513 fs::create_dir(src.path().join("sub")).unwrap();
514 write(src.path(), "root.txt", "r");
515 write(&src.path().join("sub"), "child.txt", "c");
516 #[cfg(unix)]
517 {
518 std::os::unix::fs::symlink("root.txt", src.path().join("link")).unwrap();
519 }
520
521 let out = ops.copy(src.path(), dst.path()).unwrap();
522 assert!(out.join("sub/child.txt").is_file());
523 assert_eq!(fs::read_to_string(out.join("sub/child.txt")).unwrap(), "c");
524 #[cfg(unix)]
525 assert_eq!(fs::read_link(out.join("link")).unwrap(), Path::new("root.txt"));
526 }
527
528 #[test]
529 fn move_resolves_conflict_and_falls_back_on_cross_device() {
530 let src = tempfile::tempdir().unwrap();
531 let dst = tempfile::tempdir().unwrap();
532 let ops = FileOps::new();
533
534 let _occupied = write(dst.path(), "data.bin", "other");
536 let from = write(src.path(), "data.bin", "payload");
537 let moved = ops.move_(&from, dst.path()).unwrap();
538 assert_eq!(moved.file_name().unwrap(), "data (1).bin");
539 assert!(!from.exists());
540
541 let fallback_src = write(src.path(), "x.txt", "pay");
543 let fallback_dst = dst.path().join("x.txt");
544 ops.rename_with_fallback(
545 &fallback_src,
546 &fallback_dst,
547 |_from, _to| Err(io::Error::from_raw_os_error(EXDEV)),
548 )
549 .unwrap();
550 assert!(!fallback_src.exists());
551 assert_eq!(fs::read_to_string(&fallback_dst).unwrap(), "pay");
552 }
553
554 #[test]
555 fn duplicate_file() {
556 let dir = tempfile::tempdir().unwrap();
557 let ops = FileOps::new();
558 let from = write(dir.path(), "note.md", "# hello");
559 let dup = ops.duplicate(&from).unwrap();
560 assert_eq!(dup.file_name().unwrap(), "note (1).md");
561 }
562
563 #[test]
564 fn create_link_creates_symlink_next_to_source() {
565 let dir = tempfile::tempdir().unwrap();
566 let ops = FileOps::new();
567 let from = write(dir.path(), "data.txt", "payload");
568
569 let link = ops.create_link(&from).unwrap();
570 assert_eq!(link.file_name().unwrap(), "Link to data.txt");
571 let meta = fs::symlink_metadata(&link).unwrap();
572 assert!(meta.file_type().is_symlink(), "created path is a symlink");
573 assert_eq!(fs::read_link(&link).unwrap(), from);
574
575 let second = ops.create_link(&from).unwrap();
577 assert_eq!(second.file_name().unwrap(), "Link to data (1).txt");
578 }
579
580 #[test]
581 fn trash_delegates_to_service() {
582 let dir = tempfile::tempdir().unwrap();
583 let (ops, trash) = fops();
584 let a = write(dir.path(), "a.txt", "1");
585 let b = write(dir.path(), "b.txt", "2");
586 ops.trash(&[a.clone(), b.clone()]).unwrap();
587 let trashed = trash.0.lock().unwrap();
588 assert_eq!(*trashed, vec![a, b]);
589 }
590
591 #[test]
592 fn missing_sources_report_not_found() {
593 let dir = tempfile::tempdir().unwrap();
594 let ops = FileOps::new();
595 let missing = dir.path().join("nope");
596 assert!(matches!(ops.copy(&missing, dir.path()), Err(OpsError::NotFound(_))));
597 assert!(matches!(ops.move_(&missing, dir.path()), Err(OpsError::NotFound(_))));
598 }
599
600 #[test]
601 fn split_extension_handles_dotfiles() {
602 assert_eq!(split_extension("photo.png"), ("photo".to_owned(), ".png".to_owned()));
603 assert_eq!(split_extension(".bashrc"), (".bashrc".to_owned(), String::new()));
604 assert_eq!(split_extension("noext"), ("noext".to_owned(), String::new()));
605 assert_eq!(split_extension("a.tar.gz"), ("a.tar".to_owned(), ".gz".to_owned()));
606 }
607}