use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeleteMode {
Trash,
Permanent,
}
impl DeleteMode {
pub fn parse(value: &str) -> Option<DeleteMode> {
match value.trim().to_ascii_lowercase().as_str() {
"trash" | "recycle" => Some(DeleteMode::Trash),
"permanent" | "delete" => Some(DeleteMode::Permanent),
_ => None,
}
}
}
#[derive(Debug, Error)]
pub enum FsError {
#[error("source path not found: {0}")]
SourceNotFound(PathBuf),
#[error("destination already exists: {0}")]
DestExists(PathBuf),
#[error("invalid name '{0}': {1}")]
InvalidName(String, &'static str),
#[error("{context}: {source}")]
Io {
context: String,
#[source]
source: std::io::Error,
},
#[error("could not move {path} to the recycle bin: {source}")]
Trash {
path: PathBuf,
#[source]
source: trash::Error,
},
}
fn io_err(context: impl Into<String>, source: std::io::Error) -> FsError {
FsError::Io {
context: context.into(),
source,
}
}
pub fn mkdir(cwd: &Path, name: &str) -> Result<PathBuf, FsError> {
validate_simple_name(name)?;
let target = cwd.join(name);
if target.exists() {
return Err(FsError::DestExists(target));
}
fs::create_dir(&target).map_err(|e| io_err(format!("mkdir {name}"), e))?;
Ok(target)
}
pub fn touch(cwd: &Path, name: &str) -> Result<PathBuf, FsError> {
validate_simple_name(name)?;
let target = cwd.join(name);
if target.exists() {
return Err(FsError::DestExists(target));
}
fs::write(&target, b"").map_err(|e| io_err(format!("touch {name}"), e))?;
Ok(target)
}
pub fn rename(source: &Path, new_name: &str) -> Result<PathBuf, FsError> {
if !source.exists() {
return Err(FsError::SourceNotFound(source.to_owned()));
}
validate_simple_name(new_name)?;
let dest = source.parent().unwrap_or(Path::new(".")).join(new_name);
if dest.exists() {
return Err(FsError::DestExists(dest));
}
fs::rename(source, &dest).map_err(|e| io_err(format!("rename → {new_name}"), e))?;
Ok(dest)
}
pub fn resolve_target(source: &Path, dest_str: impl AsRef<Path>, cwd: &Path) -> PathBuf {
let raw_dest = resolve_dest(dest_str, cwd);
if raw_dest.is_dir() {
raw_dest.join(source.file_name().unwrap_or_default())
} else {
raw_dest
}
}
pub fn move_path(source: &Path, dest: &Path) -> Result<(), FsError> {
move_path_with(source, dest, |from, to| fs::rename(from, to))
}
fn move_path_with(
source: &Path,
dest: &Path,
rename: impl Fn(&Path, &Path) -> std::io::Result<()>,
) -> Result<(), FsError> {
let err = match rename(source, dest) {
Ok(()) => return Ok(()),
Err(e) => e,
};
if !is_cross_device(&err) {
return Err(io_err(format!("mv → {}", dest.display()), err));
}
if let Err(e) = copy_path(source, dest) {
let _ = remove_path(dest);
return Err(e);
}
remove_path(source).map_err(|e| FsError::Io {
context: format!(
"copied to {} but could not remove the original {}",
dest.display(),
source.display()
),
source: e,
})
}
fn is_cross_device(err: &std::io::Error) -> bool {
let code = if cfg!(windows) { 17 } else { 18 };
err.raw_os_error() == Some(code)
}
pub fn copy_path(source: &Path, dest: &Path) -> Result<(), FsError> {
if source.is_dir() {
if dest.starts_with(source) {
return Err(FsError::InvalidName(
dest.display().to_string(),
"cannot copy a directory into itself",
));
}
copy_dir_recursive(source, dest)
} else {
fs::copy(source, dest)
.map(|_| ())
.map_err(|e| io_err(format!("cp → {}", dest.display()), e))
}
}
fn remove_path(path: &Path) -> std::io::Result<()> {
if path.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), FsError> {
fs::create_dir(dst).map_err(|e| io_err(format!("mkdir {}", dst.display()), e))?;
for entry in fs::read_dir(src).map_err(|e| io_err(format!("readdir {}", src.display()), e))? {
let entry = entry.map_err(|e| io_err("dir entry", e))?;
let ty = entry.file_type().map_err(|e| io_err("file_type", e))?;
let dest_path = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path)?;
} else {
fs::copy(entry.path(), &dest_path)
.map_err(|e| io_err(format!("cp {}", entry.path().display()), e))?;
}
}
Ok(())
}
pub fn delete(target: &Path, mode: DeleteMode) -> Result<(), FsError> {
if !target.exists() {
return Err(FsError::SourceNotFound(target.to_owned()));
}
match mode {
DeleteMode::Trash => trash::delete(target).map_err(|e| FsError::Trash {
path: target.to_owned(),
source: e,
}),
DeleteMode::Permanent => {
if target.is_dir() {
fs::remove_dir_all(target)
.map_err(|e| io_err(format!("delete dir {}", target.display()), e))
} else {
fs::remove_file(target)
.map_err(|e| io_err(format!("delete file {}", target.display()), e))
}
}
}
}
pub fn is_pattern(token: &str) -> bool {
token.contains('*') || token.contains('?')
}
pub fn matching_entries(cwd: &Path, pattern: &str) -> Result<Vec<PathBuf>, FsError> {
let read = fs::read_dir(cwd).map_err(|e| io_err(format!("readdir {}", cwd.display()), e))?;
let include_hidden = pattern.starts_with('.');
let mut matches: Vec<PathBuf> = read
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
if name.starts_with('.') && !include_hidden {
return None;
}
matches_pattern(&name, pattern).then(|| entry.path())
})
.collect();
matches.sort();
Ok(matches)
}
fn matches_pattern(name: &str, pattern: &str) -> bool {
let (name, pattern) = if cfg!(windows) {
(name.to_lowercase(), pattern.to_lowercase())
} else {
(name.to_owned(), pattern.to_owned())
};
let name: Vec<char> = name.chars().collect();
let pattern: Vec<char> = pattern.chars().collect();
let mut n = 0; let mut p = 0; let mut last_star: Option<(usize, usize)> = None;
while n < name.len() {
match pattern.get(p) {
Some('*') => {
last_star = Some((p, n));
p += 1;
}
Some('?') => {
p += 1;
n += 1;
}
Some(&ch) if ch == name[n] => {
p += 1;
n += 1;
}
_ => match last_star {
Some((star_p, star_n)) => {
p = star_p + 1;
n = star_n + 1;
last_star = Some((star_p, n));
}
None => return false,
},
}
}
pattern[p..].iter().all(|&ch| ch == '*')
}
fn validate_simple_name(name: &str) -> Result<(), FsError> {
if name.is_empty() {
return Err(FsError::InvalidName(
name.to_owned(),
"name must not be empty",
));
}
if name.contains('/') || name.contains('\\') {
return Err(FsError::InvalidName(
name.to_owned(),
"name must not contain path separators",
));
}
Ok(())
}
pub fn resolve_dest(dest_str: impl AsRef<Path>, cwd: &Path) -> PathBuf {
let p = dest_str.as_ref().to_path_buf();
if p.is_absolute() {
p
} else {
cwd.join(p)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn tmp() -> TempDir {
tempfile::tempdir().expect("tempdir")
}
fn mv(source: &Path, dest: &str, cwd: &Path) -> Result<PathBuf, FsError> {
let target = resolve_target(source, dest, cwd);
move_path(source, &target)?;
Ok(target)
}
fn cp(source: &Path, dest: &str, cwd: &Path) -> Result<PathBuf, FsError> {
let target = resolve_target(source, dest, cwd);
copy_path(source, &target)?;
Ok(target)
}
#[test]
fn mkdir_creates_directory() {
let dir = tmp();
let created = mkdir(dir.path(), "new_dir").unwrap();
assert!(created.is_dir());
}
#[test]
fn mkdir_rejects_path_separator() {
let dir = tmp();
let err = mkdir(dir.path(), "foo/bar").unwrap_err();
assert!(matches!(err, FsError::InvalidName(..)));
}
#[test]
fn mkdir_fails_if_exists() {
let dir = tmp();
fs::create_dir(dir.path().join("exists")).unwrap();
let err = mkdir(dir.path(), "exists").unwrap_err();
assert!(matches!(err, FsError::DestExists(_)));
}
#[test]
fn touch_creates_empty_file() {
let dir = tmp();
let created = touch(dir.path(), "new.txt").unwrap();
assert!(created.is_file());
assert_eq!(fs::read(created).unwrap(), b"");
}
#[test]
fn touch_rejects_path_separator() {
let dir = tmp();
let err = touch(dir.path(), "foo/bar.txt").unwrap_err();
assert!(matches!(err, FsError::InvalidName(..)));
}
#[test]
fn rename_renames_file() {
let dir = tmp();
let src = dir.path().join("old.txt");
fs::write(&src, b"content").unwrap();
let dest = rename(&src, "new.txt").unwrap();
assert!(!src.exists());
assert!(dest.is_file());
}
#[test]
fn rename_rejects_slash_in_name() {
let dir = tmp();
let src = dir.path().join("old.txt");
fs::write(&src, b"").unwrap();
let err = rename(&src, "new/name.txt").unwrap_err();
assert!(matches!(err, FsError::InvalidName(..)));
}
#[test]
fn rename_fails_if_source_missing() {
let dir = tmp();
let err = rename(&dir.path().join("missing.txt"), "new.txt").unwrap_err();
assert!(matches!(err, FsError::SourceNotFound(_)));
}
#[test]
fn mv_moves_file() {
let dir = tmp();
let src = dir.path().join("src.txt");
fs::write(&src, b"hello").unwrap();
let dest = mv(&src, "dest.txt", dir.path()).unwrap();
assert!(!src.exists());
assert!(dest.is_file());
}
#[test]
fn mv_into_existing_directory() {
let dir = tmp();
let src = dir.path().join("file.txt");
fs::write(&src, b"").unwrap();
let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap();
let dest = mv(&src, "sub", dir.path()).unwrap();
assert_eq!(dest, sub.join("file.txt"));
}
#[test]
fn cp_copies_file() {
let dir = tmp();
let src = dir.path().join("src.txt");
fs::write(&src, b"data").unwrap();
let dest = cp(&src, "copy.txt", dir.path()).unwrap();
assert!(src.exists());
assert!(dest.is_file());
assert_eq!(fs::read(dest).unwrap(), b"data");
}
#[test]
fn cp_copies_directory_recursively() {
let dir = tmp();
let src = dir.path().join("srcdir");
fs::create_dir(&src).unwrap();
fs::write(src.join("file.txt"), b"hi").unwrap();
let dest = cp(&src, "dstdir", dir.path()).unwrap();
assert!(dest.join("file.txt").exists());
}
#[test]
fn cp_refuses_to_copy_a_directory_into_itself() {
let dir = tmp();
let src = dir.path().join("srcdir");
fs::create_dir(&src).unwrap();
let err = cp(&src, "srcdir", dir.path()).unwrap_err();
assert!(matches!(err, FsError::InvalidName(..)), "{err}");
}
fn cross_device(_: &Path, _: &Path) -> std::io::Result<()> {
let code = if cfg!(windows) { 17 } else { 18 };
Err(std::io::Error::from_raw_os_error(code))
}
#[test]
fn a_move_across_drives_copies_then_removes_the_source() {
let dir = tmp();
let src = dir.path().join("tree");
fs::create_dir(&src).unwrap();
fs::write(src.join("leaf.txt"), b"leaf").unwrap();
let dest = dir.path().join("moved");
move_path_with(&src, &dest, cross_device).unwrap();
assert!(!src.exists(), "the source goes once the copy is complete");
assert_eq!(fs::read(dest.join("leaf.txt")).unwrap(), b"leaf");
}
#[test]
fn a_failed_copy_keeps_the_source_and_cleans_up_the_partial_copy() {
let dir = tmp();
let src = dir.path().join("tree");
fs::create_dir(&src).unwrap();
fs::write(src.join("leaf.txt"), b"leaf").unwrap();
let dest = src.join("inside");
assert!(move_path_with(&src, &dest, cross_device).is_err());
assert!(src.join("leaf.txt").exists(), "the original is untouched");
assert!(!dest.exists());
}
#[test]
fn any_other_rename_failure_is_reported_without_copying() {
let dir = tmp();
let src = dir.path().join("file.txt");
fs::write(&src, b"x").unwrap();
let dest = dir.path().join("dest.txt");
let denied = |_: &Path, _: &Path| {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"denied",
))
};
assert!(move_path_with(&src, &dest, denied).is_err());
assert!(src.exists());
assert!(
!dest.exists(),
"only a cross-drive failure falls back to a copy"
);
}
#[test]
fn delete_removes_file() {
let dir = tmp();
let f = dir.path().join("to_delete.txt");
fs::write(&f, b"").unwrap();
delete(&f, DeleteMode::Permanent).unwrap();
assert!(!f.exists());
}
#[test]
fn delete_removes_directory_recursively() {
let dir = tmp();
let d = dir.path().join("to_delete");
fs::create_dir(&d).unwrap();
fs::write(d.join("child.txt"), b"").unwrap();
delete(&d, DeleteMode::Permanent).unwrap();
assert!(!d.exists());
}
#[test]
fn delete_fails_if_missing() {
let dir = tmp();
let err = delete(&dir.path().join("nope"), DeleteMode::Trash).unwrap_err();
assert!(matches!(err, FsError::SourceNotFound(_)));
}
#[test]
fn a_star_matches_any_run_including_none() {
assert!(matches_pattern("notes.md", "*.md"));
assert!(matches_pattern(".md", "*.md"));
assert!(matches_pattern("a.md", "*"));
assert!(!matches_pattern("notes.txt", "*.md"));
assert!(matches_pattern("README", "*"));
}
#[test]
fn a_question_mark_matches_exactly_one_character() {
assert!(matches_pattern("a.md", "?.md"));
assert!(!matches_pattern("ab.md", "?.md"));
assert!(!matches_pattern(".md", "?.md"));
}
#[test]
fn a_star_backtracks_when_the_tail_does_not_fit() {
assert!(matches_pattern("archive.tar.gz", "*.tar.gz"));
assert!(matches_pattern("a.b.c.d", "*.d"));
assert!(matches_pattern("xxxxab", "*ab"));
assert!(!matches_pattern("xxxxabc", "*ab"));
assert!(matches_pattern("abcabcabd", "*abc*abd"));
assert!(matches_pattern("aaaaaaaaaaaaaaaaaaaab", "*a*a*a*a*a*b"));
assert!(!matches_pattern("aaaaaaaaaaaaaaaaaaaa", "*a*a*a*a*a*b"));
}
#[test]
fn a_pattern_is_recognised_by_its_wildcards() {
assert!(is_pattern("*.md"));
assert!(is_pattern("note?.txt"));
assert!(!is_pattern("my"));
assert!(!is_pattern("../backup"));
}
#[test]
fn matching_entries_skips_hidden_names_unless_asked() {
let dir = tmp();
fs::write(dir.path().join("one.md"), b"").unwrap();
fs::write(dir.path().join("two.md"), b"").unwrap();
fs::write(dir.path().join(".hidden.md"), b"").unwrap();
let visible = matching_entries(dir.path(), "*.md").unwrap();
assert_eq!(visible.len(), 2, "a bare `*` must not take hidden files");
let hidden = matching_entries(dir.path(), ".*.md").unwrap();
assert_eq!(
hidden.len(),
1,
"a pattern starting with a dot asks for them"
);
assert!(hidden[0].ends_with(".hidden.md"));
}
#[test]
fn delete_modes_parse_from_what_the_config_can_say() {
assert_eq!(DeleteMode::parse("trash"), Some(DeleteMode::Trash));
assert_eq!(DeleteMode::parse(" TRASH "), Some(DeleteMode::Trash));
assert_eq!(DeleteMode::parse("recycle"), Some(DeleteMode::Trash));
assert_eq!(DeleteMode::parse("permanent"), Some(DeleteMode::Permanent));
assert_eq!(DeleteMode::parse("delete"), Some(DeleteMode::Permanent));
assert_eq!(DeleteMode::parse("bin"), None);
assert_eq!(DeleteMode::parse(""), None);
}
}