1use std::fs;
8use std::path::{Path, PathBuf};
9
10use thiserror::Error;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum DeleteMode {
23 Trash,
26 Permanent,
28}
29
30impl DeleteMode {
31 pub fn parse(value: &str) -> Option<DeleteMode> {
36 match value.trim().to_ascii_lowercase().as_str() {
37 "trash" | "recycle" => Some(DeleteMode::Trash),
38 "permanent" | "delete" => Some(DeleteMode::Permanent),
39 _ => None,
40 }
41 }
42}
43
44#[derive(Debug, Error)]
46pub enum FsError {
47 #[error("source path not found: {0}")]
49 SourceNotFound(PathBuf),
50 #[error("destination already exists: {0}")]
52 DestExists(PathBuf),
53 #[error("invalid name '{0}': {1}")]
55 InvalidName(String, &'static str),
56 #[error("{context}: {source}")]
58 Io {
59 context: String,
60 #[source]
61 source: std::io::Error,
62 },
63 #[error("could not move {path} to the recycle bin: {source}")]
69 Trash {
70 path: PathBuf,
72 #[source]
73 source: trash::Error,
74 },
75}
76
77fn io_err(context: impl Into<String>, source: std::io::Error) -> FsError {
79 FsError::Io {
80 context: context.into(),
81 source,
82 }
83}
84
85pub fn mkdir(cwd: &Path, name: &str) -> Result<PathBuf, FsError> {
95 validate_simple_name(name)?;
96 let target = cwd.join(name);
97 if target.exists() {
98 return Err(FsError::DestExists(target));
99 }
100 fs::create_dir(&target).map_err(|e| io_err(format!("mkdir {name}"), e))?;
101 Ok(target)
102}
103
104pub fn touch(cwd: &Path, name: &str) -> Result<PathBuf, FsError> {
112 validate_simple_name(name)?;
113 let target = cwd.join(name);
114 if target.exists() {
115 return Err(FsError::DestExists(target));
116 }
117 fs::write(&target, b"").map_err(|e| io_err(format!("touch {name}"), e))?;
118 Ok(target)
119}
120
121pub fn rename(source: &Path, new_name: &str) -> Result<PathBuf, FsError> {
135 if !source.exists() {
136 return Err(FsError::SourceNotFound(source.to_owned()));
137 }
138 validate_simple_name(new_name)?;
139 let dest = source.parent().unwrap_or(Path::new(".")).join(new_name);
140 if dest.exists() {
141 return Err(FsError::DestExists(dest));
142 }
143 fs::rename(source, &dest).map_err(|e| io_err(format!("rename → {new_name}"), e))?;
144 Ok(dest)
145}
146
147pub fn resolve_target(source: &Path, dest_str: impl AsRef<Path>, cwd: &Path) -> PathBuf {
154 let raw_dest = resolve_dest(dest_str, cwd);
155 if raw_dest.is_dir() {
156 raw_dest.join(source.file_name().unwrap_or_default())
157 } else {
158 raw_dest
159 }
160}
161
162pub fn move_path(source: &Path, dest: &Path) -> Result<(), FsError> {
181 move_path_with(source, dest, |from, to| fs::rename(from, to))
182}
183
184fn move_path_with(
187 source: &Path,
188 dest: &Path,
189 rename: impl Fn(&Path, &Path) -> std::io::Result<()>,
190) -> Result<(), FsError> {
191 let err = match rename(source, dest) {
192 Ok(()) => return Ok(()),
193 Err(e) => e,
194 };
195 if !is_cross_device(&err) {
196 return Err(io_err(format!("mv → {}", dest.display()), err));
197 }
198
199 if let Err(e) = copy_path(source, dest) {
200 let _ = remove_path(dest);
204 return Err(e);
205 }
206 remove_path(source).map_err(|e| FsError::Io {
207 context: format!(
208 "copied to {} but could not remove the original {}",
209 dest.display(),
210 source.display()
211 ),
212 source: e,
213 })
214}
215
216fn is_cross_device(err: &std::io::Error) -> bool {
222 let code = if cfg!(windows) { 17 } else { 18 };
224 err.raw_os_error() == Some(code)
225}
226
227pub fn copy_path(source: &Path, dest: &Path) -> Result<(), FsError> {
236 if source.is_dir() {
237 if dest.starts_with(source) {
238 return Err(FsError::InvalidName(
239 dest.display().to_string(),
240 "cannot copy a directory into itself",
241 ));
242 }
243 copy_dir_recursive(source, dest)
244 } else {
245 fs::copy(source, dest)
246 .map(|_| ())
247 .map_err(|e| io_err(format!("cp → {}", dest.display()), e))
248 }
249}
250
251fn remove_path(path: &Path) -> std::io::Result<()> {
254 if path.is_dir() {
255 fs::remove_dir_all(path)
256 } else {
257 fs::remove_file(path)
258 }
259}
260
261fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
263 fs::create_dir(dst).map_err(|e| io_err(format!("mkdir {}", dst.display()), e))?;
264 for entry in fs::read_dir(src).map_err(|e| io_err(format!("readdir {}", src.display()), e))? {
265 let entry = entry.map_err(|e| io_err("dir entry", e))?;
266 let ty = entry.file_type().map_err(|e| io_err("file_type", e))?;
267 let dest_path = dst.join(entry.file_name());
268 if ty.is_dir() {
269 copy_dir_recursive(&entry.path(), &dest_path)?;
270 } else {
271 fs::copy(entry.path(), &dest_path)
272 .map_err(|e| io_err(format!("cp {}", entry.path().display()), e))?;
273 }
274 }
275 Ok(())
276}
277
278pub fn delete(target: &Path, mode: DeleteMode) -> Result<(), FsError> {
296 if !target.exists() {
297 return Err(FsError::SourceNotFound(target.to_owned()));
298 }
299 match mode {
300 DeleteMode::Trash => trash::delete(target).map_err(|e| FsError::Trash {
301 path: target.to_owned(),
302 source: e,
303 }),
304 DeleteMode::Permanent => {
305 if target.is_dir() {
306 fs::remove_dir_all(target)
307 .map_err(|e| io_err(format!("delete dir {}", target.display()), e))
308 } else {
309 fs::remove_file(target)
310 .map_err(|e| io_err(format!("delete file {}", target.display()), e))
311 }
312 }
313 }
314}
315
316pub fn is_pattern(token: &str) -> bool {
327 token.contains('*') || token.contains('?')
328}
329
330pub fn matching_entries(cwd: &Path, pattern: &str) -> Result<Vec<PathBuf>, FsError> {
344 let read = fs::read_dir(cwd).map_err(|e| io_err(format!("readdir {}", cwd.display()), e))?;
345 let include_hidden = pattern.starts_with('.');
346
347 let mut matches: Vec<PathBuf> = read
348 .filter_map(|entry| entry.ok())
349 .filter_map(|entry| {
350 let name = entry.file_name().into_string().ok()?;
351 if name.starts_with('.') && !include_hidden {
352 return None;
353 }
354 matches_pattern(&name, pattern).then(|| entry.path())
355 })
356 .collect();
357
358 matches.sort();
359 Ok(matches)
360}
361
362fn matches_pattern(name: &str, pattern: &str) -> bool {
368 let (name, pattern) = if cfg!(windows) {
372 (name.to_lowercase(), pattern.to_lowercase())
373 } else {
374 (name.to_owned(), pattern.to_owned())
375 };
376 let name: Vec<char> = name.chars().collect();
377 let pattern: Vec<char> = pattern.chars().collect();
378
379 let mut n = 0; let mut p = 0; let mut last_star: Option<(usize, usize)> = None;
382
383 while n < name.len() {
384 match pattern.get(p) {
385 Some('*') => {
386 last_star = Some((p, n));
389 p += 1;
390 }
391 Some('?') => {
392 p += 1;
393 n += 1;
394 }
395 Some(&ch) if ch == name[n] => {
396 p += 1;
397 n += 1;
398 }
399 _ => match last_star {
402 Some((star_p, star_n)) => {
403 p = star_p + 1;
404 n = star_n + 1;
405 last_star = Some((star_p, n));
406 }
407 None => return false,
408 },
409 }
410 }
411
412 pattern[p..].iter().all(|&ch| ch == '*')
414}
415
416fn validate_simple_name(name: &str) -> Result<(), FsError> {
420 if name.is_empty() {
421 return Err(FsError::InvalidName(
422 name.to_owned(),
423 "name must not be empty",
424 ));
425 }
426 if name.contains('/') || name.contains('\\') {
427 return Err(FsError::InvalidName(
428 name.to_owned(),
429 "name must not contain path separators",
430 ));
431 }
432 Ok(())
433}
434
435pub fn resolve_dest(dest_str: impl AsRef<Path>, cwd: &Path) -> PathBuf {
438 let p = dest_str.as_ref().to_path_buf();
439 if p.is_absolute() {
440 p
441 } else {
442 cwd.join(p)
443 }
444}
445
446#[cfg(test)]
449mod tests {
450 use super::*;
451 use std::fs;
452 use tempfile::TempDir;
453
454 fn tmp() -> TempDir {
455 tempfile::tempdir().expect("tempdir")
456 }
457
458 fn mv(source: &Path, dest: &str, cwd: &Path) -> Result<PathBuf, FsError> {
460 let target = resolve_target(source, dest, cwd);
461 move_path(source, &target)?;
462 Ok(target)
463 }
464
465 fn cp(source: &Path, dest: &str, cwd: &Path) -> Result<PathBuf, FsError> {
467 let target = resolve_target(source, dest, cwd);
468 copy_path(source, &target)?;
469 Ok(target)
470 }
471
472 #[test]
475 fn mkdir_creates_directory() {
476 let dir = tmp();
477 let created = mkdir(dir.path(), "new_dir").unwrap();
478 assert!(created.is_dir());
479 }
480
481 #[test]
482 fn mkdir_rejects_path_separator() {
483 let dir = tmp();
484 let err = mkdir(dir.path(), "foo/bar").unwrap_err();
485 assert!(matches!(err, FsError::InvalidName(..)));
486 }
487
488 #[test]
489 fn mkdir_fails_if_exists() {
490 let dir = tmp();
491 fs::create_dir(dir.path().join("exists")).unwrap();
492 let err = mkdir(dir.path(), "exists").unwrap_err();
493 assert!(matches!(err, FsError::DestExists(_)));
494 }
495
496 #[test]
499 fn touch_creates_empty_file() {
500 let dir = tmp();
501 let created = touch(dir.path(), "new.txt").unwrap();
502 assert!(created.is_file());
503 assert_eq!(fs::read(created).unwrap(), b"");
504 }
505
506 #[test]
507 fn touch_rejects_path_separator() {
508 let dir = tmp();
509 let err = touch(dir.path(), "foo/bar.txt").unwrap_err();
510 assert!(matches!(err, FsError::InvalidName(..)));
511 }
512
513 #[test]
516 fn rename_renames_file() {
517 let dir = tmp();
518 let src = dir.path().join("old.txt");
519 fs::write(&src, b"content").unwrap();
520 let dest = rename(&src, "new.txt").unwrap();
521 assert!(!src.exists());
522 assert!(dest.is_file());
523 }
524
525 #[test]
526 fn rename_rejects_slash_in_name() {
527 let dir = tmp();
528 let src = dir.path().join("old.txt");
529 fs::write(&src, b"").unwrap();
530 let err = rename(&src, "new/name.txt").unwrap_err();
531 assert!(matches!(err, FsError::InvalidName(..)));
532 }
533
534 #[test]
535 fn rename_fails_if_source_missing() {
536 let dir = tmp();
537 let err = rename(&dir.path().join("missing.txt"), "new.txt").unwrap_err();
538 assert!(matches!(err, FsError::SourceNotFound(_)));
539 }
540
541 #[test]
544 fn mv_moves_file() {
545 let dir = tmp();
546 let src = dir.path().join("src.txt");
547 fs::write(&src, b"hello").unwrap();
548 let dest = mv(&src, "dest.txt", dir.path()).unwrap();
549 assert!(!src.exists());
550 assert!(dest.is_file());
551 }
552
553 #[test]
554 fn mv_into_existing_directory() {
555 let dir = tmp();
556 let src = dir.path().join("file.txt");
557 fs::write(&src, b"").unwrap();
558 let sub = dir.path().join("sub");
559 fs::create_dir(&sub).unwrap();
560 let dest = mv(&src, "sub", dir.path()).unwrap();
561 assert_eq!(dest, sub.join("file.txt"));
562 }
563
564 #[test]
567 fn cp_copies_file() {
568 let dir = tmp();
569 let src = dir.path().join("src.txt");
570 fs::write(&src, b"data").unwrap();
571 let dest = cp(&src, "copy.txt", dir.path()).unwrap();
572 assert!(src.exists());
573 assert!(dest.is_file());
574 assert_eq!(fs::read(dest).unwrap(), b"data");
575 }
576
577 #[test]
578 fn cp_copies_directory_recursively() {
579 let dir = tmp();
580 let src = dir.path().join("srcdir");
581 fs::create_dir(&src).unwrap();
582 fs::write(src.join("file.txt"), b"hi").unwrap();
583 let dest = cp(&src, "dstdir", dir.path()).unwrap();
584 assert!(dest.join("file.txt").exists());
585 }
586
587 #[test]
588 fn cp_refuses_to_copy_a_directory_into_itself() {
589 let dir = tmp();
590 let src = dir.path().join("srcdir");
591 fs::create_dir(&src).unwrap();
592 let err = cp(&src, "srcdir", dir.path()).unwrap_err();
593 assert!(matches!(err, FsError::InvalidName(..)), "{err}");
594 }
595
596 fn cross_device(_: &Path, _: &Path) -> std::io::Result<()> {
601 let code = if cfg!(windows) { 17 } else { 18 };
602 Err(std::io::Error::from_raw_os_error(code))
603 }
604
605 #[test]
606 fn a_move_across_drives_copies_then_removes_the_source() {
607 let dir = tmp();
608 let src = dir.path().join("tree");
609 fs::create_dir(&src).unwrap();
610 fs::write(src.join("leaf.txt"), b"leaf").unwrap();
611 let dest = dir.path().join("moved");
612
613 move_path_with(&src, &dest, cross_device).unwrap();
614
615 assert!(!src.exists(), "the source goes once the copy is complete");
616 assert_eq!(fs::read(dest.join("leaf.txt")).unwrap(), b"leaf");
617 }
618
619 #[test]
620 fn a_failed_copy_keeps_the_source_and_cleans_up_the_partial_copy() {
621 let dir = tmp();
622 let src = dir.path().join("tree");
623 fs::create_dir(&src).unwrap();
624 fs::write(src.join("leaf.txt"), b"leaf").unwrap();
625 let dest = src.join("inside");
627
628 assert!(move_path_with(&src, &dest, cross_device).is_err());
629 assert!(src.join("leaf.txt").exists(), "the original is untouched");
630 assert!(!dest.exists());
631 }
632
633 #[test]
634 fn any_other_rename_failure_is_reported_without_copying() {
635 let dir = tmp();
636 let src = dir.path().join("file.txt");
637 fs::write(&src, b"x").unwrap();
638 let dest = dir.path().join("dest.txt");
639
640 let denied = |_: &Path, _: &Path| {
641 Err(std::io::Error::new(
642 std::io::ErrorKind::PermissionDenied,
643 "denied",
644 ))
645 };
646 assert!(move_path_with(&src, &dest, denied).is_err());
647 assert!(src.exists());
648 assert!(
649 !dest.exists(),
650 "only a cross-drive failure falls back to a copy"
651 );
652 }
653
654 #[test]
657 fn delete_removes_file() {
658 let dir = tmp();
659 let f = dir.path().join("to_delete.txt");
660 fs::write(&f, b"").unwrap();
661 delete(&f, DeleteMode::Permanent).unwrap();
662 assert!(!f.exists());
663 }
664
665 #[test]
666 fn delete_removes_directory_recursively() {
667 let dir = tmp();
668 let d = dir.path().join("to_delete");
669 fs::create_dir(&d).unwrap();
670 fs::write(d.join("child.txt"), b"").unwrap();
671 delete(&d, DeleteMode::Permanent).unwrap();
672 assert!(!d.exists());
673 }
674
675 #[test]
676 fn delete_fails_if_missing() {
677 let dir = tmp();
678 let err = delete(&dir.path().join("nope"), DeleteMode::Trash).unwrap_err();
680 assert!(matches!(err, FsError::SourceNotFound(_)));
681 }
682
683 #[test]
686 fn a_star_matches_any_run_including_none() {
687 assert!(matches_pattern("notes.md", "*.md"));
688 assert!(matches_pattern(".md", "*.md"));
689 assert!(matches_pattern("a.md", "*"));
690 assert!(!matches_pattern("notes.txt", "*.md"));
691 assert!(matches_pattern("README", "*"));
692 }
693
694 #[test]
695 fn a_question_mark_matches_exactly_one_character() {
696 assert!(matches_pattern("a.md", "?.md"));
697 assert!(!matches_pattern("ab.md", "?.md"));
698 assert!(!matches_pattern(".md", "?.md"));
699 }
700
701 #[test]
704 fn a_star_backtracks_when_the_tail_does_not_fit() {
705 assert!(matches_pattern("archive.tar.gz", "*.tar.gz"));
706 assert!(matches_pattern("a.b.c.d", "*.d"));
707 assert!(matches_pattern("xxxxab", "*ab"));
708 assert!(!matches_pattern("xxxxabc", "*ab"));
709 assert!(matches_pattern("abcabcabd", "*abc*abd"));
710 assert!(matches_pattern("aaaaaaaaaaaaaaaaaaaab", "*a*a*a*a*a*b"));
712 assert!(!matches_pattern("aaaaaaaaaaaaaaaaaaaa", "*a*a*a*a*a*b"));
713 }
714
715 #[test]
716 fn a_pattern_is_recognised_by_its_wildcards() {
717 assert!(is_pattern("*.md"));
718 assert!(is_pattern("note?.txt"));
719 assert!(!is_pattern("my"));
722 assert!(!is_pattern("../backup"));
723 }
724
725 #[test]
726 fn matching_entries_skips_hidden_names_unless_asked() {
727 let dir = tmp();
728 fs::write(dir.path().join("one.md"), b"").unwrap();
729 fs::write(dir.path().join("two.md"), b"").unwrap();
730 fs::write(dir.path().join(".hidden.md"), b"").unwrap();
731
732 let visible = matching_entries(dir.path(), "*.md").unwrap();
733 assert_eq!(visible.len(), 2, "a bare `*` must not take hidden files");
734
735 let hidden = matching_entries(dir.path(), ".*.md").unwrap();
736 assert_eq!(
737 hidden.len(),
738 1,
739 "a pattern starting with a dot asks for them"
740 );
741 assert!(hidden[0].ends_with(".hidden.md"));
742 }
743
744 #[test]
750 fn delete_modes_parse_from_what_the_config_can_say() {
751 assert_eq!(DeleteMode::parse("trash"), Some(DeleteMode::Trash));
752 assert_eq!(DeleteMode::parse(" TRASH "), Some(DeleteMode::Trash));
753 assert_eq!(DeleteMode::parse("recycle"), Some(DeleteMode::Trash));
754 assert_eq!(DeleteMode::parse("permanent"), Some(DeleteMode::Permanent));
755 assert_eq!(DeleteMode::parse("delete"), Some(DeleteMode::Permanent));
756 assert_eq!(DeleteMode::parse("bin"), None);
757 assert_eq!(DeleteMode::parse(""), None);
758 }
759}