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(usize),
}
#[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)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Action {
Up,
Down,
RequestDelete,
ConfirmYes,
ConfirmNo,
ToggleSortBySize,
ToggleSortByName,
ToggleSortByLastUsed,
Rescan,
Quit,
Noop,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
DeleteFolder {
index: usize,
path: 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::RequestDelete => {
if self.mode == Mode::Browse
&& let Some(r) = self.results.get(self.cursor)
&& !r.deleted
{
self.mode = Mode::Confirm(self.cursor);
}
Effect::None
}
Action::ConfirmYes => match self.mode.clone() {
Mode::Confirm(idx) => {
self.mode = Mode::Browse;
if let Some(r) = self.results.get(idx) {
Effect::DeleteFolder { index: idx, path: r.path.clone() }
} else {
Effect::None
}
}
_ => 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, index: usize, success: bool, error: Option<String>) {
if let Some(row) = self.results.get_mut(index) {
row.deleted = success;
if let Some(e) = error {
self.last_message = Some(format!("delete failed: {e}"));
} else if self.dry_run {
self.last_message = Some("(dry-run) would have deleted".into());
} else {
self.last_message = Some("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 delete_flow_yes_emits_effect_and_returns_to_browse() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
s.apply(Action::RequestDelete);
assert_eq!(s.mode, Mode::Confirm(0));
let eff = s.apply(Action::ConfirmYes);
match eff {
Effect::DeleteFolder { index, path } => {
assert_eq!(index, 0);
assert_eq!(path, PathBuf::from("/a/node_modules"));
}
other => panic!("expected DeleteFolder, got {other:?}"),
}
assert_eq!(s.mode, Mode::Browse);
}
#[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_eq!(s.mode, Mode::Confirm(0));
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_deleted_row() {
let mut s = fresh_state();
push(&mut s, "/a/node_modules");
s.record_delete_outcome(0, true, None);
s.apply(Action::RequestDelete);
assert_eq!(s.mode, Mode::Browse, "should not open confirm for deleted row");
}
#[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 dry_run_message_after_delete() {
let mut s = fresh_state();
s.dry_run = true;
push(&mut s, "/a/node_modules");
s.record_delete_outcome(0, true, None);
assert!(s.last_message.as_deref().unwrap().contains("dry-run"));
}
#[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"]);
}
}