todo_file/
search.rs

1use version_track::Version;
2
3use crate::{Action, TodoFile};
4
5/// Search handler for the todofile
6#[derive(Debug)]
7pub struct Search {
8	match_start_hint: usize,
9	matches: Vec<usize>,
10	rebase_todo_version: Version,
11	search_term: String,
12	selected: Option<usize>,
13}
14
15impl Search {
16	/// Create a new instance
17	#[inline]
18	#[must_use]
19	pub const fn new() -> Self {
20		Self {
21			match_start_hint: 0,
22			matches: vec![],
23			rebase_todo_version: Version::sentinel(),
24			search_term: String::new(),
25			selected: None,
26		}
27	}
28
29	/// Generate search results
30	#[inline]
31	pub fn search(&mut self, rebase_todo: &TodoFile, term: &str) -> bool {
32		if &self.rebase_todo_version != rebase_todo.version() || self.search_term != term || self.matches.is_empty() {
33			self.matches.clear();
34			self.selected = None;
35			self.search_term = String::from(term);
36			self.rebase_todo_version = *rebase_todo.version();
37			for (i, line) in rebase_todo.lines_iter().enumerate() {
38				match *line.get_action() {
39					Action::Break | Action::Noop => continue,
40					Action::Drop
41					| Action::Edit
42					| Action::Fixup
43					| Action::Pick
44					| Action::Reword
45					| Action::Squash
46					| Action::UpdateRef => {
47						if line.get_hash().starts_with(term) || line.get_content().contains(term) {
48							self.matches.push(i);
49						}
50					},
51					Action::Label | Action::Reset | Action::Merge | Action::Exec => {
52						if line.get_content().contains(term) {
53							self.matches.push(i);
54						}
55					},
56				}
57			}
58		}
59		!self.matches.is_empty()
60	}
61
62	/// Select the next search result
63	#[inline]
64	#[allow(clippy::missing_panics_doc)]
65	pub fn next(&mut self, rebase_todo: &TodoFile, term: &str) {
66		if !self.search(rebase_todo, term) {
67			return;
68		}
69
70		if let Some(mut current) = self.selected {
71			current += 1;
72			let new_value = if current >= self.matches.len() { 0 } else { current };
73			self.selected = Some(new_value);
74		}
75		else {
76			// select the line after the hint that matches
77			let mut index_match = 0;
78			for (i, v) in self.matches.iter().enumerate() {
79				if *v >= self.match_start_hint {
80					index_match = i;
81					break;
82				}
83			}
84			self.selected = Some(index_match);
85		};
86
87		self.match_start_hint = self.matches[self.selected.unwrap()];
88	}
89
90	/// Select the previous search result
91	#[inline]
92	#[allow(clippy::missing_panics_doc)]
93	pub fn previous(&mut self, rebase_todo: &TodoFile, term: &str) {
94		if !self.search(rebase_todo, term) {
95			return;
96		}
97
98		if let Some(current) = self.selected {
99			let new_value = if current == 0 {
100				self.matches.len().saturating_sub(1)
101			}
102			else {
103				current.saturating_sub(1)
104			};
105			self.selected = Some(new_value);
106		}
107		else {
108			// select the line previous to hint that matches
109			let mut index_match = self.matches.len().saturating_sub(1);
110			for (i, v) in self.matches.iter().enumerate().rev() {
111				if *v <= self.match_start_hint {
112					index_match = i;
113					break;
114				}
115			}
116			self.selected = Some(index_match);
117		}
118
119		self.match_start_hint = self.matches[self.selected.unwrap()];
120	}
121
122	/// Set a hint for which result to select first during search
123	#[inline]
124	pub fn set_search_start_hint(&mut self, hint: usize) {
125		if self.match_start_hint != hint {
126			self.match_start_hint = hint;
127		}
128	}
129
130	/// Invalidate current search results
131	#[inline]
132	pub fn invalidate(&mut self) {
133		self.matches.clear();
134	}
135
136	/// Cancel search, clearing results, selected result and search term
137	#[inline]
138	pub fn cancel(&mut self) {
139		self.selected = None;
140		self.search_term.clear();
141		self.matches.clear();
142	}
143
144	/// Get the index of the current selected result, if there is one
145	#[inline]
146	#[must_use]
147	pub fn current_match(&self) -> Option<usize> {
148		let selected = self.selected?;
149		self.matches.get(selected).copied()
150	}
151
152	/// Get the selected result number, if there is one
153	#[inline]
154	#[must_use]
155	pub const fn current_result_selected(&self) -> Option<usize> {
156		self.selected
157	}
158
159	/// Get the total number of results
160	#[inline]
161	#[must_use]
162	pub fn total_results(&self) -> usize {
163		self.matches.len()
164	}
165}
166
167#[cfg(test)]
168mod tests {
169	use claim::{assert_none, assert_some_eq};
170
171	use super::*;
172	use crate::testutil::with_todo_file;
173
174	#[test]
175	fn search_empty_rebase_file() {
176		with_todo_file(&[], |context| {
177			let mut search = Search::new();
178			assert!(!search.search(context.todo_file(), "foo"));
179		});
180	}
181
182	#[test]
183	fn search_with_one_line_no_match() {
184		with_todo_file(&["pick abcdef bar"], |context| {
185			let mut search = Search::new();
186			assert!(!search.search(context.todo_file(), "foo"));
187		});
188	}
189
190	#[test]
191	fn search_with_one_line_match() {
192		with_todo_file(&["pick abcdef foo"], |context| {
193			let mut search = Search::new();
194			assert!(search.search(context.todo_file(), "foo"));
195		});
196	}
197
198	#[test]
199	fn search_ignore_break() {
200		with_todo_file(&["break"], |context| {
201			let mut search = Search::new();
202			assert!(!search.search(context.todo_file(), "break"));
203		});
204	}
205
206	#[test]
207	fn search_ignore_noop() {
208		with_todo_file(&["noop"], |context| {
209			let mut search = Search::new();
210			assert!(!search.search(context.todo_file(), "noop"));
211		});
212	}
213
214	#[test]
215	fn search_standard_action_hash() {
216		with_todo_file(
217			&[
218				"pick aaaaa no match",
219				"drop abcdef foo",
220				"edit abcdef foo",
221				"fixup abcdef foo",
222				"pick abcdef foo",
223				"reword abcdef foo",
224				"squash abcdef foo",
225			],
226			|context| {
227				let mut search = Search::new();
228				assert!(search.search(context.todo_file(), "abcd"));
229				assert_eq!(search.total_results(), 6);
230			},
231		);
232	}
233
234	#[test]
235	fn search_standard_action_content() {
236		with_todo_file(
237			&[
238				"pick abcdef no match",
239				"drop abcdef foobar",
240				"edit abcdef foobar",
241				"fixup abcdef foobar",
242				"pick abcdef foobar",
243				"reword abcdef foobar",
244				"squash abcdef foobar",
245			],
246			|context| {
247				let mut search = Search::new();
248				assert!(search.search(context.todo_file(), "ooba"));
249				assert_eq!(search.total_results(), 6);
250			},
251		);
252	}
253
254	#[test]
255	fn search_standard_action_hash_starts_only() {
256		with_todo_file(&["pick abcdef foobar"], |context| {
257			let mut search = Search::new();
258			assert!(!search.search(context.todo_file(), "def"));
259		});
260	}
261
262	#[test]
263	fn search_standard_ignore_action() {
264		with_todo_file(&["pick abcdef foo"], |context| {
265			let mut search = Search::new();
266			assert!(!search.search(context.todo_file(), "pick"));
267		});
268	}
269
270	#[test]
271	fn search_editable_content() {
272		with_todo_file(
273			&[
274				"label no match",
275				"label foobar",
276				"reset foobar",
277				"merge foobar",
278				"exec foobar",
279				"update-ref foobar",
280			],
281			|context| {
282				let mut search = Search::new();
283				assert!(search.search(context.todo_file(), "ooba"));
284				assert_eq!(search.total_results(), 5);
285			},
286		);
287	}
288
289	#[test]
290	fn search_editable_ignore_action() {
291		with_todo_file(&["label no match"], |context| {
292			let mut search = Search::new();
293			assert!(!search.search(context.todo_file(), "label"));
294		});
295	}
296
297	#[test]
298	fn next_no_match() {
299		with_todo_file(&["pick aaa foo"], |context| {
300			let mut search = Search::new();
301			search.next(context.todo_file(), "miss");
302			assert_none!(search.current_match());
303		});
304	}
305
306	#[test]
307	fn next_first_match() {
308		with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| {
309			let mut search = Search::new();
310			search.next(context.todo_file(), "foo");
311			assert_some_eq!(search.current_match(), 0);
312		});
313	}
314
315	#[test]
316	fn next_first_match_with_hint_in_range() {
317		with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| {
318			let mut search = Search::new();
319			search.set_search_start_hint(1);
320			search.next(context.todo_file(), "foo");
321			assert_some_eq!(search.current_match(), 1);
322		});
323	}
324
325	#[test]
326	fn next_first_match_with_hint_in_range_but_behind() {
327		with_todo_file(&["pick aaa foo", "pick bbb miss", "pick bbb foobar"], |context| {
328			let mut search = Search::new();
329			search.set_search_start_hint(1);
330			search.next(context.todo_file(), "foo");
331			assert_some_eq!(search.current_match(), 2);
332		});
333	}
334
335	#[test]
336	fn next_first_match_with_hint_in_range_wrap() {
337		with_todo_file(
338			&["pick bbb miss", "pick aaa foo", "pick aaa foo", "pick bbb miss"],
339			|context| {
340				let mut search = Search::new();
341				search.set_search_start_hint(3);
342				search.next(context.todo_file(), "foo");
343				assert_some_eq!(search.current_match(), 1);
344			},
345		);
346	}
347
348	#[test]
349	fn next_first_match_with_hint_out_of_range() {
350		with_todo_file(
351			&["pick bbb miss", "pick aaa foo", "pick aaa foo", "pick bbb miss"],
352			|context| {
353				let mut search = Search::new();
354				search.set_search_start_hint(99);
355				search.next(context.todo_file(), "foo");
356				assert_some_eq!(search.current_match(), 1);
357			},
358		);
359	}
360
361	#[test]
362	fn next_continued_match() {
363		with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| {
364			let mut search = Search::new();
365			search.next(context.todo_file(), "foo");
366			search.next(context.todo_file(), "foo");
367			assert_some_eq!(search.current_match(), 1);
368		});
369	}
370
371	#[test]
372	fn next_continued_match_wrap_single_match() {
373		with_todo_file(&["pick aaa foo", "pick bbb miss"], |context| {
374			let mut search = Search::new();
375			search.next(context.todo_file(), "foo");
376			search.next(context.todo_file(), "foo");
377			assert_some_eq!(search.current_match(), 0);
378		});
379	}
380
381	#[test]
382	fn next_continued_match_wrap() {
383		with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| {
384			let mut search = Search::new();
385			search.next(context.todo_file(), "foo");
386			search.next(context.todo_file(), "foo");
387			search.next(context.todo_file(), "foo");
388			assert_some_eq!(search.current_match(), 0);
389		});
390	}
391
392	#[test]
393	fn next_updates_match_start_hint() {
394		with_todo_file(&["pick bbb miss", "pick aaa foo"], |context| {
395			let mut search = Search::new();
396			search.next(context.todo_file(), "foo");
397			assert_eq!(search.match_start_hint, 1);
398		});
399	}
400
401	#[test]
402	fn previous_no_match() {
403		with_todo_file(&["pick aaa foo"], |context| {
404			let mut search = Search::new();
405			search.previous(context.todo_file(), "miss");
406			assert_none!(search.current_match());
407		});
408	}
409
410	#[test]
411	fn previous_first_match() {
412		with_todo_file(&["pick aaa foo"], |context| {
413			let mut search = Search::new();
414			search.previous(context.todo_file(), "foo");
415			assert_some_eq!(search.current_match(), 0);
416		});
417	}
418
419	#[test]
420	fn previous_first_match_with_hint_in_range() {
421		with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| {
422			let mut search = Search::new();
423			search.set_search_start_hint(1);
424			search.previous(context.todo_file(), "foo");
425			assert_some_eq!(search.current_match(), 1);
426		});
427	}
428
429	#[test]
430	fn previous_first_match_with_hint_in_range_but_ahead() {
431		with_todo_file(
432			&["pick bbb miss", "pick aaa foo", "pick bbb miss", "pick bbb foobar"],
433			|context| {
434				let mut search = Search::new();
435				search.set_search_start_hint(2);
436				search.previous(context.todo_file(), "foo");
437				assert_some_eq!(search.current_match(), 1);
438			},
439		);
440	}
441
442	#[test]
443	fn previous_first_match_with_hint_in_range_wrap() {
444		with_todo_file(
445			&["pick bbb miss", "pick bbb miss", "pick aaa foo", "pick aaa foo"],
446			|context| {
447				let mut search = Search::new();
448				search.set_search_start_hint(1);
449				search.previous(context.todo_file(), "foo");
450				assert_some_eq!(search.current_match(), 3);
451			},
452		);
453	}
454
455	#[test]
456	fn previous_first_match_with_hint_out_of_range() {
457		with_todo_file(
458			&["pick bbb miss", "pick aaa foo", "pick aaa foo", "pick bbb miss"],
459			|context| {
460				let mut search = Search::new();
461				search.set_search_start_hint(99);
462				search.previous(context.todo_file(), "foo");
463				assert_some_eq!(search.current_match(), 2);
464			},
465		);
466	}
467
468	#[test]
469	fn previous_continued_match() {
470		with_todo_file(&["pick aaa foo", "pick aaa foo", "pick bbb foobar"], |context| {
471			let mut search = Search::new();
472			search.set_search_start_hint(2);
473			search.previous(context.todo_file(), "foo");
474			search.previous(context.todo_file(), "foo");
475			assert_some_eq!(search.current_match(), 1);
476		});
477	}
478
479	#[test]
480	fn previous_continued_match_wrap_single_match() {
481		with_todo_file(&["pick aaa foo", "pick bbb miss"], |context| {
482			let mut search = Search::new();
483			search.previous(context.todo_file(), "foo");
484			search.previous(context.todo_file(), "foo");
485			assert_some_eq!(search.current_match(), 0);
486		});
487	}
488
489	#[test]
490	fn previous_continued_match_wrap() {
491		with_todo_file(&["pick aaa foo", "pick bbb foobar"], |context| {
492			let mut search = Search::new();
493			search.previous(context.todo_file(), "foo");
494			search.previous(context.todo_file(), "foo");
495			assert_some_eq!(search.current_match(), 1);
496		});
497	}
498
499	#[test]
500	fn previous_updates_match_start_hint() {
501		with_todo_file(&["pick bbb miss", "pick aaa foo"], |context| {
502			let mut search = Search::new();
503			search.previous(context.todo_file(), "foo");
504			assert_eq!(search.match_start_hint, 1);
505		});
506	}
507
508	#[test]
509	fn invalidate() {
510		with_todo_file(&["pick abcdef foo"], |context| {
511			let mut search = Search::new();
512			search.next(context.todo_file(), "foo");
513			search.invalidate();
514			assert_eq!(search.total_results(), 0);
515		});
516	}
517
518	#[test]
519	fn cancel() {
520		with_todo_file(&["pick abcdef foo"], |context| {
521			let mut search = Search::new();
522			search.next(context.todo_file(), "foo");
523			search.cancel();
524			assert_eq!(search.total_results(), 0);
525			assert_none!(search.current_match());
526			assert!(search.search_term.is_empty());
527		});
528	}
529
530	#[test]
531	fn current_match_with_match() {
532		with_todo_file(&["pick abcdef foo"], |context| {
533			let mut search = Search::new();
534			search.next(context.todo_file(), "foo");
535			assert_some_eq!(search.current_match(), 0);
536		});
537	}
538
539	#[test]
540	fn current_match_with_no_match() {
541		with_todo_file(&["pick abcdef foo"], |context| {
542			let mut search = Search::new();
543			search.next(context.todo_file(), "miss");
544			assert_none!(search.current_match());
545		});
546	}
547
548	#[test]
549	fn current_result_selected_with_match() {
550		with_todo_file(&["pick abcdef foo"], |context| {
551			let mut search = Search::new();
552			search.next(context.todo_file(), "foo");
553			assert_some_eq!(search.current_result_selected(), 0);
554		});
555	}
556
557	#[test]
558	fn current_result_selected_with_no_match() {
559		with_todo_file(&["pick abcdef foo"], |context| {
560			let mut search = Search::new();
561			search.next(context.todo_file(), "miss");
562			assert_none!(search.current_result_selected());
563		});
564	}
565
566	#[test]
567	fn total_results() {
568		with_todo_file(&["pick abcdef foo"], |context| {
569			let mut search = Search::new();
570			search.next(context.todo_file(), "foo");
571			assert_eq!(search.total_results(), 1);
572		});
573	}
574}