diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3a2dc6f..a082c95 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,5 +32,10 @@ jobs: - name: Lint run: cargo clippy - - name: Run tests + - name: Run tests (Windows) + if: matrix.os == 'windows-latest' + run: cargo test --verbose -- --test-threads=1 + + - name: Run tests (Unix) + if: matrix.os != 'windows-latest' run: cargo test --verbose diff --git a/Cargo.lock b/Cargo.lock index 63e6206..c3eb009 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -276,6 +276,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "content_inspector" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7bda66e858c683005a53a9a60c69a4aca7eeaa45d124526e389f7aec8e62f38" +dependencies = [ + "memchr", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1042,10 +1051,11 @@ dependencies = [ [[package]] name = "scooter" -version = "0.1.2" +version = "0.2.0" dependencies = [ "anyhow", "clap", + "content_inspector", "crossterm", "dirs", "etcetera", @@ -1053,6 +1063,7 @@ dependencies = [ "ignore", "itertools", "log", + "rand", "ratatui", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index 785fbd0..ccf62b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "scooter" -version = "0.1.2" +version = "0.2.0" edition = "2021" authors = ["thomasschafer97@gmail.com"] license = "MIT" @@ -14,6 +14,7 @@ categories = ["command-line-utilities"] [dependencies] anyhow = "1.0.86" clap = { version = "4.5.18", features = ["derive"] } +content_inspector = "0.2.4" crossterm = { version = "0.27", features = ["event-stream"] } dirs = "5.0.1" etcetera = "0.8.0" @@ -31,6 +32,7 @@ tokio = { version = "1.40.0", features = ["full"] } [dev-dependencies] tempfile = "3.12.0" +rand = "0.8.5" [lib] name = "scooter" diff --git a/media/preview.gif b/media/preview.gif index 5eb4b89..5e0e241 100644 Binary files a/media/preview.gif and b/media/preview.gif differ diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..19b9a4e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" # Allows us to use MappedLockGuard diff --git a/src/app.rs b/src/app.rs index f6d52b7..1bd099f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,48 +1,33 @@ -use ignore::WalkBuilder; +use ignore::WalkState; use itertools::Itertools; -use log::{info, warn}; +use log::info; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use regex::Regex; use std::{ - cell::{RefCell, RefMut}, collections::HashMap, fs::{self, File}, io::{BufRead, BufReader, BufWriter, Write}, + mem, path::{Path, PathBuf}, - rc::Rc, + sync::{ + Arc, MappedRwLockReadGuard, MappedRwLockWriteGuard, RwLock, RwLockReadGuard, + RwLockWriteGuard, + }, + time::{Duration, Instant}, +}; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + task::JoinHandle, }; -use tokio::sync::mpsc; use crate::{ + event::{AppEvent, BackgroundProcessingEvent, ReplaceResult, SearchResult}, fields::{CheckboxField, Field, TextField}, - utils::replace_start, + parsed_fields::{ParsedFields, SearchType}, + utils::relative_path_from, + EventHandlingResult, }; -#[derive(Debug, Eq, PartialEq)] -pub enum CurrentScreen { - Searching, - PerformingSearch, - Confirmation, - PerformingReplacement, - Results, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum ReplaceResult { - Success, - Error(String), -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SearchResult { - pub path: PathBuf, - pub line_number: usize, - pub line: String, - pub replacement: String, - pub included: bool, - pub replace_result: Option, -} - #[derive(Debug, Eq, PartialEq)] pub struct SearchState { pub results: Vec, @@ -84,6 +69,28 @@ pub struct ReplaceState { } impl ReplaceState { + fn handle_key_results(&mut self, key: &KeyEvent) -> bool { + let mut exit = false; + match (key.code, key.modifiers) { + (KeyCode::Char('j') | KeyCode::Down, _) + | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { + self.scroll_replacement_errors_down(); + } + (KeyCode::Char('k') | KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { + self.scroll_replacement_errors_up(); + } + (KeyCode::Char('d'), KeyModifiers::CONTROL) => {} // TODO + (KeyCode::PageDown, _) => {} // TODO + (KeyCode::Char('u'), KeyModifiers::CONTROL) => {} // TODO + (KeyCode::PageUp, _) => {} // TODO + (KeyCode::Enter | KeyCode::Char('q'), _) => { + exit = true; + } + _ => {} + }; + exit + } + pub fn scroll_replacement_errors_up(&mut self) { if self.replacement_errors_pos == 0 { self.replacement_errors_pos = self.errors.len(); @@ -100,50 +107,53 @@ impl ReplaceState { } } -#[derive(Debug, Eq, PartialEq)] -pub enum Results { - Loading, - SearchComplete(SearchState), - ReplaceComplete(ReplaceState), +#[derive(Debug)] +pub struct SearchInProgressState { + pub search_state: SearchState, + pub last_render: Instant, + pub handle: JoinHandle<()>, + pub processing_sender: UnboundedSender, + pub processing_receiver: UnboundedReceiver, } -impl Results { - fn name(&self) -> String { - match self { - Self::Loading => "Loading", - Self::SearchComplete(_) => "SearchComplete", - Self::ReplaceComplete(_) => "ReplaceComplete", +impl SearchInProgressState { + fn new( + handle: JoinHandle<()>, + processing_sender: UnboundedSender, + processing_receiver: UnboundedReceiver, + ) -> Self { + Self { + search_state: SearchState { + results: vec![], + selected: 0, + }, + last_render: Instant::now(), + handle, + processing_sender, + processing_receiver, } - .to_owned() } } -macro_rules! complete_state_impl { - ($self:ident, $variant:ident) => { - match $self { - Results::$variant(state) => state, - _ => { - panic!("Expected {}, found {}", stringify!($variant), $self.name()) - } - } - }; +#[derive(Debug)] +pub enum Screen { + SearchFields, + SearchProgressing(SearchInProgressState), + SearchComplete(SearchState), + PerformingReplacement, + Results(ReplaceState), } -impl Results { - pub fn search_complete(&self) -> &SearchState { - complete_state_impl!(self, SearchComplete) - } - - pub fn search_complete_mut(&mut self) -> &mut SearchState { - complete_state_impl!(self, SearchComplete) - } - - pub fn replace_complete(&self) -> &ReplaceState { - complete_state_impl!(self, ReplaceComplete) - } - - pub fn replace_complete_mut(&mut self) -> &mut ReplaceState { - complete_state_impl!(self, ReplaceComplete) +impl Screen { + pub fn search_results_mut(&mut self) -> &mut SearchState { + match self { + Screen::SearchProgressing(SearchInProgressState { search_state, .. }) => search_state, + Screen::SearchComplete(search_state) => search_state, + _ => panic!( + "Expected SearchInProgress or SearchComplete, found {:?}", + self + ), + } } } @@ -157,41 +167,58 @@ pub enum FieldName { pub struct SearchField { pub name: FieldName, - pub field: Rc>, + pub field: Arc>, } -impl SearchField { - #[allow(dead_code)] // TODO: use - fn set_error(&mut self, error: String) { - self.field.borrow_mut().set_error(error); - } -} - -pub const NUM_SEARCH_FIELDS: usize = 4; // needed because Ratatui .areas method returns an array +pub const NUM_SEARCH_FIELDS: usize = 4; pub struct SearchFields { pub fields: [SearchField; NUM_SEARCH_FIELDS], pub highlighted: usize, } -// TODO: add non-mutable versions macro_rules! define_field_accessor { ($method_name:ident, $field_name:expr, $field_variant:ident, $return_type:ty) => { - pub fn $method_name(&self) -> RefMut<'_, $return_type> { - self.fields + pub fn $method_name(&self) -> MappedRwLockReadGuard<'_, $return_type> { + let field = self + .fields .iter() .find(|SearchField { name, .. }| *name == $field_name) - .and_then(|SearchField { field, .. }| { - RefMut::filter_map(field.borrow_mut(), |f| { - if let Field::$field_variant(inner) = f { - Some(inner) - } else { - None - } - }) - .ok() - }) - .expect("Couldn't find field") + .expect("Couldn't find field"); + + RwLockReadGuard::map( + field.field.read().expect("Failed to acquire read lock"), + |f| { + if let Field::$field_variant(ref inner) = f { + inner + } else { + panic!("Incorrect field type") + } + }, + ) + } + }; +} + +macro_rules! define_field_accessor_mut { + ($method_name:ident, $field_name:expr, $field_variant:ident, $return_type:ty) => { + pub fn $method_name(&self) -> MappedRwLockWriteGuard<'_, $return_type> { + let field = self + .fields + .iter() + .find(|SearchField { name, .. }| *name == $field_name) + .expect("Couldn't find field"); + + RwLockWriteGuard::map( + field.field.write().expect("Failed to acquire write lock"), + |f| { + if let Field::$field_variant(ref mut inner) = f { + inner + } else { + panic!("Incorrect field type") + } + }, + ) } }; } @@ -207,87 +234,84 @@ impl SearchFields { ); define_field_accessor!(path_pattern, FieldName::PathPattern, Text, TextField); - pub fn focus_next(&mut self) { - self.highlighted = (self.highlighted + 1) % self.fields.len(); - } - - pub fn focus_prev(&mut self) { - self.highlighted = - (self.highlighted + self.fields.len().saturating_sub(1)) % self.fields.len(); - } - - pub fn highlighted_field(&self) -> &Rc> { - &self.fields[self.highlighted].field - } - - pub fn search_type(&self) -> anyhow::Result { - let search = self.search(); - let search_text = search.text(); - let result = if self.fixed_strings().checked { - SearchType::Fixed(search_text) - } else { - SearchType::Pattern(Regex::new(&search_text)?) - }; - Ok(result) - } - - pub fn clear_errors(&mut self) { - self.fields.iter().for_each(|field| { - field.field.borrow_mut().clear_error(); - }); - } + define_field_accessor_mut!(search_mut, FieldName::Search, Text, TextField); + define_field_accessor_mut!(path_pattern_mut, FieldName::PathPattern, Text, TextField); pub fn with_values( search: impl Into, replace: impl Into, fixed_strings: bool, - filname_pattern: impl Into, + filename_pattern: impl Into, ) -> Self { Self { fields: [ SearchField { name: FieldName::Search, - field: Rc::new(RefCell::new(Field::text(search.into()))), + field: Arc::new(RwLock::new(Field::text(search.into()))), }, SearchField { name: FieldName::Replace, - field: Rc::new(RefCell::new(Field::text(replace.into()))), + field: Arc::new(RwLock::new(Field::text(replace.into()))), }, SearchField { name: FieldName::FixedStrings, - field: Rc::new(RefCell::new(Field::checkbox(fixed_strings))), + field: Arc::new(RwLock::new(Field::checkbox(fixed_strings))), }, SearchField { name: FieldName::PathPattern, - field: Rc::new(RefCell::new(Field::text(filname_pattern.into()))), + field: Arc::new(RwLock::new(Field::text(filename_pattern.into()))), }, ], highlighted: 0, } } -} -pub enum SearchType { - Pattern(Regex), - Fixed(String), -} + pub fn highlighted_field(&self) -> &Arc> { + &self.fields[self.highlighted].field + } -#[derive(Clone, Debug)] -pub enum AppEvent { - Rerender, - PerformSearch, - PerformReplacement, + pub fn focus_next(&mut self) { + self.highlighted = (self.highlighted + 1) % self.fields.len(); + } + + pub fn focus_prev(&mut self) { + self.highlighted = + (self.highlighted + self.fields.len().saturating_sub(1)) % self.fields.len(); + } + + pub fn clear_errors(&mut self) { + self.fields + .iter_mut() + .try_for_each(|field| { + field + .field + .write() + .map(|mut f| f.clear_error()) + .map_err(|e| format!("Failed to clear error: {}", e)) + }) + .expect("Failed to clear field errors"); + } + + pub fn search_type(&self) -> anyhow::Result { + let search = self.search(); + let search_text = search.text(); + let result = if self.fixed_strings().checked { + SearchType::Fixed(search_text) + } else { + SearchType::Pattern(Regex::new(&search_text)?) + }; + Ok(result) + } } pub struct App { - pub current_screen: CurrentScreen, + pub current_screen: Screen, pub search_fields: SearchFields, - pub results: Results, pub directory: PathBuf, pub include_hidden: bool, pub running: bool, - pub event_sender: mpsc::UnboundedSender, + pub app_event_sender: UnboundedSender, } const BINARY_EXTENSIONS: &[&str] = &["png", "gif", "jpg", "jpeg", "ico", "svg", "pdf"]; @@ -296,62 +320,177 @@ impl App { pub fn new( directory: Option, include_hidden: bool, - event_sender: mpsc::UnboundedSender, - ) -> App { + app_event_sender: UnboundedSender, + ) -> Self { let directory = match directory { Some(d) => d, None => std::env::current_dir().unwrap(), }; - App { - current_screen: CurrentScreen::Searching, + Self { + current_screen: Screen::SearchFields, search_fields: SearchFields::with_values("", "", false, ""), - results: Results::Loading, directory, // TODO: add this as a field that can be edited, e.g. allow glob patterns include_hidden, running: true, - event_sender, + app_event_sender, } } + pub fn cancel_search(&mut self) { + if let Screen::SearchProgressing(SearchInProgressState { handle, .. }) = + &mut self.current_screen + { + handle.abort(); + } + self.current_screen = Screen::SearchFields; + } + pub fn reset(&mut self) { + self.cancel_search(); *self = Self::new( Some(self.directory.clone()), self.include_hidden, - self.event_sender.clone(), + self.app_event_sender.clone(), ); } - pub fn handle_event(&mut self, event: AppEvent) -> bool { + pub async fn background_processing_recv(&mut self) -> Option { + if let Screen::SearchProgressing(SearchInProgressState { + processing_receiver, + .. + }) = &mut self.current_screen + { + processing_receiver.recv().await + } else { + None + } + } + + #[allow(dead_code)] + pub fn background_processing_sender( + &mut self, + ) -> Option<&mut UnboundedSender> { + if let Screen::SearchProgressing(SearchInProgressState { + processing_sender, .. + }) = &mut self.current_screen + { + Some(processing_sender) + } else { + None + } + } + + pub async fn handle_app_event(&mut self, event: AppEvent) -> EventHandlingResult { match event { - AppEvent::Rerender => {} - AppEvent::PerformSearch => { - let continue_to_confirmation = self - .update_search_results() - .expect("Failed to unwrap search results"); - self.current_screen = if continue_to_confirmation { - CurrentScreen::Confirmation - } else { - CurrentScreen::Searching - }; - self.event_sender.send(AppEvent::Rerender).unwrap(); + AppEvent::Rerender => EventHandlingResult { + exit: false, + rerender: true, + }, + AppEvent::PerformSearch => self.perform_search_if_valid(), + AppEvent::PerformReplacement(mut search_state) => { + self.perform_replacement(&mut search_state) + } + } + } + + pub fn perform_search_if_valid(&mut self) -> EventHandlingResult { + let (background_processing_sender, background_processing_receiver) = + mpsc::unbounded_channel(); + + match self + .validate_fields(background_processing_sender.clone()) + .unwrap() + { + None => { + self.current_screen = Screen::SearchFields; } - AppEvent::PerformReplacement => { - self.perform_replacement(); - self.current_screen = CurrentScreen::Results; - self.event_sender.send(AppEvent::Rerender).unwrap(); + Some(parsed_fields) => { + let handle = Self::update_search_results( + parsed_fields, + background_processing_sender.clone(), + ); + self.current_screen = Screen::SearchProgressing(SearchInProgressState::new( + handle, + background_processing_sender, + background_processing_receiver, + )); } }; - false + + EventHandlingResult { + exit: false, + rerender: true, + } + } + + pub fn perform_replacement(&mut self, search_state: &mut SearchState) -> EventHandlingResult { + for (path, results) in &search_state + .results + .iter_mut() + .filter(|res| res.included) + .chunk_by(|res| res.path.clone()) + { + let mut results = results.collect::>(); + if let Err(file_err) = Self::replace_in_file(path, &mut results) { + results.iter_mut().for_each(|res| { + res.replace_result = Some(ReplaceResult::Error(file_err.to_string())) + }); + } + } + + let replace_state = self.calculate_statistics(&search_state.results); + + self.current_screen = Screen::Results(replace_state); + EventHandlingResult { + exit: false, + rerender: true, + } + } + + pub fn handle_background_processing_event( + &mut self, + event: BackgroundProcessingEvent, + ) -> EventHandlingResult { + match event { + BackgroundProcessingEvent::AddSearchResult(result) => { + let mut rerender = false; + if let Screen::SearchProgressing(search_in_progress_state) = + &mut self.current_screen + { + search_in_progress_state.search_state.results.push(result); + + if search_in_progress_state.last_render.elapsed() >= Duration::from_millis(100) + { + rerender = true; + search_in_progress_state.last_render = Instant::now(); + } + } + EventHandlingResult { + exit: false, + rerender, + } + } + BackgroundProcessingEvent::SearchCompleted => { + if let Screen::SearchProgressing(SearchInProgressState { search_state, .. }) = + mem::replace(&mut self.current_screen, Screen::SearchFields) + { + self.current_screen = Screen::SearchComplete(search_state); + } + EventHandlingResult { + exit: false, + rerender: true, + } + } + } } fn handle_key_searching(&mut self, key: &KeyEvent) -> bool { self.search_fields.clear_errors(); match (key.code, key.modifiers) { (KeyCode::Enter, _) => { - self.current_screen = CurrentScreen::PerformingSearch; - self.event_sender.send(AppEvent::PerformSearch).unwrap(); + self.app_event_sender.send(AppEvent::PerformSearch).unwrap(); } (KeyCode::BackTab, _) | (KeyCode::Tab, KeyModifiers::ALT) => { self.search_fields.focus_prev(); @@ -362,7 +501,8 @@ impl App { (code, modifiers) => { self.search_fields .highlighted_field() - .borrow_mut() + .write() + .unwrap() .handle_keys(code, modifiers); } }; @@ -373,89 +513,93 @@ impl App { match (key.code, key.modifiers) { (KeyCode::Char('j') | KeyCode::Down, _) | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { - self.results.search_complete_mut().move_selected_down(); + self.current_screen + .search_results_mut() + .move_selected_down(); } (KeyCode::Char('k') | KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { - self.results.search_complete_mut().move_selected_up(); + // TODO: need to fix issue where screen gets out of sync with state + self.current_screen.search_results_mut().move_selected_up(); } (KeyCode::Char(' '), _) => { - self.results - .search_complete_mut() + self.current_screen + .search_results_mut() .toggle_selected_inclusion(); } (KeyCode::Enter, _) => { - self.current_screen = CurrentScreen::PerformingReplacement; - self.event_sender - .send(AppEvent::PerformReplacement) - .unwrap(); + if matches!(self.current_screen, Screen::SearchComplete(_)) { + if let Screen::SearchComplete(search_state) = + mem::replace(&mut self.current_screen, Screen::PerformingReplacement) + { + self.app_event_sender + .send(AppEvent::PerformReplacement(search_state)) + .unwrap(); + } else { + panic!("Expected SearchComplete, found {:?}", self.current_screen); + } + } } (KeyCode::Char('o'), KeyModifiers::CONTROL) => { - self.current_screen = CurrentScreen::Searching; - self.event_sender.send(AppEvent::Rerender).unwrap(); + self.cancel_search(); + self.current_screen = Screen::SearchFields; + self.app_event_sender.send(AppEvent::Rerender).unwrap(); } _ => {} }; false } - fn handle_key_results(&mut self, key: &KeyEvent) -> bool { - let mut exit = false; - match (key.code, key.modifiers) { - (KeyCode::Char('j') | KeyCode::Down, _) - | (KeyCode::Char('n'), KeyModifiers::CONTROL) => { - self.results - .replace_complete_mut() - .scroll_replacement_errors_down(); - } - (KeyCode::Char('k') | KeyCode::Up, _) | (KeyCode::Char('p'), KeyModifiers::CONTROL) => { - self.results - .replace_complete_mut() - .scroll_replacement_errors_up(); - } - (KeyCode::Char('d'), KeyModifiers::CONTROL) => {} // TODO - (KeyCode::PageDown, _) => {} // TODO - (KeyCode::Char('u'), KeyModifiers::CONTROL) => {} // TODO - (KeyCode::PageUp, _) => {} // TODO - (KeyCode::Enter | KeyCode::Char('q'), _) => { - exit = true; - } - _ => {} - }; - exit - } - - pub fn handle_key_events(&mut self, key: &KeyEvent) -> anyhow::Result { + pub fn handle_key_events(&mut self, key: &KeyEvent) -> anyhow::Result { if key.kind == KeyEventKind::Release { - return Ok(false); + return Ok(EventHandlingResult { + exit: false, + rerender: true, + }); } match (key.code, key.modifiers) { - (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => return Ok(true), + (KeyCode::Esc, _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => { + return Ok(EventHandlingResult { + exit: true, + rerender: true, + }) + } (KeyCode::Char('r'), KeyModifiers::CONTROL) => { self.reset(); - return Ok(false); + return Ok(EventHandlingResult { + exit: false, + rerender: true, + }); } (_, _) => {} } - let exit = match self.current_screen { - CurrentScreen::Searching => self.handle_key_searching(key), - CurrentScreen::Confirmation => self.handle_key_confirmation(key), - CurrentScreen::PerformingSearch | CurrentScreen::PerformingReplacement => false, - CurrentScreen::Results => self.handle_key_results(key), + let exit = match &mut self.current_screen { + Screen::SearchFields => self.handle_key_searching(key), + Screen::SearchProgressing(_) | Screen::SearchComplete(_) => { + self.handle_key_confirmation(key) + } + Screen::PerformingReplacement => false, + Screen::Results(replace_state) => replace_state.handle_key_results(key), }; - Ok(exit) + Ok(EventHandlingResult { + exit, + rerender: true, + }) } - pub fn update_search_results(&mut self) -> anyhow::Result { - let pattern = match self.search_fields.search_type() { + fn validate_fields( + &self, + background_processing_sender: UnboundedSender, + ) -> anyhow::Result> { + let search_pattern = match self.search_fields.search_type() { Err(e) => { if e.downcast_ref::().is_some() { info!("Error when parsing search regex {}", e); self.search_fields - .search() + .search_mut() .set_error("Couldn't parse regex".to_owned()); - return Ok(false); + return Ok(None); } else { return Err(e); } @@ -463,144 +607,84 @@ impl App { Ok(p) => p, }; - self.current_screen = CurrentScreen::Confirmation; - - let mut results = vec![]; - - let s = self.search_fields.path_pattern().text(); - let patt = if s.is_empty() { + let path_pattern_text = self.search_fields.path_pattern().text(); + let path_pattern = if path_pattern_text.is_empty() { None } else { - match Regex::new(s.as_str()) { + match Regex::new(path_pattern_text.as_str()) { Err(e) => { info!("Error when parsing filname pattern regex {}", e); self.search_fields - .path_pattern() + .path_pattern_mut() .set_error("Couldn't parse regex".to_owned()); - return Ok(false); + return Ok(None); } Ok(r) => Some(r), } }; - let walker = WalkBuilder::new(&self.directory) - .hidden(!self.include_hidden) - .filter_entry(|entry| entry.file_name() != ".git") - .build(); - let paths: Vec<_> = walker - .flatten() - .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file())) - .map(|entry| entry.path().to_path_buf()) - .filter(|path| { - if self.ignore_file(path) { - return false; - } - match patt.as_ref() { - Some(p) => p.is_match(self.relative_path(path.clone()).as_str()), - None => true, - } - }) - .collect(); - - for path in paths { - match File::open(path.clone()) { - Ok(file) => { - let reader = BufReader::new(file); - - for (line_number, line) in reader.lines().enumerate() { - match line { - Ok(line) => { - if let Some(res) = self.replacement_if_match( - &pattern, - line, - path.clone(), - line_number, - ) { - results.push(res); - }; - } - Err(err) => { - warn!("Error retrieving line {} of {:?}: {err}", line_number, path); - } - } - } - } - Err(err) => { - warn!("Error opening file {:?}: {err}", path); - } - } - } + Ok(Some(ParsedFields::new( + search_pattern, + self.search_fields.replace().text(), + path_pattern, + self.directory.clone(), + self.include_hidden, + background_processing_sender.clone(), + ))) + } - self.results = Results::SearchComplete(SearchState { - results, - selected: 0, - }); + pub fn update_search_results( + parsed_fields: ParsedFields, + background_processing_sender: UnboundedSender, + ) -> JoinHandle<()> { + let walker = parsed_fields.build_walker(); - Ok(true) - } + tokio::spawn(async move { + walker.run(|| { + let parsed_fields = parsed_fields.clone(); - fn replacement_if_match( - &mut self, - pattern: &SearchType, - line: String, - path: PathBuf, - line_number: usize, - ) -> Option { - let maybe_replacement = match *pattern { - SearchType::Fixed(ref s) => { - if line.contains(s) { - Some(line.replace(s, self.search_fields.replace().text().as_str())) - } else { - None - } - } - SearchType::Pattern(ref p) => { - if p.is_match(&line) { - Some( - p.replace_all(&line, self.search_fields.replace().text()) - .to_string(), - ) - } else { - None - } - } - }; + Box::new(move |entry| { + let entry = match entry { + Ok(entry) => entry, + Err(_) => return WalkState::Continue, + }; - maybe_replacement.map(|replacement| SearchResult { - path, - line_number: line_number + 1, - line: line.clone(), - replacement, - included: true, - replace_result: None, + if !entry.file_type().is_some_and(|ft| ft.is_file()) { + return WalkState::Continue; + }; + + if Self::ignore_file(entry.path()) { + return WalkState::Continue; + } + + parsed_fields.handle_path(entry.path()); + + WalkState::Continue + }) + }); + + // Ignore error: we may have gone back to the previous screen + let _ = background_processing_sender.send(BackgroundProcessingEvent::SearchCompleted); }) } - pub fn perform_replacement(&mut self) { - for (path, results) in &self - .results - .search_complete_mut() - .results - .iter_mut() - .filter(|res| res.included) - .chunk_by(|res| res.path.clone()) - { - let mut results = results.collect::>(); - if let Err(file_err) = Self::replace_in_file(path, &mut results) { - results.iter_mut().for_each(|res| { - res.replace_result = Some(ReplaceResult::Error(file_err.to_string())) - }); + fn ignore_file(path: &Path) -> bool { + if let Some(ext) = path.extension() { + if let Some(ext_str) = ext.to_str() { + if BINARY_EXTENSIONS.contains(&ext_str.to_lowercase().as_str()) { + return true; + } } } + false + } - // TODO (test): add tests for this + fn calculate_statistics(&self, results: &[SearchResult]) -> ReplaceState { let mut num_successes = 0; let mut num_ignored = 0; let mut errors = vec![]; - self.results - .search_complete() - .results + results .iter() .for_each(|res| match (res.included, &res.replace_result) { (false, _) => { @@ -621,17 +705,17 @@ impl App { } }); - self.results = Results::ReplaceComplete(ReplaceState { + ReplaceState { num_successes, num_ignored, errors, replacement_errors_pos: 0, - }); + } } fn replace_in_file( file_path: PathBuf, - results: &mut [&mut SearchResult], + results: &mut Vec<&mut SearchResult>, ) -> anyhow::Result<()> { let mut line_map: HashMap<_, _> = HashMap::from_iter(results.iter_mut().map(|res| (res.line_number, res))); @@ -663,23 +747,110 @@ impl App { Ok(()) } - pub fn relative_path(self: &App, path: PathBuf) -> String { - let current_dir = self.directory.to_str().unwrap(); - let path = path - .into_os_string() - .into_string() - .expect("Failed to display path"); - replace_start(path, current_dir, ".") + pub fn relative_path(&self, path: &Path) -> String { + relative_path_from(&self.directory, path) } +} - fn ignore_file(&self, path: &Path) -> bool { - if let Some(ext) = path.extension() { - if let Some(ext_str) = ext.to_str() { - if BINARY_EXTENSIONS.contains(&ext_str.to_lowercase().as_str()) { - return true; - } - } +#[cfg(test)] +mod tests { + use rand::Rng; + + use super::*; + use crate::EventHandler; + + fn random_num() -> usize { + let mut rng = rand::thread_rng(); + rng.gen_range(1..10000) + } + + fn success_result() -> SearchResult { + SearchResult { + path: Path::new("random/file").to_path_buf(), + line_number: random_num(), + line: "foo".to_owned(), + replacement: "bar".to_owned(), + included: true, + replace_result: Some(ReplaceResult::Success), } - false + } + + fn ignored_result() -> SearchResult { + SearchResult { + path: Path::new("random/file").to_path_buf(), + line_number: random_num(), + line: "foo".to_owned(), + replacement: "bar".to_owned(), + included: false, + replace_result: None, + } + } + + fn error_result() -> SearchResult { + SearchResult { + path: Path::new("random/file").to_path_buf(), + line_number: random_num(), + line: "foo".to_owned(), + replacement: "bar".to_owned(), + included: true, + replace_result: Some(ReplaceResult::Error("error".to_owned())), + } + } + + fn build_test_app(results: Vec) -> App { + let event_handler = EventHandler::new(); + let mut app = App::new(None, false, event_handler.app_event_sender); + app.current_screen = Screen::SearchComplete(SearchState { + results, + selected: 0, + }); + app + } + + #[tokio::test] + async fn test_calculate_statistics_all_success() { + let app = build_test_app(vec![success_result(), success_result(), success_result()]); + let stats = if let Screen::SearchComplete(search_state) = &app.current_screen { + app.calculate_statistics(&search_state.results) + } else { + panic!("Expected SearchComplete"); + }; + + assert_eq!( + stats, + ReplaceState { + num_successes: 3, + num_ignored: 0, + errors: vec![], + replacement_errors_pos: 0, + } + ); + } + + #[tokio::test] + async fn test_calculate_statistics_with_ignores_and_errors() { + let error_result = error_result(); + let app = build_test_app(vec![ + success_result(), + ignored_result(), + success_result(), + error_result.clone(), + ignored_result(), + ]); + let stats = if let Screen::SearchComplete(search_state) = &app.current_screen { + app.calculate_statistics(&search_state.results) + } else { + panic!("Expected SearchComplete"); + }; + + assert_eq!( + stats, + ReplaceState { + num_successes: 2, + num_ignored: 2, + errors: vec![error_result], + replacement_errors_pos: 0, + } + ); } } diff --git a/src/event.rs b/src/event.rs index 9b6be64..e15dfcb 100644 --- a/src/event.rs +++ b/src/event.rs @@ -1,10 +1,40 @@ -use crate::app::AppEvent; -use anyhow::anyhow; use crossterm::event::{self, Event as CrosstermEvent, KeyEvent, MouseEvent}; use futures::StreamExt; +use std::path::PathBuf; use tokio::sync::mpsc; -#[derive(Clone, Debug)] +use crate::app::SearchState; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReplaceResult { + Success, + Error(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SearchResult { + pub path: PathBuf, + pub line_number: usize, + pub line: String, + pub replacement: String, + pub included: bool, + pub replace_result: Option, +} + +#[derive(Debug)] +pub enum AppEvent { + Rerender, + PerformSearch, + PerformReplacement(SearchState), +} + +#[derive(Debug)] +pub enum BackgroundProcessingEvent { + AddSearchResult(SearchResult), + SearchCompleted, +} + +#[derive(Debug)] pub enum Event { Key(KeyEvent), App(AppEvent), @@ -16,10 +46,15 @@ pub enum Event { #[derive(Debug)] pub struct EventHandler { - receiver: mpsc::UnboundedReceiver, + pub receiver: mpsc::UnboundedReceiver, pub app_event_sender: mpsc::UnboundedSender, } +pub struct EventHandlingResult { + pub exit: bool, + pub rerender: bool, +} + impl EventHandler { pub fn new() -> Self { let (sender, receiver) = mpsc::unbounded_channel(); @@ -56,13 +91,6 @@ impl EventHandler { app_event_sender, } } - - pub async fn next(&mut self) -> anyhow::Result { - self.receiver - .recv() - .await - .ok_or(anyhow!("Event stream ended unexpectedly")) - } } impl Default for EventHandler { diff --git a/src/fields.rs b/src/fields.rs index 92f2d6e..0aa5090 100644 --- a/src/fields.rs +++ b/src/fields.rs @@ -259,6 +259,7 @@ impl Field { } } + #[allow(dead_code)] pub fn set_error(&mut self, error: String) { match self { Field::Text(f) => f.set_error(error), diff --git a/src/lib.rs b/src/lib.rs index 1f81472..6c43d7f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,10 @@ +#![feature(mapped_lock_guards)] + pub mod app; pub mod event; pub mod fields; pub mod logging; +pub mod parsed_fields; pub mod ui; pub mod utils; diff --git a/src/main.rs b/src/main.rs index 4e76092..d5b2944 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,7 @@ +#![feature(mapped_lock_guards)] + use clap::Parser; +use event::EventHandlingResult; use log::LevelFilter; use logging::{setup_logging, DEFAULT_LOG_LEVEL}; use ratatui::{backend::CrosstermBackend, Terminal}; @@ -15,6 +18,7 @@ mod app; mod event; mod fields; mod logging; +mod parsed_fields; mod tui; mod ui; mod utils; @@ -58,23 +62,35 @@ async fn main() -> anyhow::Result<()> { Some(d) => Some(validate_directory(&d)?), }; - let events = EventHandler::new(); - let app_event_sender = events.app_event_sender.clone(); + let app_events_handler = EventHandler::new(); + let app_event_sender = app_events_handler.app_event_sender.clone(); let mut app = App::new(directory, args.hidden, app_event_sender); let backend = CrosstermBackend::new(io::stdout()); let terminal = Terminal::new(backend)?; - let mut tui = Tui::new(terminal, events); + let mut tui = Tui::new(terminal, app_events_handler); tui.init()?; + tui.draw(&mut app)?; while app.running { - tui.draw(&mut app)?; - let exit = match tui.events.next().await? { - Event::Key(key_event) => app.handle_key_events(&key_event)?, - Event::Mouse(_) => false, - Event::Resize(_, _) => false, - Event::App(app_event) => app.handle_event(app_event), + let EventHandlingResult { exit, rerender } = tokio::select! { + Some(event) = tui.events.receiver.recv() => { + match event { + Event::Key(key_event) => app.handle_key_events(&key_event)?, + Event::App(app_event) => app.handle_app_event(app_event).await, + Event::Mouse(_) | Event::Resize(_, _) => EventHandlingResult { + exit: false, + rerender: true, + }, + } + } + Some(event) = app.background_processing_recv() => { + app.handle_background_processing_event(event)} }; + + if rerender { + tui.draw(&mut app)?; + } if exit { break; } diff --git a/src/parsed_fields.rs b/src/parsed_fields.rs new file mode 100644 index 0000000..be0965d --- /dev/null +++ b/src/parsed_fields.rs @@ -0,0 +1,137 @@ +use content_inspector::{inspect, ContentType}; +use ignore::{WalkBuilder, WalkParallel}; +use log::warn; +use regex::Regex; +use std::{ + fs::File, + io::{BufRead, BufReader}, + path::{Path, PathBuf}, +}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::{ + event::{BackgroundProcessingEvent, SearchResult}, + utils::relative_path_from, +}; + +#[derive(Clone, Debug)] +pub enum SearchType { + Pattern(Regex), + Fixed(String), +} + +#[derive(Clone, Debug)] +pub struct ParsedFields { + search_pattern: SearchType, + replace_string: String, + path_pattern: Option, + // TODO: `root_dir` and `include_hidden` are duplicated across this and App + root_dir: PathBuf, + include_hidden: bool, + + background_processing_sender: UnboundedSender, +} + +impl ParsedFields { + pub fn new( + search_pattern: SearchType, + replace_string: String, + path_pattern: Option, + root_dir: PathBuf, + include_hidden: bool, + background_processing_sender: UnboundedSender, + ) -> Self { + Self { + search_pattern, + replace_string, + path_pattern, + root_dir, + include_hidden, + background_processing_sender, + } + } + + pub fn handle_path(&self, path: &Path) { + if let Some(ref p) = self.path_pattern { + let matches_pattern = p.is_match(relative_path_from(&self.root_dir, path).as_str()); + if !matches_pattern { + return; + } + } + + match File::open(path) { + Ok(file) => { + let reader = BufReader::new(file); + + for (line_number, line) in reader.lines().enumerate() { + match line { + Ok(line) => { + if let Some(result) = self.replacement_if_match( + path.to_path_buf(), + line.clone(), + line_number, + ) { + if let ContentType::BINARY = inspect(line.as_bytes()) { + continue; + } + let send_result = self + .background_processing_sender + .send(BackgroundProcessingEvent::AddSearchResult(result)); + if send_result.is_err() { + // likely state reset, thread about to be killed + return; + } + } + } + Err(err) => { + warn!("Error retrieving line {} of {:?}: {err}", line_number, path); + } + } + } + } + Err(err) => { + warn!("Error opening file {:?}: {err}", path); + } + } + } + + fn replacement_if_match( + &self, + path: PathBuf, + line: String, + line_number: usize, + ) -> Option { + let maybe_replacement = match self.search_pattern { + SearchType::Fixed(ref s) => { + if line.contains(s) { + Some(line.replace(s, &self.replace_string)) + } else { + None + } + } + SearchType::Pattern(ref p) => { + if p.is_match(&line) { + Some(p.replace_all(&line, &self.replace_string).to_string()) + } else { + None + } + } + }; + + maybe_replacement.map(|replacement| SearchResult { + path, + line_number: line_number + 1, + line: line.clone(), + replacement, + included: true, + replace_result: None, + }) + } + + pub(crate) fn build_walker(&self) -> WalkParallel { + WalkBuilder::new(&self.root_dir) + .hidden(!self.include_hidden) + .filter_entry(|entry| entry.file_name() != ".git") + .build_parallel() + } +} diff --git a/src/ui.rs b/src/ui.rs index e6889c0..d332d08 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -12,9 +12,10 @@ use std::{cmp::min, iter}; use crate::{ app::{ - App, CurrentScreen, FieldName, ReplaceResult, SearchField, SearchResult, NUM_SEARCH_FIELDS, + App, FieldName, ReplaceState, Screen, SearchField, SearchInProgressState, NUM_SEARCH_FIELDS, }, - utils::group_by, + event::{ReplaceResult, SearchResult}, + utils::{first_chars, group_by}, }; impl FieldName { @@ -43,7 +44,7 @@ fn render_search_view(frame: &mut Frame<'_>, app: &App, rect: Rect) { .zip(areas) .enumerate() .for_each(|(idx, (SearchField { name, field }, field_area))| { - field.borrow().render( + field.read().unwrap().render( frame, field_area, name.title().to_owned(), @@ -52,7 +53,13 @@ fn render_search_view(frame: &mut Frame<'_>, app: &App, rect: Rect) { }); let highlighted_area = areas[app.search_fields.highlighted]; - if let Some(cursor_idx) = app.search_fields.highlighted_field().borrow().cursor_idx() { + if let Some(cursor_idx) = app + .search_fields + .highlighted_field() + .read() + .unwrap() + .cursor_idx() + { frame.set_cursor( highlighted_area.x + cursor_idx as u16 + 1, highlighted_area.y + 1, @@ -137,38 +144,50 @@ fn render_confirmation_view(frame: &mut Frame<'_>, app: &App, rect: Rect) { .flex(Flex::Start) .areas(area); - let complete_state = app.results.search_complete(); + let (is_complete, search_results) = match &app.current_screen { + Screen::SearchProgressing(SearchInProgressState { search_state, .. }) => { + (false, search_state) + } + Screen::SearchComplete(search_state) => (true, search_state), + // prevent race condition when state is being reset + _ => return, + }; let list_area_height = list_area.height as usize; let item_height = 4; // TODO: find a better way of doing this let midpoint = list_area_height / (2 * item_height); - let num_results = complete_state.results.len(); + let num_results = search_results.results.len(); frame.render_widget( - Span::raw(format!("Results: {}", num_results)), + Span::raw(format!( + "Results: {} {}", + num_results, + if is_complete { + "[Search complete]" + } else { + "[Still searching...]" + } + )), num_results_area, ); - let results_iter = complete_state + let results_iter = search_results .results .iter() .enumerate() .skip(min( - complete_state.selected.saturating_sub(midpoint), + search_results.selected.saturating_sub(midpoint), num_results.saturating_sub(list_area_height / item_height), )) .take(list_area_height / item_height + 1); // We shouldn't need the +1, but let's keep it in to ensure we have buffer when rendering let search_results = results_iter.flat_map(|(idx, result)| { - let (old_line, new_line) = line_diff(result.line.as_str(), result.replacement.as_str()); + let width = list_area.width; + let before = first_chars(&result.line, width as usize); + let after = first_chars(&result.replacement, width as usize); + let (old_line, new_line) = line_diff(before, after); - let file_path = format!( - "[{}] {}:{}", - if result.included { 'x' } else { ' ' }, - app.relative_path(result.path.clone()), - result.line_number - ); - let file_path_style = if complete_state.selected == idx { + let file_path_style = if search_results.selected == idx { Style::new().bg(if result.included { Color::Blue } else { @@ -177,9 +196,34 @@ fn render_confirmation_view(frame: &mut Frame<'_>, app: &App, rect: Rect) { } else { Style::new() }; + let right_content = format!(" ({})", idx); + let right_content_len = right_content.len() as u16; + let left_content = format!( + "[{}] {}:{}", + if result.included { 'x' } else { ' ' }, + app.relative_path(&result.path), + result.line_number, + ); + let left_content_trimmed = left_content + .chars() + .take(list_area.width.saturating_sub(right_content_len) as usize) + .collect::(); + let left_content_trimmed_len = left_content_trimmed.len() as u16; + let spacers = " ".repeat( + list_area + .width + .saturating_sub(left_content_trimmed_len + right_content_len) as usize, + ); + + let file_path = Line::from(vec![ + Span::raw(left_content_trimmed), + Span::raw(spacers), + Span::raw(right_content), + ]) + .style(file_path_style); [ - ListItem::new(Text::styled(file_path, file_path_style)), + ListItem::new(file_path), ListItem::new(diff_to_line(old_line)), ListItem::new(diff_to_line(new_line)), ListItem::new(""), @@ -189,22 +233,26 @@ fn render_confirmation_view(frame: &mut Frame<'_>, app: &App, rect: Rect) { frame.render_widget(List::new(search_results), list_area); } -fn render_results_view(frame: &mut Frame<'_>, app: &App, rect: Rect) { - let [area] = Layout::horizontal([Constraint::Percentage(80)]) - .flex(Flex::Center) - .areas(rect); +fn render_results_view( + replace_state: &ReplaceState, +) -> impl Fn(&mut Frame<'_>, &App, Rect) + use<'_> { + move |frame: &mut Frame<'_>, _app: &App, rect: Rect| { + let [area] = Layout::horizontal([Constraint::Percentage(80)]) + .flex(Flex::Center) + .areas(rect); - if app.results.replace_complete().errors.is_empty() { - render_results_success(area, app, frame); - } else { - render_results_errors(area, app, frame); + if replace_state.errors.is_empty() { + render_results_success(area, replace_state, frame); + } else { + render_results_errors(area, replace_state, frame); + } } } const ERROR_ITEM_HEIGHT: u16 = 3; const NUM_TALLIES: usize = 3; -fn render_results_success(area: Rect, app: &App, frame: &mut Frame<'_>) { +fn render_results_success(area: Rect, replace_state: &ReplaceState, frame: &mut Frame<'_>) { let [_, success_title_area, results_area, _] = Layout::vertical([ Constraint::Fill(1), Constraint::Length(3), @@ -214,7 +262,7 @@ fn render_results_success(area: Rect, app: &App, frame: &mut Frame<'_>) { .flex(Flex::Start) .areas(area); - render_results_tallies(results_area, frame, app); + render_results_tallies(results_area, frame, replace_state); let text = "Success!"; let area = center( @@ -225,7 +273,7 @@ fn render_results_success(area: Rect, app: &App, frame: &mut Frame<'_>) { frame.render_widget(Text::raw(text), area); } -fn render_results_errors(area: Rect, app: &App, frame: &mut Frame<'_>) { +fn render_results_errors(area: Rect, replace_state: &ReplaceState, frame: &mut Frame<'_>) { let [results_area, list_title_area, list_area] = Layout::vertical([ Constraint::Length(ERROR_ITEM_HEIGHT * NUM_TALLIES as u16), // TODO: find a better way of doing this Constraint::Length(1), @@ -234,9 +282,7 @@ fn render_results_errors(area: Rect, app: &App, frame: &mut Frame<'_>) { .flex(Flex::Start) .areas(area); - let errors = app - .results - .replace_complete() + let errors = replace_state .errors .iter() .map(|res| { @@ -251,18 +297,16 @@ fn render_results_errors(area: Rect, app: &App, frame: &mut Frame<'_>) { }, ) }) - .skip(app.results.replace_complete().replacement_errors_pos) + .skip(replace_state.replacement_errors_pos) .take(list_area.height as usize / 3 + 1); // TODO: don't hardcode height - render_results_tallies(results_area, frame, app); + render_results_tallies(results_area, frame, replace_state); frame.render_widget(Text::raw("Errors:"), list_title_area); frame.render_widget(List::new(errors.flatten()), list_area); } -fn render_results_tallies(results_area: Rect, frame: &mut Frame<'_>, app: &App) { - let replace_results = app.results.replace_complete(); - +fn render_results_tallies(results_area: Rect, frame: &mut Frame<'_>, replace_state: &ReplaceState) { let [success_area, ignored_area, errors_area] = Layout::vertical([ Constraint::Length(3), Constraint::Length(3), @@ -273,11 +317,11 @@ fn render_results_tallies(results_area: Rect, frame: &mut Frame<'_>, app: &App) let widgets: [_; NUM_TALLIES] = [ ( "Successful replacements:", - replace_results.num_successes, + replace_state.num_successes, success_area, ), - ("Ignored:", replace_results.num_ignored, ignored_area), - ("Errors:", replace_results.errors.len(), errors_area), + ("Ignored:", replace_state.num_ignored, ignored_area), + ("Errors:", replace_state.errors.len(), errors_area), ]; let widgets = widgets.into_iter().map(|(title, num, area)| { ( @@ -334,7 +378,7 @@ fn error_result(result: &SearchResult, error: &str) -> [ratatui::widgets::ListIt .map(|(s, style)| ListItem::new(Text::styled(s, style))) } -type RenderFn = Box, &App, Rect)>; +type RenderFn<'a> = Box, &'a App, Rect) + 'a>; pub fn render(app: &App, frame: &mut Frame<'_>) { let chunks = Layout::default() @@ -352,36 +396,40 @@ pub fn render(app: &App, frame: &mut Frame<'_>) { .alignment(Alignment::Center); frame.render_widget(title, chunks[0]); - let render_fn: RenderFn = match app.current_screen { - CurrentScreen::Searching => Box::new(render_search_view), - CurrentScreen::PerformingSearch => { - Box::new(render_loading_view("Performing search...".to_owned())) + let render_fn: RenderFn<'_> = match &app.current_screen { + Screen::SearchFields => Box::new(render_search_view), + Screen::SearchProgressing(_) | Screen::SearchComplete(_) => { + Box::new(render_confirmation_view) } - CurrentScreen::Confirmation => Box::new(render_confirmation_view), - CurrentScreen::PerformingReplacement => { + Screen::PerformingReplacement => { Box::new(render_loading_view("Performing replacement...".to_owned())) } - CurrentScreen::Results => Box::new(render_results_view), + Screen::Results(ref replace_state) => Box::new(render_results_view(replace_state)), }; render_fn(frame, app, chunks[1]); let current_keys = match app.current_screen { - CurrentScreen::Searching => { + Screen::SearchFields => { vec![" search", " focus next", " focus prev"] } - - CurrentScreen::Confirmation => { - vec![ - " replace", + Screen::SearchProgressing(_) | Screen::SearchComplete(_) => { + let mut keys = if let Screen::SearchComplete(_) = app.current_screen { + // TODO: actually prevent confirmation when search is in progress + vec![" replace"] + } else { + vec![] + }; + keys.append(&mut vec![ " toggle", " down", " up", " back", - ] + ]); + keys } - CurrentScreen::PerformingSearch | CurrentScreen::PerformingReplacement => vec![], - CurrentScreen::Results => { - if !app.results.replace_complete().errors.is_empty() { + Screen::PerformingReplacement => vec![], + Screen::Results(ref replace_state) => { + if !replace_state.errors.is_empty() { vec![" down", " up"] } else { vec![] @@ -389,7 +437,7 @@ pub fn render(app: &App, frame: &mut Frame<'_>) { } }; - let additional_keys = if matches!(app.current_screen, CurrentScreen::PerformingReplacement) { + let additional_keys = if matches!(app.current_screen, Screen::PerformingReplacement) { vec![] } else { vec![" reset", " quit"] diff --git a/src/utils.rs b/src/utils.rs index b8e419f..8879135 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -9,6 +9,12 @@ pub fn replace_start(s: String, from: &str, to: &str) -> String { } } +pub fn relative_path_from(root_dir: &Path, path: &Path) -> String { + let root_dir = root_dir.to_str().unwrap(); + let path = path.to_str().unwrap().to_owned(); + replace_start(path, root_dir, ".") +} + pub fn group_by(iter: I, predicate: F) -> Vec> where I: IntoIterator, @@ -45,6 +51,13 @@ pub fn validate_directory(dir_str: &str) -> Result { } } +pub fn first_chars(s: &str, n: usize) -> &str { + match s.char_indices().nth(n) { + Some((idx, _)) => &s[..idx], + None => s, + } +} + #[cfg(test)] mod tests { use super::*; @@ -192,4 +205,14 @@ mod tests { assert!(result.is_ok()); assert_eq!(result.unwrap(), special_dir); } + + #[test] + fn test_first_chars() { + let text = "Hello, 世界!"; + assert_eq!(first_chars(text, 0), ""); + assert_eq!(first_chars(text, 3), "Hel"); + assert_eq!(first_chars(text, 6), "Hello,"); + assert_eq!(first_chars(text, 8), "Hello, 世"); + assert_eq!(first_chars(text, 100), "Hello, 世界!"); + } } diff --git a/tests/app.rs b/tests/app.rs index c3c681f..c8e0e23 100644 --- a/tests/app.rs +++ b/tests/app.rs @@ -1,11 +1,14 @@ use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; use scooter::{ - App, CurrentScreen, EventHandler, ReplaceResult, ReplaceState, Results, SearchFields, - SearchResult, SearchState, + App, EventHandler, ReplaceResult, ReplaceState, Screen, SearchFields, SearchResult, SearchState, }; -use std::fs::{create_dir_all, File}; +use std::cmp::max; +use std::fs::{self, create_dir_all, File}; use std::io::Write; +use std::mem; use std::path::{Path, PathBuf}; +use std::thread::sleep; +use std::time::{Duration, Instant}; use tempfile::TempDir; #[tokio::test] @@ -80,8 +83,7 @@ async fn test_replace_state() { async fn test_app_reset() { let events = EventHandler::new(); let mut app = App::new(None, false, events.app_event_sender); - app.current_screen = CurrentScreen::Results; - app.results = Results::ReplaceComplete(ReplaceState { + app.current_screen = Screen::Results(ReplaceState { num_successes: 5, num_ignored: 2, errors: vec![], @@ -90,18 +92,20 @@ async fn test_app_reset() { app.reset(); - assert!(matches!(app.current_screen, CurrentScreen::Searching)); - assert!(matches!(app.results, Results::Loading)); + assert!(matches!(app.current_screen, Screen::SearchFields)); } #[tokio::test] async fn test_back_from_results() { let events = EventHandler::new(); let mut app = App::new(None, false, events.app_event_sender); - app.current_screen = CurrentScreen::Confirmation; + app.current_screen = Screen::SearchComplete(SearchState { + results: vec![], + selected: 0, + }); app.search_fields = SearchFields::with_values("foo", "bar", true, "pattern"); - let exit = app + let res = app .handle_key_events(&KeyEvent { code: KeyCode::Char('o'), modifiers: KeyModifiers::CONTROL, @@ -109,161 +113,395 @@ async fn test_back_from_results() { state: KeyEventState::NONE, }) .unwrap(); - assert!(!exit); + assert!(!res.exit); assert_eq!(app.search_fields.search().text, "foo"); assert_eq!(app.search_fields.replace().text, "bar"); assert!(app.search_fields.fixed_strings().checked); assert_eq!(app.search_fields.path_pattern().text, "pattern"); - assert_eq!(app.current_screen, CurrentScreen::Searching); - assert_eq!(app.results, Results::Loading); + assert!(matches!(app.current_screen, Screen::SearchFields)); } macro_rules! create_test_files { - ($temp_dir:expr, $($name:expr => {$($line:expr),+ $(,)?}),+ $(,)?) => { + ($($name:expr => {$($line:expr),+ $(,)?}),+ $(,)?) => { { + let temp_dir = TempDir::new().unwrap(); $( let contents = concat!($($line,"\n",)+); - let path = [$temp_dir.path().to_str().unwrap(), $name].join("/"); + let path = [temp_dir.path().to_str().unwrap(), $name].join("/"); let path = Path::new(&path); create_dir_all(path.parent().unwrap()).unwrap(); - let mut file = File::create(path).unwrap(); - file.write_all(contents.as_bytes()).unwrap(); - file.sync_all().unwrap(); + { + let mut file = File::create(path).unwrap(); + file.write_all(contents.as_bytes()).unwrap(); + file.sync_all().unwrap(); + } )+ + + #[cfg(windows)] + sleep(Duration::from_millis(100)); + + temp_dir } }; } +fn collect_files(dir: &Path, base: &Path, files: &mut Vec) { + for entry in fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + let rel_path = path + .strip_prefix(base) + .unwrap() + .to_str() + .unwrap() + .to_string() + .replace("\\", "/"); + files.push(rel_path); + } else if path.is_dir() { + collect_files(&path, base, files); + } + } +} -fn setup_env_simple_files() -> App { - let temp_dir = TempDir::new().unwrap(); +macro_rules! assert_test_files { + ($temp_dir:expr, $($name:expr => {$($line:expr),+ $(,)?}),+ $(,)?) => { + { + use std::fs; + use std::path::Path; - create_test_files! { - temp_dir, - "file1.txt" => { - "This is a test file", - "It contains some test content", - "For testing purposes", - }, - "file2.txt" => { - "Another test file", - "With different content", - "Also for testing", - }, - "file3.txt" => { - "something", - "123 bar[a-b]+.*bar)(baz 456", - "something", + $( + let expected_contents = concat!($($line,"\n",)+); + let path = Path::new($temp_dir.path()).join($name); + + assert!(path.exists(), "File {} does not exist", $name); + + let actual_contents = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("Failed to read file {}: {}", $name, e)); + assert_eq!( + actual_contents, + expected_contents, + "Contents mismatch for file {}.\nExpected:\n{}\nActual:\n{}", + $name, + expected_contents, + actual_contents + ); + )+ + + let mut expected_files: Vec = vec![$($name.to_string()),+]; + expected_files.sort(); + + let mut actual_files = Vec::new(); + collect_files( + $temp_dir.path(), + $temp_dir.path(), + &mut actual_files + ); + actual_files.sort(); + + assert_eq!( + actual_files, + expected_files, + "Directory contains unexpected files.\nExpected files: {:?}\nActual files: {:?}", + expected_files, + actual_files + ); } }; - - let events = EventHandler::new(); - App::new(Some(temp_dir.into_path()), false, events.app_event_sender) +} +pub fn wait_until(condition: F, timeout: Duration) -> bool +where + F: Fn() -> bool, +{ + let start = Instant::now(); + let sleep_duration = max(timeout / 50, Duration::from_millis(1)); + while !condition() && start.elapsed() <= timeout { + sleep(sleep_duration); + } + condition() } -#[tokio::test] -async fn test_update_search_results_fixed_string() { - let mut app = setup_env_simple_files(); +async fn process_bp_events(app: &mut App) { + let timeout = Duration::from_secs(5); + let start = Instant::now(); - app.search_fields = SearchFields::with_values(".*", "example", true, ""); + while let Some(event) = app.background_processing_recv().await { + app.handle_background_processing_event(event); + if start.elapsed() > timeout { + panic!("Couldn't process background events in a reasonable time"); + } + } +} - app.update_search_results().unwrap(); +macro_rules! wait_for_screen { + ($app:expr, $variant:path) => { + wait_until( + || matches!($app.current_screen, $variant(_)), + Duration::from_secs(1), + ) + }; +} - if let scooter::Results::SearchComplete(search_state) = &app.results { - assert_eq!(search_state.results.len(), 1); +fn setup_app(temp_dir: &TempDir, search_fields: SearchFields, include_hidden: bool) -> App { + let events = EventHandler::new(); + let mut app = App::new( + Some(temp_dir.path().to_path_buf()), + include_hidden, + events.app_event_sender, + ); + app.search_fields = search_fields; + app +} - for (file_name, num_matches) in [("file1.txt", 0), ("file1.txt", 0), ("file3.txt", 1)] { +async fn search_and_replace_test( + temp_dir: &TempDir, + search_fields: SearchFields, + include_hidden: bool, + expected_matches: Vec<(&Path, usize)>, +) { + let num_expected_matches = expected_matches + .iter() + .map(|(_, count)| count) + .sum::(); + + let mut app = setup_app(temp_dir, search_fields, include_hidden); + let res = app.perform_search_if_valid(); + assert!(!res.exit); + + process_bp_events(&mut app).await; + assert!(wait_for_screen!(&app, Screen::SearchComplete)); + + // TODO: this mem::replace needs to be kept in sync with the same action that happens in app.rs - can we fix this? + let mut search_state = if let Screen::SearchComplete(search_state) = + mem::replace(&mut app.current_screen, Screen::PerformingReplacement) + { + for (file_path, num_matches) in &expected_matches { assert_eq!( search_state .results .iter() - .filter(|r| r.path.file_name().unwrap() == file_name) + .filter(|result| { + let result_path = result.path.to_str().unwrap(); + let file_path = file_path.to_str().unwrap(); + result_path.contains(file_path) + }) .count(), - num_matches + *num_matches ); } - for result in &search_state.results { - assert!(result.line.contains(".*")); - assert_eq!(result.replacement, result.line.replace(".*", "example")); - } + assert_eq!(search_state.results.len(), num_expected_matches); + + search_state + } else { + panic!( + "Expected SearchComplete results, found {:?}", + app.current_screen + ); + }; + + app.perform_replacement(&mut search_state); + + if let Screen::Results(search_state) = &app.current_screen { + assert_eq!(search_state.num_successes, num_expected_matches); + assert_eq!(search_state.num_ignored, 0); + assert_eq!(search_state.errors.len(), 0); } else { - panic!("Expected SearchComplete results"); + panic!( + "Expected screen to be Screen::Results, instead found {:?}", + app.current_screen + ); } } #[tokio::test] -async fn test_update_search_results_regex() { - let mut app = setup_env_simple_files(); - - app.search_fields = SearchFields::with_values(r"\b\w+ing\b", "VERB", false, ""); +async fn test_perform_search_fixed_string() { + let temp_dir = create_test_files! { + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something", + } + }; - app.update_search_results().unwrap(); + search_and_replace_test( + &temp_dir, + SearchFields::with_values(".*", "example", true, ""), + false, + vec![ + (Path::new("file1.txt"), 0), + (Path::new("file2.txt"), 0), + (Path::new("file3.txt"), 1), + ], + ) + .await; - if let scooter::Results::SearchComplete(search_state) = &app.results { - assert_eq!(search_state.results.len(), 4); + assert_test_files! { + &temp_dir, + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+examplebar)(baz 456", + "something", + } + }; +} - let mut file_match_counts = std::collections::HashMap::new(); +#[tokio::test] +async fn test_update_search_results_regex() { + let temp_dir = &create_test_files! { + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something", + } + }; - for result in &search_state.results { - *file_match_counts - .entry( - result - .path - .file_name() - .unwrap() - .to_str() - .unwrap() - .to_string(), - ) - .or_insert(0) += 1; + search_and_replace_test( + temp_dir, + SearchFields::with_values(r"\b\w+ing\b", "VERB", false, ""), + false, + vec![ + (Path::new("file1.txt"), 1), + (Path::new("file2.txt"), 1), + (Path::new("file3.txt"), 2), + ], + ) + .await; - assert!(result.line.contains("testing") || result.line.contains("something"),); - assert_eq!( - result.replacement, - result - .line - .replace("testing", "VERB") - .replace("something", "VERB"), - ); + assert_test_files! { + temp_dir, + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For VERB purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for VERB", + }, + "file3.txt" => { + "VERB", + "123 bar[a-b]+.*bar)(baz 456", + "VERB", } - - assert_eq!(*file_match_counts.get("file1.txt").unwrap_or(&0), 1); - assert_eq!(*file_match_counts.get("file2.txt").unwrap_or(&0), 1); - assert_eq!(*file_match_counts.get("file3.txt").unwrap_or(&0), 2); - } else { - panic!("Expected SearchComplete results"); - } + }; } + #[tokio::test] async fn test_update_search_results_no_matches() { - let mut app = setup_env_simple_files(); - - app.search_fields = SearchFields::with_values("nonexistent", "replacement", false, ""); + let temp_dir = &create_test_files! { + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something", + } + }; - app.update_search_results().unwrap(); + search_and_replace_test( + temp_dir, + SearchFields::with_values(r"nonexistent-string", "replacement", true, ""), + false, + vec![ + (Path::new("file1.txt"), 0), + (Path::new("file2.txt"), 0), + (Path::new("file3.txt"), 0), + ], + ) + .await; - if let scooter::Results::SearchComplete(search_state) = &app.results { - assert_eq!(search_state.results.len(), 0); - } else { - panic!("Expected SearchComplete results"); - } + assert_test_files! { + temp_dir, + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something", + } + }; } #[tokio::test] async fn test_update_search_results_invalid_regex() { - let mut app = setup_env_simple_files(); + let temp_dir = &create_test_files! { + "file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "file2.txt" => { + "Another test file", + "With different content", + "Also for testing", + }, + "file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something", + } + }; - app.search_fields = SearchFields::with_values(r"[invalid regex", "replacement", false, ""); + let search_fields = SearchFields::with_values(r"[invalid regex", "replacement", false, ""); + let mut app = setup_app(temp_dir, search_fields, false); - let result = app.update_search_results(); - assert!(result.is_ok()); + let res = app.perform_search_if_valid(); + assert!(!res.exit); + assert!(matches!(app.current_screen, Screen::SearchFields)); + process_bp_events(&mut app).await; + assert!(!wait_for_screen!(&app, Screen::SearchComplete)); // We shouldn't get to the SearchComplete page, so assert that we never get there + assert!(matches!(app.current_screen, Screen::SearchFields)); } -fn setup_env_files_in_dirs() -> App { - let temp_dir = TempDir::new().unwrap(); - - create_test_files! { - temp_dir, +#[tokio::test] +async fn test_update_search_results_filtered_dir() { + let temp_dir = &create_test_files! { "dir1/file1.txt" => { "This is a test file", "It contains some test content", @@ -281,60 +519,41 @@ fn setup_env_files_in_dirs() -> App { } }; - let events = EventHandler::new(); - App::new(Some(temp_dir.into_path()), false, events.app_event_sender) -} - -#[tokio::test] -async fn test_update_search_results_filtered_dir() { - let mut app = setup_env_files_in_dirs(); - - app.search_fields = SearchFields::with_values(r"testing", "f", false, "dir2"); - - let result = app.update_search_results(); - assert!(result.is_ok()); - - if let scooter::Results::SearchComplete(search_state) = &app.results { - let expected_matches = [ - (Path::new("dir1").join("file1.txt"), 0), - (Path::new("dir2").join("file2.txt"), 1), - (Path::new("dir2").join("file3.txt"), 1), - ]; - for (file_path, num_matches) in expected_matches.clone() { - assert_eq!( - search_state - .results - .iter() - .filter(|result| { - let result_path = result.path.to_str().unwrap(); - let file_path = file_path.to_str().unwrap(); - result_path.contains(file_path) - }) - .count(), - num_matches - ); - } - assert_eq!( - search_state.results.len(), - expected_matches - .map(|(_, count)| count) - .into_iter() - .sum::() - ); + search_and_replace_test( + temp_dir, + SearchFields::with_values(r"testing", "f", false, "dir2"), + false, + vec![ + (&Path::new("dir1").join("file1.txt"), 0), + (&Path::new("dir2").join("file2.txt"), 1), + (&Path::new("dir2").join("file3.txt"), 1), + ], + ) + .await; - for result in &search_state.results { - assert_eq!(result.replacement, result.line.replace("testing", "f")); + assert_test_files! { + temp_dir, + "dir1/file1.txt" => { + "This is a test file", + "It contains some test content", + "For testing purposes", + }, + "dir2/file2.txt" => { + "Another test file", + "With different content", + "Also for f", + }, + "dir2/file3.txt" => { + "something", + "123 bar[a-b]+.*bar)(baz 456", + "something f", } - } else { - panic!("Expected SearchComplete results"); - } + }; } -fn setup_env_files_with_gif() -> App { - let temp_dir = TempDir::new().unwrap(); - - create_test_files! { - temp_dir, +#[tokio::test] +async fn test_ignores_gif_file() { + let temp_dir = &create_test_files! { "dir1/file1.txt" => { "This is a text file", }, @@ -346,60 +565,35 @@ fn setup_env_files_with_gif() -> App { } }; - let events = EventHandler::new(); - App::new(Some(temp_dir.into_path()), false, events.app_event_sender) -} - -#[tokio::test] -async fn test_ignores_gif_file() { - let mut app = setup_env_files_with_gif(); - - app.search_fields = SearchFields::with_values(r"is", "", false, ""); - - let result = app.update_search_results(); - assert!(result.is_ok()); - - if let scooter::Results::SearchComplete(search_state) = &app.results { - let expected_matches = [ - (Path::new("dir1").join("file1.txt"), 1), - (Path::new("dir2").join("file2.gif"), 0), - (Path::new("file3.txt").to_path_buf(), 1), - ]; - for (file_path, num_matches) in expected_matches.clone() { - assert_eq!( - search_state - .results - .iter() - .filter(|result| { - let result_path = result.path.to_str().unwrap(); - let file_path = file_path.to_str().unwrap(); - result_path.contains(file_path) - }) - .count(), - num_matches - ); - } - assert_eq!( - search_state.results.len(), - expected_matches - .map(|(_, count)| count) - .into_iter() - .sum::() - ); + search_and_replace_test( + temp_dir, + SearchFields::with_values(r"is", "", false, ""), + false, + vec![ + (&Path::new("dir1").join("file1.txt"), 1), + (&Path::new("dir2").join("file2.gif"), 0), + (Path::new("file3.txt"), 1), + ], + ) + .await; - for result in &search_state.results { - assert_eq!(result.replacement, "Th a text file"); + assert_test_files! { + temp_dir, + "dir1/file1.txt" => { + "Th a text file", + }, + "dir2/file2.gif" => { + "This is a gif file", + }, + "file3.txt" => { + "Th a text file", } - } else { - panic!("Expected SearchComplete results"); - } + }; } -fn setup_env_files_with_hidden(include_hidden: bool) -> App { - let temp_dir = TempDir::new().unwrap(); - - create_test_files! { - temp_dir, +#[tokio::test] +async fn test_ignores_hidden_files_by_default() { + let temp_dir = &create_test_files! { "dir1/file1.txt" => { "This is a text file", }, @@ -411,72 +605,74 @@ fn setup_env_files_with_hidden(include_hidden: bool) -> App { } }; - let events = EventHandler::new(); - App::new( - Some(temp_dir.into_path()), - include_hidden, - events.app_event_sender, + search_and_replace_test( + temp_dir, + SearchFields::with_values(r"\bis\b", "REPLACED", false, ""), + false, + vec![ + (&Path::new("dir1").join("file1.txt"), 1), + (&Path::new(".dir2").join("file2.rs"), 0), + (Path::new(".file3.txt"), 0), + ], ) -} + .await; -async fn hidden_files_test_impl(include_hidden: bool) { - let mut app = setup_env_files_with_hidden(include_hidden); - - app.search_fields = SearchFields::with_values(r"This", "bar", false, ""); - - let result = app.update_search_results(); - assert!(result.is_ok()); - - if let scooter::Results::SearchComplete(search_state) = &app.results { - let expected_matches = [ - (Path::new("dir1").join("file1.txt"), 1), - ( - Path::new(".dir2").join("file2.rs"), - if include_hidden { 1 } else { 0 }, - ), - ( - Path::new(".file3.txt").to_path_buf(), - if include_hidden { 1 } else { 0 }, - ), - ]; - for (file_path, num_matches) in expected_matches.clone() { - assert_eq!( - search_state - .results - .iter() - .filter(|result| { - let result_path = result.path.to_str().unwrap(); - let file_path = file_path.to_str().unwrap(); - result_path.contains(file_path) - }) - .count(), - num_matches - ); + assert_test_files! { + temp_dir, + "dir1/file1.txt" => { + "This REPLACED a text file", + }, + ".dir2/file2.rs" => { + "This is a file in a hidden directory", + }, + ".file3.txt" => { + "This is a hidden text file", } - assert_eq!( - search_state.results.len(), - expected_matches - .map(|(_, count)| count) - .into_iter() - .sum::() - ); - } else { - panic!("Expected SearchComplete results"); - } -} - -#[tokio::test] -async fn test_ignores_hidden_files_by_default() { - hidden_files_test_impl(false).await; + }; } #[tokio::test] async fn test_includes_hidden_files_with_flag() { - hidden_files_test_impl(true).await; + let temp_dir = &create_test_files! { + "dir1/file1.txt" => { + "This is a text file", + }, + ".dir2/file2.rs" => { + "This is a file in a hidden directory", + }, + ".file3.txt" => { + "This is a hidden text file", + } + }; + + search_and_replace_test( + temp_dir, + SearchFields::with_values(r"\bis\b", "REPLACED", false, ""), + true, + vec![ + (&Path::new("dir1").join("file1.txt"), 1), + (&Path::new(".dir2").join("file2.rs"), 1), + (Path::new(".file3.txt"), 1), + ], + ) + .await; + + assert_test_files! { + temp_dir, + "dir1/file1.txt" => { + "This REPLACED a text file", + }, + ".dir2/file2.rs" => { + "This REPLACED a file in a hidden directory", + }, + ".file3.txt" => { + "This REPLACED a hidden text file", + } + }; } // TODO: -// - Add tests for: -// - replacing in files -// - more tests for passing in directory via CLI arg +// - Add: +// - more tests for replacing in files +// - tests for passing in directory via CLI arg // - Tidy up tests - lots of duplication diff --git a/tests/fields.rs b/tests/fields.rs index e12858e..bb26141 100644 --- a/tests/fields.rs +++ b/tests/fields.rs @@ -1,7 +1,9 @@ use ratatui::crossterm::event::{KeyCode, KeyModifiers}; -use scooter::{CheckboxField, Field, FieldName, SearchField, SearchFields, SearchType, TextField}; -use std::cell::RefCell; -use std::rc::Rc; +use scooter::{ + parsed_fields::SearchType, CheckboxField, Field, FieldName, SearchField, SearchFields, + TextField, +}; +use std::sync::{Arc, RwLock}; #[test] fn test_text_field_operations() { @@ -76,19 +78,19 @@ fn test_search_fields() { fields: [ SearchField { name: FieldName::Search, - field: Rc::new(RefCell::new(Field::text(""))), + field: Arc::new(RwLock::new(Field::text(""))), }, SearchField { name: FieldName::Replace, - field: Rc::new(RefCell::new(Field::text(""))), + field: Arc::new(RwLock::new(Field::text(""))), }, SearchField { name: FieldName::FixedStrings, - field: Rc::new(RefCell::new(Field::checkbox(false))), + field: Arc::new(RwLock::new(Field::checkbox(false))), }, SearchField { name: FieldName::PathPattern, - field: Rc::new(RefCell::new(Field::text(""))), + field: Arc::new(RwLock::new(Field::text(""))), }, ], highlighted: 0, @@ -112,7 +114,8 @@ fn test_search_fields() { for c in "test search".chars() { search_fields .highlighted_field() - .borrow_mut() + .write() + .unwrap() .handle_keys(KeyCode::Char(c), KeyModifiers::NONE); } assert_eq!(search_fields.search().text, "test search"); @@ -122,7 +125,8 @@ fn test_search_fields() { for c in "test replace".chars() { search_fields .highlighted_field() - .borrow_mut() + .write() + .unwrap() .handle_keys(KeyCode::Char(c), KeyModifiers::NONE); } assert_eq!(search_fields.replace().text, "test replace"); @@ -131,7 +135,8 @@ fn test_search_fields() { assert_eq!(search_fields.highlighted, 2); search_fields .highlighted_field() - .borrow_mut() + .write() + .unwrap() .handle_keys(KeyCode::Char(' '), KeyModifiers::NONE); assert!(search_fields.fixed_strings().checked); @@ -143,7 +148,8 @@ fn test_search_fields() { search_fields .highlighted_field() - .borrow_mut() + .write() + .unwrap() .handle_keys(KeyCode::Char(' '), KeyModifiers::NONE); let search_type = search_fields.search_type().unwrap(); match search_type {