diff options
author | Tim Oram <dev@mitmaro.ca> | 2024-02-17 09:56:13 -0330 |
---|---|---|
committer | Tim Oram <dev@mitmaro.ca> | 2024-02-18 19:21:50 -0330 |
commit | 578bd87571af87283844ee7857cc7dd5d8c10137 (patch) | |
tree | 86fb7d0f76dcf0359c71e7eb2c81724205910d48 | |
parent | 12235df6a584cf44ec985d06df0cae74dbfc7ebd (diff) |
Implement Searchable for list module search
24 files changed, 1700 insertions, 1088 deletions
diff --git a/src/components/search_bar.rs b/src/components/search_bar.rs index 7b9991d..b91e225 100644 --- a/src/components/search_bar.rs +++ b/src/components/search_bar.rs @@ -54,10 +54,23 @@ impl SearchBar { } } - pub(crate) const fn read_event(&self, event: Event) -> Option<Event> { + pub(crate) fn read_event(&self, event: Event) -> Option<Event> { match self.state { State::Deactivated | State::Searching => None, - State::Editing => Some(event), + State::Editing => { + let evt = match event { + Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + }) => Event::from(StandardEvent::SearchFinish), + Event::Key(KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + }) => Event::from(StandardEvent::SearchCancel), + _ => event, + }; + Some(evt) + }, } } @@ -72,11 +85,7 @@ impl SearchBar { Event::Standard(StandardEvent::SearchPrevious) => { SearchBarAction::Previous(String::from(self.editable_line.get_content())) }, - Event::Standard(StandardEvent::SearchFinish) - | Event::Key(KeyEvent { - code: KeyCode::Enter, - modifiers: KeyModifiers::NONE, - }) => { + Event::Standard(StandardEvent::SearchFinish) => { self.editable_line.set_read_only(true); self.state = State::Searching; SearchBarAction::Start(String::from(self.editable_line.get_content())) @@ -85,10 +94,7 @@ impl SearchBar { self.state = State::Deactivated; SearchBarAction::Cancel }, - Event::Key(KeyEvent { - code: KeyCode::Esc, - modifiers: KeyModifiers::NONE, - }) => { + Event::Standard(StandardEvent::SearchCancel) => { self.reset(); SearchBarAction::Cancel }, @@ -121,10 +127,6 @@ impl SearchBar { self.state == State::Editing } - pub(crate) fn is_searching(&self) -> bool { - self.state == State::Searching - } - pub(crate) fn build_view_line(&self) -> ViewLine { ViewLine::from(self.editable_line.line_segments()) } diff --git a/src/components/search_bar/options.rs b/src/components/search_bar/options.rs deleted file mode 100644 index 3da59f1..0000000 --- a/src/components/search_bar/options.rs +++ /dev/null @@ -1,15 +0,0 @@ -use crate::input::Event; - -pub(crate) struct Options { - pub(crate) next_result_event: Vec<Event>, - pub(crate) previous_result_event: Vec<Event>, -} - -impl Options { - pub(crate) fn new(next_result_event: Vec<Event>, previous_result_event: Vec<Event>) -> Self { - Self { - next_result_event, - previous_result_event, - } - } -} diff --git a/src/components/search_bar/tests.rs b/src/components/search_bar/tests.rs index 382163d..7d2e44c 100644 --- a/src/components/search_bar/tests.rs +++ b/src/components/search_bar/tests.rs @@ -87,13 +87,33 @@ fn read_event_searching() { } #[test] -fn read_event_editing() { +fn read_event_editing_other() { let mut search_bar = SearchBar::new(); search_bar.state = State::Editing; assert_some_eq!(search_bar.read_event(Event::from('a')), Event::from('a')); } #[test] +fn read_event_editing_with_enter() { + let mut search_bar = SearchBar::new(); + search_bar.state = State::Editing; + assert_some_eq!( + search_bar.read_event(Event::from(KeyCode::Enter)), + Event::from(StandardEvent::SearchFinish) + ); +} + +#[test] +fn read_event_editing_ith_esc() { + let mut search_bar = SearchBar::new(); + search_bar.state = State::Editing; + assert_some_eq!( + search_bar.read_event(Event::from(KeyCode::Esc)), + Event::from(StandardEvent::SearchCancel) + ); +} + +#[test] fn handle_event_inactive() { let mut search_bar = SearchBar::new(); search_bar.state = State::Deactivated; @@ -137,21 +157,10 @@ fn handle_event_search_finish() { } #[test] -fn handle_event_search_finish_with_enter() { - let mut search_bar = SearchBar::new(); - search_bar.start_search(Some("foo")); - let event = Event::from(KeyCode::Enter); - assert_eq!( - search_bar.handle_event(event), - SearchBarAction::Start(String::from("foo")) - ); -} - -#[test] -fn handle_event_search_cancel_with_esc() { +fn handle_event_search_cancel_with_search_cancel() { let mut search_bar = SearchBar::new(); search_bar.start_search(Some("foo")); - let event = Event::from(KeyCode::Esc); + let event = Event::from(StandardEvent::SearchCancel); assert_eq!(search_bar.handle_event(event), SearchBarAction::Cancel); } @@ -245,17 +254,6 @@ fn is_editing() { } #[test] -fn is_searching() { - let mut search_bar = SearchBar::new(); - search_bar.state = State::Editing; - assert!(!search_bar.is_searching()); - search_bar.state = State::Deactivated; - assert!(!search_bar.is_searching()); - search_bar.state = State::Searching; - assert!(search_bar.is_searching()); -} - -#[test] fn build_view_line() { assert_rendered_output!( Options AssertRenderOptions::INCLUDE_STYLE | AssertRenderOptions::BODY_ONLY, diff --git a/src/input/event_handler.rs b/src/input/event_handler.rs index 02d163c..0a2621f 100644 --- a/src/input/event_handler.rs +++ b/src/input/event_handler.rs @@ -120,6 +120,14 @@ impl EventHandler { e if key_bindings.search_next.contains(&e) => Some(Event::from(StandardEvent::SearchNext)), e if key_bindings.search_previous.contains(&e) => Some(Event::from(StandardEvent::SearchPrevious)), e if key_bindings.search_start.contains(&e) => Some(Event::from(StandardEvent::SearchStart)), + Event::Key(KeyEvent { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + }) => Some(Event::from(StandardEvent::SearchCancel)), + Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + }) => Some(Event::from(StandardEvent::SearchFinish)), _ => None, } } @@ -256,6 +264,8 @@ mod tests { #[case::search_next(Event::from('n'), Event::from(StandardEvent::SearchNext))] #[case::search_previous(Event::from('N'), Event::from(StandardEvent::SearchPrevious))] #[case::search_start(Event::from('/'), Event::from(StandardEvent::SearchStart))] + #[case::esc(Event::from(KeyCode::Esc), Event::from(StandardEvent::SearchCancel))] + #[case::enter(Event::from(KeyCode::Enter), Event::from(StandardEvent::SearchFinish))] #[case::other(Event::from('a'), Event::from(KeyCode::Null))] fn search_inputs(#[case] event: Event, #[case] expected: Event) { let event_handler = EventHandler::new(create_test_keybindings()); diff --git a/src/input/standard_event.rs b/src/input/standard_event.rs index ddbec51..69283a5 100644 --- a/src/input/standard_event.rs +++ b/src/input/standard_event.rs @@ -34,6 +34,8 @@ pub(crate) enum StandardEvent { SearchNext, /// Previous search result meta event. SearchPrevious, + /// Cancel search mode meta event. + SearchCancel, /// Finish search mode meta event. SearchFinish, /// The abort meta event. diff --git a/src/main.rs b/src/main.rs index 60e400a..9d536c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,14 +2,13 @@ #![cfg_attr(allow_unknown_lints, allow(unknown_lints))] #![cfg_attr(allow_unknown_lints, allow(renamed_and_removed_lints))] // enable all rustc's built-in lints -#![deny( +#![warn( future_incompatible, nonstandard_style, rust_2018_compatibility, rust_2018_idioms, rust_2021_compatibility, - unused, - warnings + unused )] // rustc's additional allowed by default lints #![deny( @@ -112,6 +111,7 @@ clippy::panic, clippy::shadow_reuse, clippy::shadow_unrelated, + clippy::struct_field_names, clippy::undocumented_unsafe_blocks, clippy::unimplemented, clippy::unreachable diff --git a/src/modules/list.rs b/src/modules/list.rs index ea397ad..8f106f1 100644 --- a/src/modules/list.rs +++ b/src/modules/list.rs @@ -1,3 +1,4 @@ +mod search; #[cfg(all(unix, test))] mod tests; mod utils; @@ -5,20 +6,23 @@ mod utils; use std::{cmp::min, sync::Arc}; use captur::capture; -use if_chain::if_chain; use parking_lot::Mutex; -use self::utils::{ - get_list_normal_mode_help_lines, - get_list_visual_mode_help_lines, - get_todo_line_segments, - TodoLineSegmentsOptions, +use self::{ + search::Search, + utils::{ + get_list_normal_mode_help_lines, + get_list_visual_mode_help_lines, + get_todo_line_segments, + TodoLineSegmentsOptions, + }, }; use crate::{ components::{ edit::Edit, help::Help, search_bar::{SearchBar, SearchBarAction}, + spin_indicator::SpinIndicator, }, config::Config, display::DisplayColor, @@ -26,8 +30,9 @@ use crate::{ module::{ExitStatus, Module, State}, modules::list::utils::get_line_action_maximum_width, process::Results, + search::Searchable, select, - todo_file::{Action, EditContext, Line, Search, TodoFile}, + todo_file::{Action, EditContext, Line, TodoFile}, view::{LineSegment, RenderContext, ViewData, ViewLine}, }; @@ -60,6 +65,7 @@ pub(crate) struct List { search: Search, search_bar: SearchBar, selected_line_action: Option<Action>, + spin_indicator: SpinIndicator, state: ListState, todo_file: Arc<Mutex<TodoFile>>, view_data: ViewData, @@ -70,7 +76,12 @@ pub(crate) struct List { impl Module for List { fn activate(&mut self, _: State) -> Results { self.selected_line_action = self.todo_file.lock().get_selected_line().map(|line| *line.get_action()); - Results::new() + let searchable: Box<dyn Searchable> = Box::new(self.search.clone()); + let mut results = Results::from(searchable); + if let Some(term) = self.search_bar.search_value() { + results.search_term(term); + } + results } fn build_view_data(&mut self, context: &RenderContext) -> &ViewData { @@ -140,14 +151,17 @@ impl List { updater.set_show_help(true); }); + let search = Search::new(Arc::clone(&todo_file)); + Self { auto_select_next: config.auto_select_next, edit: Edit::new(), height: 0, normal_mode_help: Help::new_from_keybindings(&get_list_normal_mode_help_lines(&config.key_bindings)), - search: Search::new(), + search, search_bar: SearchBar::new(), selected_line_action: None, + spin_indicator: SpinIndicator::new(), state: ListState::Normal, todo_file, view_data, @@ -301,7 +315,7 @@ impl List { #[allow(clippy::unused_self)] fn open_in_editor(&mut self, results: &mut Results) { - self.search_bar.reset(); + results.search_cancel(); results.state(State::ExternalEditor); } @@ -317,7 +331,20 @@ impl List { } fn search_start(&mut self) { - self.search_bar.start_search(None); + self.search_bar.start_search(Some("")); + } + + fn search_update(&mut self) { + self.spin_indicator.refresh(); + // select the first match, if it is available and has not been previously selected + if let Some(selected) = self.search.current_match() { + _ = self.update_cursor(CursorUpdate::Set(selected.index())); + } + else if !self.search_bar.is_editing() { + if let Some(selected) = self.search.next() { + _ = self.update_cursor(CursorUpdate::Set(selected)); + } + } } fn help(&mut self) { @@ -388,6 +415,7 @@ impl List { if let Some(selected_line) = todo_file.get_selected_line() { if selected_line.is_editable() { self.state = ListState::Edit; + self.edit.reset(); self.edit.set_content(selected_line.get_content()); self.edit.set_label(format!("{} ", selected_line.get_action()).as_str()); } @@ -405,10 +433,12 @@ impl List { let selected_index = todo_file.get_selected_line_index(); let visual_index = self.visual_index_start.unwrap_or(selected_index); let search_view_line = self.search_bar.is_editing().then(|| self.search_bar.build_view_line()); - let search_results_total = self.search_bar.is_searching().then(|| self.search.total_results()); - let search_results_current = self.search.current_result_selected(); + let search_results_total = self.search.total_results(); + let search_results_current = self.search.current_result_selected().unwrap_or(0); let search_term = self.search_bar.search_value(); let search_index = self.search.current_match(); + let search_active = self.search.is_active(); + let spin_indicator = self.spin_indicator.indicator(); self.view_data.update_view_data(|updater| { capture!(todo_file); @@ -422,6 +452,7 @@ impl List { else { let maximum_action_width = get_line_action_maximum_width(&todo_file); for (index, line) in todo_file.lines_iter().enumerate() { + let search_match = self.search.match_at_index(index); let selected_line = is_visual_mode && ((visual_index <= selected_index && index >= visual_index && index <= selected_index) || (visual_index > selected_index && index >= selected_index && index <= visual_index)); @@ -435,11 +466,17 @@ impl List { if context.is_full_width() { todo_line_segment_options.insert(TodoLineSegmentsOptions::FULL_WIDTH); } - if search_index.map_or(false, |v| v == index) { + if search_index.map_or(false, |v| v.index() == index) { todo_line_segment_options.insert(TodoLineSegmentsOptions::SEARCH_LINE); } let mut view_line = ViewLine::new_with_pinned_segments( - get_todo_line_segments(line, search_term, todo_line_segment_options, maximum_action_width), + get_todo_line_segments( + line, + search_term, + search_match, + todo_line_segment_options, + maximum_action_width, + ), if line.has_reference() { 2 } else { 3 }, ) .set_selected(selected_index == index || selected_line); @@ -456,16 +493,17 @@ impl List { else if let Some(s_term) = search_term { let mut search_line_segments = vec![]; search_line_segments.push(LineSegment::new(format!("[{s_term}]: ").as_str())); - if_chain! { - if let Some(s_total) = search_results_total; - if let Some(s_index) = search_results_current; - if s_total != 0; - then { - search_line_segments.push(LineSegment::new(format!("{}/{s_total}", s_index + 1).as_str())); - } - else { - search_line_segments.push(LineSegment::new("No Results")); - } + if search_results_total == 0 && !search_active { + search_line_segments.push(LineSegment::new("No Results")); + } + else { + search_line_segments.push(LineSegment::new( + format!("{}/{search_results_total}", search_results_current + 1).as_str(), + )); + } + + if search_active { + search_line_segments.push(LineSegment::new(format!(" Searching [{spin_indicator}]").as_str())); } updater.push_trailing_line(ViewLine::from(search_line_segments)); } @@ -567,34 +605,48 @@ impl List { } fn handle_search_input(&mut self, event: Event) -> Option<Results> { - if self.search_bar.is_active() { - let todo_file = self.todo_file.lock(); - match self.search_bar.handle_event(event) { - SearchBarAction::Start(term) => { - if term.is_empty() { - self.search.cancel(); - self.search_bar.reset(); - } - else { - self.search.next(&todo_file, term.as_str()); - } - }, - SearchBarAction::Next(term) => self.search.next(&todo_file, term.as_str()), - SearchBarAction::Previous(term) => self.search.previous(&todo_file, term.as_str()), - SearchBarAction::Cancel => { - self.search.cancel(); - return Some(Results::from(event)); - }, - SearchBarAction::None | SearchBarAction::Update(_) => return None, - } - drop(todo_file); + if !self.search_bar.is_active() { + return None; + } - if let Some(selected) = self.search.current_match() { - _ = self.update_cursor(CursorUpdate::Set(selected)); - } - return Some(Results::from(event)); + let mut results = Results::from(event); + let todo_file = self.todo_file.lock(); + match self.search_bar.handle_event(event) { + SearchBarAction::Update(term) => { + if term.is_empty() { + results.search_cancel(); + } + else { + results.search_term(term.as_str()); + } + }, + SearchBarAction::Start(term) => { + if term.is_empty() { + results.search_cancel(); + self.search_bar.reset(); + } + else { + results.search_term(term.as_str()); + } + }, + SearchBarAction::Next(term) => { + results.search_term(term.as_str()); + _ = self.search.next(); + }, + SearchBarAction::Previous(term) => { + results.search_term(term.as_str()); + _ = self.search.previous(); + }, + SearchBarAction::Cancel => { + results.search_cancel(); + return Some(results); + }, + SearchBarAction::None => return None, } - None + drop(todo_file); + + self.search_update(); + Some(results) } #[allow(clippy::integer_division)] diff --git a/src/modules/list/search.rs b/src/modules/list/search.rs new file mode 100644 index 0000000..fd0e793 --- /dev/null +++ b/src/modules/list/search.rs @@ -0,0 +1,817 @@ +mod line_match; +mod state; + +use std::{ + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + time::Duration, +}; + +use parking_lot::{Mutex, RwLock}; + +pub(crate) use self::{line_match::LineMatch, state::State}; +use crate::{ + search::{Interrupter, SearchResult, Searchable, Status}, + todo_file::{Action, TodoFile}, +}; + +const LOCK_DURATION: Duration = Duration::from_millis(100); + +#[derive(Clone, Debug)] +pub(crate) struct Search { + cursor: Arc<AtomicUsize>, + state: Arc<RwLock<State>>, + todo_file: Arc<Mutex<TodoFile>>, +} + +impl Searchable for Search { + fn reset(&mut self) { + self.state.write().reset(); + } + + fn search(&mut self, interrupter: Interrupter, term: &str) -> SearchResult { + let Some(todo_file) = self.todo_file.try_lock_for(LOCK_DURATION) + else { + return SearchResult::None; + }; + let Some(mut state) = self.state.try_write_for(LOCK_DURATION) + else { + return SearchResult::None; + }; + if state.try_invalidate_search(todo_file.version(), term) { + self.cursor.store(0, Ordering::Release); + } + let mut has_matches = false; + let mut complete = false; + + state.set_status(Status::Active); + let mut cursor = self.cursor.load(Ordering::Acquire); + while interrupter.should_continue() { + let Some(line) = todo_file.get_line(cursor) + else { + complete = true; + break; + }; + + let action = *line.get_action(); + + let has_hash_match = match action { + Action::Break | Action::Noop | Action::Label | Action::Reset | Action::Merge | Action::Exec => false, + Action::Drop + | Action::Edit + | Action::Fixup + | Action::Pick + | Action::Reword + | Action::Squash + | Action::UpdateRef => line.get_hash().starts_with(term), + }; + let has_content_match = match action { + Action::Break | Action::Noop => false, + Action::Drop + | Action::Edit + | Action::Fixup + | Action::Pick + | Action::Reword + | Action::Squash + | Action::UpdateRef + | Action::Label + | Action::Reset + | Action::Merge + | Action::Exec => line.get_content().contains(term), + }; + + has_matches = state.push_match(LineMatch::new(cursor, has_hash_match, has_content_match)) || has_matches; + + cursor += 1; + } + + self.cursor.store(cursor, Ordering::Release); + + if has_matches { + SearchResult::Updated + } + else if complete { + state.set_status(Status::Complete); + SearchResult::Complete + } + else { + SearchResult::None + } + } +} + +impl Search { + /// Create a new instance + #[inline] + #[must_use] + pub(crate) fn new(todo_file: Arc<Mutex<TodoFile>>) -> Self { + Self { + cursor: Arc::new(AtomicUsize::new(0)), + state: Arc::new(RwLock::new(State::new())), + todo_file, + } + } + + /// Select the next search result + #[inline] + #[allow(clippy::missing_panics_doc)] + pub(crate) fn next(&mut self) -> Option<usize> { + let mut state = self.state.write(); + + if state.matches().is_empty() { + return None; + } + + let new_selected = if let Some(mut current) = state.selected() { + current += 1; + if current >= state.number_matches() { 0 } else { current } + } + else { + // select the line after the hint that matches + let mut index_match = 0; + for (i, v) in state.matches().iter().copied().enumerate() { + if v.index() >= state.match_start_hint() { + index_match = i; + break; + } + } + index_match + }; + state.set_selected(new_selected); + + let new_match_hint = state.match_value(new_selected).map_or(0, |s| s.index()); + state.set_match_start_hint(new_match_hint); + Some(new_match_hint) + } + + /// Select the previous search result + #[inline] + #[allow(clippy::missing_panics_doc)] + pub(crate) fn previous(&mut self) -> Option<usize> { + let mut state = self.state.write(); + if state.matches().is_empty() { + return None; + } + + let new_selected = if let Some(current) = state.selected() { + if current == 0 { + state.number_matches().saturating_sub(1) + } + else { + current.saturating_sub(1) + } + } + else { + // select the line previous to hint that matches + let mut index_match = state.number_matches().saturating_sub(1); + for (i, v) in state.matches().iter().copied().enumerate().rev() { + if v.index() <= state.match_start_hint() { + index_match = i; + break; + } + } + index_match + }; + state.set_selected(new_selected); + + let new_match_hint = state.match_value(new_selected).map_or(0, |s| s.index()); + state.set_match_start_hint(new_match_hint); + Some(new_match_hint) + } + + /// Set a hint for which result to select first during search + #[inline] + pub(crate) fn set_search_start_hint(&mut self, hint: usize) { + self.state.write().set_match_start_hint(hint); + } + + /// Get the index of the current selected result, if there is one + #[inline] + #[must_use] + pub(crate) fn current_match(&self) -> Option<LineMatch> { + let state = self.state.read(); + let selected = state.selected()?; + state.match_value(selected) + } + + /// Get the index of the current selected result, if there is one + #[inline] + #[must_use] + pub(crate) fn match_at_index(&self, index: usize) -> Option<LineMatch> { + self.state.read().match_value_for_line(index) + } + + /// Get the selected result number, if there is one + #[inline] + #[must_use] + pub(crate) fn current_result_selected(&self) -> Option<usize> { + self.state.read().selected() + } + + /// Get the total number of results + #[inline] + #[must_use] + pub(crate) fn total_results(&self) -> usize { + self.state.read().number_matches() + } + + /// Is search active + #[inline] + #[must_use] + pub(crate) fn is_active(&self) -> bool { + self.state.read().status() == Status::Active + } +} + +#[cfg(test)] +mod tests { + use claims::{assert_none, assert_some_eq}; + use rstest::rstest; + + use super::*; + use crate::test_helpers::{testers, with_todo_file}; + + pub(crate) fn create_search(todo_file: TodoFile) -> Search { + Search::new(Arc::new(Mutex::new(todo_file))) + } + + pub(crate) fn create_and_run_search(todo_file: TodoFile, term: &str, result: SearchResult) -> Search { + let search = Search::new(Arc::new(Mutex::new(todo_file))); + assert_eq!(testers::SearchableRunner::new(&search).run_search(term), result); + search + } + + #[test] + fn reset() { + with_todo_file(&[], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_search(todo_file); + search.state.write().set_status(Status::Active); + search.reset(); + assert!(!search.is_active()); + }); + } + + #[test] + fn search_empty_rebase_file() { + with_todo_file(&[], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let mut search = create_and_run_search(todo_file, "foo", SearchResult::Complete); + assert_eq!(search.total_results(), 0); + }); + } + + #[test] + fn search_with_one_line_no_match() { + with_todo_file(&["pick abcdef bar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let search = create_and_run_search(todo_file, "foo", SearchResult::Complete); + assert_eq!(search.total_results(), 0); + }); + } + + #[test] + fn search_with_incomplete() { + with_todo_file(&["pick abcdef bar"; 10], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let search = create_search(todo_file); + assert_eq!( + testers::SearchableRunner::new(&search).run_search_with_time("foo", 0), + SearchResult::None + ); + assert_eq!(search.total_results(), 0); + }); + } + + #[test] + fn search_with_one_line_match() { + with_todo_file(&["pick abcdef foo"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let search = create_and_run_search(todo_file, "foo", SearchResult::Updated); + assert_eq!(search.total_results(), 1); + assert_some_eq!(search.match_at_index(0), LineMatch::new(0, false, true)); + }); + } + + #[test] + fn search_standard_action_hash() { + with_todo_file( + &[ + "pick aaaaa no match", + "drop abcdef foo", + "edit abcdef foo", + "fixup abcdef foo", + "pick abcdef foo", + "reword abcdef foo", + "squash abcdef foo", + ], + |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let search = create_and_run_search(todo_file, "abcd", SearchResult::Updated); + assert_eq!(search.total_results(), 6); + assert_none!(search.match_at_index(0)); + assert_some_eq!(search.match_at_index(1), LineMatch::new(1, true, false)); + assert_some_eq!(search.match_at_index(2), LineMatch::new(2, true, false)); + assert_some_eq!(search.match_at_index(3), LineMatch::new(3, true, false)); + assert_some_eq!(search.match_at_index(4), LineMatch::new(4, true, false)); + assert_some_eq!(search.match_at_index(5), LineMatch::new(5, true, false)); + }, + ); + } + + #[test] + fn search_content() { + with_todo_file( + &[ + "pick abcdef no match", + "drop abcdef foobar", + "edit abcdef foobar", + "fixup abcdef foobar", + "pick abcdef foobar", + "reword abcdef foobar", + "squash abcdef foobar", + "label foobar", + "reset foobar", + "merge foobar", + "exec foobar", + "update-ref foobar", + ], + |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let search = create_and_run_search(todo_file, "ooba", SearchResult::Updated); + assert_eq!(search.total_results(), 11); + assert_none!(search.match_at_index(0)); + assert_some_eq!(search.match_at_index(1), LineMatch::new(1, false, true)); + assert_some_eq!(search.match_at_index(2), LineMatch::new(2, false, true)); + assert_some_eq!(search.match_at_index(3), LineMatch::new(3, false, true)); + assert_some_eq!(search.match_at_index(4), LineMatch::new(4, false, true)); + assert_some_eq!(search.match_at_index(5), LineMatch::new(5, false, true)); + assert_some_eq!(search.match_at_index(6), LineMatch::new(6, false, true)); + assert_some_eq!(search.match_at_index(7), LineMatch::new(7, false, true)); + assert_some_eq!(search.match_at_index(8), LineMatch::new(8, false, true)); + assert_some_eq!(search.match_at_index(9), LineMatch::new(9, false, true)); + assert_some_eq!(search.match_at_index(10), LineMatch::new(10, false, true)); + }, + ); + } + + #[test] + fn search_standard_action_hash_starts_only() { + with_todo_file(&["pick abcdef foobar"], |context| { + let (_todo_file_path, todo_file) = context.to_owned(); + let search = create_and_run_search(todo_file, "def", SearchResult::Complete); + assert_eq!(search.total_results(), 0); + }); + } + + #[rstest] + #[case::pick("noop")] + #[case::pick("break")] + #[case::pick("pick")] + #[case::drop("drop")] |