1use version_track::Version;
2
3use crate::{Action, TodoFile};
4
5#[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 #[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 #[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 #[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 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 #[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 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 #[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 #[inline]
132 pub fn invalidate(&mut self) {
133 self.matches.clear();
134 }
135
136 #[inline]
138 pub fn cancel(&mut self) {
139 self.selected = None;
140 self.search_term.clear();
141 self.matches.clear();
142 }
143
144 #[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 #[inline]
154 #[must_use]
155 pub const fn current_result_selected(&self) -> Option<usize> {
156 self.selected
157 }
158
159 #[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}