use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub(crate) const ID_SEP: char = '\u{1F}';
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FsOp {
CreateDir(PathBuf),
CreateFile(PathBuf),
Trash(PathBuf),
Rename { from: PathBuf, to: PathBuf },
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct BufEntry {
id: Option<u64>,
path: PathBuf,
is_dir: bool,
}
fn parse_buffer(buffer: &str, root: &Path) -> Vec<BufEntry> {
let mut stack: Vec<(usize, PathBuf)> = Vec::new();
let mut entries: Vec<BufEntry> = Vec::new();
for (line_idx, line) in buffer.lines().enumerate() {
if line_idx == 0 {
continue;
}
let (left, id_opt) = if let Some(sep_pos) = line.find(ID_SEP) {
let id_str = &line[sep_pos + ID_SEP.len_utf8()..];
let digits: String = id_str.chars().take_while(|c| c.is_ascii_digit()).collect();
let id: Option<u64> = if digits.is_empty() {
None
} else {
digits.parse().ok()
};
(&line[..sep_pos], id)
} else {
(line, None)
};
if left.trim().is_empty() {
continue;
}
let indent = left.len() - left.trim_start_matches(' ').len();
let depth = ((indent.saturating_sub(2)) / 2).max(1);
let raw = &left[indent..];
let is_dir = raw.ends_with('/');
let name = if is_dir { &raw[..raw.len() - 1] } else { raw };
if name.is_empty() {
continue;
}
if Path::new(name).components().any(|c| {
matches!(
c,
std::path::Component::ParentDir
| std::path::Component::RootDir
| std::path::Component::Prefix(_)
)
}) {
continue;
}
while stack.last().map(|(d, _)| *d >= depth).unwrap_or(false) {
stack.pop();
}
let parent = if depth == 1 {
root
} else {
match stack.last().filter(|(d, _)| *d == depth - 1) {
Some((_, p)) => p.as_path(),
None => continue, }
};
let target = parent.join(name);
if is_dir {
stack.push((depth, target.clone()));
}
entries.push(BufEntry {
id: id_opt,
path: target,
is_dir,
});
}
entries
}
fn component_count(p: &Path) -> usize {
p.components().count()
}
pub(crate) fn reconcile(baseline: &[(u64, PathBuf, bool)], buffer: &str, root: &Path) -> Vec<FsOp> {
let current = parse_buffer(buffer, root);
let mut base_by_id: HashMap<u64, (&PathBuf, bool)> = HashMap::new();
for (id, path, is_dir) in baseline.iter().skip(1) {
base_by_id.insert(*id, (path, *is_dir));
}
let mut renames: Vec<FsOp> = Vec::new();
let mut trashes: Vec<FsOp> = Vec::new();
let mut creates: Vec<FsOp> = Vec::new();
let mut seen: HashSet<u64> = HashSet::new();
for entry in ¤t {
match entry.id {
Some(id) if base_by_id.contains_key(&id) && !seen.contains(&id) => {
seen.insert(id);
let (bpath, b_is_dir) = base_by_id[&id];
if b_is_dir == entry.is_dir {
if bpath != &entry.path {
renames.push(FsOp::Rename {
from: bpath.clone(),
to: entry.path.clone(),
});
}
} else {
trashes.push(FsOp::Trash(bpath.clone()));
if entry.is_dir {
creates.push(FsOp::CreateDir(entry.path.clone()));
} else {
creates.push(FsOp::CreateFile(entry.path.clone()));
}
}
}
_ => {
if entry.is_dir {
creates.push(FsOp::CreateDir(entry.path.clone()));
} else {
creates.push(FsOp::CreateFile(entry.path.clone()));
}
}
}
}
for (id, path, _is_dir) in baseline.iter().skip(1) {
if !seen.contains(id) {
trashes.push(FsOp::Trash(path.clone()));
}
}
renames.sort_by_key(|op| {
if let FsOp::Rename { from, .. } = op {
component_count(from)
} else {
0
}
});
trashes.sort_by_key(|op| {
if let FsOp::Trash(p) = op {
std::cmp::Reverse(component_count(p))
} else {
std::cmp::Reverse(0)
}
});
creates.sort_by_key(|op| match op {
FsOp::CreateDir(p) | FsOp::CreateFile(p) => component_count(p),
_ => 0,
});
let mut ops = renames;
ops.extend(trashes);
ops.extend(creates);
ops
}
#[derive(Debug, Clone)]
pub(crate) enum AppliedOp {
Created(PathBuf),
Trashed { original: PathBuf, dest: PathBuf },
Renamed { from: PathBuf, to: PathBuf },
Restored { from_trash: PathBuf, to: PathBuf },
}
fn move_file(src: &Path, dst: &Path) -> std::io::Result<()> {
match std::fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
std::fs::copy(src, dst)?;
std::fs::remove_file(src)?;
Ok(())
}
Err(e) => Err(e),
}
}
fn move_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
match std::fs::rename(src, dst) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => {
copy_dir_recursive(src, dst)?;
std::fs::remove_dir_all(src)?;
Ok(())
}
Err(e) => Err(e),
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
let mut stack = vec![src.to_path_buf()];
while let Some(dir) = stack.pop() {
let rel = dir
.strip_prefix(src)
.map_err(|_| std::io::Error::other("strip_prefix failed"))?;
let dst_dir = dst.join(rel);
std::fs::create_dir_all(&dst_dir)?;
for entry in std::fs::read_dir(&dir)?.flatten() {
let path = entry.path();
let rel_entry = path
.strip_prefix(src)
.map_err(|_| std::io::Error::other("strip_prefix failed"))?;
let dst_path = dst.join(rel_entry);
let ft = std::fs::symlink_metadata(&path)?.file_type();
if ft.is_symlink() {
copy_symlink(&path, &dst_path)?;
} else if ft.is_dir() {
stack.push(path);
} else {
std::fs::copy(&path, &dst_path)?;
}
}
}
Ok(())
}
#[cfg(unix)]
fn copy_symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(std::fs::read_link(src)?, dst)
}
#[cfg(windows)]
fn copy_symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
let target = std::fs::read_link(src)?;
if std::fs::metadata(src).map(|m| m.is_dir()).unwrap_or(false) {
std::os::windows::fs::symlink_dir(target, dst)
} else {
std::os::windows::fs::symlink_file(target, dst)
}
}
fn is_same_file(a: &Path, b: &Path) -> bool {
match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
(Ok(ca), Ok(cb)) => ca == cb,
_ => false,
}
}
fn temp_sibling_path(from: &Path) -> PathBuf {
let parent = from.parent().unwrap_or_else(|| Path::new("."));
let base = from
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
let pid = std::process::id();
(0u64..)
.map(|n| parent.join(format!(".hjkl-rename-{pid}-{n}-{base}")))
.find(|cand| cand.symlink_metadata().is_err())
.expect("some counter yields a fresh temp name")
}
pub(crate) fn apply_ops(
ops: &[FsOp],
trashed: &mut Vec<(String, PathBuf)>,
) -> (Vec<PathBuf>, Vec<AppliedOp>, Vec<String>) {
let mut created: Vec<PathBuf> = Vec::new();
let mut applied: Vec<AppliedOp> = Vec::new();
let mut errors: Vec<String> = Vec::new();
let will_vacate: HashSet<&Path> = ops
.iter()
.filter_map(|op| match op {
FsOp::Rename { from, .. } => Some(from.as_path()),
FsOp::Trash(p) => Some(p.as_path()),
_ => None,
})
.collect();
let mut deferred: Vec<(PathBuf, PathBuf, PathBuf)> = Vec::new();
for op in ops {
match op {
FsOp::Rename { from, to } => {
if !from.exists() && to.exists() {
continue;
}
if to.symlink_metadata().is_ok() && !is_same_file(from, to) {
if will_vacate.contains(to.as_path()) {
let temp = temp_sibling_path(from);
let parked = if from.is_dir() {
move_dir(from, &temp)
} else {
move_file(from, &temp)
};
match parked {
Ok(()) => {
applied.push(AppliedOp::Renamed {
from: from.clone(),
to: temp.clone(),
});
deferred.push((from.clone(), temp, to.clone()));
}
Err(e) => {
errors.push(format!(
"rename {from:?} → {to:?}: park at {temp:?} failed: {e}"
));
}
}
} else {
errors.push(format!(
"rename {from:?} → {to:?}: destination exists — refusing to overwrite"
));
}
continue;
}
if let Some(parent) = to.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
errors.push(format!("rename: create_dir_all({parent:?}): {e}"));
continue;
}
let result = if from.is_dir() {
move_dir(from, to)
} else {
move_file(from, to)
};
match result {
Ok(()) => {
applied.push(AppliedOp::Renamed {
from: from.clone(),
to: to.clone(),
});
}
Err(e) => {
errors.push(format!("rename {from:?} → {to:?}: {e}"));
}
}
}
FsOp::Trash(path) => {
let is_dir = path.is_dir();
let dest = match hjkl_app::trash::trash_path(path, is_dir) {
Ok(d) => d,
Err(e) => {
errors.push(format!("trash_path({path:?}): {e}"));
continue;
}
};
let result = if is_dir {
move_dir(path, &dest)
} else {
move_file(path, &dest)
};
match result {
Ok(()) => {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
trashed.push((name, dest.clone()));
applied.push(AppliedOp::Trashed {
original: path.clone(),
dest,
});
}
Err(e) => {
errors.push(format!("trash {path:?}: {e}"));
}
}
}
FsOp::CreateDir(path) => {
let dir_name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let restore_idx = trashed
.iter()
.enumerate()
.rev()
.find(|(_, (name, _))| name == &dir_name)
.map(|(i, _)| i);
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
errors.push(format!("create_dir: create_dir_all({parent:?}): {e}"));
continue;
}
if let Some(idx) = restore_idx {
let (_, trash_dest) = trashed.remove(idx);
match move_dir(&trash_dest, path) {
Ok(()) => {
applied.push(AppliedOp::Restored {
from_trash: trash_dest,
to: path.clone(),
});
}
Err(e) => {
errors.push(format!("restore dir {trash_dest:?} → {path:?}: {e}"));
}
}
} else if let Err(e) = std::fs::create_dir_all(path) {
errors.push(format!("create_dir_all({path:?}): {e}"));
} else {
applied.push(AppliedOp::Created(path.clone()));
}
}
FsOp::CreateFile(path) => {
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let restore_idx = trashed
.iter()
.enumerate()
.rev()
.find(|(_, (name, _))| name == &file_name)
.map(|(i, _)| i);
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
errors.push(format!("create_file: create_dir_all({parent:?}): {e}"));
continue;
}
if let Some(idx) = restore_idx {
let (_, trash_dest) = trashed.remove(idx);
match move_file(&trash_dest, path) {
Ok(()) => {
applied.push(AppliedOp::Restored {
from_trash: trash_dest,
to: path.clone(),
});
}
Err(e) => {
errors.push(format!("restore {trash_dest:?} → {path:?}: {e}"));
}
}
} else {
match std::fs::File::create(path) {
Ok(_) => {
created.push(path.clone());
applied.push(AppliedOp::Created(path.clone()));
}
Err(e) => {
errors.push(format!("create_file({path:?}): {e}"));
}
}
}
}
}
}
for (orig_from, temp, to) in deferred {
if to.symlink_metadata().is_ok() {
errors.push(format!(
"rename {orig_from:?} → {to:?}: destination still exists — keeping original name"
));
let back = if temp.is_dir() {
move_dir(&temp, &orig_from)
} else {
move_file(&temp, &orig_from)
};
match back {
Ok(()) => {
if let Some(pos) = applied.iter().rposition(|a| {
matches!(a, AppliedOp::Renamed { from, to }
if from == &orig_from && to == &temp)
}) {
applied.remove(pos);
}
}
Err(e) => {
errors.push(format!("restore {temp:?} → {orig_from:?}: {e}"));
}
}
continue;
}
let result = if temp.is_dir() {
move_dir(&temp, &to)
} else {
move_file(&temp, &to)
};
match result {
Ok(()) => {
applied.push(AppliedOp::Renamed {
from: temp.clone(),
to: to.clone(),
});
}
Err(e) => {
errors.push(format!("rename {temp:?} → {to:?}: {e}"));
}
}
}
(created, applied, errors)
}
pub(crate) fn revert_ops(
applied: &[AppliedOp],
trashed: &mut Vec<(String, PathBuf)>,
) -> (Vec<AppliedOp>, Vec<String>) {
let mut redo_journal: Vec<AppliedOp> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for op in applied.iter().rev() {
match op {
AppliedOp::Created(path) => {
let is_dir = path.is_dir();
let dest = match hjkl_app::trash::trash_path(path, is_dir) {
Ok(d) => d,
Err(e) => {
errors.push(format!("revert created: trash_path({path:?}): {e}"));
continue;
}
};
let result = if is_dir {
move_dir(path, &dest)
} else {
move_file(path, &dest)
};
match result {
Ok(()) => {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
trashed.push((name, dest.clone()));
redo_journal.push(AppliedOp::Restored {
from_trash: dest,
to: path.clone(),
});
}
Err(e) => {
errors.push(format!("revert created: trash {path:?}: {e}"));
}
}
}
AppliedOp::Trashed { original, dest } => {
if let Some(parent) = original.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
errors.push(format!("revert trashed: create_dir_all({parent:?}): {e}"));
continue;
}
let result = if dest.is_dir() {
move_dir(dest, original)
} else {
move_file(dest, original)
};
match result {
Ok(()) => {
if let Some(pos) = trashed.iter().position(|(_, d)| d == dest) {
trashed.remove(pos);
}
redo_journal.push(AppliedOp::Trashed {
original: original.clone(),
dest: dest.clone(),
});
}
Err(e) => {
errors.push(format!(
"revert trashed: restore {dest:?} → {original:?}: {e}"
));
}
}
}
AppliedOp::Renamed { from, to } => {
if let Some(parent) = from.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
errors.push(format!("revert renamed: create_dir_all({parent:?}): {e}"));
continue;
}
let result = if to.is_dir() {
move_dir(to, from)
} else {
move_file(to, from)
};
match result {
Ok(()) => {
redo_journal.push(AppliedOp::Renamed {
from: from.clone(),
to: to.clone(),
});
}
Err(e) => {
errors.push(format!("revert renamed: {to:?} → {from:?}: {e}"));
}
}
}
AppliedOp::Restored { from_trash: _, to } => {
let is_dir = to.is_dir();
let new_dest = match hjkl_app::trash::trash_path(to, is_dir) {
Ok(d) => d,
Err(e) => {
errors.push(format!("revert restored: trash_path({to:?}): {e}"));
continue;
}
};
let result = if is_dir {
move_dir(to, &new_dest)
} else {
move_file(to, &new_dest)
};
match result {
Ok(()) => {
let name = to
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
trashed.push((name, new_dest.clone()));
redo_journal.push(AppliedOp::Restored {
from_trash: new_dest,
to: to.clone(),
});
}
Err(e) => {
errors.push(format!("revert restored: trash {to:?}: {e}"));
}
}
}
}
}
redo_journal.reverse();
(redo_journal, errors)
}
pub(crate) fn apply_applied(
ops: &[AppliedOp],
trashed: &mut Vec<(String, PathBuf)>,
) -> (Vec<PathBuf>, Vec<AppliedOp>, Vec<String>) {
let mut created: Vec<PathBuf> = Vec::new();
let mut new_applied: Vec<AppliedOp> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for op in ops {
match op {
AppliedOp::Created(path) => {
let result = if path.extension().is_none() && !path.to_string_lossy().contains('.')
{
std::fs::File::create(path).map(|_| ())
} else {
std::fs::File::create(path).map(|_| ())
};
match result {
Ok(()) => {
created.push(path.clone());
new_applied.push(AppliedOp::Created(path.clone()));
}
Err(e) => {
errors.push(format!("redo created: create {path:?}: {e}"));
}
}
}
AppliedOp::Trashed { original, dest: _ } => {
let is_dir = original.is_dir();
let new_dest = match hjkl_app::trash::trash_path(original, is_dir) {
Ok(d) => d,
Err(e) => {
errors.push(format!("redo trashed: trash_path({original:?}): {e}"));
continue;
}
};
let result = if is_dir {
move_dir(original, &new_dest)
} else {
move_file(original, &new_dest)
};
match result {
Ok(()) => {
let name = original
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
trashed.push((name, new_dest.clone()));
new_applied.push(AppliedOp::Trashed {
original: original.clone(),
dest: new_dest,
});
}
Err(e) => {
errors.push(format!("redo trashed: trash {original:?}: {e}"));
}
}
}
AppliedOp::Renamed { from, to } => {
if let Some(parent) = to.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
errors.push(format!("redo renamed: create_dir_all({parent:?}): {e}"));
continue;
}
let result = if from.is_dir() {
move_dir(from, to)
} else {
move_file(from, to)
};
match result {
Ok(()) => {
new_applied.push(AppliedOp::Renamed {
from: from.clone(),
to: to.clone(),
});
}
Err(e) => {
errors.push(format!("redo renamed: {from:?} → {to:?}: {e}"));
}
}
}
AppliedOp::Restored { from_trash, to } => {
if let Some(parent) = to.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
errors.push(format!("redo restored: create_dir_all({parent:?}): {e}"));
continue;
}
let is_dir = std::fs::symlink_metadata(from_trash)
.map(|m| m.is_dir())
.unwrap_or(false);
let result = if is_dir {
move_dir(from_trash, to)
} else {
move_file(from_trash, to)
};
match result {
Ok(()) => {
if let Some(pos) = trashed.iter().position(|(_, d)| d == from_trash) {
trashed.remove(pos);
}
new_applied.push(AppliedOp::Restored {
from_trash: from_trash.clone(),
to: to.clone(),
});
}
Err(e) => {
errors.push(format!("redo restored: {from_trash:?} → {to:?}: {e}"));
}
}
}
}
}
(created, new_applied, errors)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn make_baseline(items: &[(&str, bool)]) -> Vec<(u64, PathBuf, bool)> {
let r = root();
let mut v: Vec<(u64, PathBuf, bool)> = Vec::new();
v.push((0, r.clone(), true)); for (i, (rel, is_dir)) in items.iter().enumerate() {
v.push(((i + 1) as u64, r.join(rel), *is_dir));
}
v
}
fn idline(depth: usize, name: &str, id: u64) -> String {
let indent = depth * 2 + 2;
format!("{}{}{}{}", " ".repeat(indent), name, ID_SEP, id)
}
fn root_header() -> &'static str {
" project"
}
fn render_baseline(baseline: &[(u64, PathBuf, bool)]) -> String {
let mut out = String::new();
for (i, (id, path, is_dir)) in baseline.iter().enumerate() {
if i > 0 {
out.push('\n');
}
let root = &baseline[0].1;
let depth = match path.strip_prefix(root) {
Ok(rel) => rel.components().count(),
Err(_) => 0,
};
let indent = depth * 2 + 2;
let name = if depth == 0 {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned())
} else {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
};
out.push_str(&" ".repeat(indent));
out.push_str(&name);
if *is_dir && depth > 0 {
out.push('/');
}
if i > 0 {
out.push(ID_SEP);
out.push_str(&id.to_string());
}
}
out
}
fn root() -> PathBuf {
PathBuf::from("/project")
}
#[test]
fn reconcile_rejects_root_escape() {
let baseline = make_baseline(&[("keep.rs", false)]);
let evil_rel = format!("{}../evil", " ".repeat(4)); let evil_abs = format!("{}/etc/passwd", " ".repeat(4)); let buffer = format!(
"{}\n{}\n{}\n{}",
root_header(),
idline(1, "keep.rs", 1),
evil_rel,
evil_abs,
);
let ops = reconcile(&baseline, &buffer, &root());
for op in &ops {
let paths: Vec<&PathBuf> = match op {
FsOp::CreateFile(p) | FsOp::CreateDir(p) | FsOp::Trash(p) => vec![p],
FsOp::Rename { from, to } => vec![from, to],
};
for p in paths {
assert!(
p.starts_with(root()),
"op escaped explorer root: {op:?} (path {p:?})"
);
}
}
}
#[test]
fn bulk_create() {
let baseline = make_baseline(&[("existing.rs", false)]);
let buffer = format!(
"{}\n{}\n new_a.rs\n new_b.rs\n new_c.rs",
root_header(),
idline(1, "existing.rs", 1),
);
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(ops.len(), 3, "expected 3 creates, got {ops:?}");
assert!(ops.contains(&FsOp::CreateFile(root().join("new_a.rs"))));
assert!(ops.contains(&FsOp::CreateFile(root().join("new_b.rs"))));
assert!(ops.contains(&FsOp::CreateFile(root().join("new_c.rs"))));
assert!(
ops.iter().all(|op| matches!(op, FsOp::CreateFile(_))),
"expected only CreateFile ops, got {ops:?}"
);
}
#[test]
fn create_dir_trailing_slash() {
let baseline = make_baseline(&[]);
let buffer = format!("{}\n newdir/", root_header());
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(ops, vec![FsOp::CreateDir(root().join("newdir"))]);
}
#[test]
fn create_nested() {
let baseline = make_baseline(&[]);
let buffer = format!("{}\n a/b.rs", root_header());
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(ops, vec![FsOp::CreateFile(root().join("a").join("b.rs"))]);
}
#[test]
fn delete_to_trash() {
let baseline = make_baseline(&[("to_delete.rs", false)]);
let buffer = root_header().to_string();
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(
ops,
vec![FsOp::Trash(root().join("to_delete.rs"))],
"removed file must produce exactly one Trash op"
);
}
#[test]
fn rename_in_place_is_rename_not_trash_create() {
let baseline = make_baseline(&[("foo.rs", false)]);
let buffer = format!("{}\n{}", root_header(), idline(1, "bar.rs", 1));
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(
ops,
vec![FsOp::Rename {
from: root().join("foo.rs"),
to: root().join("bar.rs"),
}],
"in-place rename must produce exactly Rename{{foo.rs → bar.rs}}, got {ops:?}"
);
for op in &ops {
assert!(
!matches!(op, FsOp::Trash(_)),
"Trash must not be emitted for an in-place rename"
);
assert!(
!matches!(op, FsOp::CreateFile(_) | FsOp::CreateDir(_)),
"Create must not be emitted for an in-place rename"
);
}
}
#[test]
fn rename_dir() {
let baseline = make_baseline(&[("olddir", true), ("olddir/child.rs", false)]);
let buffer = format!(
"{}\n{}\n{}",
root_header(),
idline(1, "newdir/", 1),
idline(2, "child.rs", 2),
);
let ops = reconcile(&baseline, &buffer, &root());
let rename_dir_op = FsOp::Rename {
from: root().join("olddir"),
to: root().join("newdir"),
};
assert!(
ops.contains(&rename_dir_op),
"must contain Rename{{olddir → newdir}}, got {ops:?}"
);
let rename_child_op = FsOp::Rename {
from: root().join("olddir").join("child.rs"),
to: root().join("newdir").join("child.rs"),
};
assert!(
ops.contains(&rename_child_op),
"must contain child rename, got {ops:?}"
);
let dir_pos = ops.iter().position(|op| op == &rename_dir_op).unwrap();
let child_pos = ops.iter().position(|op| op == &rename_child_op).unwrap();
assert!(
dir_pos < child_pos,
"ancestor rename must precede child rename: dir={dir_pos} child={child_pos}"
);
}
#[test]
fn unchanged_no_ops() {
let baseline =
make_baseline(&[("src", true), ("src/main.rs", false), ("Cargo.toml", false)]);
let buffer = render_baseline(&baseline);
let ops = reconcile(&baseline, &buffer, &root());
assert!(
ops.is_empty(),
"unchanged buffer must produce no ops, got {ops:?}\nbuffer:\n{buffer}"
);
}
#[test]
fn delete_one_keep_rest() {
let baseline =
make_baseline(&[("alpha.rs", false), ("beta.rs", false), ("gamma.rs", false)]);
let buffer = format!(
"{}\n{}\n{}",
root_header(),
idline(1, "alpha.rs", 1),
idline(1, "gamma.rs", 3),
);
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(
ops,
vec![FsOp::Trash(root().join("beta.rs"))],
"only beta.rs must be trashed, got {ops:?}"
);
}
#[test]
fn mixed() {
let baseline =
make_baseline(&[("old.rs", false), ("keep.rs", false), ("remove.rs", false)]);
let buffer = format!(
"{}\n{}\n{}\n{}\n added.rs",
root_header(),
idline(1, "new.rs", 1),
idline(1, "keep.rs", 2),
idline(1, "fresh.rs", 3),
);
let ops = reconcile(&baseline, &buffer, &root());
let has_rename_old = ops.contains(&FsOp::Rename {
from: root().join("old.rs"),
to: root().join("new.rs"),
});
let has_rename_remove = ops.contains(&FsOp::Rename {
from: root().join("remove.rs"),
to: root().join("fresh.rs"),
});
let has_create = ops.contains(&FsOp::CreateFile(root().join("added.rs")));
assert!(
has_rename_old,
"must have Rename{{old.rs → new.rs}}, got {ops:?}"
);
assert!(
has_rename_remove,
"must have Rename{{remove.rs → fresh.rs}}, got {ops:?}"
);
assert!(has_create, "must have CreateFile(added.rs), got {ops:?}");
let rename_old_pos = ops
.iter()
.position(|op| {
matches!(op, FsOp::Rename { from, to }
if from == &root().join("old.rs") && to == &root().join("new.rs"))
})
.unwrap();
let rename_remove_pos = ops
.iter()
.position(|op| {
matches!(op, FsOp::Rename { from, to }
if from == &root().join("remove.rs") && to == &root().join("fresh.rs"))
})
.unwrap();
let create_pos = ops
.iter()
.position(|op| matches!(op, FsOp::CreateFile(p) if p == &root().join("added.rs")))
.unwrap();
assert!(
rename_old_pos < create_pos,
"renames must precede creates: rename={rename_old_pos} create={create_pos}"
);
assert!(
rename_remove_pos < create_pos,
"renames must precede creates: rename={rename_remove_pos} create={create_pos}"
);
}
#[test]
fn mixed_pure_delete_produces_trash_not_rename() {
let baseline = make_baseline(&[("a.rs", false), ("b.rs", false), ("c.rs", false)]);
let buffer = format!(
"{}\n{}\n{}",
root_header(),
idline(1, "a.rs", 1),
idline(1, "c.rs", 3),
);
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(ops, vec![FsOp::Trash(root().join("b.rs"))]);
}
#[test]
fn rename_empty_dir() {
let baseline = make_baseline(&[("emptydir", true)]);
let buffer = format!("{}\n{}", root_header(), idline(1, "renamed/", 1));
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(
ops,
vec![FsOp::Rename {
from: root().join("emptydir"),
to: root().join("renamed"),
}]
);
}
#[test]
fn type_change_file_to_dir() {
let baseline = make_baseline(&[("thing", false)]);
let buffer = format!("{}\n{}", root_header(), idline(1, "thing/", 1));
let ops = reconcile(&baseline, &buffer, &root());
assert!(
ops.contains(&FsOp::Trash(root().join("thing"))),
"must trash old file, got {ops:?}"
);
assert!(
ops.contains(&FsOp::CreateDir(root().join("thing"))),
"must create new dir, got {ops:?}"
);
assert_eq!(ops.len(), 2, "exactly Trash + CreateDir, got {ops:?}");
}
#[test]
fn type_change_dir_to_file() {
let baseline = make_baseline(&[("thing", true)]);
let buffer = format!("{}\n{}", root_header(), idline(1, "thing", 1));
let ops = reconcile(&baseline, &buffer, &root());
assert!(
ops.contains(&FsOp::Trash(root().join("thing"))),
"must trash old dir"
);
assert!(
ops.contains(&FsOp::CreateFile(root().join("thing"))),
"must create new file"
);
assert_eq!(ops.len(), 2, "exactly Trash + CreateFile, got {ops:?}");
}
#[test]
fn trash_ordering_deep_before_shallow() {
let baseline = make_baseline(&[("parent", true), ("parent/child.rs", false)]);
let buffer = root_header().to_string();
let ops = reconcile(&baseline, &buffer, &root());
let child_pos = ops
.iter()
.position(
|op| matches!(op, FsOp::Trash(p) if p == &root().join("parent").join("child.rs")),
)
.unwrap();
let parent_pos = ops
.iter()
.position(|op| matches!(op, FsOp::Trash(p) if p == &root().join("parent")))
.unwrap();
assert!(
child_pos < parent_pos,
"child must be trashed before parent: child={child_pos} parent={parent_pos}"
);
}
#[test]
fn create_ordering_shallow_before_deep() {
let baseline = make_baseline(&[]);
let buffer = format!("{}\n newdir/\n newfile.rs", root_header());
let ops = reconcile(&baseline, &buffer, &root());
let dir_pos = ops
.iter()
.position(|op| matches!(op, FsOp::CreateDir(p) if p == &root().join("newdir")))
.expect("CreateDir(newdir) must be present");
let file_pos = ops
.iter()
.position(|op| matches!(op, FsOp::CreateFile(p) if p == &root().join("newdir").join("newfile.rs")))
.expect("CreateFile(newdir/newfile.rs) must be present");
assert!(
dir_pos < file_pos,
"CreateDir must precede CreateFile: dir={dir_pos} file={file_pos}"
);
}
#[test]
fn render_baseline_roundtrips() {
let baseline = make_baseline(&[
("docs", true),
("docs/README.md", false),
("src", true),
("src/main.rs", false),
("src/lib.rs", false),
("Cargo.toml", false),
]);
let buf = render_baseline(&baseline);
let ops = reconcile(&baseline, &buf, &root());
assert!(
ops.is_empty(),
"render_baseline must produce an unchanged buffer, got {ops:?}\nbuffer:\n{buf}"
);
}
#[test]
fn empty_baseline_empty_buffer() {
let baseline = vec![(0u64, root(), true)];
let buffer = root_header().to_string();
let ops = reconcile(&baseline, &buffer, &root());
assert!(ops.is_empty());
}
#[test]
fn blank_lines_ignored() {
let baseline = make_baseline(&[("foo.rs", false)]);
let buffer = format!("{}\n\n{}\n\n", root_header(), idline(1, "foo.rs", 1),);
let ops = reconcile(&baseline, &buffer, &root());
assert!(ops.is_empty(), "blank lines must be ignored, got {ops:?}");
}
#[test]
fn multiple_creates_are_all_present() {
let baseline = make_baseline(&[]);
let buffer = format!("{}\n x.rs\n y.rs\n z.rs", root_header());
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(ops.len(), 3);
assert!(ops.iter().all(|op| matches!(op, FsOp::CreateFile(_))));
}
#[test]
fn duplicate_id_creates_copy() {
let baseline = make_baseline(&[("orig.rs", false)]);
let buffer = format!(
"{}\n{}\n{}",
root_header(),
idline(1, "orig.rs", 1), idline(1, "orig.rs", 1), );
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(
ops.len(),
1,
"duplicate id must yield one CreateFile, got {ops:?}"
);
assert!(
ops.contains(&FsOp::CreateFile(root().join("orig.rs"))),
"expected CreateFile(orig.rs), got {ops:?}"
);
}
#[test]
fn dd_open_dir_orphans_are_trashed_not_reparented() {
let baseline = make_baseline(&[
("mydir", true),
("mydir/a.rs", false),
("mydir/b.rs", false),
]);
let buffer = format!(
"{}\n{}\n{}",
root_header(),
idline(2, "a.rs", 2),
idline(2, "b.rs", 3),
);
let ops = reconcile(&baseline, &buffer, &root());
assert!(
ops.contains(&FsOp::Trash(root().join("mydir"))),
"dir must be trashed, got {ops:?}"
);
assert!(
ops.contains(&FsOp::Trash(root().join("mydir").join("a.rs"))),
"child a.rs must be trashed, got {ops:?}"
);
assert!(
ops.contains(&FsOp::Trash(root().join("mydir").join("b.rs"))),
"child b.rs must be trashed, got {ops:?}"
);
assert!(
ops.iter().all(|op| matches!(op, FsOp::Trash(_))),
"open-dir dd must produce only Trash ops, got {ops:?}"
);
}
#[test]
fn indent_corruption_safe() {
let baseline = make_baseline(&[
("mydir", true),
("mydir/file.rs", false),
("sibling.rs", false),
]);
let buffer = format!(
"{}\n{}\n{}\n{}",
root_header(),
idline(1, "mydir/", 1),
idline(1, "file.rs", 2), idline(1, "sibling.rs", 3),
);
let ops = reconcile(&baseline, &buffer, &root());
let has_trash = ops.iter().any(|op| matches!(op, FsOp::Trash(_)));
assert!(
!has_trash,
"no Trash expected when all ids are present, got {ops:?}"
);
assert!(
ops.contains(&FsOp::Rename {
from: root().join("mydir").join("file.rs"),
to: root().join("file.rs"),
}),
"mangled indent should produce Rename (reparent), got {ops:?}"
);
}
#[test]
fn whitespace_names_preserved() {
let baseline = make_baseline(&[]);
let buffer = format!("{}\n a b.txt\n trailing .txt", root_header());
let ops = reconcile(&baseline, &buffer, &root());
assert_eq!(ops.len(), 2, "expected 2 CreateFile ops, got {ops:?}");
assert!(
ops.contains(&FsOp::CreateFile(root().join("a b.txt"))),
"must create 'a b.txt', got {ops:?}"
);
assert!(
ops.contains(&FsOp::CreateFile(root().join("trailing .txt"))),
"must create 'trailing .txt' with trailing space, got {ops:?}"
);
}
#[test]
fn conceal_byte_positions() {
let line = format!(" x{}{}", ID_SEP, 5);
let us_byte = line.find(ID_SEP).expect("US must be present");
let visible = &line[..us_byte];
assert_eq!(visible, " x", "visible text must be indent+name");
assert_eq!(us_byte, 3, "US at byte 3 for ' x'");
assert_eq!(line.len(), 3 + ID_SEP.len_utf8() + 1, "total line length");
let conceal_end = line.len();
let tail = &line[us_byte..conceal_end];
assert!(
tail.starts_with(ID_SEP),
"tail must start with ID_SEP, got {tail:?}"
);
}
#[must_use]
fn isolated_trash(td: &tempfile::TempDir) -> crate::test_cwd::EnvVarGuard {
crate::test_cwd::EnvVarGuard::set("XDG_CACHE_HOME", td.path())
}
#[test]
fn apply_create_file_makes_empty_file() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let target = td.path().join("new.rs");
let ops = vec![FsOp::CreateFile(target.clone())];
let (created, applied, errors) = apply_ops(&ops, &mut Vec::new());
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(created, vec![target.clone()]);
assert_eq!(applied.len(), 1, "one AppliedOp must be recorded");
assert!(
matches!(&applied[0], AppliedOp::Created(p) if p == &target),
"must record Created AppliedOp"
);
assert!(target.exists(), "file must exist after CreateFile");
assert_eq!(
std::fs::metadata(&target).unwrap().len(),
0,
"created file must be empty"
);
}
#[test]
fn apply_create_nested_makes_parents() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let target = td.path().join("a").join("b").join("c.rs");
let ops = vec![FsOp::CreateFile(target.clone())];
let (created, applied, errors) = apply_ops(&ops, &mut Vec::new());
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(created, vec![target.clone()]);
assert_eq!(applied.len(), 1);
assert!(target.exists(), "nested file must exist");
}
#[test]
fn apply_trash_moves_into_trash_not_deleted() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let src = td.path().join("source.txt");
std::fs::write(&src, b"hello").unwrap();
let ops = vec![FsOp::Trash(src.clone())];
let mut trashed = Vec::new();
let (created, applied, errors) = apply_ops(&ops, &mut trashed);
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert!(created.is_empty());
assert_eq!(applied.len(), 1, "one AppliedOp must be recorded");
assert!(!src.exists(), "source must be gone after Trash");
assert_eq!(trashed.len(), 1, "one entry must be in trashed registry");
let (name, dest) = &trashed[0];
assert_eq!(name, "source.txt");
assert!(dest.exists(), "trash destination must exist: {dest:?}");
assert_eq!(std::fs::read(dest).unwrap(), b"hello");
assert!(
matches!(&applied[0], AppliedOp::Trashed { original, dest: d }
if original == &src && d.exists()),
"AppliedOp::Trashed must record original + dest"
);
}
#[test]
fn apply_rename_preserves_content() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let foo = td.path().join("foo.rs");
std::fs::write(&foo, b"hi").unwrap();
let bar = td.path().join("bar.rs");
let ops = vec![FsOp::Rename {
from: foo.clone(),
to: bar.clone(),
}];
let (created, applied, errors) = apply_ops(&ops, &mut Vec::new());
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert!(created.is_empty());
assert_eq!(applied.len(), 1);
assert!(
matches!(&applied[0], AppliedOp::Renamed { from, to }
if from == &foo && to == &bar),
"must record Renamed AppliedOp"
);
assert!(!foo.exists(), "source must be gone after rename");
assert!(bar.exists(), "destination must exist after rename");
assert_eq!(
std::fs::read(&bar).unwrap(),
b"hi",
"content must be preserved"
);
}
#[test]
fn apply_rename_onto_existing_file_is_refused() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let a = td.path().join("a.txt");
let b = td.path().join("b.txt");
std::fs::write(&a, b"AAA").unwrap();
std::fs::write(&b, b"BBB").unwrap();
let ops = vec![FsOp::Rename {
from: a.clone(),
to: b.clone(),
}];
let (created, applied, errors) = apply_ops(&ops, &mut Vec::new());
assert!(created.is_empty());
assert!(
applied.is_empty(),
"refused rename must journal nothing, got {applied:?}"
);
assert_eq!(
errors.len(),
1,
"refusal must be reported to the user, got {errors:?}"
);
assert_eq!(std::fs::read(&a).unwrap(), b"AAA", "source must be intact");
assert_eq!(
std::fs::read(&b).unwrap(),
b"BBB",
"target content must NOT be clobbered"
);
}
#[test]
fn apply_swap_two_files_in_one_batch() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let a = td.path().join("a.txt");
let b = td.path().join("b.txt");
std::fs::write(&a, b"AAA").unwrap();
std::fs::write(&b, b"BBB").unwrap();
let ops = vec![
FsOp::Rename {
from: a.clone(),
to: b.clone(),
},
FsOp::Rename {
from: b.clone(),
to: a.clone(),
},
];
let mut trashed = Vec::new();
let (created, applied, errors) = apply_ops(&ops, &mut trashed);
assert!(errors.is_empty(), "swap must succeed, got {errors:?}");
assert!(created.is_empty());
assert_eq!(
std::fs::read(&a).unwrap(),
b"BBB",
"a must now hold b's old content"
);
assert_eq!(
std::fs::read(&b).unwrap(),
b"AAA",
"b must now hold a's old content"
);
let names: Vec<String> = std::fs::read_dir(td.path())
.unwrap()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(
names.iter().all(|n| !n.starts_with(".hjkl-rename-")),
"no temp-hop files may remain, got {names:?}"
);
let (_redo, errs) = revert_ops(&applied, &mut trashed);
assert!(errs.is_empty(), "undo of swap: {errs:?}");
assert_eq!(std::fs::read(&a).unwrap(), b"AAA", "undo restores a");
assert_eq!(std::fs::read(&b).unwrap(), b"BBB", "undo restores b");
}
#[test]
fn apply_rename_onto_trashed_target_in_one_batch() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let a = td.path().join("a.txt");
let b = td.path().join("b.txt");
std::fs::write(&a, b"AAA").unwrap();
std::fs::write(&b, b"BBB").unwrap();
let ops = vec![
FsOp::Rename {
from: a.clone(),
to: b.clone(),
},
FsOp::Trash(b.clone()),
];
let mut trashed = Vec::new();
let (_, _, errors) = apply_ops(&ops, &mut trashed);
assert!(errors.is_empty(), "must succeed, got {errors:?}");
assert!(!a.exists(), "a was renamed away");
assert_eq!(
std::fs::read(&b).unwrap(),
b"AAA",
"b must hold a's content after the trash vacated it"
);
assert_eq!(trashed.len(), 1, "old b must be in the trash");
assert_eq!(
std::fs::read(&trashed[0].1).unwrap(),
b"BBB",
"old b content must be recoverable from trash"
);
}
#[test]
fn apply_ancestor_rename_skip_still_works() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let olddir = td.path().join("olddir");
std::fs::create_dir_all(&olddir).unwrap();
std::fs::write(olddir.join("child.txt"), b"C").unwrap();
let newdir = td.path().join("newdir");
let ops = vec![
FsOp::Rename {
from: olddir.clone(),
to: newdir.clone(),
},
FsOp::Rename {
from: olddir.join("child.txt"),
to: newdir.join("child.txt"),
},
];
let (_, applied, errors) = apply_ops(&ops, &mut Vec::new());
assert!(
errors.is_empty(),
"ancestor skip must not error: {errors:?}"
);
assert_eq!(
applied.len(),
1,
"only the dir rename is journaled (child op skipped), got {applied:?}"
);
assert_eq!(
std::fs::read(newdir.join("child.txt")).unwrap(),
b"C",
"child moved with its dir"
);
}
#[test]
fn apply_move_via_trash_then_create_restores_content() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let src_dir = td.path().join("src_dir");
std::fs::create_dir_all(&src_dir).unwrap();
let foo = src_dir.join("foo.rs");
std::fs::write(&foo, b"x").unwrap();
let mut trashed: Vec<(String, PathBuf)> = Vec::new();
let trash_ops = vec![FsOp::Trash(foo.clone())];
let (c, _a1, e) = apply_ops(&trash_ops, &mut trashed);
assert!(e.is_empty(), "trash must succeed: {e:?}");
assert!(c.is_empty());
assert_eq!(trashed.len(), 1);
assert!(!foo.exists());
let dir2 = td.path().join("dir2");
let dest = dir2.join("foo.rs");
let create_ops = vec![FsOp::CreateFile(dest.clone())];
let (created, applied, errors) = apply_ops(&create_ops, &mut trashed);
assert!(errors.is_empty(), "restore must succeed: {errors:?}");
assert!(
created.is_empty(),
"restored file must NOT appear in created list"
);
assert!(trashed.is_empty(), "trashed registry must be drained");
assert!(dest.exists(), "destination must exist after restore");
assert_eq!(
std::fs::read(&dest).unwrap(),
b"x",
"content must be preserved through trash-restore cycle"
);
assert!(
matches!(&applied[0], AppliedOp::Restored { to, .. } if to == &dest),
"must record Restored AppliedOp"
);
}
#[test]
fn apply_create_dir_restores_trashed_subtree() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let mover = td.path().join("mover");
std::fs::create_dir_all(&mover).unwrap();
std::fs::write(mover.join("inner.txt"), b"deep").unwrap();
let mut trashed: Vec<(String, PathBuf)> = Vec::new();
let (_, _, e) = apply_ops(&[FsOp::Trash(mover.clone())], &mut trashed);
assert!(e.is_empty(), "trash dir: {e:?}");
assert!(!mover.exists());
assert_eq!(trashed.len(), 1);
let dest = td.path().join("target").join("mover");
let (created, applied, errors) = apply_ops(&[FsOp::CreateDir(dest.clone())], &mut trashed);
assert!(errors.is_empty(), "restore dir: {errors:?}");
assert!(created.is_empty(), "restored dir is not a fresh create");
assert!(trashed.is_empty(), "trashed registry drained");
assert!(dest.is_dir(), "destination dir must exist");
assert_eq!(
std::fs::read(dest.join("inner.txt")).unwrap(),
b"deep",
"the dir's contents must survive the move (collapsed-dir move is lossless)"
);
assert!(
matches!(&applied[0], AppliedOp::Restored { to, .. } if to == &dest),
"must record Restored AppliedOp for the dir"
);
}
#[test]
fn revert_create_removes_file_redo_recreates() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let target = td.path().join("round.rs");
let ops = vec![FsOp::CreateFile(target.clone())];
let mut trashed = Vec::new();
let (_, applied, errors) = apply_ops(&ops, &mut trashed);
assert!(errors.is_empty());
assert!(target.exists(), "file must exist before revert");
let (redo_journal, errs) = revert_ops(&applied, &mut trashed);
assert!(errs.is_empty(), "revert errors: {errs:?}");
assert!(!target.exists(), "file must be gone after revert");
let (_, _redo_applied, errs2) = apply_applied(&redo_journal, &mut trashed);
assert!(errs2.is_empty(), "redo errors: {errs2:?}");
assert!(target.exists(), "file must exist again after redo");
}
#[test]
fn revert_trash_restores_file_redo_retrashes() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let src = td.path().join("restore_me.txt");
std::fs::write(&src, b"data").unwrap();
let ops = vec![FsOp::Trash(src.clone())];
let mut trashed = Vec::new();
let (_, applied, errors) = apply_ops(&ops, &mut trashed);
assert!(errors.is_empty());
assert!(!src.exists(), "must be trashed");
let (redo_journal, errs) = revert_ops(&applied, &mut trashed);
assert!(errs.is_empty(), "revert errors: {errs:?}");
assert!(src.exists(), "file must be back on disk after revert");
assert_eq!(
std::fs::read(&src).unwrap(),
b"data",
"content must survive"
);
let (_, _redo_applied, errs2) = apply_applied(&redo_journal, &mut trashed);
assert!(errs2.is_empty(), "redo errors: {errs2:?}");
assert!(!src.exists(), "file must be trashed again after redo");
}
#[test]
fn revert_rename_swaps_back_redo_renames_again() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let foo = td.path().join("orig.rs");
let bar = td.path().join("renamed.rs");
std::fs::write(&foo, b"content").unwrap();
let ops = vec![FsOp::Rename {
from: foo.clone(),
to: bar.clone(),
}];
let mut trashed = Vec::new();
let (_, applied, errors) = apply_ops(&ops, &mut trashed);
assert!(errors.is_empty());
assert!(!foo.exists() && bar.exists(), "rename must have happened");
let (redo_journal, errs) = revert_ops(&applied, &mut trashed);
assert!(errs.is_empty(), "revert errors: {errs:?}");
assert!(
foo.exists() && !bar.exists(),
"must be back to orig after revert"
);
let (_, _, errs2) = apply_applied(&redo_journal, &mut trashed);
assert!(errs2.is_empty(), "redo errors: {errs2:?}");
assert!(!foo.exists() && bar.exists(), "redo must rename again");
}
#[test]
fn revert_restore_retrashes_redo_restores() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let src = td.path().join("moved.txt");
std::fs::write(&src, b"hello").unwrap();
let mut trashed: Vec<(String, PathBuf)> = Vec::new();
let (_, applied_trash, e) = apply_ops(&[FsOp::Trash(src.clone())], &mut trashed);
assert!(e.is_empty());
assert!(!src.exists());
let dest = td.path().join("dest_dir").join("moved.txt");
let (_, applied_restore, e2) = apply_ops(&[FsOp::CreateFile(dest.clone())], &mut trashed);
assert!(e2.is_empty());
assert!(dest.exists(), "restored file must exist at dest");
let mut all_applied = applied_trash;
all_applied.extend(applied_restore);
let (redo_journal, errs) = revert_ops(&all_applied, &mut trashed);
assert!(errs.is_empty(), "revert errors: {errs:?}");
assert!(!dest.exists(), "dest must be trashed after revert");
let (_, _, errs2) = apply_applied(&redo_journal, &mut trashed);
assert!(errs2.is_empty(), "redo errors: {errs2:?}");
assert!(dest.exists(), "dest must be restored again after redo");
}
#[test]
fn revert_restore_dir_retrashes_redo_restores() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let dir = td.path().join("mydir");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("inner.txt"), b"deep").unwrap();
let mut trashed: Vec<(String, PathBuf)> = Vec::new();
let (_, _trash_applied, e) = apply_ops(&[FsOp::Trash(dir.clone())], &mut trashed);
assert!(e.is_empty(), "trash dir: {e:?}");
assert!(!dir.exists());
assert_eq!(trashed.len(), 1);
let trash_path = trashed[0].1.clone();
let restored = AppliedOp::Restored {
from_trash: trash_path,
to: dir.clone(),
};
let (_, redo_applied, errs) = apply_applied(&[restored], &mut trashed);
assert!(errs.is_empty(), "apply_applied dir: {errs:?}");
assert!(dir.is_dir(), "dir must be restored after apply_applied");
assert!(dir.join("inner.txt").exists(), "contents must survive");
let (redo_journal, errs) = revert_ops(&redo_applied, &mut trashed);
assert!(errs.is_empty(), "revert dir: {errs:?}");
assert!(!dir.exists(), "dir must be trashed again after revert");
let (_, _, errs) = apply_applied(&redo_journal, &mut trashed);
assert!(errs.is_empty(), "redo dir: {errs:?}");
assert!(dir.is_dir(), "dir must be restored again after redo");
assert!(dir.join("inner.txt").exists(), "contents must survive redo");
}
#[test]
fn restore_dir_cross_device_fallback_preserves_contents() {
let td = tempfile::tempdir().unwrap();
let _trash = isolated_trash(&td);
let src = td.path().join("trashed_dir");
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::write(src.join("sub/deep.txt"), b"nested").unwrap();
std::fs::write(src.join("top.txt"), b"top-level").unwrap();
let restored = td.path().join("restored_dir");
copy_dir_recursive(&src, &restored).unwrap();
std::fs::remove_dir_all(&src).unwrap();
assert!(restored.is_dir(), "restored dir must exist");
assert!(restored.join("sub").is_dir(), "subdir must be restored");
assert_eq!(
std::fs::read_to_string(restored.join("sub/deep.txt")).unwrap(),
"nested"
);
assert_eq!(
std::fs::read_to_string(restored.join("top.txt")).unwrap(),
"top-level"
);
assert!(
!src.exists(),
"source must be removed after cross-device move"
);
}
#[cfg(unix)]
#[test]
fn copy_dir_recursive_preserves_symlinks_without_following() {
let td = tempfile::tempdir().unwrap();
let real = td.path().join("real");
std::fs::create_dir_all(real.join("inner")).unwrap();
std::fs::write(real.join("inner/keep.txt"), "keep").unwrap();
let src = td.path().join("tree");
std::fs::create_dir_all(src.join("sub")).unwrap();
std::fs::write(src.join("sub/file.txt"), "data").unwrap();
std::os::unix::fs::symlink(&real, src.join("link_to_real")).unwrap();
std::os::unix::fs::symlink(&src, src.join("loop")).unwrap();
let dst = td.path().join("moved");
copy_dir_recursive(&src, &dst).unwrap();
std::fs::remove_dir_all(&src).unwrap();
assert_eq!(
std::fs::read_to_string(dst.join("sub/file.txt")).unwrap(),
"data"
);
let meta = std::fs::symlink_metadata(dst.join("link_to_real")).unwrap();
assert!(meta.file_type().is_symlink(), "symlink was materialized");
assert_eq!(std::fs::read_link(dst.join("link_to_real")).unwrap(), real);
assert!(
std::fs::symlink_metadata(dst.join("loop"))
.unwrap()
.file_type()
.is_symlink()
);
assert_eq!(
std::fs::read_to_string(real.join("inner/keep.txt")).unwrap(),
"keep"
);
}
}