use std::path::PathBuf;
use std::time::SystemTime;
use crate::core::sort::sort_results;
use crate::core::types::{FolderResult, ScanFoundFolder, SortBy, SortDirection};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Mode {
Browse,
Confirm(Vec<PathBuf>),
Deleting(DeleteProgress),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteProgress {
pub total: usize,
pub completed: usize,
pub failed: usize,
pub bytes_done: u64,
}
impl DeleteProgress {
pub fn new(total: usize) -> Self {
Self { total, completed: 0, failed: 0, bytes_done: 0 }
}
pub fn done(&self) -> bool {
self.completed + self.failed >= self.total
}
}
#[derive(Debug)]
pub struct AppState {
pub root: PathBuf,
pub targets: Vec<String>,
pub dry_run: bool,
pub results: Vec<FolderResult>,
pub cursor: usize,
pub mode: Mode,
pub sort: SortBy,
pub sort_direction: SortDirection,
pub scan_finished: bool,
pub user_navigated: bool,
pub dirs_scanned: u64,
pub last_message: Option<String>,
}
impl AppState {
pub fn new(root: PathBuf, targets: Vec<String>, dry_run: bool, sort: SortBy) -> Self {
Self::with_sort(root, targets, dry_run, sort, SortDirection::default())
}
pub fn with_sort(
root: PathBuf,
targets: Vec<String>,
dry_run: bool,
sort: SortBy,
sort_direction: SortDirection,
) -> Self {
Self {
root,
targets,
dry_run,
results: Vec::new(),
cursor: 0,
mode: Mode::Browse,
sort,
sort_direction,
scan_finished: false,
user_navigated: false,
dirs_scanned: 0,
last_message: None,
}
}
pub fn total_size(&self) -> u64 {
self.results.iter().filter_map(|r| r.size_bytes).sum()
}
pub fn releasable_bytes(&self) -> u64 {
self.results.iter().filter(|r| !r.deleted).filter_map(|r| r.size_bytes).sum()
}
pub fn saved_bytes(&self) -> u64 {
self.results.iter().filter(|r| r.deleted).filter_map(|r| r.size_bytes).sum()
}
pub fn selected(&self) -> Option<&FolderResult> {
self.results.get(self.cursor)
}
pub fn selection_count(&self) -> usize {
self.results.iter().filter(|r| r.selected && !r.deleted).count()
}
pub fn selection_bytes(&self) -> u64 {
self.results.iter().filter(|r| r.selected && !r.deleted).filter_map(|r| r.size_bytes).sum()
}
pub fn pending_delete_targets(&self) -> Vec<PathBuf> {
if self.selection_count() > 0 {
self.results
.iter()
.filter(|r| r.selected && !r.deleted)
.map(|r| r.path.clone())
.collect()
} else {
self.selected().filter(|r| !r.deleted).map(|r| vec![r.path.clone()]).unwrap_or_default()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Up,
Down,
ToggleSelect,
ClearSelection,
RequestDelete,
ConfirmYes,
ConfirmNo,
ToggleSortBySize,
ToggleSortByName,
ToggleSortByLastUsed,
Rescan,
Quit,
Noop,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
DeleteBatch(Vec<PathBuf>),
Quit,
Rescan,
None,
}
impl AppState {
pub fn apply(&mut self, action: Action) -> Effect {
self.last_message = None;
match action {
Action::Up => {
self.user_navigated = true;
if self.cursor > 0 {
self.cursor -= 1;
}
Effect::None
}
Action::Down => {
self.user_navigated = true;
if !self.results.is_empty() && self.cursor + 1 < self.results.len() {
self.cursor += 1;
}
Effect::None
}
Action::ToggleSelect => {
if self.mode == Mode::Browse
&& let Some(row) = self.results.get_mut(self.cursor)
&& !row.deleted
{
row.selected = !row.selected;
}
Effect::None
}
Action::ClearSelection => {
if self.mode == Mode::Browse {
for r in &mut self.results {
r.selected = false;
}
}
Effect::None
}
Action::RequestDelete => {
if self.mode == Mode::Browse {
let targets = self.pending_delete_targets();
if !targets.is_empty() {
self.mode = Mode::Confirm(targets);
}
}
Effect::None
}
Action::ConfirmYes => match self.mode.clone() {
Mode::Confirm(paths) if !paths.is_empty() => {
self.mode = Mode::Deleting(DeleteProgress::new(paths.len()));
Effect::DeleteBatch(paths)
}
_ => {
self.mode = Mode::Browse;
Effect::None
}
},
Action::ConfirmNo => {
if matches!(self.mode, Mode::Confirm(_)) {
self.mode = Mode::Browse;
}
Effect::None
}
Action::ToggleSortBySize => {
self.toggle_or_switch_sort(SortBy::Size, SortDirection::Desc);
Effect::None
}
Action::ToggleSortByName => {
self.toggle_or_switch_sort(SortBy::Path, SortDirection::Asc);
Effect::None
}
Action::ToggleSortByLastUsed => {
self.toggle_or_switch_sort(SortBy::Age, SortDirection::Desc);
Effect::None
}
Action::Rescan => {
if matches!(self.mode, Mode::Browse) {
self.clear_for_rescan();
Effect::Rescan
} else {
Effect::None
}
}
Action::Quit => Effect::Quit,
Action::Noop => Effect::None,
}
}
pub fn clear_for_rescan(&mut self) {
self.results.clear();
self.cursor = 0;
self.user_navigated = false;
self.scan_finished = false;
self.dirs_scanned = 0;
self.last_message = Some("rescanning…".into());
}
fn toggle_or_switch_sort(&mut self, by: SortBy, default_direction: SortDirection) {
if self.sort == by {
self.sort_direction = self.sort_direction.toggle();
} else {
self.sort = by;
self.sort_direction = default_direction;
}
self.resort();
}
pub fn resort(&mut self) {
let selected_path =
if self.user_navigated { self.selected().map(|r| r.path.clone()) } else { None };
sort_results(&mut self.results, self.sort, self.sort_direction);
if let Some(p) = selected_path
&& let Some(idx) = self.results.iter().position(|r| r.path == p)
{
self.cursor = idx;
} else if !self.user_navigated {
self.cursor = 0;
} else if self.cursor >= self.results.len() && !self.results.is_empty() {
self.cursor = self.results.len() - 1;
}
}
pub fn push_result(&mut self, found: ScanFoundFolder) {
self.results.push(FolderResult::from_scan(found));
self.resort();
}
pub fn push_result_with_mtime(
&mut self,
found: ScanFoundFolder,
last_modified: Option<SystemTime>,
) {
let mut row = FolderResult::from_scan(found);
row.last_modified = last_modified;
self.results.push(row);
self.resort();
}
pub fn record_size(&mut self, path: &std::path::Path, size: u64) {
let changed = if let Some(row) = self.results.iter_mut().find(|r| r.path == path) {
row.size_bytes = Some(size);
true
} else {
false
};
if changed && self.sort == SortBy::Size {
self.resort();
}
}
pub fn record_delete_outcome(
&mut self,
path: &std::path::Path,
success: bool,
error: Option<String>,
) {
let idx = self.results.iter().position(|r| r.path == path);
let size = idx.and_then(|i| self.results[i].size_bytes);
if let Some(i) = idx {
if success {
if self.dry_run {
self.results[i].deleted = true;
self.results[i].selected = false;
} else {
self.results.remove(i);
if !self.results.is_empty() && self.cursor >= self.results.len() {
self.cursor = self.results.len() - 1;
}
}
} else {
self.results[i].selected = false;
}
}
if !success && error.is_some() {
let msg = error.clone().unwrap_or_else(|| "unknown error".into());
self.last_message = Some(format!("delete failed: {msg}"));
}
if let Mode::Deleting(prog) = &mut self.mode {
if success {
prog.completed += 1;
if let Some(sz) = size {
prog.bytes_done = prog.bytes_done.saturating_add(sz);
}
} else {
prog.failed += 1;
}
if prog.done() {
let (completed, failed, total) = (prog.completed, prog.failed, prog.total);
self.mode = Mode::Browse;
if total > 1 {
self.last_message = Some(if failed == 0 {
format!("deleted {completed} folders")
} else {
format!("{completed}/{total} deleted · {failed} failed")
});
} else if failed == 0 {
self.last_message = Some(if self.dry_run {
"(dry-run) would have deleted".into()
} else {
"deleted".into()
});
}
}
}
}
pub fn mark_scan_finished(&mut self) {
let was_already_finished = self.scan_finished;
self.scan_finished = true;
if !was_already_finished && !self.results.is_empty() {
self.cursor = 0;
self.user_navigated = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use std::time::Duration;
fn fresh_state() -> AppState {
AppState::new(PathBuf::from("/root"), vec!["node_modules".into()], false, SortBy::Size)
}
fn push(state: &mut AppState, p: &str) {
state.push_result(ScanFoundFolder::new(PathBuf::from(p), None));
}
#[test]
fn default_sort_is_size_desc() {
let s = fresh_state();
assert_eq!(s.sort, SortBy::Size);
assert_eq!(s.sort_direction, SortDirection::Desc);
}
#[test]
fn navigation_stays_in_bounds() {
let mut s = fresh_state();
push(&mut s, "/a");
push(&mut s, "/b");
push(&mut s, "/c");
assert_eq!(s.cursor, 0);
s.apply(Action::Up);
assert_eq!(s.cursor, 0); s.apply(Action::Down);
s.apply(Action::Down);
s.apply(Action::Down); assert_eq!(s.cursor, 2);
}
#[test]
fn single_delete_emits_one_path_batch_and_stays_in_deleting() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
s.apply(Action::RequestDelete);
assert_eq!(s.mode, Mode::Confirm(vec![PathBuf::from("/a/node_modules")]));
let eff = s.apply(Action::ConfirmYes);
match eff {
Effect::DeleteBatch(paths) => {
assert_eq!(paths, vec![PathBuf::from("/a/node_modules")]);
}
other => panic!("expected DeleteBatch, got {other:?}"),
}
assert!(matches!(s.mode, Mode::Deleting(_)));
s.record_delete_outcome(Path::new("/a/node_modules"), true, None);
assert_eq!(s.mode, Mode::Browse);
assert!(s.results.is_empty(), "real-delete success should drop the row");
assert_eq!(s.last_message.as_deref(), Some("deleted"));
}
#[test]
fn delete_failure_keeps_row_and_surfaces_error() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
s.apply(Action::RequestDelete);
s.apply(Action::ConfirmYes);
assert!(matches!(s.mode, Mode::Deleting(_)));
s.record_delete_outcome(Path::new("/a/node_modules"), false, Some("perm denied".into()));
assert_eq!(s.mode, Mode::Browse);
assert_eq!(s.results.len(), 1, "failed delete must leave the row visible");
assert!(
s.last_message.as_deref().unwrap().contains("perm denied"),
"got {:?}",
s.last_message
);
}
#[test]
fn dry_run_keeps_row_and_marks_deleted_visually() {
let mut s = fresh_state();
s.dry_run = true;
push(&mut s, "/a/node_modules");
s.apply(Action::RequestDelete);
s.apply(Action::ConfirmYes);
s.record_delete_outcome(Path::new("/a/node_modules"), true, None);
assert_eq!(s.results.len(), 1, "dry-run never deletes; row stays");
assert!(s.results[0].deleted, "dry-run still marks the row visually");
assert!(s.last_message.as_deref().unwrap().contains("dry-run"));
}
#[test]
fn cursor_clamps_after_removing_last_row() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
push(&mut s, "/b/node_modules");
push(&mut s, "/c/node_modules");
s.apply(Action::Down);
s.apply(Action::Down);
assert_eq!(s.cursor, 2);
s.apply(Action::RequestDelete);
s.apply(Action::ConfirmYes);
s.record_delete_outcome(Path::new("/c/node_modules"), true, None);
assert_eq!(s.results.len(), 2);
assert_eq!(s.cursor, 1, "cursor should clamp to the new last row");
}
#[test]
fn delete_flow_no_returns_to_browse_without_effect() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
s.apply(Action::RequestDelete);
assert!(matches!(s.mode, Mode::Confirm(_)));
let eff = s.apply(Action::ConfirmNo);
assert_eq!(eff, Effect::None);
assert_eq!(s.mode, Mode::Browse);
}
#[test]
fn cannot_request_delete_when_no_rows() {
let mut s = fresh_state();
s.apply(Action::RequestDelete);
assert_eq!(s.mode, Mode::Browse);
}
#[test]
fn cannot_redelete_a_dryrun_deleted_row() {
let mut s = fresh_state();
s.dry_run = true;
push(&mut s, "/a/node_modules");
s.apply(Action::RequestDelete);
s.apply(Action::ConfirmYes);
s.record_delete_outcome(Path::new("/a/node_modules"), true, None);
assert!(s.results[0].deleted, "precondition: dry-run leaves row marked");
s.apply(Action::RequestDelete);
assert_eq!(s.mode, Mode::Browse, "no targets to delete; modal stays closed");
}
#[test]
fn space_toggles_selection_on_cursor_row() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
push(&mut s, "/b/node_modules");
assert_eq!(s.selection_count(), 0);
s.apply(Action::ToggleSelect);
assert_eq!(s.selection_count(), 1);
s.apply(Action::Down);
s.apply(Action::ToggleSelect);
assert_eq!(s.selection_count(), 2);
s.apply(Action::ToggleSelect); assert_eq!(s.selection_count(), 1);
}
#[test]
fn clear_selection_unsets_every_row() {
let mut s = fresh_state();
push(&mut s, "/a");
push(&mut s, "/b");
s.apply(Action::ToggleSelect);
s.apply(Action::Down);
s.apply(Action::ToggleSelect);
assert_eq!(s.selection_count(), 2);
s.apply(Action::ClearSelection);
assert_eq!(s.selection_count(), 0);
}
#[test]
fn request_delete_uses_selection_when_present() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
push(&mut s, "/b/node_modules");
push(&mut s, "/c/node_modules");
s.apply(Action::ToggleSelect); s.apply(Action::Down);
s.apply(Action::Down);
s.apply(Action::ToggleSelect); s.apply(Action::Up); s.apply(Action::RequestDelete);
match &s.mode {
Mode::Confirm(paths) => {
let strs: Vec<_> = paths.iter().map(|p| p.to_string_lossy().into_owned()).collect();
assert!(strs.contains(&"/a/node_modules".to_string()));
assert!(strs.contains(&"/c/node_modules".to_string()));
assert!(!strs.contains(&"/b/node_modules".to_string()));
assert_eq!(strs.len(), 2);
}
other => panic!("expected Confirm with 2 paths, got {other:?}"),
}
}
#[test]
fn batch_delete_progress_closes_modal_when_all_done() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
push(&mut s, "/b/node_modules");
push(&mut s, "/c/node_modules");
s.apply(Action::ToggleSelect);
s.apply(Action::Down);
s.apply(Action::ToggleSelect);
s.apply(Action::Down);
s.apply(Action::ToggleSelect);
assert_eq!(s.selection_count(), 3);
s.apply(Action::RequestDelete);
let eff = s.apply(Action::ConfirmYes);
let Effect::DeleteBatch(paths) = eff else { panic!("expected DeleteBatch") };
assert_eq!(paths.len(), 3);
match &s.mode {
Mode::Deleting(p) => {
assert_eq!(p.total, 3);
assert_eq!(p.completed, 0);
}
other => panic!("expected Deleting, got {other:?}"),
}
s.record_delete_outcome(Path::new("/a/node_modules"), true, None);
assert!(matches!(s.mode, Mode::Deleting(_)), "modal stays open until all done");
s.record_delete_outcome(Path::new("/b/node_modules"), true, None);
assert!(matches!(s.mode, Mode::Deleting(_)));
s.record_delete_outcome(Path::new("/c/node_modules"), true, None);
assert_eq!(s.mode, Mode::Browse);
assert!(s.results.is_empty());
assert_eq!(s.last_message.as_deref(), Some("deleted 3 folders"));
}
#[test]
fn batch_delete_summary_counts_failures() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
push(&mut s, "/b/node_modules");
s.apply(Action::ToggleSelect);
s.apply(Action::Down);
s.apply(Action::ToggleSelect);
s.apply(Action::RequestDelete);
s.apply(Action::ConfirmYes);
s.record_delete_outcome(Path::new("/a/node_modules"), true, None);
s.record_delete_outcome(Path::new("/b/node_modules"), false, Some("perm".into()));
assert_eq!(s.mode, Mode::Browse);
assert!(s.last_message.as_deref().unwrap().contains("1/2 deleted"));
assert!(s.last_message.as_deref().unwrap().contains("1 failed"));
}
#[test]
fn record_size_updates_matching_row() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
push(&mut s, "/b/node_modules");
s.record_size(Path::new("/a/node_modules"), 42_000);
let row_a = s.results.iter().find(|r| r.path == Path::new("/a/node_modules")).unwrap();
let row_b = s.results.iter().find(|r| r.path == Path::new("/b/node_modules")).unwrap();
assert_eq!(row_a.size_bytes, Some(42_000));
assert_eq!(row_b.size_bytes, None);
}
#[test]
fn total_size_sums_known_sizes() {
let mut s = fresh_state();
push(&mut s, "/a");
push(&mut s, "/b");
s.record_size(Path::new("/a"), 1_000);
s.record_size(Path::new("/b"), 2_500);
assert_eq!(s.total_size(), 3_500);
}
#[test]
fn quit_returns_quit_effect() {
let mut s = fresh_state();
assert_eq!(s.apply(Action::Quit), Effect::Quit);
}
#[test]
fn pressing_size_again_flips_direction() {
let mut s = fresh_state();
assert_eq!(s.sort_direction, SortDirection::Desc);
s.apply(Action::ToggleSortBySize);
assert_eq!(s.sort, SortBy::Size);
assert_eq!(s.sort_direction, SortDirection::Asc);
s.apply(Action::ToggleSortBySize);
assert_eq!(s.sort_direction, SortDirection::Desc);
}
#[test]
fn switching_from_size_to_name_uses_default_asc() {
let mut s = fresh_state(); s.apply(Action::ToggleSortByName);
assert_eq!(s.sort, SortBy::Path);
assert_eq!(s.sort_direction, SortDirection::Asc);
}
#[test]
fn switching_to_last_used_uses_default_desc() {
let mut s = fresh_state();
s.apply(Action::ToggleSortByName); s.apply(Action::ToggleSortByLastUsed);
assert_eq!(s.sort, SortBy::Age);
assert_eq!(s.sort_direction, SortDirection::Desc);
s.apply(Action::ToggleSortByLastUsed);
assert_eq!(s.sort_direction, SortDirection::Asc);
}
#[test]
fn resort_keeps_cursor_on_same_row_after_user_navigates() {
let mut s = fresh_state(); push(&mut s, "/aaa");
push(&mut s, "/bbb");
push(&mut s, "/ccc");
s.record_size(Path::new("/aaa"), 100);
s.record_size(Path::new("/bbb"), 999);
s.record_size(Path::new("/ccc"), 500);
s.apply(Action::Down);
assert!(s.user_navigated);
let ccc_idx = s.results.iter().position(|r| r.path == Path::new("/ccc")).unwrap();
assert_eq!(s.cursor, ccc_idx);
s.apply(Action::ToggleSortBySize);
let new_idx = s.results.iter().position(|r| r.path == Path::new("/ccc")).unwrap();
assert_eq!(s.cursor, new_idx);
}
#[test]
fn cursor_pinned_at_top_during_streaming_until_user_navigates() {
let mut s = fresh_state();
let scan = |p: &str| ScanFoundFolder::new(PathBuf::from(p), None);
s.push_result(scan("/small"));
s.record_size(Path::new("/small"), 100);
s.push_result(scan("/big"));
s.record_size(Path::new("/big"), 10_000);
assert_eq!(s.cursor, 0);
assert_eq!(s.selected().unwrap().path, PathBuf::from("/big"));
s.apply(Action::Down);
assert!(s.user_navigated);
let small_idx = s.results.iter().position(|r| r.path == Path::new("/small")).unwrap();
assert_eq!(s.cursor, small_idx);
s.push_result(scan("/medium"));
s.record_size(Path::new("/medium"), 1_000);
assert_eq!(s.selected().unwrap().path, PathBuf::from("/small"));
}
#[test]
fn scan_finished_snaps_cursor_to_top_even_if_user_navigated() {
let mut s = fresh_state();
push(&mut s, "/a");
push(&mut s, "/b");
push(&mut s, "/c");
s.apply(Action::Down);
s.apply(Action::Down);
assert!(s.cursor > 0);
s.mark_scan_finished();
assert_eq!(s.cursor, 0, "post-scan cursor must snap to top");
assert!(s.scan_finished);
assert!(!s.user_navigated);
}
#[test]
fn scan_finished_is_idempotent_does_not_reset_cursor_on_redundant_calls() {
let mut s = fresh_state();
push(&mut s, "/a");
push(&mut s, "/b");
push(&mut s, "/c");
s.mark_scan_finished();
assert_eq!(s.cursor, 0);
s.apply(Action::Down);
assert_eq!(s.cursor, 1);
s.mark_scan_finished();
assert_eq!(s.cursor, 1, "redundant mark_scan_finished should be a no-op");
}
#[test]
fn rescan_clears_results_and_emits_effect() {
let mut s = fresh_state();
push(&mut s, "/a");
push(&mut s, "/b");
s.apply(Action::Down);
s.mark_scan_finished();
assert_eq!(s.results.len(), 2);
assert!(s.scan_finished);
let eff = s.apply(Action::Rescan);
assert_eq!(eff, Effect::Rescan);
assert!(s.results.is_empty());
assert_eq!(s.cursor, 0);
assert!(!s.scan_finished);
assert!(!s.user_navigated);
assert!(s.last_message.as_deref().unwrap_or("").contains("rescan"));
}
#[test]
fn rescan_ignored_in_confirm_mode() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
s.apply(Action::RequestDelete);
assert!(matches!(s.mode, Mode::Confirm(_)));
let eff = s.apply(Action::Rescan);
assert_eq!(eff, Effect::None);
assert!(matches!(s.mode, Mode::Confirm(_)));
assert_eq!(s.results.len(), 1);
}
#[test]
fn push_keeps_results_sorted() {
let mut s = fresh_state();
let scan = |p: &str| ScanFoundFolder::new(PathBuf::from(p), None);
s.push_result(scan("/small"));
s.record_size(Path::new("/small"), 100);
s.push_result(scan("/big"));
s.record_size(Path::new("/big"), 10_000);
s.push_result(scan("/medium"));
s.record_size(Path::new("/medium"), 1_000);
let paths: Vec<_> =
s.results.iter().map(|r| r.path.to_string_lossy().into_owned()).collect();
assert_eq!(paths, vec!["/big", "/medium", "/small"]);
}
#[test]
fn age_sort_uses_last_modified() {
let mut s = fresh_state();
let now = SystemTime::now();
s.push_result_with_mtime(
ScanFoundFolder::new(PathBuf::from("/recent"), None),
Some(now - Duration::from_secs(10)),
);
s.push_result_with_mtime(
ScanFoundFolder::new(PathBuf::from("/ancient"), None),
Some(now - Duration::from_secs(10_000)),
);
s.push_result_with_mtime(ScanFoundFolder::new(PathBuf::from("/no-mtime"), None), None);
s.apply(Action::ToggleSortByLastUsed); let paths: Vec<_> =
s.results.iter().map(|r| r.path.to_string_lossy().into_owned()).collect();
assert_eq!(paths, vec!["/recent", "/ancient", "/no-mtime"]);
}
}