use std::io::{self, Read, Write};
use std::path::{Component, Path, PathBuf};
use crate::cancel::CancelToken;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Conflict {
#[default]
Rename,
Overwrite,
Skip,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum LinkKind {
Symbolic { relative: bool },
Hard,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum Op {
Copy {
from: Vec<PathBuf>,
into: PathBuf,
conflict: Conflict,
},
Move {
from: Vec<PathBuf>,
into: PathBuf,
conflict: Conflict,
},
Delete(Vec<PathBuf>),
Trash(Vec<PathBuf>),
Link {
from: Vec<PathBuf>,
into: PathBuf,
kind: LinkKind,
conflict: Conflict,
},
}
impl Op {
pub fn title(&self) -> String {
let count = |paths: &[PathBuf]| match paths {
[one] => one.file_name().map_or_else(
|| one.display().to_string(),
|n| n.to_string_lossy().into_owned(),
),
many => format!("{} items", many.len()),
};
let name = |dir: &Path| {
dir.file_name().map_or_else(
|| dir.display().to_string(),
|n| n.to_string_lossy().into_owned(),
)
};
match self {
Op::Copy { from, into, .. } => format!("Copy {} to {}", count(from), name(into)),
Op::Move { from, into, .. } => format!("Move {} to {}", count(from), name(into)),
Op::Delete(paths) => format!("Delete {}", count(paths)),
Op::Trash(paths) => format!("Trash {}", count(paths)),
Op::Link {
from, into, kind, ..
} => {
let what = match kind {
LinkKind::Symbolic { .. } => "Link",
LinkKind::Hard => "Hard link",
};
format!("{what} {} in {}", count(from), name(into))
}
}
}
pub fn paths(&self) -> &[PathBuf] {
match self {
Op::Copy { from, .. } | Op::Move { from, .. } | Op::Link { from, .. } => from,
Op::Delete(paths) | Op::Trash(paths) => paths,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct Progress {
pub bytes: u64,
pub total_bytes: u64,
pub items: usize,
pub total_items: usize,
pub current: Option<PathBuf>,
}
impl Progress {
pub fn fraction(&self) -> f64 {
if self.total_bytes > 0 {
self.bytes as f64 / self.total_bytes as f64
} else if self.total_items > 0 {
self.items as f64 / self.total_items as f64
} else {
1.0
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
pub struct Report {
pub made: Vec<PathBuf>,
pub errors: Vec<String>,
pub cancelled: bool,
}
const CHUNK: usize = 256 * 1024;
pub fn run(op: &Op, cancel: &CancelToken, progress: &mut dyn FnMut(&Progress)) -> Report {
let mut runner = Runner {
cancel,
progress: Progress::default(),
emit: progress,
report: Report::default(),
};
for path in op.paths() {
let (items, bytes) = match op {
Op::Trash(_)
| Op::Link {
kind: LinkKind::Symbolic { .. },
..
} => (1, 0),
Op::Delete(_) | Op::Link { .. } => (count(path).0, 0),
_ => count(path),
};
runner.progress.total_items += items;
runner.progress.total_bytes += bytes;
}
runner.tell();
for path in op.paths() {
if runner.stopped() {
break;
}
let name = path.file_name().map_or_else(
|| path.display().to_string(),
|n| n.to_string_lossy().into_owned(),
);
let done = match op {
Op::Copy { into, conflict, .. } => runner.paste(path, into, *conflict, false),
Op::Move { into, conflict, .. } => runner.paste(path, into, *conflict, true),
Op::Delete(_) => runner.delete(path),
Op::Trash(_) => crate::trash::send(path).map(|_| {
runner.progress.items += 1;
runner.tell();
}),
Op::Link {
into,
kind,
conflict,
..
} => runner.link(path, into, *kind, *conflict),
};
if let Err(error) = done
&& error.kind() != io::ErrorKind::Interrupted
{
runner.report.errors.push(format!("{name}: {error}"));
}
}
runner.progress.current = None;
runner.tell();
runner.report
}
fn count(path: &Path) -> (usize, u64) {
let Ok(meta) = std::fs::symlink_metadata(path) else {
return (0, 0);
};
if !meta.is_dir() {
return (1, if meta.is_file() { meta.len() } else { 0 });
}
let mut total = (1, 0);
if let Ok(read) = std::fs::read_dir(path) {
for entry in read.filter_map(Result::ok) {
let (items, bytes) = count(&entry.path());
total.0 += items;
total.1 += bytes;
}
}
total
}
struct Runner<'a> {
cancel: &'a CancelToken,
progress: Progress,
emit: &'a mut dyn FnMut(&Progress),
report: Report,
}
fn interrupted() -> io::Error {
io::Error::new(io::ErrorKind::Interrupted, "cancelled")
}
impl Runner<'_> {
fn tell(&mut self) {
(self.emit)(&self.progress);
}
fn stopped(&mut self) -> bool {
if self.cancel.is_cancelled() {
self.report.cancelled = true;
}
self.report.cancelled
}
fn paste(&mut self, from: &Path, into: &Path, conflict: Conflict, cut: bool) -> io::Result<()> {
let Some(name) = from.file_name() else {
return Ok(());
};
let meta = std::fs::symlink_metadata(from)?;
if meta.is_dir() && into.starts_with(from) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"can't go inside itself",
));
}
let mut target = into.join(name);
if target == from {
if cut {
let (items, bytes) = count(from);
self.progress.items += items;
self.progress.bytes += bytes;
self.tell();
return Ok(());
}
target = unique(&target);
} else if std::fs::symlink_metadata(&target).is_ok() {
match conflict {
Conflict::Rename => target = unique(&target),
Conflict::Skip => {
let (items, bytes) = count(from);
self.progress.items += items;
self.progress.bytes += bytes;
self.tell();
return Ok(());
}
Conflict::Overwrite => remove_all(&target)?,
}
}
if cut && std::fs::rename(from, &target).is_ok() {
let (items, bytes) = count(&target);
self.progress.items += items;
self.progress.bytes += bytes;
self.tell();
self.report.made.push(target);
return Ok(());
}
self.copy(from, &target)?;
if cut {
remove_all(from)?;
}
self.report.made.push(target);
Ok(())
}
fn copy(&mut self, from: &Path, to: &Path) -> io::Result<()> {
if self.stopped() {
return Err(interrupted());
}
self.progress.current = Some(from.to_path_buf());
let meta = std::fs::symlink_metadata(from)?;
if meta.file_type().is_symlink() {
#[cfg(unix)]
std::os::unix::fs::symlink(std::fs::read_link(from)?, to)?;
#[cfg(not(unix))]
std::fs::copy(from, to)?;
} else if meta.is_dir() {
std::fs::create_dir(to)?;
let mut names: Vec<_> = std::fs::read_dir(from)?
.filter_map(Result::ok)
.map(|e| e.file_name())
.collect();
names.sort();
self.progress.items += 1;
self.tell();
for name in names {
self.copy(&from.join(&name), &to.join(&name))?;
}
std::fs::set_permissions(to, meta.permissions())?;
return Ok(());
} else {
self.copy_file(from, to, &meta)?;
}
self.progress.items += 1;
self.tell();
Ok(())
}
fn copy_file(&mut self, from: &Path, to: &Path, meta: &std::fs::Metadata) -> io::Result<()> {
let mut reader = std::fs::File::open(from)?;
let mut writer = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(to)?;
let mut buffer = vec![0u8; CHUNK];
let copied = (|| -> io::Result<()> {
loop {
if self.stopped() {
return Err(interrupted());
}
let n = reader.read(&mut buffer)?;
if n == 0 {
return Ok(());
}
writer.write_all(&buffer[..n])?;
self.progress.bytes += n as u64;
self.tell();
}
})();
drop(writer);
if let Err(error) = copied {
let _ = std::fs::remove_file(to);
return Err(error);
}
std::fs::set_permissions(to, meta.permissions())?;
if let Ok(modified) = meta.modified() {
let _ = std::fs::File::options()
.write(true)
.open(to)
.and_then(|f| f.set_modified(modified));
}
Ok(())
}
fn link(
&mut self,
from: &Path,
into: &Path,
kind: LinkKind,
conflict: Conflict,
) -> io::Result<()> {
let Some(name) = from.file_name() else {
return Ok(());
};
let meta = std::fs::symlink_metadata(from)?;
let mut target = into.join(name);
if std::fs::symlink_metadata(&target).is_ok() {
match conflict {
Conflict::Rename => target = unique(&target),
Conflict::Skip => {
self.progress.items += match kind {
LinkKind::Symbolic { .. } => 1,
LinkKind::Hard => count(from).0,
};
self.tell();
return Ok(());
}
Conflict::Overwrite => {
if target == from {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"can't link a file over itself",
));
}
remove_all(&target)?;
}
}
}
match kind {
LinkKind::Symbolic { relative } => {
let from = absolute(from)?;
let to = if relative {
relative_to(&from, &absolute(into)?)
} else {
from.clone()
};
symlink(&to, &target, meta.is_dir())?;
self.progress.items += 1;
self.tell();
}
LinkKind::Hard => {
if meta.is_dir() && into.starts_with(from) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"can't go inside itself",
));
}
self.hard_link(from, &target)?;
}
}
self.report.made.push(target);
Ok(())
}
fn hard_link(&mut self, from: &Path, to: &Path) -> io::Result<()> {
if self.stopped() {
return Err(interrupted());
}
self.progress.current = Some(from.to_path_buf());
let meta = std::fs::symlink_metadata(from)?;
if meta.file_type().is_symlink() {
let points = std::fs::read_link(from)?;
let dir = std::fs::metadata(from).is_ok_and(|m| m.is_dir());
symlink(&points, to, dir)?;
} else if meta.is_dir() {
std::fs::create_dir(to)?;
let mut names: Vec<_> = std::fs::read_dir(from)?
.filter_map(Result::ok)
.map(|e| e.file_name())
.collect();
names.sort();
self.progress.items += 1;
self.tell();
for name in names {
self.hard_link(&from.join(&name), &to.join(&name))?;
}
std::fs::set_permissions(to, meta.permissions())?;
return Ok(());
} else {
std::fs::hard_link(from, to)?;
}
self.progress.items += 1;
self.tell();
Ok(())
}
fn delete(&mut self, path: &Path) -> io::Result<()> {
if self.stopped() {
return Err(interrupted());
}
self.progress.current = Some(path.to_path_buf());
let meta = std::fs::symlink_metadata(path)?;
if meta.is_dir() {
for entry in std::fs::read_dir(path)? {
self.delete(&entry?.path())?;
}
std::fs::remove_dir(path)?;
} else {
std::fs::remove_file(path)?;
}
self.progress.items += 1;
self.tell();
Ok(())
}
}
fn symlink(to: &Path, link: &Path, dir: bool) -> io::Result<()> {
#[cfg(unix)]
{
let _ = dir;
std::os::unix::fs::symlink(to, link)
}
#[cfg(windows)]
{
if dir {
std::os::windows::fs::symlink_dir(to, link)
} else {
std::os::windows::fs::symlink_file(to, link)
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = (to, link, dir);
Err(io::Error::new(
io::ErrorKind::Unsupported,
"symbolic links are not supported here",
))
}
}
fn absolute(path: &Path) -> io::Result<PathBuf> {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let mut out = PathBuf::new();
for part in joined.components() {
match part {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other),
}
}
Ok(out)
}
pub fn relative_to(path: &Path, base: &Path) -> PathBuf {
let (path_parts, base_parts): (Vec<_>, Vec<_>) =
(path.components().collect(), base.components().collect());
let shared = path_parts
.iter()
.zip(&base_parts)
.take_while(|(a, b)| a == b)
.count();
let rooted = |parts: &[Component]| {
parts
.first()
.is_some_and(|c| matches!(c, Component::Prefix(_) | Component::RootDir))
};
if shared == 0 && (rooted(&path_parts) || rooted(&base_parts)) {
return path.to_path_buf();
}
let mut out = PathBuf::new();
for _ in shared..base_parts.len() {
out.push("..");
}
for part in &path_parts[shared..] {
out.push(part);
}
out
}
pub fn remove_all(path: &Path) -> io::Result<()> {
let meta = std::fs::symlink_metadata(path)?;
if meta.is_dir() {
std::fs::remove_dir_all(path)
} else {
std::fs::remove_file(path)
}
}
pub fn unique(path: &Path) -> PathBuf {
if std::fs::symlink_metadata(path).is_err() {
return path.to_path_buf();
}
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = path
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
(1..)
.map(|n| path.with_file_name(format!("{stem}_{n}{ext}")))
.find(|p| std::fs::symlink_metadata(p).is_err())
.expect("a free name")
}
pub fn under(dir: &Path, typed: &str) -> Result<PathBuf, String> {
let typed = typed.trim_end_matches(['/', '\\']);
let rel = Path::new(typed);
if typed.is_empty() {
return Err("No name given".into());
}
if rel
.components()
.any(|c| !matches!(c, Component::Normal(_) | Component::CurDir))
{
return Err(format!("{typed:?} must stay inside this directory"));
}
Ok(dir.join(rel))
}
#[cfg(test)]
mod tests {
use super::*;
fn base(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("rich-ext-ops-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src/inner")).unwrap();
std::fs::create_dir_all(dir.join("dst")).unwrap();
std::fs::write(dir.join("src/a.txt"), "aaaa").unwrap();
std::fs::write(dir.join("src/inner/b.txt"), "bb").unwrap();
dir
}
fn quiet(op: &Op) -> Report {
run(op, &CancelToken::new(), &mut |_| {})
}
#[test]
fn copies_trees_and_counts_progress() {
let dir = base("copy");
let op = Op::Copy {
from: vec![dir.join("src")],
into: dir.join("dst"),
conflict: Conflict::Rename,
};
let mut last = Progress::default();
let report = run(&op, &CancelToken::new(), &mut |p| last = p.clone());
assert!(report.errors.is_empty(), "{report:?}");
assert_eq!(report.made, [dir.join("dst/src")]);
assert_eq!((last.items, last.total_items), (4, 4));
assert_eq!((last.bytes, last.total_bytes), (6, 6));
assert_eq!(last.fraction(), 1.0);
assert_eq!(
std::fs::read_to_string(dir.join("dst/src/inner/b.txt")).unwrap(),
"bb"
);
quiet(&op);
assert!(dir.join("dst/src_1/a.txt").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn conflicts_skip_overwrite_and_never_into_itself() {
let dir = base("conflict");
std::fs::write(dir.join("dst/a.txt"), "old").unwrap();
let op = |conflict| Op::Copy {
from: vec![dir.join("src/a.txt")],
into: dir.join("dst"),
conflict,
};
quiet(&op(Conflict::Skip));
assert_eq!(
std::fs::read_to_string(dir.join("dst/a.txt")).unwrap(),
"old"
);
quiet(&op(Conflict::Overwrite));
assert_eq!(
std::fs::read_to_string(dir.join("dst/a.txt")).unwrap(),
"aaaa"
);
let into_itself = quiet(&Op::Copy {
from: vec![dir.join("src")],
into: dir.join("src/inner"),
conflict: Conflict::Rename,
});
assert_eq!(into_itself.errors, ["src: can't go inside itself"]);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn moves_and_deletes() {
let dir = base("move");
let report = quiet(&Op::Move {
from: vec![dir.join("src/inner")],
into: dir.join("dst"),
conflict: Conflict::Rename,
});
assert!(report.errors.is_empty(), "{report:?}");
assert!(!dir.join("src/inner").exists());
assert!(dir.join("dst/inner/b.txt").exists());
quiet(&Op::Move {
from: vec![dir.join("dst/inner")],
into: dir.join("dst"),
conflict: Conflict::Rename,
});
assert!(dir.join("dst/inner/b.txt").exists());
let mut items = 0;
let report = run(
&Op::Delete(vec![dir.join("dst")]),
&CancelToken::new(),
&mut |p| items = p.items,
);
assert!(report.errors.is_empty());
assert_eq!(items, 3);
assert!(!dir.join("dst").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn cancelling_stops_and_removes_the_half_copied_file() {
let dir = base("cancel");
std::fs::write(dir.join("src/big.bin"), vec![7u8; CHUNK * 3]).unwrap();
let cancel = CancelToken::new();
let stop = cancel.clone();
let report = run(
&Op::Copy {
from: vec![dir.join("src/big.bin")],
into: dir.join("dst"),
conflict: Conflict::Rename,
},
&cancel,
&mut |p| {
if p.bytes >= CHUNK as u64 {
stop.cancel();
}
},
);
assert!(report.cancelled);
assert!(report.errors.is_empty(), "{report:?}");
assert!(!dir.join("dst/big.bin").exists());
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn links_absolute_relative_and_hard() {
let dir = base("link");
let link = |kind| {
quiet(&Op::Link {
from: vec![dir.join("src/a.txt"), dir.join("src/inner")],
into: dir.join("dst"),
kind,
conflict: Conflict::Rename,
})
};
let report = link(LinkKind::Symbolic { relative: false });
assert!(report.errors.is_empty(), "{report:?}");
assert_eq!(report.made, [dir.join("dst/a.txt"), dir.join("dst/inner")]);
assert_eq!(
std::fs::read_link(dir.join("dst/a.txt")).unwrap(),
dir.join("src/a.txt")
);
assert_eq!(
std::fs::read_to_string(dir.join("dst/inner/b.txt")).unwrap(),
"bb"
);
let report = link(LinkKind::Symbolic { relative: true });
assert!(report.errors.is_empty(), "{report:?}");
assert_eq!(
std::fs::read_link(dir.join("dst/a_1.txt")).unwrap(),
Path::new("../src/a.txt")
);
assert_eq!(
std::fs::read_link(dir.join("dst/inner_1")).unwrap(),
Path::new("../src/inner")
);
std::fs::remove_dir_all(dir.join("dst")).unwrap();
std::fs::create_dir(dir.join("dst")).unwrap();
let mut last = Progress::default();
let report = run(
&Op::Link {
from: vec![dir.join("src")],
into: dir.join("dst"),
kind: LinkKind::Hard,
conflict: Conflict::Rename,
},
&CancelToken::new(),
&mut |p| last = p.clone(),
);
assert!(report.errors.is_empty(), "{report:?}");
assert_eq!((last.items, last.total_items), (4, 4));
let meta = std::fs::symlink_metadata(dir.join("dst/src/inner/b.txt")).unwrap();
assert!(meta.is_file());
std::fs::write(dir.join("src/inner/b.txt"), "changed").unwrap();
assert_eq!(
std::fs::read_to_string(dir.join("dst/src/inner/b.txt")).unwrap(),
"changed"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn relative_paths_climb_to_what_they_share() {
assert_eq!(
relative_to(Path::new("/a/b/c.txt"), Path::new("/a/d/e")),
Path::new("../../b/c.txt")
);
assert_eq!(relative_to(Path::new("/a"), Path::new("/a")), Path::new(""));
}
#[test]
fn titles() {
let op = Op::Copy {
from: vec![PathBuf::from("/a/x.txt")],
into: PathBuf::from("/b/docs"),
conflict: Conflict::Rename,
};
assert_eq!(op.title(), "Copy x.txt to docs");
assert_eq!(
Op::Delete(vec![PathBuf::from("a"), PathBuf::from("b")]).title(),
"Delete 2 items"
);
let link = |kind| Op::Link {
from: vec![PathBuf::from("/a/x.txt")],
into: PathBuf::from("/b/docs"),
kind,
conflict: Conflict::Rename,
};
assert_eq!(link(LinkKind::Hard).title(), "Hard link x.txt in docs");
assert_eq!(
link(LinkKind::Symbolic { relative: true }).title(),
"Link x.txt in docs"
);
}
}