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;
}
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 => {
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();
if path.is_dir() {
stack.push(path);
} else {
let rel_file = path
.strip_prefix(src)
.map_err(|_| std::io::Error::other("strip_prefix failed"))?;
std::fs::copy(&path, dst.join(rel_file))?;
}
}
}
std::fs::remove_dir_all(src)?;
Ok(())
}
Err(e) => Err(e),
}
}
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();
for op in ops {
match op {
FsOp::Rename { from, to } => {
if !from.exists() && to.exists() {
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 dest = match hjkl_app::trash::trash_path(path) {
Ok(d) => d,
Err(e) => {
errors.push(format!("trash_path({path:?}): {e}"));
continue;
}
};
let result = if path.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}"));
}
}
}
}
}
}
(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 dest = match hjkl_app::trash::trash_path(path) {
Ok(d) => d,
Err(e) => {
errors.push(format!("revert created: trash_path({path:?}): {e}"));
continue;
}
};
let result = if path.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 new_dest = match hjkl_app::trash::trash_path(to) {
Ok(d) => d,
Err(e) => {
errors.push(format!("revert restored: trash_path({to:?}): {e}"));
continue;
}
};
let result = if to.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 new_dest = match hjkl_app::trash::trash_path(original) {
Ok(d) => d,
Err(e) => {
errors.push(format!("redo trashed: trash_path({original:?}): {e}"));
continue;
}
};
let result = if original.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;
}
match move_file(from_trash, to) {
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 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:?}"
);
}
fn isolated_trash(td: &tempfile::TempDir) {
unsafe { std::env::set_var("XDG_CACHE_HOME", td.path()) };
}
#[test]
fn apply_create_file_makes_empty_file() {
let td = tempfile::tempdir().unwrap();
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();
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();
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();
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_move_via_trash_then_create_restores_content() {
let td = tempfile::tempdir().unwrap();
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();
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();
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();
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();
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();
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");
}
}