pub mod chrome;
pub mod keymap;
pub mod lens;
pub mod moving;
pub mod render;
pub mod state;
pub mod treemap;
use std::io::{self, Write};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use ignore::gitignore::Gitignore;
use ratatui::crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, MouseButton, MouseEventKind,
};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::layout::Position;
use ratatui::{Terminal, TerminalOptions, Viewport, backend::CrosstermBackend};
use crate::delete::{Deleter, Planner, Removal, Step, Target};
use crate::size::{Measurer, SizeMode, human};
use crate::tree::Tree;
use crate::walk::{Found, Priced, WalkOutcome, Walker};
use crate::{Ruleset, WalkError};
use chrome::{Chrome, Decor, Status};
use keymap::{Action, Gesture, Motion, action_for, finish};
use render::{Placed, Spot};
use state::{Effect, Notice, View, plural};
use treemap::{Drawn, Maps, Pane, Screen};
const TICK: Duration = Duration::from_millis(100);
const FRAME: Duration = Duration::from_millis(33);
const DOUBLE_CLICK: Duration = Duration::from_millis(400);
#[derive(Debug, Clone)]
pub struct Options {
pub root: PathBuf,
pub min_size: u64,
pub size_mode: SizeMode,
pub one_file_system: bool,
pub older_than: Option<Duration>,
pub ignored_files: bool,
pub excludes: Arc<Gitignore>,
}
enum Message {
Found(Found),
Scanned(WalkOutcome),
Removing(Step),
Deleted(Box<Removal>),
Repriced {
claims: Vec<PathBuf>,
errors: Vec<WalkError>,
},
}
#[derive(Debug, Default)]
pub struct Outcome {
pub errors: Vec<WalkError>,
pub failures: usize,
pub freed: u64,
}
impl Outcome {
#[must_use]
pub fn whole(&self) -> bool {
self.errors.is_empty() && self.failures == 0
}
}
#[derive(Debug)]
struct Restore<W: Write> {
raw: bool,
alternate: bool,
mouse: bool,
chrome: Chrome<W>,
screen: Screen<W>,
}
impl<W: Write> Restore<W> {
fn new(chrome: Chrome<W>, screen: Screen<W>) -> Self {
Self {
raw: false,
alternate: false,
mouse: false,
chrome,
screen,
}
}
fn finish(&mut self) -> io::Result<()> {
let mut first = self.screen.restore();
first = first.and(self.chrome.restore());
if std::mem::take(&mut self.mouse) {
first = first.and(execute!(io::stdout(), DisableMouseCapture));
}
if std::mem::take(&mut self.raw) {
first = first.and(disable_raw_mode());
}
if std::mem::take(&mut self.alternate) {
first = first.and(execute!(io::stdout(), LeaveAlternateScreen));
}
first
}
}
impl<W: Write> Drop for Restore<W> {
fn drop(&mut self) {
let _ = self.finish();
}
}
#[derive(Default)]
struct Batch(Option<JoinHandle<()>>);
impl Batch {
fn takes_over(&mut self, removal: JoinHandle<()>) {
self.join();
self.0 = Some(removal);
}
fn join(&mut self) {
if let Some(removal) = self.0.take() {
let _ = removal.join();
}
}
}
impl Drop for Batch {
fn drop(&mut self) {
self.join();
}
}
#[derive(Clone, Copy, Debug)]
struct Press {
spot: Spot,
double: bool,
moved: bool,
}
#[derive(Debug, Default)]
struct Pointer {
last: Option<(Spot, Instant)>,
press: Option<Press>,
}
impl Pointer {
fn read(&mut self, kind: MouseEventKind, spot: Spot, now: Instant) -> Action {
match kind {
MouseEventKind::Down(MouseButton::Left) => {
let double = self.judge(spot, now);
self.press = Some(Press {
spot,
double,
moved: false,
});
keymap::pointer(Gesture::Aim, spot)
}
MouseEventKind::Drag(MouseButton::Left) => {
self.last = None;
if let Some(press) = self.press.as_mut() {
press.moved = true;
}
Action::Ignore
}
MouseEventKind::Up(MouseButton::Left) => match self.press.take() {
Some(press) if !press.moved => finish(press.spot, press.double, spot),
_ => Action::Ignore,
},
MouseEventKind::Moved => keymap::pointer(Gesture::Aim, spot),
MouseEventKind::ScrollUp => keymap::pointer(Gesture::Wheel(Motion::Up), spot),
MouseEventKind::ScrollDown => keymap::pointer(Gesture::Wheel(Motion::Down), spot),
_ => Action::Ignore,
}
}
fn judge(&mut self, spot: Spot, now: Instant) -> bool {
let double = self.last.is_some_and(|(before, when)| {
before == spot && now.saturating_duration_since(when) <= DOUBLE_CLICK
});
self.last = (!double).then_some((spot, now));
double
}
}
trait Events {
fn poll(&mut self, timeout: Duration) -> io::Result<bool>;
fn read(&mut self) -> io::Result<Event>;
}
struct Keyboard;
impl Events for Keyboard {
fn poll(&mut self, timeout: Duration) -> io::Result<bool> {
event::poll(timeout)
}
fn read(&mut self) -> io::Result<Event> {
event::read()
}
}
pub fn run(options: &Options, ruleset: Arc<Ruleset>) -> io::Result<Outcome> {
let decor = Decor::detect();
let mut restore = Restore::new(
Chrome::new(io::stdout(), decor),
Screen::new(io::stdout(), decor.graphics),
);
enable_raw_mode()?;
restore.raw = true;
execute!(io::stdout(), EnterAlternateScreen)?;
restore.alternate = true;
execute!(io::stdout(), EnableMouseCapture)?;
restore.mouse = true;
restore.chrome.enter()?;
let mut terminal = Terminal::with_options(
CrosstermBackend::new(io::stdout()),
TerminalOptions {
viewport: Viewport::Fullscreen,
},
)?;
let outcome = drive(
&mut terminal,
&mut Keyboard,
&mut restore.chrome,
&mut restore.screen,
options,
ruleset,
);
if !outcome.as_ref().is_ok_and(Outcome::whole) {
restore.chrome.failed();
}
let shown = terminal.show_cursor();
let restored = restore.finish();
outcome.and_then(|outcome| shown.and(restored).map(|()| outcome))
}
fn drive<B: ratatui::backend::Backend<Error = io::Error>, W: Write>(
terminal: &mut Terminal<B>,
events: &mut dyn Events,
chrome: &mut Chrome<W>,
screen: &mut Screen<W>,
options: &Options,
ruleset: Arc<Ruleset>,
) -> io::Result<Outcome> {
let (post, inbox) = channel();
let mut view = View::new(Tree::new(&options.root));
if options.ignored_files {
view = view.showing_files();
}
let walker = spawn_walk(options, ruleset, post.clone());
let mut outcome = Outcome::default();
let mut batch = Batch::default();
let scan_since = Instant::now();
let mut batch_since = Instant::now();
let mut scanning = view.is_scanning();
let mut deleting = view.is_deleting();
let mut placed = Placed::default();
let mut pointer = Pointer::default();
let mut pricer: Option<Sender<Vec<PathBuf>>> = None;
loop {
drain(&mut view, &inbox, &mut outcome);
reap(&mut view, &inbox, &mut outcome, batch.0.as_ref());
view.animate(Instant::now());
if scanning && !view.is_scanning() {
chrome.announce(&scanned(&view), scan_since.elapsed())?;
}
if deleting && !view.is_deleting() {
let said = view.notice().unwrap_or("the removal finished").to_owned();
chrome.announce(&said, batch_since.elapsed())?;
}
scanning = view.is_scanning();
deleting = view.is_deleting();
chrome.show(Status::of(&view, outcome.freed))?;
let cell = cell_size(terminal);
view.allow_maps(screen.mapping(cell));
chrome.begin_frame()?;
let drawn = terminal
.draw(|frame| placed = render::draw(frame, &mut view, &outcome.errors))
.map(|_| ());
let mapped = map(screen, &view, &placed, cell);
let ended = chrome.end_frame();
let shown = drawn.and(mapped)?;
ended?;
if let Drawn::Cannot(why) = shown {
view.allow_maps(why);
}
if view.wants_to_quit() {
break;
}
if !events.poll(if view.is_moving() { FRAME } else { TICK })? {
continue;
}
let event = events.read()?;
let action = match &event {
Event::Resize(..) => continue,
Event::FocusGained => {
chrome.focused(true);
continue;
}
Event::FocusLost => {
chrome.focused(false);
continue;
}
Event::Mouse(mouse) => {
let spot = render::hit(&view, &placed, Position::new(mouse.column, mouse.row));
pointer.read(mouse.kind, spot, Instant::now())
}
_ => action_for(&event, view.overlay()),
};
match view.apply(action) {
Effect::None => {}
Effect::Quit => break,
Effect::Plan(targets) => {
let plan = Planner::new(&options.root)
.one_file_system(options.one_file_system)
.older_than(options.older_than)
.plan(targets);
view.ask(&plan);
}
Effect::Delete(targets) => {
batch_since = Instant::now();
batch.takes_over(spawn_delete(options, targets, post.clone()));
}
Effect::Price(claims) => {
let queue = pricer.get_or_insert_with(|| spawn_pricer(options, post.clone()));
if let Err(returned) = queue.send(claims) {
view.repriced(
&returned.0,
Notice::standing("the pricing ended without reporting what it did"),
);
pricer = None;
}
}
}
}
drop(walker);
Ok(outcome)
}
fn cell_size<B: ratatui::backend::Backend<Error = io::Error>>(
terminal: &mut Terminal<B>,
) -> Option<(u16, u16)> {
let window = terminal.backend_mut().window_size().ok()?;
let (across, down) = (window.columns_rows.width, window.columns_rows.height);
if across == 0 || down == 0 {
return None;
}
let cell = (window.pixels.width / across, window.pixels.height / down);
(cell.0 > 0 && cell.1 > 0).then_some(cell)
}
fn map<W: Write>(
screen: &mut Screen<W>,
view: &View,
placed: &Placed,
cell: Option<(u16, u16)>,
) -> io::Result<Drawn> {
let Some(cells) = placed.map.filter(|_| view.overlay().is_none()) else {
screen.hide()?;
return Ok(Drawn::Nothing);
};
let Some(cell) = cell else {
screen.hide()?;
return Ok(Drawn::Cannot(Maps::Unmeasured));
};
screen.show(view, Pane { cells, cell }, Instant::now())
}
fn reap(
view: &mut View,
inbox: &Receiver<Message>,
outcome: &mut Outcome,
deleter: Option<&JoinHandle<()>>,
) {
if !view.is_deleting() || !deleter.is_some_and(JoinHandle::is_finished) {
return;
}
drain(view, inbox, outcome);
if view.is_deleting() {
outcome.failures += 1;
view.deleted(
Notice::standing("the removal ended without reporting what it did"),
outcome.freed,
);
}
}
fn drain(view: &mut View, inbox: &Receiver<Message>, outcome: &mut Outcome) {
loop {
match inbox.try_recv() {
Ok(Message::Found(Found::Claim(hit))) => view.found(hit),
Ok(Message::Found(Found::Pricing(path))) => view.pricing(&path),
Ok(Message::Found(Found::Priced(priced))) => view.priced(&priced.path, priced.size),
Ok(Message::Scanned(walk)) => {
outcome.errors.extend(walk.errors);
view.scanned();
}
Ok(Message::Removing(Step::Freeing(freeing))) => {
view.freeing(&freeing.path, freeing.bytes);
}
Ok(Message::Removing(Step::Finished(removed))) => {
view.removed(&removed.path, removed.bytes, removed.complete);
}
Ok(Message::Removing(Step::Swept(path))) => view.swept(&path),
Ok(Message::Repriced { claims, errors }) => {
outcome.errors.extend(errors);
view.repriced(
&claims,
Notice::passing(format!(
"priced {}",
plural(claims.len(), "directory", "directories")
)),
);
}
Ok(Message::Deleted(removal)) => {
outcome.failures += removal.failures.len();
outcome.freed += removal.bytes_freed();
view.refused(&removal.kept);
view.deleted(summarise(&removal), outcome.freed);
}
Err(TryRecvError::Empty | TryRecvError::Disconnected) => return,
}
}
}
fn spawn_walk(options: &Options, ruleset: Arc<Ruleset>, post: Sender<Message>) -> JoinHandle<()> {
let walker = Walker::new(&options.root, ruleset)
.size_mode(options.size_mode.clone())
.same_file_system(options.one_file_system)
.ignored_files(true)
.min_size(options.min_size)
.excludes(Arc::clone(&options.excludes));
std::thread::spawn(move || {
let reporting = post.clone();
let outcome = walker.run(move |found| {
let _ = reporting.send(Message::Found(found));
});
let _ = post.send(Message::Scanned(outcome));
})
}
fn spawn_delete(options: &Options, targets: Vec<PathBuf>, post: Sender<Message>) -> JoinHandle<()> {
let planner = Planner::new(&options.root)
.one_file_system(options.one_file_system)
.older_than(options.older_than);
std::thread::spawn(move || {
let plan = planner.plan(targets.iter().map(Target::at));
let reporting = post.clone();
let removal = Deleter::new()
.watching(move |step| {
let _ = reporting.send(Message::Removing(step.clone()));
})
.remove(&plan);
let _ = post.send(Message::Deleted(Box::new(removal)));
})
}
fn spawn_pricer(options: &Options, post: Sender<Message>) -> Sender<Vec<PathBuf>> {
let (ask, queue) = channel::<Vec<PathBuf>>();
let measurer = Measurer::new(SizeMode::Breakdown).same_file_system(options.one_file_system);
std::thread::spawn(move || {
while let Ok(claims) = queue.recv() {
let mut errors = Vec::new();
for path in &claims {
let metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(err) => {
errors.push(WalkError {
path: Some(path.clone()),
forbidden: err.kind() == io::ErrorKind::PermissionDenied,
message: err.to_string(),
});
continue;
}
};
let sized = measurer.measure(path, &metadata);
errors.extend(sized.unreadable.into_iter().map(|path| WalkError {
path: Some(path),
message: "unreadable, so this size is a lower bound".to_owned(),
forbidden: false,
}));
let _ = post.send(Message::Found(Found::Priced(Priced {
path: path.clone(),
size: sized.size,
})));
}
let _ = post.send(Message::Repriced { claims, errors });
}
});
ask
}
fn scanned(view: &View) -> String {
let total = view.total();
format!(
"{} reclaimable in {}",
human(total.bytes),
plural(total.claims, "directory", "directories")
)
}
fn summarise(removal: &Removal) -> Notice {
let mut said = vec![format!(
"removed {} from {}",
human(removal.bytes_freed()),
plural(removal.removed.len(), "directory", "directories")
)];
if !removal.kept.is_empty() {
said.push(format!(
"{} left alone",
plural(removal.kept.len(), "directory", "directories")
));
}
if !removal.failures.is_empty() {
said.push(format!(
"{} failed",
plural(removal.failures.len(), "directory", "directories")
));
}
let said = said.join(", ");
if removal.kept.is_empty() && removal.failures.is_empty() {
Notice::passing(said)
} else {
Notice::standing(said)
}
}
#[must_use]
pub fn size_mode(asked: SizeMode) -> SizeMode {
match asked {
SizeMode::BreakdownUnder(scope) => SizeMode::BreakdownUnder(scope),
SizeMode::Skip | SizeMode::Breakdown => SizeMode::Breakdown,
}
}
#[cfg(test)]
mod tests {
use super::{
Batch, Chrome, Decor, Drawn, Maps, Message, Options, Outcome, Placed, Restore, Screen,
SizeMode, drain, map, reap, spawn_pricer, summarise,
};
use crate::delete::{Failure, Refusal, Refused, Removal, Removed};
use crate::fixture::priced;
use crate::tree::Tree;
use crate::tui::chrome::XTERM_STACK;
use crate::tui::state::View;
use crate::walk::{Found, WalkError, WalkOutcome};
use ratatui::layout::Rect;
use std::path::Path;
use std::sync::mpsc::channel;
fn view() -> View {
View::new(Tree::new("/scan"))
}
#[test]
fn a_pane_with_no_cell_size_to_draw_in_answers_with_the_reason_rather_than_with_nothing() {
let mut tree = Tree::new("/scan");
tree.insert(priced("/scan/nx/node_modules", 8 * 1024 * 1024));
let mut view = View::new(tree);
view.allow_maps(Maps::Can);
view.sync();
let mut screen = Screen::new(Vec::new(), true);
let placed = Placed {
map: Some(Rect::new(60, 1, 40, 20)),
..Placed::default()
};
assert_eq!(
map(&mut screen, &view, &placed, None).unwrap(),
Drawn::Cannot(Maps::Unmeasured)
);
assert!(screen.sink().is_empty(), "an image was sized from a guess");
assert_eq!(
map(&mut screen, &view, &placed, Some((9, 19))).unwrap(),
Drawn::Map
);
assert!(!screen.sink().is_empty());
}
fn removed(path: &str) -> Removed {
Removed {
path: path.into(),
bytes: 1024,
entries: 3,
complete: true,
}
}
#[test]
fn a_scan_that_could_not_read_a_path_is_not_a_whole_run() {
let (post, inbox) = channel();
let mut view = view();
let mut outcome = Outcome::default();
assert!(outcome.whole());
post.send(Message::Found(Found::Claim(priced("/scan/a/target", 8))))
.unwrap();
post.send(Message::Scanned(WalkOutcome {
errors: vec![WalkError {
path: Some("/scan/locked".into()),
message: "Permission denied".to_owned(),
forbidden: true,
}],
..WalkOutcome::default()
}))
.unwrap();
drain(&mut view, &inbox, &mut outcome);
assert_eq!(outcome.errors.len(), 1);
assert!(!outcome.whole());
assert!(!view.is_scanning());
}
#[test]
fn a_removals_progress_reaches_the_rows_and_the_freed_counter_from_one_event() {
use crate::delete::{Freeing, Step};
use crate::size::Size;
use std::time::Instant;
let (post, inbox) = channel();
let mut tree = Tree::new("/scan");
tree.insert(crate::fixture::hit(
"/scan/app/node_modules",
Size::Measured(1000),
0,
));
let mut view = View::new(tree);
view.viewport(20);
let mut outcome = Outcome::default();
let start = Instant::now();
view.animate(start);
assert_eq!(view.drawn_total().bytes, 1000);
assert!(!view.has_freed());
post.send(Message::Removing(Step::Freeing(Freeing {
path: "/scan/app/node_modules".into(),
bytes: 600,
entries: 12,
})))
.unwrap();
drain(&mut view, &inbox, &mut outcome);
view.animate(start);
assert_eq!(view.drawn_total().bytes, 400);
assert_eq!(view.drawn_freed(), 600);
let row = view
.tree()
.find(Path::new("/scan/app/node_modules"))
.unwrap();
assert!(view.is_freeing(row));
assert!(!view.is_spent(row));
post.send(Message::Removing(Step::Finished(Removed {
path: "/scan/app/node_modules".into(),
bytes: 1000,
entries: 20,
complete: true,
})))
.unwrap();
drain(&mut view, &inbox, &mut outcome);
view.animate(start);
assert_eq!(view.drawn_total().bytes, 0);
assert_eq!(view.drawn_freed(), 1000);
assert!(view.is_spent(row));
}
#[test]
fn a_real_deleter_reports_paths_the_view_can_find_its_rows_by() {
use crate::delete::{Deleter, Planner, Target};
use crate::size::Size;
use crate::tui::keymap::Turn;
use crate::tui::state::Effect;
use std::time::Instant;
let tmp = tempfile::TempDir::new().unwrap();
let base = std::fs::canonicalize(tmp.path()).unwrap();
std::fs::create_dir_all(base.join("real")).unwrap();
let root = base.join("link");
std::os::unix::fs::symlink(base.join("real"), &root).unwrap();
let target = root.join("app/node_modules");
std::fs::create_dir_all(target.join("dep")).unwrap();
std::fs::write(target.join("dep/index.js"), vec![b'x'; 8192]).unwrap();
let mut tree = Tree::new(&root);
tree.insert(crate::fixture::hit(
target.to_str().unwrap(),
Size::Measured(8192),
0,
));
let mut view = View::new(tree);
view.viewport(20);
let start = Instant::now();
view.animate(start);
assert_eq!(view.drawn_total().bytes, 8192);
view.ask(&Planner::new(&root).plan([Target::at(&target)]));
view.apply(Action::Highlight(Turn::Next));
let deed = view.apply(Action::Answer);
assert_eq!(view.removing().unwrap().weighed(), Some((0, 8192)));
let Effect::Delete(deed) = deed else {
panic!("the confirmation did not produce a removal: {deed:?}");
};
let (post, inbox) = channel();
let plan = Planner::new(&root).plan(deed.iter().map(Target::at));
let reporting = post.clone();
let removal = Deleter::new()
.watching(move |step| {
let _ = reporting.send(Message::Removing(step.clone()));
})
.remove(&plan);
assert!(removal.is_clean(), "{:?}", removal.failures);
let mut outcome = Outcome::default();
drain(&mut view, &inbox, &mut outcome);
view.animate(start);
let row = view
.tree()
.find(&target)
.expect("the row is still in the tree");
assert!(
view.is_spent(row),
"the row never emptied, so the report never found it"
);
assert_eq!(view.drawn_total().bytes, 0);
let (freed, planned) = view.removing().unwrap().weighed().unwrap();
assert_eq!(planned, 8192);
assert!(freed > 0, "the batch freed {freed} of {planned}");
}
#[test]
fn the_batchs_position_advances_on_a_target_the_deleter_could_not_touch() {
use crate::delete::Step;
let (post, inbox) = channel();
let mut view = view();
let mut outcome = Outcome::default();
view.deleting_for_test();
assert_eq!(view.removing().unwrap().counted(), (0, 1));
post.send(Message::Removing(Step::Swept("/scan/a/target".into())))
.unwrap();
drain(&mut view, &inbox, &mut outcome);
assert_eq!(view.removing().unwrap().counted(), (1, 1));
assert_eq!(view.removing().unwrap().percent(), 100);
}
#[test]
fn a_removal_that_failed_is_not_a_whole_run_and_a_removal_that_was_refused_is() {
let (post, inbox) = channel();
let mut view = view();
let mut outcome = Outcome::default();
post.send(Message::Deleted(Box::new(Removal {
removed: vec![removed("/scan/a/target")],
kept: vec![Refused {
path: "/scan/b/node_modules".into(),
reason: Refusal::HoldsCheckout,
}],
failures: Vec::new(),
})))
.unwrap();
drain(&mut view, &inbox, &mut outcome);
assert!(outcome.whole());
assert!(view.notice().unwrap().contains("left alone"));
post.send(Message::Deleted(Box::new(Removal {
removed: Vec::new(),
kept: Vec::new(),
failures: vec![Failure {
path: "/scan/c/target".into(),
message: "Device or resource busy".to_owned(),
}],
})))
.unwrap();
drain(&mut view, &inbox, &mut outcome);
assert_eq!(outcome.failures, 1);
assert!(!outcome.whole());
}
#[test]
fn a_removal_that_ended_without_reporting_does_not_leave_the_view_unable_to_quit() {
let (post, inbox) = channel();
let mut view = view();
let mut outcome = Outcome::default();
let dead = std::thread::spawn(|| {});
while !dead.is_finished() {
std::thread::yield_now();
}
view.deleting_for_test();
reap(&mut view, &inbox, &mut outcome, Some(&dead));
assert!(!view.is_deleting(), "the view would never quit again");
assert_eq!(outcome.failures, 1);
drop(post);
}
#[test]
fn a_removal_that_reported_on_its_way_out_is_read_rather_than_called_a_failure() {
let (post, inbox) = channel();
let mut view = view();
let mut outcome = Outcome::default();
let dead = std::thread::spawn(|| {});
while !dead.is_finished() {
std::thread::yield_now();
}
view.deleting_for_test();
post.send(Message::Deleted(Box::default())).unwrap();
reap(&mut view, &inbox, &mut outcome, Some(&dead));
assert!(!view.is_deleting());
assert_eq!(outcome.failures, 0);
assert!(outcome.whole());
}
#[test]
fn a_batch_waits_for_its_removal_however_the_scope_ends() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
let finished = Arc::new(AtomicBool::new(false));
let worker = Arc::clone(&finished);
{
let mut batch = Batch::default();
batch.takes_over(std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
worker.store(true, Ordering::SeqCst);
}));
}
assert!(
finished.load(Ordering::SeqCst),
"the removal was abandoned rather than waited for"
);
}
#[test]
fn one_pricing_worker_answers_a_queue_of_requests_and_ends_when_the_loop_lets_go() {
let tmp = tempfile::TempDir::new().unwrap();
let first = tmp.path().join("a/node_modules");
let second = tmp.path().join("b/node_modules");
for dir in [&first, &second] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("f.js"), "xxxx").unwrap();
}
let (post, inbox) = channel();
let queue = spawn_pricer(
&Options {
root: tmp.path().to_path_buf(),
min_size: crate::DEFAULT_MIN_SIZE,
size_mode: SizeMode::Skip,
one_file_system: true,
older_than: None,
ignored_files: false,
excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
},
post,
);
queue.send(vec![first.clone()]).unwrap();
queue.send(vec![second.clone()]).unwrap();
let mut reported = Vec::new();
let mut sized = 0;
while reported.len() < 2 {
match inbox.recv().unwrap() {
Message::Repriced { claims, errors } => {
assert!(errors.is_empty(), "{errors:?}");
reported.push(claims);
}
Message::Found(Found::Priced(priced)) => {
assert!(priced.size.bytes().is_some_and(|bytes| bytes > 0));
sized += 1;
}
_ => panic!("the pricer said something else"),
}
}
assert_eq!(reported, [vec![first], vec![second]]);
assert_eq!(sized, 2);
drop(queue);
assert!(matches!(inbox.recv(), Err(std::sync::mpsc::RecvError)));
}
#[test]
fn a_summary_names_what_was_left_behind_as_well_as_what_went() {
let removal = Removal {
removed: vec![removed("/scan/a/target")],
kept: vec![Refused {
path: "/scan/b".into(),
reason: Refusal::HoldsCheckout,
}],
failures: vec![Failure {
path: "/scan/c".into(),
message: "busy".to_owned(),
}],
};
let notice = summarise(&removal);
let said = notice.said();
assert!(said.contains("removed 1.0 KiB from 1 directory"), "{said}");
assert!(said.contains("1 directory left alone"), "{said}");
assert!(said.contains("1 directory failed"), "{said}");
assert!(notice.stands(), "{said}");
}
#[test]
fn a_removal_that_left_nothing_behind_does_not_have_to_be_dismissed() {
let notice = summarise(&Removal {
removed: vec![removed("/scan/a/target")],
..Removal::default()
});
assert_eq!(notice.said(), "removed 1.0 KiB from 1 directory");
assert!(!notice.stands());
}
use super::{Action, Motion, Pointer};
use crate::tree::Order;
use crate::tui::render::{Spot, Zone};
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
use std::time::{Duration, Instant};
fn down() -> MouseEventKind {
MouseEventKind::Down(MouseButton::Left)
}
fn up() -> MouseEventKind {
MouseEventKind::Up(MouseButton::Left)
}
fn row(id: crate::tree::NodeId) -> Spot {
Spot::Row {
id,
zone: Zone::Name,
}
}
#[test]
fn a_press_aims_and_the_click_happens_when_it_is_let_go() {
let mut pointer = Pointer::default();
let now = Instant::now();
let heading = Spot::Heading(Order::Age);
assert_eq!(pointer.read(down(), heading, now), Action::Ignore);
assert_eq!(pointer.read(up(), heading, now), Action::SortBy(Order::Age));
assert_eq!(pointer.read(up(), heading, now), Action::Ignore);
}
#[test]
fn a_press_that_moved_is_a_drag_and_never_becomes_a_click() {
let mut pointer = Pointer::default();
let now = Instant::now();
let heading = Spot::Heading(Order::Size);
pointer.read(down(), heading, now);
assert_eq!(
pointer.read(MouseEventKind::Drag(MouseButton::Left), Spot::Tree, now),
Action::Ignore
);
assert_eq!(pointer.read(up(), heading, now), Action::Ignore);
}
#[test]
fn a_drag_cannot_be_half_of_a_double_click() {
let mut pointer = Pointer::default();
let now = Instant::now();
pointer.read(down(), row(4), now);
pointer.read(MouseEventKind::Drag(MouseButton::Left), row(4), now);
pointer.read(up(), row(4), now);
pointer.read(down(), row(4), now + Duration::from_millis(50));
assert_eq!(
pointer.read(up(), row(4), now + Duration::from_millis(50)),
Action::Select(4)
);
}
#[test]
fn a_completed_double_click_starts_over_rather_than_arming_the_next_press() {
let mut pointer = Pointer::default();
let mut now = Instant::now();
let click = |pointer: &mut Pointer, now: Instant| {
pointer.read(down(), row(9), now);
pointer.read(up(), row(9), now)
};
assert_eq!(click(&mut pointer, now), Action::Select(9));
now += Duration::from_millis(50);
assert_eq!(click(&mut pointer, now), Action::Price(9));
now += Duration::from_millis(50);
assert_eq!(click(&mut pointer, now), Action::Select(9));
}
#[test]
fn two_presses_far_enough_apart_are_two_clicks() {
let mut pointer = Pointer::default();
let now = Instant::now();
pointer.read(down(), row(2), now);
pointer.read(up(), row(2), now);
let late = now + super::DOUBLE_CLICK + Duration::from_millis(1);
pointer.read(down(), row(2), late);
assert_eq!(pointer.read(up(), row(2), late), Action::Select(2));
}
#[test]
fn a_row_that_moved_under_a_steady_finger_is_a_first_press_and_not_a_double() {
let mut pointer = Pointer::default();
let now = Instant::now();
pointer.read(down(), row(4), now);
pointer.read(up(), row(4), now);
pointer.read(down(), row(11), now + Duration::from_millis(50));
assert_eq!(
pointer.read(up(), row(11), now + Duration::from_millis(50)),
Action::Select(11)
);
}
#[test]
fn the_wheel_needs_no_press_behind_it() {
let mut pointer = Pointer::default();
assert_eq!(
pointer.read(MouseEventKind::ScrollDown, Spot::Tree, Instant::now()),
Action::ScrollRows(Motion::Down)
);
assert_eq!(
pointer.read(MouseEventKind::ScrollUp, Spot::Tree, Instant::now()),
Action::ScrollRows(Motion::Up)
);
}
#[test]
fn the_buttons_pristine_does_not_use_are_left_to_the_terminals_own_menus() {
let mut pointer = Pointer::default();
let now = Instant::now();
for kind in [
MouseEventKind::Down(MouseButton::Right),
MouseEventKind::Up(MouseButton::Right),
MouseEventKind::Down(MouseButton::Middle),
] {
assert_eq!(pointer.read(kind, row(1), now), Action::Ignore, "{kind:?}");
}
pointer.read(MouseEventKind::Down(MouseButton::Right), row(1), now);
assert_eq!(pointer.read(up(), row(1), now), Action::Ignore);
}
#[test]
fn restoring_the_terminal_attempts_every_step_that_was_reached() {
let mut restore = Restore::new(
Chrome::new(Vec::new(), Decor::silent()),
Screen::new(Vec::new(), false),
);
assert!(restore.finish().is_ok(), "nothing was taken");
restore.raw = true;
restore.alternate = true;
restore.mouse = true;
assert!(restore.finish().is_ok());
assert!(!restore.raw, "raw mode would be disabled twice");
assert!(
!restore.alternate,
"the alternate screen would be left twice"
);
assert!(!restore.mouse, "the mouse would be released twice");
}
#[test]
fn the_decorations_are_handed_back_by_the_same_guard_and_not_a_second_one() {
let mut restore = Restore::new(
Chrome::new(
Vec::new(),
Decor {
sync: true,
title: Some(XTERM_STACK),
progress: true,
notify: None,
graphics: false,
},
),
Screen::new(Vec::new(), false),
);
restore.chrome.enter().unwrap();
restore.chrome.begin_frame().unwrap();
restore.finish().unwrap();
let said = String::from_utf8(restore.chrome.sink().clone()).unwrap();
assert!(said.contains("\x1b[?2026l"), "a frozen screen: {said:?}");
assert!(said.contains("\x1b]9;4;0;0\x07"), "the bar was left up");
assert!(said.ends_with("\x1b[23;2t"), "the title was not put back");
restore.finish().unwrap();
assert_eq!(
restore.chrome.sink().len(),
said.len(),
"the undoing was written a second time"
);
}
}
#[cfg(test)]
mod loop_tests {
use super::{Chrome, Decor, Events, Options, Screen, drive};
use crate::Ruleset;
use crate::size::SizeMode;
use crate::tui::chrome::XTERM_STACK;
use ratatui::Terminal;
use ratatui::backend::{Backend, TestBackend, WindowSize};
use ratatui::buffer::Cell;
use ratatui::crossterm::event::{
Event, KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::{Position, Size};
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tempfile::TempDir;
fn never<T>(result: Result<T, std::convert::Infallible>) -> io::Result<T> {
result.map_err(|never| match never {})
}
struct Flaky {
inner: TestBackend,
broken: Arc<AtomicBool>,
blind: bool,
}
impl Backend for Flaky {
type Error = io::Error;
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a Cell)>,
{
if self.broken.load(Ordering::SeqCst) {
return Err(io::Error::other("the terminal went away"));
}
never(self.inner.draw(content))
}
fn hide_cursor(&mut self) -> io::Result<()> {
never(self.inner.hide_cursor())
}
fn show_cursor(&mut self) -> io::Result<()> {
never(self.inner.show_cursor())
}
fn get_cursor_position(&mut self) -> io::Result<Position> {
never(self.inner.get_cursor_position())
}
fn set_cursor_position<P: Into<Position>>(&mut self, position: P) -> io::Result<()> {
never(self.inner.set_cursor_position(position))
}
fn clear(&mut self) -> io::Result<()> {
never(self.inner.clear())
}
fn clear_region(&mut self, clear_type: ratatui::backend::ClearType) -> io::Result<()> {
never(self.inner.clear_region(clear_type))
}
fn size(&self) -> io::Result<Size> {
never(self.inner.size())
}
fn window_size(&mut self) -> io::Result<WindowSize> {
let window = never(self.inner.window_size())?;
Ok(WindowSize {
pixels: if self.blind {
Size::new(0, 0)
} else {
window.pixels
},
..window
})
}
fn flush(&mut self) -> io::Result<()> {
never(self.inner.flush())
}
}
const PATIENCE: Duration = Duration::from_secs(60);
struct Script {
events: Vec<Event>,
at: usize,
until: Option<Box<dyn Fn() -> bool + Send>>,
patience: Duration,
started: Option<Instant>,
expired: Option<Duration>,
}
impl Script {
fn new(events: Vec<Event>) -> Self {
Self {
events,
at: 0,
until: None,
patience: PATIENCE,
started: None,
expired: None,
}
}
fn until(mut self, finished: impl Fn() -> bool + Send + 'static) -> Self {
self.until = Some(Box::new(finished));
self
}
fn patience(mut self, patience: Duration) -> Self {
self.patience = patience;
self
}
fn gave_up(&self) -> Option<Duration> {
self.expired
}
fn why(&self, otherwise: &str, target: &Path) -> String {
let Some(waited) = self.expired else {
return otherwise.to_owned();
};
format!(
"timed out after {waited:?}: the run was still going when the script ran out of \
patience, and {} still holds {} files. A starved machine and a real regression \
both look like this from here, so this names neither — the assertion that was \
waiting is {otherwise:?}",
target.display(),
files_under(target),
)
}
}
impl Events for Script {
fn poll(&mut self, _timeout: Duration) -> io::Result<bool> {
std::thread::yield_now();
Ok(true)
}
fn read(&mut self) -> io::Result<Event> {
let started = *self.started.get_or_insert_with(Instant::now);
let waited = started.elapsed();
if waited >= self.patience {
self.expired.get_or_insert(waited);
}
let leaving =
self.expired.is_some() || self.until.as_ref().is_some_and(|finished| finished());
let event = if leaving {
key(KeyCode::Char('q'))
} else {
self.events[self.at % self.events.len()].clone()
};
self.at += 1;
Ok(event)
}
}
fn key(code: KeyCode) -> Event {
Event::Key(KeyEvent::new(code, KeyModifiers::NONE))
}
fn at(kind: MouseEventKind, column: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
})
}
fn files_under(dir: &Path) -> usize {
let mut count = 0;
let mut stack = vec![dir.to_path_buf()];
while let Some(at) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&at) else {
continue;
};
for entry in entries.flatten() {
if entry.path().is_dir() {
stack.push(entry.path());
} else {
count += 1;
}
}
}
count
}
fn fixture() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("app/node_modules");
std::fs::create_dir_all(tmp.path().join("app")).unwrap();
std::fs::write(tmp.path().join("app/package.json"), "{}").unwrap();
for n in 0..40 {
let dir = target.join(format!("p{n}"));
std::fs::create_dir_all(&dir).unwrap();
for f in 0..50 {
std::fs::write(dir.join(format!("f{f}.js")), "x").unwrap();
}
}
(tmp, target)
}
#[test]
fn a_script_that_runs_out_of_patience_reports_a_timeout_rather_than_a_verdict() {
let (tmp, target) = fixture();
let mut terminal = Terminal::new(Flaky {
inner: TestBackend::new(100, 24),
broken: Arc::new(AtomicBool::new(false)),
blind: false,
})
.unwrap();
let mut idle = Script::new(vec![key(KeyCode::Down), key(KeyCode::Up)])
.patience(Duration::from_millis(250));
let outcome = drive(
&mut terminal,
&mut idle,
&mut Chrome::new(Vec::new(), Decor::silent()),
&mut Screen::new(Vec::new(), false),
&Options {
root: tmp.path().to_path_buf(),
min_size: crate::DEFAULT_MIN_SIZE,
size_mode: SizeMode::Skip,
one_file_system: true,
older_than: None,
ignored_files: false,
excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
},
Arc::new(Ruleset::builtin().unwrap()),
)
.unwrap();
assert!(idle.gave_up().is_some(), "the run ended some other way");
assert!(target.exists(), "the phrase cannot mark, so nothing can go");
assert!(
outcome.whole(),
"a run that did nothing is still a whole run"
);
let said = idle.why("the pointer marked nothing", &target);
assert!(said.contains("timed out after"), "{said}");
assert!(said.contains("holds 2000 files"), "{said}");
assert!(said.contains("\"the pointer marked nothing\""), "{said}");
assert!(said.contains("names neither"), "{said}");
assert!(
!said.contains("NOT"),
"the timeout exonerated the code under test: {said}"
);
assert_eq!(
Script::new(Vec::new()).why("the pointer marked nothing", &target),
"the pointer marked nothing"
);
}
#[test]
fn a_terminal_that_will_not_say_how_big_a_cell_is_costs_the_tree_no_columns() {
let (tmp, target) = fixture();
let mut terminal = Terminal::new(Flaky {
inner: TestBackend::new(100, 24),
broken: Arc::new(AtomicBool::new(false)),
blind: true,
})
.unwrap();
const { assert!(100 >= crate::tui::treemap::MIN_WIDTH) };
let mut idle = Script::new(vec![key(KeyCode::Down), key(KeyCode::Up)])
.patience(Duration::from_millis(250));
let mut screen = Screen::new(Vec::new(), true);
drive(
&mut terminal,
&mut idle,
&mut Chrome::new(Vec::new(), Decor::silent()),
&mut screen,
&Options {
root: tmp.path().to_path_buf(),
min_size: crate::DEFAULT_MIN_SIZE,
size_mode: SizeMode::Skip,
one_file_system: true,
older_than: None,
ignored_files: false,
excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
},
Arc::new(Ruleset::builtin().unwrap()),
)
.unwrap();
assert!(target.exists(), "the phrase cannot mark, so nothing can go");
assert!(
screen.sink().is_empty(),
"an image was sized from a guess at the cell"
);
let buffer = terminal.backend().inner.buffer().clone();
let heading: Vec<_> = (0..buffer.area.width).map(|x| buffer[(x, 1)].bg).collect();
assert!(
heading
.iter()
.all(|bg| *bg == ratatui::style::Color::Rgb(24, 24, 30)),
"the tree gave up columns for a map that could never be drawn in them: {heading:?}"
);
}
#[test]
fn a_terminal_that_fails_mid_removal_still_waits_for_the_batch() {
let (tmp, target) = fixture();
let whole = files_under(&target);
assert_eq!(whole, 2000);
let broken = Arc::new(AtomicBool::new(false));
let arming = Arc::clone(&broken);
let counting = target.clone();
let watcher = std::thread::spawn(move || {
while files_under(&counting) == whole {
std::thread::yield_now();
}
arming.store(true, Ordering::SeqCst);
});
let mut terminal = Terminal::new(Flaky {
inner: TestBackend::new(100, 24),
broken: Arc::clone(&broken),
blind: false,
})
.unwrap();
let mut keys = Script::new(vec![
key(KeyCode::Char(' ')),
key(KeyCode::Char('x')),
key(KeyCode::Right),
key(KeyCode::Enter),
]);
let outcome = drive(
&mut terminal,
&mut keys,
&mut Chrome::new(Vec::new(), Decor::silent()),
&mut Screen::new(Vec::new(), false),
&Options {
root: tmp.path().to_path_buf(),
min_size: crate::DEFAULT_MIN_SIZE,
size_mode: SizeMode::Skip,
one_file_system: true,
older_than: None,
ignored_files: false,
excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
},
Arc::new(Ruleset::builtin().unwrap()),
);
watcher.join().unwrap();
assert!(
outcome.is_err(),
"{}",
keys.why("the terminal was supposed to fail", &target)
);
assert!(
!target.exists(),
"{}",
keys.why(
&format!("{} survived a removal that was abandoned", target.display()),
&target
)
);
}
#[test]
fn a_batch_marked_with_the_pointer_is_removed_through_the_real_loop() {
let (tmp, target) = fixture();
let mut terminal = Terminal::new(Flaky {
inner: TestBackend::new(100, 24),
broken: Arc::new(AtomicBool::new(false)),
blind: false,
})
.unwrap();
let box_of_root = (0, 2);
let name_of_root = (10, 2);
let gone = target.clone();
let mut hand = Script::new(vec![
at(
MouseEventKind::Down(MouseButton::Left),
box_of_root.0,
box_of_root.1,
),
at(
MouseEventKind::Up(MouseButton::Left),
box_of_root.0,
box_of_root.1,
),
key(KeyCode::Char('x')),
key(KeyCode::Right),
key(KeyCode::Enter),
at(
MouseEventKind::Down(MouseButton::Left),
name_of_root.0,
name_of_root.1,
),
at(
MouseEventKind::Up(MouseButton::Left),
name_of_root.0,
name_of_root.1,
),
])
.until(move || !gone.exists());
let outcome = drive(
&mut terminal,
&mut hand,
&mut Chrome::new(Vec::new(), Decor::silent()),
&mut Screen::new(Vec::new(), false),
&Options {
root: tmp.path().to_path_buf(),
min_size: crate::DEFAULT_MIN_SIZE,
size_mode: SizeMode::Skip,
one_file_system: true,
older_than: None,
ignored_files: false,
excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
},
Arc::new(Ruleset::builtin().unwrap()),
)
.unwrap();
assert!(
!target.exists(),
"{}",
hand.why("the pointer marked nothing", &target)
);
assert!(outcome.whole(), "{outcome:?}");
assert!(
tmp.path().join("app/package.json").exists(),
"too much went"
);
}
#[test]
fn a_marked_batch_is_removed_through_the_real_loop() {
let (tmp, target) = fixture();
let mut terminal = Terminal::new(Flaky {
inner: TestBackend::new(100, 24),
broken: Arc::new(AtomicBool::new(false)),
blind: false,
})
.unwrap();
let gone = target.clone();
let mut keys = Script::new(vec![
key(KeyCode::Char(' ')),
key(KeyCode::Char('x')),
key(KeyCode::Right),
key(KeyCode::Enter),
])
.until(move || !gone.exists());
let mut chrome = Chrome::new(
Vec::new(),
Decor {
sync: true,
title: Some(XTERM_STACK),
progress: true,
notify: None,
graphics: true,
},
);
let mut screen = Screen::new(Vec::new(), true);
let outcome = drive(
&mut terminal,
&mut keys,
&mut chrome,
&mut screen,
&Options {
root: tmp.path().to_path_buf(),
min_size: crate::DEFAULT_MIN_SIZE,
size_mode: SizeMode::Skip,
one_file_system: true,
older_than: None,
ignored_files: false,
excludes: std::sync::Arc::new(ignore::gitignore::Gitignore::empty()),
},
Arc::new(Ruleset::builtin().unwrap()),
)
.unwrap();
assert!(
!target.exists(),
"{}",
keys.why("nothing was removed", &target)
);
assert!(outcome.whole(), "{outcome:?}");
assert!(
tmp.path().join("app/package.json").exists(),
"too much went"
);
let said = String::from_utf8(chrome.sink().clone()).unwrap();
assert!(said.contains("\x1b[?2026h"), "nothing was ever wrapped");
let mut open = 0i32;
for frame in said.split("\x1b[?2026").skip(1) {
match frame.as_bytes().first() {
Some(b'h') => open += 1,
Some(b'l') => open -= 1,
other => panic!("a private mode nobody wrote: {other:?}"),
}
assert!((0..=1).contains(&open), "the frames did not alternate");
}
assert_eq!(open, 0, "a frame was left open");
assert!(said.contains("\x1b]0;pristine — "), "{said:?}");
assert!(said.contains("\x1b]9;4;"), "no progress was ever reported");
let drawn = String::from_utf8_lossy(screen.sink()).into_owned();
assert!(drawn.contains("\x1b_Ga=T,"), "no map was ever drawn");
assert!(
drawn.matches("a=d,d=I").count() >= drawn.matches("a=T,").count(),
"an image was transmitted without a delete to match it"
);
assert_eq!(
drawn.matches("\x1b7").count(),
drawn.matches("\x1b8").count(),
"the cursor was not put back after a placement"
);
}
}