use super::*;
fn trashed(scratch: &Scratch) -> Harness<Demo> {
let mut h = Harness::new(Demo::trashing(scratch.root(), scratch.0.join("Trash")), SIZE.0, SIZE.1);
h.set_glyph_mode(GlyphMode::Unicode).set_reduced_motion(true).render();
h
}
fn open_trash(h: &mut Harness<Demo>) {
right_click(h, "Project");
let menu = h.screen();
assert!(menu.contains("Open the trash"), "the root's menu offers the trash:\n{menu}");
h.click_text("Open the trash").advance(MOMENT);
}
fn into_trash(h: &mut Harness<Demo>, name: &str) {
right_click(h, name);
h.click_text("Move to the trash").advance(MOMENT);
}
fn deleted_of(trash: &Path, name: &str) -> String {
let note = fs::read_to_string(trash.join("info").join(format!("{name}.trashinfo"))).expect("its note");
let stamp = note.lines().find_map(|line| line.strip_prefix("DeletionDate=")).expect("when it went");
format!("{} {}", &stamp[..10], &stamp[11..16])
}
#[test]
fn an_entry_the_manager_trashed_is_listed_in_the_trash_under_its_own_name() {
let scratch = Scratch::new("trash-place");
let root = scratch.root();
fs::write(root.join("plan.txt"), "a plan\n").expect("a second file to leave where it is");
let mut h = trashed(&scratch);
assert!(!h.screen().contains("Trash"), "the trash is not shown until it is asked for:\n{}", h.screen());
into_trash(&mut h, "README.md");
open_trash(&mut h);
assert!(state(&h).in_trash(), "{}", h.screen());
let screen = h.screen();
assert!(screen.contains("Trash"), "the place has a row of its own:\n{screen}");
assert!(screen.contains("README.md"), "the entry is listed under the name it has where it came from:\n{screen}");
assert!(!screen.contains("plan.txt"), "and nothing else went with it:\n{screen}");
let entry = state(&h).trashed_entry("README.md").expect("the entry is listed");
assert_eq!(entry.origin, Some(root.join("README.md")));
assert!(entry.deleted.is_some(), "when it went is known too");
right_click(&mut h, "Trash");
let menu = h.screen();
assert!(menu.contains("Back to the folder") && menu.contains("Empty trash"), "the place's own menu:\n{menu}");
h.click_text("Back to the folder").advance(MOMENT);
assert!(!state(&h).in_trash(), "the trash is left:\n{}", h.screen());
let screen = h.screen();
assert!(screen.contains("plan.txt") && screen.contains("src"), "and the root is shown again:\n{screen}");
}
#[test]
fn the_trash_lists_where_its_entries_came_from_and_when_they_went() {
let scratch = Scratch::new("trash-columns");
let trash = scratch.0.join("Trash");
fs::write(scratch.root().join("plan.txt"), "a plan\n").expect("a second file");
let mut h = trashed(&scratch);
into_trash(&mut h, "README.md");
into_trash(&mut h, "plan.txt");
h.send(Msg::View(FileView::List));
open_trash(&mut h);
let screen = h.screen();
assert!(screen.contains("From") && screen.contains("Changed"), "the columns of the place:\n{screen}");
assert!(!screen.contains("Permissions") && !screen.contains("Size"), "not the ones of a folder:\n{screen}");
for name in ["README.md", "plan.txt"] {
assert!(screen.contains(name), "{name} is listed:\n{screen}");
}
assert!(screen.contains(&deleted_of(&trash, "README.md")), "when it went, as the note wrote it:\n{screen}");
}
fn trash_of(scratch: &Scratch, entries: &[(&str, &str)]) {
let trash = scratch.0.join("Trash");
fs::create_dir_all(trash.join("files")).expect("the entries of a trash of its own");
fs::create_dir_all(trash.join("info")).expect("and the notes of it");
for (name, when) in entries {
fs::write(trash.join("files").join(name), "an entry\n").expect("an entry");
let note = format!("[Trash Info]\nPath=/home/one/{name}\nDeletionDate={when}\n");
fs::write(trash.join("info").join(format!("{name}.trashinfo")), note).expect("its note");
}
}
#[test]
fn the_trash_is_put_in_order_and_narrowed_the_way_a_folder_is() {
let scratch = Scratch::new("trash-order");
trash_of(
&scratch,
&[
("zebra.txt", "2026-09-18T09:15:00"),
("apple.txt", "2026-09-19T09:15:00"),
("mango.md", "2026-09-20T09:15:00"),
],
);
let names = ["apple.txt", "mango.md", "zebra.txt"];
let mut h = trashed(&scratch);
h.send(Msg::View(FileView::List));
open_trash(&mut h);
assert_eq!(listed(&h, &names), names, "by name to begin with:\n{}", h.screen());
h.click_text("Changed").advance(MOMENT);
assert_eq!(listed(&h, &names), ["zebra.txt", "apple.txt", "mango.md"], "the oldest went first:\n{}", h.screen());
assert_eq!(h.app().manager.sort(), crate::widgets::Sort::by(crate::widgets::SortBy::Changed));
h.click_text("Changed").advance(MOMENT);
assert_eq!(
listed(&h, &names),
["mango.md", "apple.txt", "zebra.txt"],
"a second press turns it round:\n{}",
h.screen()
);
h.click_text("Name").advance(MOMENT);
h.send(Msg::Files(FileManagerMsg::Filter(Some(String::new()))));
h.advance(MOMENT);
h.click_text("Type to narrow the folder").type_text("AN").advance(MOMENT);
assert_eq!(listed(&h, &names), ["mango.md"], "whatever the case:\n{}", h.screen());
h.press("esc").advance(MOMENT);
assert_eq!(listed(&h, &names), names, "and Esc shows it all again, in the order the title says:\n{}", h.screen());
}
#[test]
fn restoring_an_entry_puts_it_back_where_it_came_from_and_takes_its_note_away() {
let scratch = Scratch::new("trash-restore");
let trash = scratch.0.join("Trash");
let mut h = trashed(&scratch);
into_trash(&mut h, "README.md");
open_trash(&mut h);
right_click(&mut h, "README.md");
let menu = h.screen();
assert!(menu.contains("Restore"), "an entry of the trash can be put back:\n{menu}");
assert!(!menu.contains("New file") && !menu.contains("Paste"), "and nothing can be made there:\n{menu}");
h.click_text("Restore").advance(MOMENT);
let root = scratch.root();
assert_eq!(
fs::read_to_string(root.join("README.md")).ok().as_deref(),
Some("hello\n"),
"back where it was:\n{}",
h.screen()
);
assert!(!trash.join("files/README.md").exists(), "out of the trash");
assert!(!trash.join("info/README.md.trashinfo").exists(), "and its note is gone with it");
assert!(state(&h).trashed_entry("README.md").is_none(), "the trash no longer lists it:\n{}", h.screen());
}
#[test]
fn a_name_taken_again_is_asked_about_and_nothing_is_overwritten() {
let scratch = Scratch::new("trash-clash");
let root = scratch.root();
let mut h = trashed(&scratch);
into_trash(&mut h, "README.md");
fs::write(root.join("README.md"), "not the one that went\n").expect("a file of the same name");
open_trash(&mut h);
right_click(&mut h, "README.md");
h.click_text("Restore").advance(MOMENT);
let asked = h.screen();
assert!(asked.contains("README.md is in the way"), "the person is asked:\n{asked}");
assert!(asked.contains("README.md.1"), "with a name that is free there offered:\n{asked}");
assert_eq!(
fs::read_to_string(root.join("README.md")).ok().as_deref(),
Some("not the one that went\n"),
"nothing was overwritten before the answer"
);
h.click_text("Cancel").advance(MOMENT);
assert!(!h.screen().contains("is in the way"), "the question is gone:\n{}", h.screen());
assert_eq!(
fs::read_to_string(root.join("README.md")).ok().as_deref(),
Some("not the one that went\n"),
"and nothing was overwritten by the answer"
);
assert!(scratch.0.join("Trash/files/README.md").is_file(), "the entry is still in the trash:\n{}", h.screen());
right_click(&mut h, "README.md");
h.click_text("Restore").advance(MOMENT);
h.click_text("Put it back as README.md.1").advance(MOMENT);
assert_eq!(
fs::read_to_string(root.join("README.md.1")).ok().as_deref(),
Some("hello\n"),
"back under the offered name:\n{}",
h.screen()
);
assert_eq!(
fs::read_to_string(root.join("README.md")).ok().as_deref(),
Some("not the one that went\n"),
"and the file that was there is untouched"
);
}
#[test]
fn an_entry_whose_note_says_nothing_about_where_it_came_from_is_listed_and_is_said_to_be() {
let scratch = Scratch::new("trash-broken");
let trash = scratch.0.join("Trash");
fs::create_dir_all(trash.join("files")).expect("the entries of a trash of its own");
fs::create_dir_all(trash.join("info")).expect("and the notes of it");
fs::write(trash.join("files/left-behind.txt"), "no note of this\n").expect("an entry without a note");
fs::write(trash.join("files/half.txt"), "a note of its own\n").expect("another one");
fs::write(trash.join("info/half.txt.trashinfo"), "[Trash Info]\nDeletionDate=2026-09-20T14:32:11\n")
.expect("a note that says nothing about where it came from");
let mut h = trashed(&scratch);
open_trash(&mut h);
let screen = h.screen();
assert!(
screen.contains("left-behind.txt") && screen.contains("half.txt"),
"both are listed all the same:\n{screen}"
);
assert!(
state(&h).trashed_entry("half.txt").expect("listed").deleted.is_some(),
"what the note does say is still read"
);
h.send(Msg::View(FileView::List));
let screen = h.screen();
assert!(
screen.contains("left-behind.txt") && screen.contains("half.txt"),
"one broken note never drops the rest of the list:\n{screen}"
);
assert!(screen.contains("unknown"), "with where they came from unknown:\n{screen}");
h.send(Msg::View(FileView::Tree));
right_click(&mut h, "left-behind.txt");
h.click_text("Restore").advance(MOMENT);
let said = h.screen();
assert!(said.contains("Nothing says where this came from"), "the reason is said:\n{said}");
assert!(trash.join("files/left-behind.txt").is_file(), "and it is still in the trash");
assert!(h.screen().contains("left-behind.txt"), "and still listed:\n{}", h.screen());
}
#[test]
fn deleting_an_entry_of_the_trash_for_good_asks_first_and_no_deletes_nothing() {
let scratch = Scratch::new("trash-purge");
let trash = scratch.0.join("Trash");
let mut h = trashed(&scratch);
into_trash(&mut h, "README.md");
open_trash(&mut h);
right_click(&mut h, "README.md");
let menu = h.screen();
assert!(menu.contains("Delete permanently"), "an entry of the trash can be taken out of it for good:\n{menu}");
h.click_text("Delete permanently").advance(MOMENT);
let asked = h.screen();
assert!(asked.contains("Delete README.md for good?"), "{asked}");
assert!(asked.contains("cannot be undone"), "{asked}");
h.click_text("Cancel").advance(MOMENT);
assert!(
trash.join("files/README.md").is_file() && trash.join("info/README.md.trashinfo").is_file(),
"\"No\" deletes nothing:\n{}",
h.screen()
);
assert!(h.screen().contains("README.md"), "and the entry is still listed:\n{}", h.screen());
right_click(&mut h, "README.md");
h.click_text("Delete permanently").advance(MOMENT);
h.click_text("Delete permanently").advance(MOMENT);
assert!(!trash.join("files/README.md").exists(), "\"Yes\" takes the entry out of the trash:\n{}", h.screen());
assert!(!trash.join("info/README.md.trashinfo").exists(), "and its note with it");
assert!(!h.screen().contains("README.md"), "the trash no longer lists it:\n{}", h.screen());
}
#[test]
fn emptying_the_trash_asks_first_and_yes_empties_both_of_its_folders() {
let scratch = Scratch::new("trash-empty");
let trash = scratch.0.join("Trash");
fs::write(scratch.root().join("plan.txt"), "a plan\n").expect("a second file");
let mut h = trashed(&scratch);
into_trash(&mut h, "README.md");
into_trash(&mut h, "plan.txt");
open_trash(&mut h);
right_click(&mut h, "Trash");
h.click_text("Empty trash").advance(MOMENT);
let asked = h.screen();
assert!(asked.contains("Empty the trash?") && asked.contains("cannot be undone"), "{asked}");
assert_eq!(
fs::read_dir(trash.join("files")).expect("the entries").count(),
2,
"nothing happened before the answer"
);
h.click_text("Cancel").advance(MOMENT);
assert_eq!(
fs::read_dir(trash.join("files")).expect("the entries").count(),
2,
"\"No\" deletes nothing:\n{}",
h.screen()
);
right_click(&mut h, "Trash");
h.click_text("Empty trash").advance(MOMENT);
h.click_text("Empty trash").advance(MOMENT);
assert_eq!(
fs::read_dir(trash.join("files")).expect("the entries").count(),
0,
"\"Yes\" empties the entries:\n{}",
h.screen()
);
assert_eq!(fs::read_dir(trash.join("info")).expect("the notes").count(), 0, "and the notes with them");
assert!(h.screen().contains("empty"), "the empty trash says so:\n{}", h.screen());
right_click(&mut h, "Trash");
assert!(!h.screen().contains("Empty trash"), "there is nothing left to empty:\n{}", h.screen());
}
#[test]
fn nothing_that_acts_below_the_root_reaches_an_entry_of_the_trash() {
let scratch = Scratch::new("trash-guarded");
let root = scratch.root();
let mut h = trashed(&scratch);
into_trash(&mut h, "README.md");
fs::write(root.join("README.md"), "the root's own\n").expect("and a file of the same name is there again");
open_trash(&mut h);
let (entry_x, entry_y) = h.find("README.md").expect("the row of the entry in the trash");
h.click(entry_x, entry_y).press("ctrl+x").advance(MOMENT);
assert!(state(&h).cut().is_empty(), "nothing is cut in the trash:\n{}", h.screen());
let (place_x, place_y) = h.find("Trash").expect("the place's own row");
h.drag((entry_x, entry_y), (place_x, place_y)).advance(MOMENT);
assert_eq!(
fs::read_to_string(root.join("README.md")).ok().as_deref(),
Some("the root's own\n"),
"and a drag onto the place moves nothing:\n{}",
h.screen()
);
}
#[test]
fn a_name_that_is_not_one_entry_of_the_trash_reaches_nothing_outside_it() {
let scratch = Scratch::new("trash-escape");
let trash = scratch.0.join("Trash");
let mut h = trashed(&scratch);
into_trash(&mut h, "README.md");
fs::write(trash.join("beside.txt"), "kept\n").expect("a file beside the trash's folders");
let keys = ["", ".", "..", "../beside.txt", "../info", "README.md"].map(str::to_owned).to_vec();
h.send(Msg::Files(FileManagerMsg::PurgeConfirmed(keys.clone()))).advance(MOMENT);
h.send(Msg::Files(FileManagerMsg::Restore(keys.clone()))).advance(MOMENT);
for key in keys {
h.send(Msg::Files(FileManagerMsg::RestoreAs(key, "back.txt".to_owned()))).advance(MOMENT);
}
assert!(trash.join("files/README.md").is_file(), "the entries stay:\n{}", h.screen());
assert!(trash.join("info/README.md.trashinfo").is_file(), "and their notes");
assert!(trash.join("beside.txt").is_file(), "and nothing beside the trash is touched");
}
#[test]
fn the_trash_itself_refuses_a_name_that_is_not_one_of_its_entries() {
let scratch = Scratch::new("trash-escape-below");
let trash = scratch.0.join("Trash");
let mut h = trashed(&scratch);
into_trash(&mut h, "README.md");
fs::write(trash.join("beside.txt"), "kept\n").expect("a file beside the trash's folders");
for name in ["", ".", "..", "../beside.txt", "../info"] {
let refused = Err(FileError::Outside);
assert_eq!(super::super::trash::purge(&trash, name), refused, "{name:?} is not deleted");
assert_eq!(super::super::trash::restore(&trash, name, None), refused, "{name:?} is not put back");
}
assert!(trash.join("files/README.md").is_file(), "the entries stay");
assert!(trash.join("info/README.md.trashinfo").is_file(), "and their notes");
assert!(trash.join("beside.txt").is_file(), "and nothing beside the trash is touched");
}