use std::collections::HashSet;
use std::path::PathBuf;
use crate::cli::ThemeArg;
use crate::duplicates::DuplicateGroup;
use crate::tui::theme::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AppMode {
#[default]
Scanning,
Reviewing,
Previewing,
Confirming,
SelectingFolder,
Quitting,
}
impl AppMode {
#[must_use]
pub fn is_navigable(&self) -> bool {
matches!(self, Self::Reviewing | Self::SelectingFolder)
}
#[must_use]
pub fn is_done(&self) -> bool {
matches!(self, Self::Quitting)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Action {
NavigateUp,
NavigateDown,
NextGroup,
PreviousGroup,
ToggleSelect,
SelectAllInGroup,
SelectAllDuplicates,
SelectOldest,
SelectNewest,
SelectSmallest,
SelectLargest,
DeselectAll,
Preview,
SelectFolder,
Delete,
ToggleTheme,
Confirm,
Cancel,
Quit,
}
#[derive(Debug, Clone, Default)]
pub struct ScanProgress {
pub phase: String,
pub current_path: String,
pub current: usize,
pub total: usize,
pub message: String,
}
impl ScanProgress {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn percentage(&self) -> u16 {
if self.total == 0 {
0
} else {
((self.current as f64 / self.total as f64) * 100.0).min(100.0) as u16
}
}
}
#[derive(Debug, Clone)]
pub struct App {
mode: AppMode,
groups: Vec<DuplicateGroup>,
group_index: usize,
file_index: usize,
group_scroll: usize,
file_scroll: usize,
selected_files: HashSet<PathBuf>,
scan_progress: ScanProgress,
error_message: Option<String>,
preview_content: Option<String>,
folder_list: Vec<PathBuf>,
folder_index: usize,
reference_paths: Vec<PathBuf>,
reclaimable_space: u64,
visible_rows: usize,
dry_run: bool,
theme_arg: ThemeArg,
theme: Theme,
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
#[must_use]
pub fn new() -> Self {
Self {
mode: AppMode::Scanning,
groups: Vec::new(),
group_index: 0,
file_index: 0,
group_scroll: 0,
file_scroll: 0,
selected_files: HashSet::new(),
scan_progress: ScanProgress::new(),
error_message: None,
preview_content: None,
folder_list: Vec::new(),
folder_index: 0,
reference_paths: Vec::new(),
reclaimable_space: 0,
visible_rows: 20, dry_run: false,
theme_arg: ThemeArg::Auto,
theme: Theme::dark(),
}
}
pub fn with_theme(mut self, theme_arg: ThemeArg) -> Self {
self.theme_arg = theme_arg;
self.theme = match theme_arg {
ThemeArg::Auto => Theme::auto(),
ThemeArg::Light => Theme::light(),
ThemeArg::Dark => Theme::dark(),
};
self
}
pub fn toggle_theme(&mut self) {
self.theme_arg = match self.theme_arg {
ThemeArg::Auto => {
if self.theme.is_light() {
ThemeArg::Dark
} else {
ThemeArg::Light
}
}
ThemeArg::Dark => ThemeArg::Light,
ThemeArg::Light => ThemeArg::Dark,
};
self.theme = match self.theme_arg {
ThemeArg::Light => Theme::light(),
ThemeArg::Dark => Theme::dark(),
ThemeArg::Auto => Theme::auto(), };
log::debug!("Theme toggled to {:?}", self.theme_arg);
}
#[must_use]
pub fn theme(&self) -> &Theme {
&self.theme
}
pub fn with_dry_run(mut self, dry_run: bool) -> Self {
self.dry_run = dry_run;
self
}
#[must_use]
pub fn is_dry_run(&self) -> bool {
self.dry_run
}
pub fn with_reference_paths(mut self, paths: Vec<PathBuf>) -> Self {
self.reference_paths = paths;
self
}
pub fn set_reference_paths(&mut self, paths: Vec<PathBuf>) {
self.reference_paths = paths;
}
pub fn is_in_reference_dir(&self, path: &std::path::Path) -> bool {
self.reference_paths.iter().any(|ref_path| {
if cfg!(windows) {
let p = std::path::PathBuf::from(path.to_string_lossy().to_lowercase());
let r = std::path::PathBuf::from(ref_path.to_string_lossy().to_lowercase());
p.starts_with(r)
} else {
path.starts_with(ref_path)
}
})
}
#[must_use]
pub fn with_groups(groups: Vec<DuplicateGroup>) -> Self {
let reclaimable = groups.iter().map(DuplicateGroup::wasted_space).sum();
let mode = if groups.is_empty() {
AppMode::Scanning
} else {
AppMode::Reviewing
};
Self {
mode,
groups,
group_index: 0,
file_index: 0,
group_scroll: 0,
file_scroll: 0,
selected_files: HashSet::new(),
scan_progress: ScanProgress::new(),
error_message: None,
preview_content: None,
folder_list: Vec::new(),
folder_index: 0,
reference_paths: Vec::new(),
reclaimable_space: reclaimable,
visible_rows: 20,
dry_run: false,
theme_arg: ThemeArg::Auto,
theme: Theme::dark(),
}
}
pub fn apply_session(
&mut self,
user_selections: std::collections::BTreeSet<PathBuf>,
group_index: usize,
file_index: usize,
) {
self.selected_files = user_selections.into_iter().collect();
if group_index < self.groups.len() {
self.group_index = group_index;
if file_index < self.groups[group_index].files.len() {
self.file_index = file_index;
} else {
self.file_index = 0;
}
} else {
self.group_index = 0;
self.file_index = 0;
}
self.update_group_scroll();
self.update_file_scroll();
log::debug!(
"Applied session: {} selections, pos ({}, {})",
self.selected_files.len(),
self.group_index,
self.file_index
);
}
#[must_use]
pub fn mode(&self) -> AppMode {
self.mode
}
pub fn set_mode(&mut self, mode: AppMode) {
log::debug!("Mode transition: {:?} -> {:?}", self.mode, mode);
self.mode = mode;
}
#[must_use]
pub fn should_quit(&self) -> bool {
self.mode.is_done()
}
#[must_use]
pub fn groups(&self) -> &[DuplicateGroup] {
&self.groups
}
pub fn set_groups(&mut self, groups: Vec<DuplicateGroup>) {
self.reclaimable_space = groups.iter().map(DuplicateGroup::wasted_space).sum();
self.groups = groups;
self.group_index = 0;
self.file_index = 0;
self.group_scroll = 0;
self.file_scroll = 0;
self.selected_files.clear();
log::info!(
"Loaded {} duplicate groups, {} bytes reclaimable",
self.groups.len(),
self.reclaimable_space
);
}
#[must_use]
pub fn group_count(&self) -> usize {
self.groups.len()
}
#[must_use]
pub fn has_groups(&self) -> bool {
!self.groups.is_empty()
}
#[must_use]
pub fn reclaimable_space(&self) -> u64 {
self.reclaimable_space
}
#[must_use]
pub fn duplicate_file_count(&self) -> usize {
self.groups.iter().map(|g| g.files.len()).sum()
}
#[must_use]
pub fn group_index(&self) -> usize {
self.group_index
}
#[must_use]
pub fn file_index(&self) -> usize {
self.file_index
}
#[must_use]
pub fn navigation_position(&self) -> (usize, usize) {
(self.group_index, self.file_index)
}
#[must_use]
pub fn group_scroll(&self) -> usize {
self.group_scroll
}
#[must_use]
pub fn file_scroll(&self) -> usize {
self.file_scroll
}
pub fn set_visible_rows(&mut self, rows: usize) {
self.visible_rows = rows.max(1);
}
#[must_use]
pub fn current_group(&self) -> Option<&DuplicateGroup> {
self.groups.get(self.group_index)
}
#[must_use]
pub fn current_file(&self) -> Option<&PathBuf> {
self.current_group()
.and_then(|g| g.files.get(self.file_index))
.map(|f| &f.path)
}
#[must_use]
pub fn current_file_entry(&self) -> Option<&crate::scanner::FileEntry> {
self.current_group()
.and_then(|g| g.files.get(self.file_index))
}
pub fn next(&mut self) {
if !self.mode.is_navigable() || self.groups.is_empty() {
return;
}
match self.mode {
AppMode::Reviewing => {
if let Some(group) = self.current_group() {
if self.file_index + 1 < group.files.len() {
self.file_index += 1;
self.update_file_scroll();
log::trace!("Navigate next: file_index = {}", self.file_index);
}
}
}
AppMode::SelectingFolder => {
if self.folder_index + 1 < self.folder_list.len() {
self.folder_index += 1;
log::trace!("Navigate next folder: folder_index = {}", self.folder_index);
}
}
_ => {}
}
}
pub fn previous(&mut self) {
if !self.mode.is_navigable() || self.groups.is_empty() {
return;
}
match self.mode {
AppMode::Reviewing => {
if self.file_index > 0 {
self.file_index -= 1;
self.update_file_scroll();
log::trace!("Navigate previous: file_index = {}", self.file_index);
}
}
AppMode::SelectingFolder => {
if self.folder_index > 0 {
self.folder_index -= 1;
log::trace!(
"Navigate previous folder: folder_index = {}",
self.folder_index
);
}
}
_ => {}
}
}
pub fn next_group(&mut self) {
if !self.mode.is_navigable() || self.groups.is_empty() {
return;
}
if self.group_index + 1 < self.groups.len() {
self.group_index += 1;
self.file_index = 0;
self.file_scroll = 0;
self.update_group_scroll();
log::trace!("Navigate next group: group_index = {}", self.group_index);
}
}
pub fn previous_group(&mut self) {
if !self.mode.is_navigable() || self.groups.is_empty() {
return;
}
if self.group_index > 0 {
self.group_index -= 1;
self.file_index = 0;
self.file_scroll = 0;
self.update_group_scroll();
log::trace!(
"Navigate previous group: group_index = {}",
self.group_index
);
}
}
fn update_file_scroll(&mut self) {
if self.file_index >= self.file_scroll + self.visible_rows {
self.file_scroll = self.file_index - self.visible_rows + 1;
}
if self.file_index < self.file_scroll {
self.file_scroll = self.file_index;
}
}
fn update_group_scroll(&mut self) {
if self.group_index >= self.group_scroll + self.visible_rows {
self.group_scroll = self.group_index - self.visible_rows + 1;
}
if self.group_index < self.group_scroll {
self.group_scroll = self.group_index;
}
}
#[must_use]
pub fn selected_files(&self) -> &HashSet<PathBuf> {
&self.selected_files
}
#[must_use]
pub fn selected_files_btree(&self) -> std::collections::BTreeSet<PathBuf> {
self.selected_files.iter().cloned().collect()
}
#[must_use]
pub fn selected_files_vec(&self) -> Vec<PathBuf> {
let mut files: Vec<PathBuf> = self.selected_files.iter().cloned().collect();
files.sort();
files
}
#[must_use]
pub fn selected_count(&self) -> usize {
self.selected_files.len()
}
#[must_use]
pub fn has_selections(&self) -> bool {
!self.selected_files.is_empty()
}
#[must_use]
pub fn is_file_selected(&self, path: &PathBuf) -> bool {
self.selected_files.contains(path)
}
#[must_use]
pub fn is_current_selected(&self) -> bool {
self.current_file()
.is_some_and(|f| self.selected_files.contains(f))
}
pub fn toggle_select(&mut self) {
if let Some(path) = self.current_file().cloned() {
if self.is_in_reference_dir(&path) {
self.set_error("Cannot select file in protected reference directory");
return;
}
if self.selected_files.contains(&path) {
self.selected_files.remove(&path);
log::debug!("Deselected: {}", path.display());
} else {
self.selected_files.insert(path.clone());
log::debug!("Selected: {}", path.display());
}
}
}
pub fn select(&mut self, path: PathBuf) {
self.selected_files.insert(path);
}
pub fn deselect(&mut self, path: &PathBuf) {
self.selected_files.remove(path);
}
pub fn select_all_in_group(&mut self) {
let files_to_select: Vec<PathBuf> = self
.current_group()
.map(|g| {
g.files
.iter()
.skip(1)
.filter(|f| !self.is_in_reference_dir(&f.path))
.map(|f| f.path.clone())
.collect()
})
.unwrap_or_default();
let count = files_to_select.len();
for path in files_to_select {
self.selected_files.insert(path);
}
if count > 0 {
log::debug!(
"Selected {} files in group (keeping first and skipping references)",
count
);
}
}
pub fn select_all_duplicates(&mut self) {
let mut count = 0;
for group in &self.groups {
for file in group.files.iter().skip(1) {
if !self.is_in_reference_dir(&file.path)
&& self.selected_files.insert(file.path.clone())
{
count += 1;
}
}
}
log::info!("Selected {} duplicates across ALL groups", count);
}
pub fn select_oldest(&mut self) {
let mut count = 0;
for group in &self.groups {
if let Some(newest) = group.files.iter().max_by_key(|f| f.modified) {
for file in &group.files {
if file.path != newest.path
&& !self.is_in_reference_dir(&file.path)
&& self.selected_files.insert(file.path.clone())
{
count += 1;
}
}
}
}
log::info!("Selected {} oldest files (kept newest)", count);
}
pub fn select_newest(&mut self) {
let mut count = 0;
for group in &self.groups {
if let Some(oldest) = group.files.iter().min_by_key(|f| f.modified) {
for file in &group.files {
if file.path != oldest.path
&& !self.is_in_reference_dir(&file.path)
&& self.selected_files.insert(file.path.clone())
{
count += 1;
}
}
}
}
log::info!("Selected {} newest files (kept oldest)", count);
}
pub fn select_smallest(&mut self) {
self.select_all_duplicates();
}
pub fn select_largest(&mut self) {
self.select_all_duplicates();
}
pub fn deselect_all(&mut self) {
let count = self.selected_files.len();
self.selected_files.clear();
log::debug!("Deselected all {} files", count);
}
pub fn remove_deleted_files(&mut self, deleted: &[PathBuf]) {
let deleted_set: std::collections::HashSet<&PathBuf> = deleted.iter().collect();
self.selected_files.retain(|p| !deleted_set.contains(p));
for group in &mut self.groups {
group.files.retain(|f| !deleted_set.contains(&f.path));
}
self.groups.retain(|g| g.files.len() >= 2);
self.reclaimable_space = self.groups.iter().map(DuplicateGroup::wasted_space).sum();
if self.group_index >= self.groups.len() && !self.groups.is_empty() {
self.group_index = self.groups.len() - 1;
}
if let Some(group) = self.current_group() {
if self.file_index >= group.files.len() && !group.files.is_empty() {
self.file_index = group.files.len() - 1;
}
} else {
self.file_index = 0;
}
log::info!(
"Removed {} deleted files, {} groups remaining",
deleted.len(),
self.groups.len()
);
}
#[must_use]
pub fn scan_progress(&self) -> &ScanProgress {
&self.scan_progress
}
pub fn update_scan_progress(&mut self, phase: &str, current: usize, total: usize, path: &str) {
self.scan_progress.phase = phase.to_string();
self.scan_progress.current = current;
self.scan_progress.total = total;
self.scan_progress.current_path = path.to_string();
}
pub fn set_scan_message(&mut self, message: &str) {
self.scan_progress.message = message.to_string();
}
#[must_use]
pub fn error_message(&self) -> Option<&str> {
self.error_message.as_deref()
}
pub fn set_error(&mut self, message: &str) {
self.error_message = Some(message.to_string());
log::error!("App error: {}", message);
}
pub fn clear_error(&mut self) {
self.error_message = None;
}
#[must_use]
pub fn preview_content(&self) -> Option<&str> {
self.preview_content.as_deref()
}
pub fn set_preview(&mut self, content: String) {
self.preview_content = Some(content);
}
pub fn clear_preview(&mut self) {
self.preview_content = None;
}
#[must_use]
pub fn folder_list(&self) -> &[PathBuf] {
&self.folder_list
}
#[must_use]
pub fn folder_index(&self) -> usize {
self.folder_index
}
pub fn enter_folder_selection(&mut self) {
if let Some(group) = self.current_group() {
let mut folders: Vec<PathBuf> = group
.files
.iter()
.filter_map(|f| f.path.parent().map(|p| p.to_path_buf()))
.collect();
folders.sort();
folders.dedup();
self.folder_list = folders;
self.folder_index = 0;
self.set_mode(AppMode::SelectingFolder);
}
}
pub fn select_by_folder(&mut self) {
let folder = match self.folder_list.get(self.folder_index) {
Some(f) => f.clone(),
None => {
self.set_mode(AppMode::Reviewing);
return;
}
};
let files_to_select: Vec<PathBuf> = if let Some(group) = self.current_group() {
let in_folder_count = group
.files
.iter()
.filter(|f| f.path.starts_with(&folder))
.count();
if in_folder_count >= group.files.len() {
self.set_error("Cannot select all files in group - at least one must be preserved");
self.set_mode(AppMode::Reviewing);
return;
}
group
.files
.iter()
.filter(|f| f.path.starts_with(&folder) && !self.is_in_reference_dir(&f.path))
.map(|f| f.path.clone())
.collect()
} else {
Vec::new()
};
let count = files_to_select.len();
for path in files_to_select {
self.selected_files.insert(path);
}
if count > 0 {
log::info!(
"Selected {} files in folder {} (skipping references)",
count,
folder.display()
);
}
self.set_mode(AppMode::Reviewing);
}
pub fn handle_action(&mut self, action: Action) -> bool {
log::trace!("Handling action: {:?} in mode {:?}", action, self.mode);
match action {
Action::NavigateUp => {
self.previous();
true
}
Action::NavigateDown => {
self.next();
true
}
Action::NextGroup => {
self.next_group();
true
}
Action::PreviousGroup => {
self.previous_group();
true
}
Action::ToggleSelect => {
self.toggle_select();
true
}
Action::SelectAllInGroup => {
self.select_all_in_group();
true
}
Action::SelectAllDuplicates => {
self.select_all_duplicates();
true
}
Action::SelectOldest => {
self.select_oldest();
true
}
Action::SelectNewest => {
self.select_newest();
true
}
Action::SelectSmallest => {
self.select_smallest();
true
}
Action::SelectLargest => {
self.select_largest();
true
}
Action::DeselectAll => {
self.deselect_all();
true
}
Action::Preview => {
if self.mode == AppMode::Reviewing && self.current_file().is_some() {
self.set_mode(AppMode::Previewing);
true
} else {
false
}
}
Action::SelectFolder => {
if self.mode == AppMode::Reviewing && self.current_group().is_some() {
self.enter_folder_selection();
true
} else {
false
}
}
Action::Delete => {
if self.dry_run {
self.set_error("Cannot delete files in dry-run mode");
return true; }
if self.mode == AppMode::Reviewing && self.has_selections() {
self.set_mode(AppMode::Confirming);
true
} else {
false
}
}
Action::ToggleTheme => {
self.toggle_theme();
true
}
Action::Confirm => {
if self.mode == AppMode::SelectingFolder {
self.select_by_folder();
true
} else {
true
}
}
Action::Cancel => {
match self.mode {
AppMode::Previewing => {
self.clear_preview();
self.set_mode(AppMode::Reviewing);
}
AppMode::Confirming => {
self.set_mode(AppMode::Reviewing);
}
AppMode::SelectingFolder => {
self.set_mode(AppMode::Reviewing);
}
_ => {}
}
true
}
Action::Quit => {
self.set_mode(AppMode::Quitting);
true
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_group(size: u64, paths: Vec<&str>) -> DuplicateGroup {
DuplicateGroup::new(
[0u8; 32],
size,
paths
.into_iter()
.map(|p| {
crate::scanner::FileEntry::new(
PathBuf::from(p),
size,
std::time::SystemTime::now(),
)
})
.collect(),
Vec::new(),
)
}
#[test]
fn test_app_new() {
let app = App::new();
assert_eq!(app.mode(), AppMode::Scanning);
assert!(app.groups().is_empty());
assert_eq!(app.group_index(), 0);
assert_eq!(app.file_index(), 0);
assert!(!app.has_selections());
}
#[test]
fn test_app_with_groups() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let app = App::with_groups(groups);
assert_eq!(app.mode(), AppMode::Reviewing);
assert_eq!(app.group_count(), 1);
assert_eq!(app.reclaimable_space(), 100); }
#[test]
fn test_app_with_empty_groups() {
let app = App::with_groups(vec![]);
assert_eq!(app.mode(), AppMode::Scanning);
assert!(!app.has_groups());
}
#[test]
fn test_set_groups() {
let mut app = App::new();
let groups = vec![
make_group(100, vec!["/a.txt", "/b.txt"]),
make_group(200, vec!["/c.txt", "/d.txt", "/e.txt"]),
];
app.set_groups(groups);
assert_eq!(app.group_count(), 2);
assert_eq!(app.reclaimable_space(), 100 + 400); assert_eq!(app.group_index(), 0);
assert_eq!(app.file_index(), 0);
}
#[test]
fn test_navigation_next_previous() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt", "/c.txt"])];
let mut app = App::with_groups(groups);
assert_eq!(app.file_index(), 0);
app.next();
assert_eq!(app.file_index(), 1);
app.next();
assert_eq!(app.file_index(), 2);
app.next();
assert_eq!(app.file_index(), 2);
app.previous();
assert_eq!(app.file_index(), 1);
app.previous();
assert_eq!(app.file_index(), 0);
app.previous();
assert_eq!(app.file_index(), 0);
}
#[test]
fn test_navigation_groups() {
let groups = vec![
make_group(100, vec!["/a.txt", "/b.txt"]),
make_group(200, vec!["/c.txt", "/d.txt"]),
make_group(300, vec!["/e.txt", "/f.txt"]),
];
let mut app = App::with_groups(groups);
assert_eq!(app.group_index(), 0);
app.next_group();
assert_eq!(app.group_index(), 1);
assert_eq!(app.file_index(), 0);
app.next_group();
assert_eq!(app.group_index(), 2);
app.next_group();
assert_eq!(app.group_index(), 2);
app.previous_group();
assert_eq!(app.group_index(), 1);
app.previous_group();
assert_eq!(app.group_index(), 0);
app.previous_group();
assert_eq!(app.group_index(), 0);
}
#[test]
fn test_navigation_not_in_reviewing_mode() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
app.set_mode(AppMode::Scanning);
app.next();
assert_eq!(app.file_index(), 0); }
#[test]
fn test_folder_selection() {
let groups = vec![make_group(
100,
vec!["/dir1/a.txt", "/dir1/b.txt", "/dir2/c.txt"],
)];
let mut app = App::with_groups(groups);
app.enter_folder_selection();
assert_eq!(app.mode(), AppMode::SelectingFolder);
assert_eq!(app.folder_list().len(), 2);
assert!(app.folder_list().contains(&PathBuf::from("/dir1")));
assert!(app.folder_list().contains(&PathBuf::from("/dir2")));
if app.folder_list()[0] != PathBuf::from("/dir1") {
app.next();
}
assert_eq!(
app.folder_list()[app.folder_index()],
PathBuf::from("/dir1")
);
app.select_by_folder();
assert_eq!(app.mode(), AppMode::Reviewing);
assert!(app.is_file_selected(&PathBuf::from("/dir1/a.txt")));
assert!(app.is_file_selected(&PathBuf::from("/dir1/b.txt")));
assert!(!app.is_file_selected(&PathBuf::from("/dir2/c.txt")));
}
#[test]
fn test_folder_selection_prevents_selecting_all() {
let groups = vec![make_group(100, vec!["/dir1/a.txt", "/dir1/b.txt"])];
let mut app = App::with_groups(groups);
app.enter_folder_selection();
app.select_by_folder();
assert!(app.error_message().is_some());
assert_eq!(app.selected_count(), 0);
}
#[test]
fn test_toggle_select() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
assert!(!app.is_current_selected());
app.toggle_select();
assert!(app.is_current_selected());
assert_eq!(app.selected_count(), 1);
app.toggle_select();
assert!(!app.is_current_selected());
assert_eq!(app.selected_count(), 0);
}
#[test]
fn test_select_all_in_group() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt", "/c.txt"])];
let mut app = App::with_groups(groups);
app.select_all_in_group();
assert!(!app.is_file_selected(&PathBuf::from("/a.txt")));
assert!(app.is_file_selected(&PathBuf::from("/b.txt")));
assert!(app.is_file_selected(&PathBuf::from("/c.txt")));
assert_eq!(app.selected_count(), 2);
}
#[test]
fn test_deselect_all() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt", "/c.txt"])];
let mut app = App::with_groups(groups);
app.select_all_in_group();
assert_eq!(app.selected_count(), 2);
app.deselect_all();
assert_eq!(app.selected_count(), 0);
}
#[test]
fn test_selected_files_vec() {
let groups = vec![make_group(100, vec!["/z.txt", "/a.txt", "/m.txt"])];
let mut app = App::with_groups(groups);
app.select_all_in_group();
let selected = app.selected_files_vec();
assert_eq!(
selected,
vec![PathBuf::from("/a.txt"), PathBuf::from("/m.txt")]
);
}
#[test]
fn test_remove_deleted_files() {
let groups = vec![
make_group(100, vec!["/a.txt", "/b.txt", "/c.txt"]),
make_group(200, vec!["/d.txt", "/e.txt"]),
];
let mut app = App::with_groups(groups);
app.select(PathBuf::from("/b.txt"));
app.select(PathBuf::from("/e.txt"));
app.remove_deleted_files(&[PathBuf::from("/b.txt"), PathBuf::from("/e.txt")]);
assert_eq!(app.groups()[0].files.len(), 2);
assert!(!app.groups()[0]
.files
.iter()
.any(|f| f.path == PathBuf::from("/b.txt")));
assert_eq!(app.group_count(), 1);
assert!(!app.is_file_selected(&PathBuf::from("/b.txt")));
assert!(!app.is_file_selected(&PathBuf::from("/e.txt")));
}
#[test]
fn test_current_file() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
assert_eq!(app.current_file(), Some(&PathBuf::from("/a.txt")));
app.next();
assert_eq!(app.current_file(), Some(&PathBuf::from("/b.txt")));
}
#[test]
fn test_current_group() {
let groups = vec![
make_group(100, vec!["/a.txt", "/b.txt"]),
make_group(200, vec!["/c.txt", "/d.txt"]),
];
let mut app = App::with_groups(groups);
let group = app.current_group().unwrap();
assert_eq!(group.size, 100);
app.next_group();
let group = app.current_group().unwrap();
assert_eq!(group.size, 200);
}
#[test]
fn test_mode_transitions() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
assert_eq!(app.mode(), AppMode::Reviewing);
app.set_mode(AppMode::Previewing);
assert_eq!(app.mode(), AppMode::Previewing);
app.set_mode(AppMode::Confirming);
assert_eq!(app.mode(), AppMode::Confirming);
app.set_mode(AppMode::Quitting);
assert!(app.should_quit());
}
#[test]
fn test_handle_action_navigate() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt", "/c.txt"])];
let mut app = App::with_groups(groups);
assert!(app.handle_action(Action::NavigateDown));
assert_eq!(app.file_index(), 1);
assert!(app.handle_action(Action::NavigateUp));
assert_eq!(app.file_index(), 0);
}
#[test]
fn test_handle_action_toggle_select() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
assert!(app.handle_action(Action::ToggleSelect));
assert!(app.is_current_selected());
}
#[test]
fn test_handle_action_preview() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
assert!(app.handle_action(Action::Preview));
assert_eq!(app.mode(), AppMode::Previewing);
}
#[test]
fn test_handle_action_delete_requires_selection() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
assert!(!app.handle_action(Action::Delete));
assert_eq!(app.mode(), AppMode::Reviewing);
app.toggle_select();
assert!(app.handle_action(Action::Delete));
assert_eq!(app.mode(), AppMode::Confirming);
}
#[test]
fn test_handle_action_cancel() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
app.set_mode(AppMode::Previewing);
assert!(app.handle_action(Action::Cancel));
assert_eq!(app.mode(), AppMode::Reviewing);
app.toggle_select();
app.set_mode(AppMode::Confirming);
assert!(app.handle_action(Action::Cancel));
assert_eq!(app.mode(), AppMode::Reviewing);
}
#[test]
fn test_handle_action_quit() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups);
assert!(app.handle_action(Action::Quit));
assert!(app.should_quit());
}
#[test]
fn test_dry_run_blocks_delete() {
let groups = vec![make_group(100, vec!["/a.txt", "/b.txt"])];
let mut app = App::with_groups(groups).with_dry_run(true);
app.toggle_select();
assert!(app.has_selections());
assert!(app.handle_action(Action::Delete));
assert_eq!(app.mode(), AppMode::Reviewing);
assert!(app.error_message().is_some());
assert!(app.error_message().unwrap().contains("dry-run"));
}
#[test]
fn test_scan_progress() {
let mut app = App::new();
app.update_scan_progress("Walking", 50, 100, "/some/path/file.txt");
let progress = app.scan_progress();
assert_eq!(progress.phase, "Walking");
assert_eq!(progress.current, 50);
assert_eq!(progress.total, 100);
assert_eq!(progress.percentage(), 50);
}
#[test]
fn test_scan_progress_percentage() {
let mut progress = ScanProgress::new();
assert_eq!(progress.percentage(), 0);
progress.total = 100;
progress.current = 25;
assert_eq!(progress.percentage(), 25);
progress.current = 100;
assert_eq!(progress.percentage(), 100);
progress.current = 150;
assert_eq!(progress.percentage(), 100);
}
#[test]
fn test_error_handling() {
let mut app = App::new();
assert!(app.error_message().is_none());
app.set_error("Something went wrong");
assert_eq!(app.error_message(), Some("Something went wrong"));
app.clear_error();
assert!(app.error_message().is_none());
}
#[test]
fn test_preview_handling() {
let mut app = App::new();
assert!(app.preview_content().is_none());
app.set_preview("File content here".to_string());
assert_eq!(app.preview_content(), Some("File content here"));
app.clear_preview();
assert!(app.preview_content().is_none());
}
#[test]
fn test_app_mode_is_navigable() {
assert!(!AppMode::Scanning.is_navigable());
assert!(AppMode::Reviewing.is_navigable());
assert!(!AppMode::Previewing.is_navigable());
assert!(!AppMode::Confirming.is_navigable());
assert!(!AppMode::Quitting.is_navigable());
}
#[test]
fn test_app_mode_is_done() {
assert!(!AppMode::Scanning.is_done());
assert!(!AppMode::Reviewing.is_done());
assert!(!AppMode::Previewing.is_done());
assert!(!AppMode::Confirming.is_done());
assert!(AppMode::Quitting.is_done());
}
#[test]
fn test_duplicate_file_count() {
let groups = vec![
make_group(100, vec!["/a.txt", "/b.txt"]), make_group(200, vec!["/c.txt", "/d.txt", "/e.txt"]), ];
let app = App::with_groups(groups);
assert_eq!(app.duplicate_file_count(), 5);
}
#[test]
fn test_apply_session_validation() {
let groups = vec![
make_group(100, vec!["/a.txt", "/b.txt"]),
make_group(200, vec!["/c.txt", "/d.txt"]),
];
let mut app = App::with_groups(groups);
let mut selections = std::collections::BTreeSet::new();
selections.insert(PathBuf::from("/b.txt"));
app.apply_session(selections, 1, 1);
assert_eq!(app.group_index(), 1);
assert_eq!(app.file_index(), 1);
assert!(app.is_file_selected(&PathBuf::from("/b.txt")));
app.apply_session(std::collections::BTreeSet::new(), 5, 0);
assert_eq!(app.group_index(), 0);
assert_eq!(app.file_index(), 0);
app.apply_session(std::collections::BTreeSet::new(), 0, 10);
assert_eq!(app.group_index(), 0);
assert_eq!(app.file_index(), 0);
}
}