use super::collect;
use super::plan::{Kind, Node, NodeKind, Plan, Step};
use std::fs::{self, File};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::sync::Arc;
use std::time::{Duration, Instant};
const PROGRESS_EVERY: Duration = Duration::from_millis(50);
#[cfg(unix)]
const CROSS_DEVICE: i32 = 18;
#[cfg(windows)]
const CROSS_DEVICE: i32 = 17;
pub enum Msg {
Progress {
items: usize,
bytes: u64,
current: PathBuf,
},
Done(Report),
}
#[derive(Debug)]
pub struct Report {
pub kind: Kind,
pub direction: Direction,
pub items: usize,
pub bytes: u64,
pub failures: Vec<Failure>,
pub notes: Vec<String>,
pub journal: Journal,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
#[default]
Forward,
Undo,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Failure {
pub path: PathBuf,
pub msg: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Journal {
pub kind: Kind,
pub steps: Vec<Undoable>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Undoable {
Moved { from: PathBuf, to: PathBuf },
Created { path: PathBuf },
Trashed { path: PathBuf },
}
pub struct Run {
rx: Receiver<Msg>,
cancel: Arc<AtomicBool>,
}
impl Run {
pub fn drain(&self) -> Vec<Msg> {
self.rx.try_iter().collect()
}
pub fn cancel(&self) {
self.cancel.store(true, Ordering::Relaxed);
}
}
impl Drop for Run {
fn drop(&mut self) {
self.cancel();
}
}
trait Trash {
fn send(&self, path: &Path) -> Result<(), String>;
}
struct OsTrash;
impl Trash for OsTrash {
fn send(&self, path: &Path) -> Result<(), String> {
trash_context().delete(path).map_err(|e| {
format!(
"cannot send {} to the trash ({e}), and sucher never permanently deletes, so nothing was removed",
path.display()
)
})
}
}
fn trash_context() -> trash::TrashContext {
#[allow(unused_mut)]
let mut ctx = trash::TrashContext::default();
#[cfg(target_os = "macos")]
{
use trash::macos::{DeleteMethod, TrashContextExtMacos};
ctx.set_delete_method(DeleteMethod::NsFileManager);
}
ctx
}
type Rename = fn(&Path, &Path) -> io::Result<()>;
fn os_rename(from: &Path, to: &Path) -> io::Result<()> {
fs::rename(from, to)
}
pub fn start(plan: Plan) -> Run {
let (tx, rx) = mpsc::channel();
let cancel = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&cancel);
std::thread::spawn(move || {
execute(plan, &OsTrash, os_rename, &tx, &flag);
});
Run { rx, cancel }
}
pub fn start_undo(journal: Journal) -> Run {
let (tx, rx) = mpsc::channel();
let cancel = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&cancel);
std::thread::spawn(move || {
execute_undo(journal, &OsTrash, os_rename, &tx, &flag);
});
Run { rx, cancel }
}
fn execute(plan: Plan, trash: &dyn Trash, rename: Rename, tx: &Sender<Msg>, cancel: &AtomicBool) {
let Plan { kind, steps, .. } = plan;
let mut ex = Exec::new(kind, Direction::Forward, trash, rename, tx, cancel);
for step in steps {
if ex.cancelled() {
break;
}
let outcome = match kind {
Kind::Copy => ex.copy_step(&step),
Kind::Move => ex.move_step(&step),
Kind::Rename => ex.rename_step(&step),
Kind::Create => ex.create_step(&step),
Kind::Trash => ex.trash_step(&step),
};
if let Err(msg) = outcome {
let at = if step.dest.as_os_str().is_empty() {
&step.src
} else {
&step.dest
};
ex.fail(at, msg);
}
}
ex.finish();
}
fn execute_undo(
journal: Journal,
trash: &dyn Trash,
rename: Rename,
tx: &Sender<Msg>,
cancel: &AtomicBool,
) {
let Journal { kind, steps } = journal;
let mut ex = Exec::new(kind, Direction::Undo, trash, rename, tx, cancel);
let mut trash_only: Vec<PathBuf> = Vec::new();
for step in steps.iter().rev() {
if ex.cancelled() {
break;
}
match step {
Undoable::Moved { from, to } => ex.restore(from, to),
Undoable::Created { path } => ex.remove(path),
Undoable::Trashed { path } => trash_only.push(path.clone()),
}
}
if !trash_only.is_empty() {
ex.note(trash_only_note(&trash_only));
}
ex.finish();
}
fn trash_only_note(paths: &[PathBuf]) -> String {
let named: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
format!(
"{} went to the system trash, and that is where {} come back from: sucher does not restore from it",
named.join(", "),
if paths.len() == 1 { "it does" } else { "they do" }
)
}
struct Exec<'a> {
trash: &'a dyn Trash,
rename: Rename,
tx: &'a Sender<Msg>,
cancel: &'a AtomicBool,
direction: Direction,
items: usize,
bytes: u64,
current: PathBuf,
last: Instant,
failures: Vec<Failure>,
notes: Vec<String>,
journal: Journal,
}
impl<'a> Exec<'a> {
fn new(
kind: Kind,
direction: Direction,
trash: &'a dyn Trash,
rename: Rename,
tx: &'a Sender<Msg>,
cancel: &'a AtomicBool,
) -> Self {
Exec {
trash,
rename,
tx,
cancel,
direction,
items: 0,
bytes: 0,
current: PathBuf::new(),
last: Instant::now(),
failures: Vec::new(),
notes: Vec::new(),
journal: Journal {
kind,
steps: Vec::new(),
},
}
}
fn fail(&mut self, path: &Path, msg: String) {
self.failures.push(Failure {
path: path.to_path_buf(),
msg,
});
}
fn note(&mut self, text: String) {
self.notes.push(text);
}
fn tick(&mut self, current: &Path, items: usize, bytes: u64) {
self.items += items;
self.bytes += bytes;
self.current = current.to_path_buf();
if self.last.elapsed() >= PROGRESS_EVERY {
self.flush();
}
}
fn flush(&mut self) {
self.last = Instant::now();
let _ = self.tx.send(Msg::Progress {
items: self.items,
bytes: self.bytes,
current: self.current.clone(),
});
}
fn finish(mut self) {
self.flush();
let kind = self.journal.kind;
let _ = self.tx.send(Msg::Done(Report {
kind,
direction: self.direction,
items: self.items,
bytes: self.bytes,
failures: self.failures,
notes: self.notes,
journal: self.journal,
}));
}
fn clear_dest(&mut self, step: &Step) -> Result<(), String> {
if fs::symlink_metadata(&step.dest).is_err() {
return Ok(());
}
if !step.overwrite {
return Err(format!(
"{} exists now although it did not when the plan was made, so nothing was written",
step.dest.display()
));
}
self.trash.send(&step.dest)?;
self.journal.steps.push(Undoable::Trashed {
path: step.dest.clone(),
});
Ok(())
}
fn copy_step(&mut self, step: &Step) -> Result<(), String> {
self.clear_dest(step)?;
copy_tree(step.kind, &step.src, &step.dest, &step.nodes, self)?;
self.journal.steps.push(Undoable::Created {
path: step.dest.clone(),
});
Ok(())
}
fn transplant(&mut self, step: &Step) -> Result<(), String> {
if !copy_tree(step.kind, &step.src, &step.dest, &step.nodes, self)? {
return Err(format!(
"{} did not copy whole, so the original was left in place",
step.dest.display()
));
}
self.trash.send(&step.src).map_err(|e| {
format!(
"{} was copied to {} but the original could not be removed: {e}",
step.src.display(),
step.dest.display()
)
})
}
fn move_step(&mut self, step: &Step) -> Result<(), String> {
self.clear_dest(step)?;
match (self.rename)(&step.src, &step.dest) {
Ok(()) => self.tick(&step.dest, step.items, step.bytes),
Err(e) if is_cross_device(&e) => self.transplant(step)?,
Err(e) => {
return Err(format!(
"cannot move {} to {}: {e}",
step.src.display(),
step.dest.display()
))
}
}
self.journal.steps.push(Undoable::Moved {
from: step.src.clone(),
to: step.dest.clone(),
});
Ok(())
}
fn rename_step(&mut self, step: &Step) -> Result<(), String> {
self.clear_dest(step)?;
(self.rename)(&step.src, &step.dest).map_err(|e| {
format!(
"cannot rename {} to {}: {e}",
step.src.display(),
step.dest.display()
)
})?;
self.tick(&step.dest, step.items, step.bytes);
self.journal.steps.push(Undoable::Moved {
from: step.src.clone(),
to: step.dest.clone(),
});
Ok(())
}
fn create_step(&mut self, step: &Step) -> Result<(), String> {
self.clear_dest(step)?;
match step.kind {
NodeKind::Dir => fs::create_dir(&step.dest)
.map_err(|e| format!("cannot create {}: {e}", step.dest.display()))?,
_ => {
File::create_new(&step.dest)
.map_err(|e| format!("cannot create {}: {e}", step.dest.display()))?;
}
}
self.journal.steps.push(Undoable::Created {
path: step.dest.clone(),
});
self.tick(&step.dest, 1, 0);
Ok(())
}
fn trash_step(&mut self, step: &Step) -> Result<(), String> {
self.trash.send(&step.src)?;
self.journal.steps.push(Undoable::Trashed {
path: step.src.clone(),
});
self.tick(&step.src, step.items, step.bytes);
Ok(())
}
fn restore(&mut self, from: &Path, to: &Path) {
if fs::symlink_metadata(from).is_ok() {
let msg = format!(
"{} exists again, so {} was left where it is",
from.display(),
to.display()
);
self.fail(from, msg);
return;
}
match (self.rename)(to, from) {
Ok(()) => self.tick(from, 1, 0),
Err(e) if is_cross_device(&e) => self.carry_back(to, from),
Err(e) => {
let msg = format!(
"cannot put {} back to {}: {e}",
to.display(),
from.display()
);
self.fail(to, msg);
}
}
}
fn remove(&mut self, path: &Path) {
let sent = self.trash.send(path);
match sent {
Ok(()) => self.tick(path, 1, 0),
Err(msg) => self.fail(path, msg),
}
}
fn carry_back(&mut self, to: &Path, from: &Path) {
let source = match collect(std::slice::from_ref(&to.to_path_buf())) {
Ok(found) => match found.sources.into_iter().next() {
Some(source) => source,
None => {
let msg = format!(
"{} is no longer there, so there is nothing to put back",
to.display()
);
self.fail(to, msg);
return;
}
},
Err(refusal) => {
let msg = format!("cannot read {} to put it back: {refusal}", to.display());
self.fail(to, msg);
return;
}
};
let carried = copy_tree(
source.kind,
to,
from,
&source.nodes,
&mut CarryBack(&mut *self),
);
match carried {
Ok(true) => {}
Ok(false) => {
let msg = format!(
"{} did not copy back whole, so {} was left alone",
from.display(),
to.display()
);
self.fail(from, msg);
return;
}
Err(msg) => {
self.fail(from, msg);
return;
}
}
self.tick(from, 1, 0);
let removed = self.trash.send(to);
if let Err(msg) = removed {
let msg = format!(
"{} was restored, but the copy left at {} could not be removed: {msg}",
from.display(),
to.display()
);
self.fail(to, msg);
}
}
}
trait TreeSink {
fn landed(&mut self, path: &Path, bytes: u64);
fn failed(&mut self, path: &Path, msg: String);
fn cancelled(&self) -> bool {
false
}
}
impl TreeSink for Exec<'_> {
fn landed(&mut self, path: &Path, bytes: u64) {
self.tick(path, 1, bytes);
}
fn failed(&mut self, path: &Path, msg: String) {
self.fail(path, msg);
}
fn cancelled(&self) -> bool {
self.cancel.load(Ordering::Relaxed)
}
}
struct CarryBack<'a, 'e>(&'e mut Exec<'a>);
impl TreeSink for CarryBack<'_, '_> {
fn landed(&mut self, path: &Path, bytes: u64) {
self.0.tick(path, 0, bytes);
}
fn failed(&mut self, path: &Path, msg: String) {
self.0.fail(path, msg);
}
fn cancelled(&self) -> bool {
self.0.cancelled()
}
}
fn copy_tree(
kind: NodeKind,
src: &Path,
dest: &Path,
nodes: &[Node],
sink: &mut dyn TreeSink,
) -> Result<bool, String> {
let bytes = place(kind, src, dest)?;
sink.landed(dest, bytes);
if kind != NodeKind::Dir {
return Ok(true);
}
let mut whole = true;
for (done, node) in nodes.iter().enumerate() {
if sink.cancelled() {
let msg = format!(
"cancelled after {done} of {} entries, so {} is incomplete",
nodes.len(),
dest.display()
);
sink.failed(dest, msg);
return Ok(false);
}
let from = src.join(&node.rel);
let to = dest.join(&node.rel);
match place(node.kind, &from, &to) {
Ok(bytes) => sink.landed(&to, bytes),
Err(msg) => {
whole = false;
sink.failed(&to, msg);
}
}
}
Ok(whole)
}
fn place(kind: NodeKind, src: &Path, dest: &Path) -> Result<u64, String> {
match kind {
NodeKind::File => copy_file(src, dest),
NodeKind::Symlink => copy_symlink(src, dest).map(|()| 0),
NodeKind::Dir => make_dir(src, dest).map(|()| 0),
}
}
fn copy_file(src: &Path, dest: &Path) -> Result<u64, String> {
fs::copy(src, dest)
.map_err(|e| format!("cannot copy {} to {}: {e}", src.display(), dest.display()))
}
fn copy_symlink(src: &Path, dest: &Path) -> Result<(), String> {
let target =
fs::read_link(src).map_err(|e| format!("cannot read the link {}: {e}", src.display()))?;
symlink_to(&target, src, dest)
}
#[cfg(unix)]
fn symlink_to(target: &Path, _src: &Path, dest: &Path) -> Result<(), String> {
std::os::unix::fs::symlink(target, dest)
.map_err(|e| format!("cannot create the link {}: {e}", dest.display()))
}
#[cfg(windows)]
fn symlink_to(target: &Path, src: &Path, dest: &Path) -> Result<(), String> {
let to_dir = fs::metadata(src).map(|m| m.is_dir()).unwrap_or(false);
let made = if to_dir {
std::os::windows::fs::symlink_dir(target, dest)
} else {
std::os::windows::fs::symlink_file(target, dest)
};
made.map_err(|e| format!("cannot create the link {}: {e}", dest.display()))
}
#[cfg(not(any(unix, windows)))]
fn symlink_to(_target: &Path, _src: &Path, dest: &Path) -> Result<(), String> {
Err(format!(
"this platform cannot recreate the symlink {}",
dest.display()
))
}
fn make_dir(src: &Path, dest: &Path) -> Result<(), String> {
fs::create_dir(dest).map_err(|e| format!("cannot create {}: {e}", dest.display()))?;
if let Ok(meta) = fs::metadata(src) {
let _ = fs::set_permissions(dest, meta.permissions());
}
Ok(())
}
fn is_cross_device(e: &io::Error) -> bool {
e.kind() == io::ErrorKind::CrossesDevices || raw_cross_device(e)
}
#[cfg(any(unix, windows))]
fn raw_cross_device(e: &io::Error) -> bool {
e.raw_os_error() == Some(CROSS_DEVICE)
}
#[cfg(not(any(unix, windows)))]
fn raw_cross_device(_e: &io::Error) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fileop::plan::{plan, Conflict, Op, PlanCtx, Source};
use std::fs;
use std::sync::Mutex;
use tempfile::TempDir;
struct Recorder {
seen: Mutex<Vec<(PathBuf, Option<String>)>>,
fail: bool,
trip: Option<Arc<AtomicBool>>,
}
impl Recorder {
fn new() -> Recorder {
Recorder {
seen: Mutex::new(Vec::new()),
fail: false,
trip: None,
}
}
fn failing() -> Recorder {
Recorder {
fail: true,
..Recorder::new()
}
}
fn tripping(flag: Arc<AtomicBool>) -> Recorder {
Recorder {
trip: Some(flag),
..Recorder::new()
}
}
fn paths(&self) -> Vec<PathBuf> {
self.seen
.lock()
.expect("recorder lock")
.iter()
.map(|(p, _)| p.clone())
.collect()
}
fn contents(&self) -> Vec<Option<String>> {
self.seen
.lock()
.expect("recorder lock")
.iter()
.map(|(_, c)| c.clone())
.collect()
}
}
impl Trash for Recorder {
fn send(&self, path: &Path) -> Result<(), String> {
let content = fs::read_to_string(path).ok();
self.seen
.lock()
.expect("recorder lock")
.push((path.to_path_buf(), content));
if let Some(flag) = &self.trip {
flag.store(true, Ordering::Relaxed);
}
if self.fail {
return Err(format!("no trash for {}", path.display()));
}
Ok(())
}
}
struct Driven {
report: Report,
progress: Vec<(usize, u64, PathBuf)>,
}
fn always_cross_device(_from: &Path, _to: &Path) -> io::Result<()> {
Err(io::Error::from(io::ErrorKind::CrossesDevices))
}
fn always_denied(_from: &Path, _to: &Path) -> io::Result<()> {
Err(io::Error::from(io::ErrorKind::PermissionDenied))
}
fn drained(rx: Receiver<Msg>) -> Driven {
let mut progress = Vec::new();
let mut report = None;
for msg in rx.try_iter() {
match msg {
Msg::Progress {
items,
bytes,
current,
} => progress.push((items, bytes, current)),
Msg::Done(r) => report = Some(r),
}
}
Driven {
report: report.expect("exactly one Done"),
progress,
}
}
fn drive_with(plan: Plan, trash: &dyn Trash, rename: Rename, cancel: &AtomicBool) -> Driven {
let (tx, rx) = mpsc::channel();
execute(plan, trash, rename, &tx, cancel);
drop(tx);
drained(rx)
}
fn drive(plan: Plan, trash: &dyn Trash, cancel: &AtomicBool) -> Driven {
drive_with(plan, trash, os_rename, cancel)
}
fn run(plan: Plan, trash: &dyn Trash) -> Driven {
drive(plan, trash, &AtomicBool::new(false))
}
fn drive_undo(
journal: Journal,
trash: &dyn Trash,
rename: Rename,
cancel: &AtomicBool,
) -> Driven {
let (tx, rx) = mpsc::channel();
execute_undo(journal, trash, rename, &tx, cancel);
drop(tx);
drained(rx)
}
fn undone_with(journal: Journal, trash: &dyn Trash, rename: Rename) -> Driven {
drive_undo(journal, trash, rename, &AtomicBool::new(false))
}
fn undone(journal: Journal, trash: &dyn Trash) -> Report {
undone_with(journal, trash, os_rename).report
}
fn write(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(path, body).expect("write");
}
fn read(path: &Path) -> String {
fs::read_to_string(path).expect("read back")
}
fn listing(dir: &Path) -> Vec<String> {
let mut names: Vec<String> = fs::read_dir(dir)
.expect("read_dir")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
fn sources(paths: &[PathBuf]) -> Vec<Source> {
collect(paths).expect("collect").sources
}
fn resolve(op: Op, cwd: &Path, listing: &[String], policy: Conflict) -> Plan {
plan(
op,
&PlanCtx {
dest_listing: listing,
cwd,
missing: &[],
policy,
},
)
.expect("plan should resolve")
}
fn copy_plan(srcs: &[PathBuf], dest: &Path, listing: &[String], policy: Conflict) -> Plan {
resolve(
Op::Copy {
sources: sources(srcs),
dest: dest.to_path_buf(),
},
Path::new("/nowhere"),
listing,
policy,
)
}
#[test]
fn a_file_copy_lands_with_the_right_content() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("src/a.txt");
let dst = tmp.path().join("dst");
write(&src, "hello");
fs::create_dir_all(&dst).expect("dst");
let rec = Recorder::new();
let out = run(copy_plan(&[src], &dst, &[], Conflict::Rename), &rec);
assert_eq!(read(&dst.join("a.txt")), "hello");
assert!(out.report.failures.is_empty(), "{:?}", out.report.failures);
assert!(out.report.notes.is_empty());
assert_eq!(out.report.direction, Direction::Forward);
assert_eq!(out.report.items, 1);
assert_eq!(out.report.bytes, 5);
assert_eq!(
out.report.journal.steps,
vec![Undoable::Created {
path: dst.join("a.txt")
}]
);
assert_eq!(out.progress.last().map(|p| (p.0, p.1)), Some((1, 5)));
assert!(rec.paths().is_empty(), "a plain copy trashes nothing");
}
#[test]
fn a_directory_copy_replays_the_whole_tree() {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path().join("src/tree");
let dst = tmp.path().join("dst");
write(&root.join("a.txt"), "aa");
write(&root.join("sub/b.txt"), "bbb");
write(&root.join("sub/deep/c.txt"), "cccc");
fs::create_dir_all(&dst).expect("dst");
let rec = Recorder::new();
let out = run(copy_plan(&[root], &dst, &[], Conflict::Rename), &rec);
assert!(out.report.failures.is_empty(), "{:?}", out.report.failures);
assert_eq!(read(&dst.join("tree/a.txt")), "aa");
assert_eq!(read(&dst.join("tree/sub/b.txt")), "bbb");
assert_eq!(read(&dst.join("tree/sub/deep/c.txt")), "cccc");
assert_eq!(out.report.items, 6);
assert_eq!(out.report.bytes, 9);
assert_eq!(
out.report.journal.steps,
vec![Undoable::Created {
path: dst.join("tree")
}]
);
}
#[test]
#[cfg(unix)]
fn a_symlink_is_recreated_as_a_link_and_not_dereferenced() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().expect("tempdir");
let target = tmp.path().join("target.txt");
let link = tmp.path().join("src/link");
let dst = tmp.path().join("dst");
write(&target, "secret");
fs::create_dir_all(link.parent().expect("parent")).expect("src");
fs::create_dir_all(&dst).expect("dst");
symlink(&target, &link).expect("symlink");
let rec = Recorder::new();
let out = run(copy_plan(&[link], &dst, &[], Conflict::Rename), &rec);
assert!(out.report.failures.is_empty(), "{:?}", out.report.failures);
let landed = dst.join("link");
let meta = fs::symlink_metadata(&landed).expect("the link exists");
assert!(meta.file_type().is_symlink(), "copied as a real file");
assert_eq!(fs::read_link(&landed).expect("read_link"), target);
}
#[test]
fn a_same_device_move_renames_in_place() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("src/a.txt");
let dst = tmp.path().join("dst");
write(&src, "moved");
fs::create_dir_all(&dst).expect("dst");
let p = resolve(
Op::Move {
sources: sources(std::slice::from_ref(&src)),
dest: dst.clone(),
},
Path::new("/nowhere"),
&[],
Conflict::Rename,
);
let rec = Recorder::new();
let out = run(p, &rec);
assert!(out.report.failures.is_empty(), "{:?}", out.report.failures);
assert!(!src.exists(), "the source is gone");
assert_eq!(read(&dst.join("a.txt")), "moved");
assert_eq!(
out.report.journal.steps,
vec![Undoable::Moved {
from: src,
to: dst.join("a.txt")
}]
);
assert!(rec.paths().is_empty());
}
#[test]
fn a_destination_that_appeared_after_planning_fails_instead_of_clobbering() {
let tmp = TempDir::new().expect("tempdir");
let a = tmp.path().join("src/a.txt");
let b = tmp.path().join("src/b.txt");
let dst = tmp.path().join("dst");
write(&a, "new a");
write(&b, "new b");
fs::create_dir_all(&dst).expect("dst");
let p = copy_plan(&[a, b], &dst, &[], Conflict::Rename);
write(&dst.join("a.txt"), "someone else's a");
let rec = Recorder::new();
let out = run(p, &rec);
assert_eq!(out.report.failures.len(), 1, "{:?}", out.report.failures);
assert_eq!(out.report.failures[0].path, dst.join("a.txt"));
assert_eq!(read(&dst.join("a.txt")), "someone else's a");
assert_eq!(read(&dst.join("b.txt")), "new b");
assert_eq!(
out.report.journal.steps,
vec![Undoable::Created {
path: dst.join("b.txt")
}]
);
assert!(rec.paths().is_empty(), "a stale snapshot never trashes");
}
#[test]
fn an_overwrite_trashes_the_displaced_entry_before_writing() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("src/a.txt");
let dst = tmp.path().join("dst");
write(&src, "fresh");
write(&dst.join("a.txt"), "displaced");
let p = copy_plan(&[src], &dst, &listing(&dst), Conflict::Overwrite);
assert!(p.steps[0].overwrite, "the plan asked for an overwrite");
let rec = Recorder::new();
let out = run(p, &rec);
assert!(out.report.failures.is_empty(), "{:?}", out.report.failures);
assert_eq!(rec.paths(), vec![dst.join("a.txt")]);
assert_eq!(rec.contents(), vec![Some("displaced".to_string())]);
assert_eq!(read(&dst.join("a.txt")), "fresh");
assert_eq!(
out.report.journal.steps,
vec![
Undoable::Trashed {
path: dst.join("a.txt")
},
Undoable::Created {
path: dst.join("a.txt")
},
]
);
}
#[test]
fn an_unavailable_trash_refuses_the_overwrite_rather_than_deleting() {
let tmp = TempDir::new().expect("tempdir");
let src = tmp.path().join("src/a.txt");
let dst = tmp.path().join("dst");
write(&src, "fresh");
write(&dst.join("a.txt"), "displaced");
let p = copy_plan(&[src], &dst, &listing(&dst), Conflict::Overwrite);
let rec = Recorder::failing();
let out = run(p, &rec);
assert_eq!(out.report.failures.len(), 1, "{:?}", out.report.failures);
assert_eq!(read(&dst.join("a.txt")), "displaced");
assert!(out.report.journal.steps.is_empty());
}
#[test]
fn create_makes_a_file_and_a_directory() {
let tmp = TempDir::new().expect("tempdir");
let dst = tmp.path().to_path_buf();
let rec = Recorder::new();
let file = resolve(
Op::Create {
parent: dst.clone(),
name: "notes.md".to_string(),
},
Path::new("/nowhere"),
&[],
Conflict::Rename,
);
let out = run(file, &rec);
assert!(out.report.failures.is_empty(), "{:?}", out.report.failures);
assert!(dst.join("notes.md").is_file());
assert_eq!(read(&dst.join("notes.md")), "");
assert_eq!(
out.report.journal.steps,
vec![Undoable::Created {
path: dst.join("notes.md")
}]
);
let dir = resolve(
Op::Create {
parent: dst.clone(),
name: "sub/".to_string(),
},
Path::new("/nowhere"),
&listing(&dst),
Conflict::Rename,
);
let out = run(dir, &rec);
assert!(out.report.failures.is_empty(), "{:?}", out.report.failures);
assert!(dst.join("sub").is_dir());
}
#[test]
fn a_trash_step_hands_every_source_to_the_seam() {
let tmp = TempDir::new().expect("tempdir");
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
write(&a, "a");
write(&b, "b");
let p = resolve(
Op::Trash {
sources: sources(&[a.clone(), b.clone()]),
},
Path::new("/nowhere"),
&[],
Conflict::Rename,
);
let rec = Recorder::new();
let out = run(p, &rec);
assert!(out.report.failures.is_empty(), "{:?}", out.report.failures);
assert_eq!(rec.paths(), vec![a.clone(), b.clone()]);
assert_eq!(
out.report.journal.steps,
vec![Undoable::Trashed { path: a }, Undoable::Trashed { path: b },]
);
}
#[test]
fn an_unavailable_trash_says_so_rather_than_deleting() {
let tmp = TempDir::new().expect("tempdir");
let a = tmp.path().join("a.txt");
write(&a, "a");
let p = resolve(
Op::Trash {
sources: sources(std::slice::from_ref(&a)),
},
Path::new("/nowhere"),
&[],
Conflict::Rename,
);
let out = run(p, &Recorder::failing());
assert_eq!(out.report.failures.len(), 1);
assert!(a.exists(), "sucher never permanently deletes");
assert!(out.report.journal.steps.is_empty());
}
#[test]
fn cancellation_stops_partway_and_the_journal_holds_only_what_happened() {
let tmp = TempDir::new().expect("tempdir");
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
let c = tmp.path().join("c.txt");
write(&a, "a");
write(&b, "b");
write(&c, "c");
let p = resolve(
Op::Trash {
sources: sources(&[a.clone(), b.clone(), c.clone()]),
},
Path::new("/nowhere"),
&[],
Conflict::Rename,
);
let cancel = Arc::new(AtomicBool::new(false));
let rec = Recorder::tripping(Arc::clone(&cancel));
let out = drive(p, &rec, &cancel);
assert_eq!(rec.paths(), vec![a.clone()], "stopped after the first step");
assert_eq!(
out.report.journal.steps,
vec![Undoable::Trashed { path: a }]
);
assert_eq!(out.report.items, 1);
}
#[test]
fn undo_of_a_move_puts_it_back() {
let tmp = TempDir::new().expect("tempdir");
let from = tmp.path().join("src/a.txt");
let to = tmp.path().join("dst/a.txt");
write(&to, "moved");
fs::create_dir_all(from.parent().expect("parent")).expect("src");
let journal = Journal {
kind: Kind::Move,
steps: vec![Undoable::Moved {
from: from.clone(),
to: to.clone(),
}],
};
let rec = Recorder::new();
let report = undone(journal, &rec);
assert_eq!(report.items, 1);
assert!(report.failures.is_empty(), "{:?}", report.failures);
assert_eq!(read(&from), "moved");
assert!(!to.exists());
}
#[test]
fn undo_of_a_move_refuses_when_the_original_path_is_occupied_again() {
let tmp = TempDir::new().expect("tempdir");
let from = tmp.path().join("src/a.txt");
let to = tmp.path().join("dst/a.txt");
write(&to, "moved");
write(&from, "someone else got here first");
let journal = Journal {
kind: Kind::Move,
steps: vec![Undoable::Moved {
from: from.clone(),
to: to.clone(),
}],
};
let report = undone(journal, &Recorder::new());
assert_eq!(report.items, 0);
assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].path, from);
assert_eq!(read(&from), "someone else got here first");
assert_eq!(read(&to), "moved", "the moved file stays put");
}
#[test]
fn undo_of_a_create_sends_it_to_the_trash() {
let tmp = TempDir::new().expect("tempdir");
let made = tmp.path().join("copied");
write(&made.join("inside.txt"), "a file the user added later");
let journal = Journal {
kind: Kind::Copy,
steps: vec![Undoable::Created { path: made.clone() }],
};
let rec = Recorder::new();
let report = undone(journal, &rec);
assert_eq!(report.items, 1);
assert_eq!(rec.paths(), vec![made]);
assert!(report.notes.is_empty(), "nothing here is trash-only");
}
#[test]
fn undo_of_a_trash_reports_it_as_trash_only_rather_than_pretending() {
let tmp = TempDir::new().expect("tempdir");
let gone = tmp.path().join("gone.txt");
let journal = Journal {
kind: Kind::Trash,
steps: vec![Undoable::Trashed { path: gone.clone() }],
};
let rec = Recorder::new();
let report = undone(journal, &rec);
assert_eq!(report.items, 0);
assert!(report.failures.is_empty());
assert_eq!(report.notes.len(), 1, "{:?}", report.notes);
assert!(
report.notes[0].contains(&gone.display().to_string())
&& report.notes[0].contains("system trash"),
"the note neither names the path nor says where it comes back from: {}",
report.notes[0]
);
assert!(rec.paths().is_empty());
}
#[test]
fn undo_replays_the_inverses_in_reverse_order() {
let tmp = TempDir::new().expect("tempdir");
let displaced = tmp.path().join("dst/a.txt");
let created = tmp.path().join("dst/a.txt");
write(&created, "fresh");
let journal = Journal {
kind: Kind::Copy,
steps: vec![
Undoable::Trashed {
path: displaced.clone(),
},
Undoable::Created {
path: created.clone(),
},
],
};
let rec = Recorder::new();
let report = undone(journal, &rec);
assert_eq!(rec.paths(), vec![created]);
assert_eq!(report.notes.len(), 1);
assert!(report.notes[0].contains(&displaced.display().to_string()));
assert_eq!(report.items, 1);
}
#[test]
#[cfg(unix)]
fn cross_device_is_detected_precisely_and_nothing_else_is() {
assert!(is_cross_device(&io::Error::from_raw_os_error(18)));
assert!(is_cross_device(&io::Error::from(
io::ErrorKind::CrossesDevices
)));
assert!(!is_cross_device(&io::Error::from_raw_os_error(13)));
assert!(!is_cross_device(&io::Error::from_raw_os_error(1)));
assert!(!is_cross_device(&io::Error::from(
io::ErrorKind::PermissionDenied
)));
}
fn move_tree_plan(root: &Path, dest_dir: &Path) -> Plan {
write(&root.join("a.txt"), "aa");
write(&root.join("sub/b.txt"), "bbb");
fs::create_dir_all(dest_dir).expect("dst");
resolve(
Op::Move {
sources: sources(std::slice::from_ref(&root.to_path_buf())),
dest: dest_dir.to_path_buf(),
},
Path::new("/nowhere"),
&[],
Conflict::Rename,
)
}
#[test]
fn a_cross_device_move_copies_the_tree_then_trashes_the_source() {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path().join("src/tree");
let dest_dir = tmp.path().join("dst");
let p = move_tree_plan(&root, &dest_dir);
let dest = dest_dir.join("tree");
let rec = Recorder::new();
let out = drive_with(p, &rec, always_cross_device, &AtomicBool::new(false));
assert!(out.report.failures.is_empty(), "{:?}", out.report.failures);
assert_eq!(read(&dest.join("a.txt")), "aa");
assert_eq!(read(&dest.join("sub/b.txt")), "bbb");
assert_eq!(rec.paths(), vec![root.clone()]);
assert_eq!(
out.report.journal.steps,
vec![Undoable::Moved {
from: root,
to: dest
}]
);
}
#[cfg(unix)]
fn seal(dir: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o555)).expect("chmod");
fs::File::create(dir.join(".probe")).is_err()
}
#[cfg(unix)]
fn unseal(dir: &Path) {
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o755));
}
#[test]
#[cfg(unix)]
fn an_incomplete_cross_device_move_leaves_the_original_alone() {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path().join("src/tree");
let dest_dir = tmp.path().join("dst");
let p = move_tree_plan(&root, &dest_dir);
if !seal(&root) {
unseal(&root);
return;
}
let rec = Recorder::new();
let out = drive_with(p, &rec, always_cross_device, &AtomicBool::new(false));
unseal(&root);
assert!(rec.paths().is_empty(), "the original was trashed anyway");
assert!(root.join("a.txt").exists(), "the original is still there");
assert!(!out.report.failures.is_empty());
assert!(out.report.journal.steps.is_empty());
}
#[test]
fn undo_of_a_cross_device_move_carries_the_tree_back_and_trashes_the_copy() {
let tmp = TempDir::new().expect("tempdir");
let from = tmp.path().join("src/tree");
let to = tmp.path().join("dst/tree");
write(&to.join("a.txt"), "aa");
write(&to.join("sub/b.txt"), "bbb");
fs::create_dir_all(from.parent().expect("parent")).expect("src");
#[cfg(unix)]
std::os::unix::fs::symlink(Path::new("a.txt"), to.join("link")).expect("symlink");
let journal = Journal {
kind: Kind::Move,
steps: vec![Undoable::Moved {
from: from.clone(),
to: to.clone(),
}],
};
let rec = Recorder::new();
let out = undone_with(journal, &rec, always_cross_device);
let report = &out.report;
assert!(report.failures.is_empty(), "{:?}", report.failures);
assert_eq!(report.items, 1);
assert_eq!(report.bytes, 5);
assert_eq!(read(&from.join("a.txt")), "aa");
assert_eq!(read(&from.join("sub/b.txt")), "bbb");
#[cfg(unix)]
{
let back = from.join("link");
assert!(
fs::symlink_metadata(&back)
.expect("the link came back")
.file_type()
.is_symlink(),
"the link was dereferenced on the way back"
);
assert_eq!(fs::read_link(&back).expect("read_link"), Path::new("a.txt"));
}
assert_eq!(rec.paths(), vec![to]);
assert!(report.notes.is_empty());
}
#[test]
fn undo_of_a_cross_device_move_refuses_when_the_original_path_is_occupied_again() {
let tmp = TempDir::new().expect("tempdir");
let from = tmp.path().join("src/tree");
let to = tmp.path().join("dst/tree");
write(&to.join("a.txt"), "aa");
write(&from.join("a.txt"), "someone else got here first");
let journal = Journal {
kind: Kind::Move,
steps: vec![Undoable::Moved {
from: from.clone(),
to: to.clone(),
}],
};
let rec = Recorder::new();
let report = undone_with(journal, &rec, always_cross_device).report;
assert_eq!(report.items, 0);
assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].path, from);
assert_eq!(read(&from.join("a.txt")), "someone else got here first");
assert_eq!(read(&to.join("a.txt")), "aa");
assert!(rec.paths().is_empty(), "nothing was trashed");
}
#[test]
#[cfg(unix)]
fn an_incomplete_carry_back_leaves_the_copy_alone_and_says_so() {
let tmp = TempDir::new().expect("tempdir");
let from = tmp.path().join("src/tree");
let to = tmp.path().join("dst/tree");
write(&to.join("a.txt"), "aa");
fs::create_dir_all(from.parent().expect("parent")).expect("src");
if !seal(&to) {
unseal(&to);
return;
}
let journal = Journal {
kind: Kind::Move,
steps: vec![Undoable::Moved {
from: from.clone(),
to: to.clone(),
}],
};
let rec = Recorder::new();
let report = undone_with(journal, &rec, always_cross_device).report;
unseal(&to);
unseal(&from);
assert_eq!(report.items, 0, "an incomplete restore is not a restore");
assert!(
report
.failures
.iter()
.any(|f| f.msg.contains("did not copy back whole")),
"the incompleteness is not named: {:?}",
report.failures
);
assert!(rec.paths().is_empty(), "the copy was trashed anyway");
assert_eq!(read(&to.join("a.txt")), "aa");
}
#[test]
fn a_carry_back_whose_trash_refuses_still_counts_as_restored() {
let tmp = TempDir::new().expect("tempdir");
let from = tmp.path().join("src/a.txt");
let to = tmp.path().join("dst/a.txt");
write(&to, "moved");
fs::create_dir_all(from.parent().expect("parent")).expect("src");
let journal = Journal {
kind: Kind::Move,
steps: vec![Undoable::Moved {
from: from.clone(),
to: to.clone(),
}],
};
let rec = Recorder::failing();
let report = undone_with(journal, &rec, always_cross_device).report;
assert_eq!(report.items, 1);
assert_eq!(read(&from), "moved");
assert_eq!(report.failures.len(), 1);
assert_eq!(report.failures[0].path, to);
assert!(report.failures[0].msg.contains("restored"));
}
#[test]
fn a_rename_error_that_is_not_cross_device_stays_a_plain_failure() {
let tmp = TempDir::new().expect("tempdir");
let from = tmp.path().join("src/a.txt");
let to = tmp.path().join("dst/a.txt");
write(&to, "moved");
fs::create_dir_all(from.parent().expect("parent")).expect("src");
let journal = Journal {
kind: Kind::Move,
steps: vec![Undoable::Moved {
from: from.clone(),
to: to.clone(),
}],
};
let rec = Recorder::new();
let report = undone_with(journal, &rec, always_denied).report;
assert_eq!(report.items, 0);
assert_eq!(report.failures.len(), 1);
assert!(!from.exists(), "nothing was copied back");
assert_eq!(read(&to), "moved");
assert!(rec.paths().is_empty());
}
fn awaited(run: Run) -> Report {
for _ in 0..2_000 {
for msg in run.drain() {
if let Msg::Done(report) = msg {
return report;
}
}
std::thread::sleep(Duration::from_millis(1));
}
panic!("the worker never sent Done");
}
#[test]
fn start_undo_streams_a_done_that_names_what_it_reversed() {
let tmp = TempDir::new().expect("tempdir");
let from = tmp.path().join("src/a.txt");
let to = tmp.path().join("dst/a.txt");
write(&to, "moved");
fs::create_dir_all(from.parent().expect("parent")).expect("src");
let journal = Journal {
kind: Kind::Move,
steps: vec![Undoable::Moved {
from: from.clone(),
to: to.clone(),
}],
};
let report = awaited(start_undo(journal));
assert_eq!(report.direction, Direction::Undo);
assert_eq!(report.kind, Kind::Move);
assert_eq!(report.items, 1, "one journal step was put back");
assert!(report.failures.is_empty(), "{:?}", report.failures);
assert!(report.notes.is_empty());
assert_eq!(read(&from), "moved");
assert!(!to.exists());
}
#[test]
fn an_undo_reports_an_empty_journal_so_u_cannot_stack_undos() {
let tmp = TempDir::new().expect("tempdir");
let from = tmp.path().join("src/a.txt");
let to = tmp.path().join("dst/a.txt");
let made = tmp.path().join("dst/made.txt");
let gone = tmp.path().join("dst/gone.txt");
write(&to, "moved");
write(&made, "created");
fs::create_dir_all(from.parent().expect("parent")).expect("src");
let journal = Journal {
kind: Kind::Move,
steps: vec![
Undoable::Moved {
from: from.clone(),
to: to.clone(),
},
Undoable::Created { path: made },
Undoable::Trashed { path: gone },
],
};
let rec = Recorder::new();
let report = undone(journal, &rec);
assert_eq!(report.items, 2);
assert!(
report.journal.steps.is_empty(),
"an undo left something for `U` to undo: {:?}",
report.journal.steps
);
assert_eq!(report.journal.kind, Kind::Move);
}
#[test]
fn a_trash_only_path_arrives_as_a_note_and_never_joins_the_failures() {
let tmp = TempDir::new().expect("tempdir");
let made = tmp.path().join("copied.txt");
let gone = tmp.path().join("gone.txt");
write(&made, "created");
let journal = Journal {
kind: Kind::Copy,
steps: vec![
Undoable::Trashed { path: gone.clone() },
Undoable::Created { path: made.clone() },
],
};
let report = undone(journal, &Recorder::failing());
assert_eq!(report.failures.len(), 1, "{:?}", report.failures);
assert_eq!(report.failures[0].path, made);
assert_eq!(report.notes.len(), 1, "{:?}", report.notes);
assert!(report.notes[0].contains(&gone.display().to_string()));
assert!(
!report
.failures
.iter()
.any(|f| f.msg.contains(&gone.display().to_string())),
"the trash-only path leaked into the failures: {:?}",
report.failures
);
assert_eq!(report.items, 0);
}
#[test]
fn cancelling_an_undo_stops_it_and_reports_what_it_managed() {
let tmp = TempDir::new().expect("tempdir");
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
let c = tmp.path().join("c.txt");
write(&a, "a");
write(&b, "b");
write(&c, "c");
let journal = Journal {
kind: Kind::Copy,
steps: vec![
Undoable::Created { path: a.clone() },
Undoable::Created { path: b.clone() },
Undoable::Created { path: c.clone() },
],
};
let cancel = Arc::new(AtomicBool::new(false));
let rec = Recorder::tripping(Arc::clone(&cancel));
let out = drive_undo(journal, &rec, os_rename, &cancel);
assert_eq!(rec.paths(), vec![c], "stopped after the first step");
assert_eq!(out.report.items, 1, "an undo reports what it managed");
assert!(a.exists() && b.exists(), "the rest was left alone");
assert!(out.report.journal.steps.is_empty());
assert_eq!(out.progress.last().map(|p| p.0), Some(1));
}
}