scooter_core/
replace.rs

1use frep_core::{
2    replace::{ReplaceResult, replace_in_file, replacement_if_match},
3    search::{FileSearcher, SearchResultWithReplacement},
4};
5use rayon::prelude::{IntoParallelIterator, ParallelIterator};
6use std::{
7    collections::HashMap,
8    path::PathBuf,
9    sync::{
10        Arc,
11        atomic::{AtomicBool, AtomicUsize, Ordering},
12    },
13    thread,
14    time::{Duration, Instant},
15};
16use tokio::{
17    sync::mpsc::{UnboundedReceiver, UnboundedSender},
18    task::JoinHandle,
19};
20
21use crate::{
22    app::{BackgroundProcessingEvent, Event, EventHandlingResult},
23    commands::CommandResults,
24};
25
26pub fn split_results(
27    results: Vec<SearchResultWithReplacement>,
28) -> (Vec<SearchResultWithReplacement>, usize) {
29    let (included, excluded): (Vec<_>, Vec<_>) = results
30        .into_iter()
31        .partition(|res| res.search_result.included);
32    let num_ignored = excluded.len();
33    (included, num_ignored)
34}
35
36fn group_results(
37    included: Vec<SearchResultWithReplacement>,
38) -> HashMap<Option<PathBuf>, Vec<SearchResultWithReplacement>> {
39    let mut path_groups = HashMap::<Option<PathBuf>, Vec<SearchResultWithReplacement>>::new();
40    for res in included {
41        path_groups
42            .entry(res.search_result.path.clone())
43            .or_default()
44            .push(res);
45    }
46    path_groups
47}
48
49pub fn spawn_replace_included<T: Fn(SearchResultWithReplacement) + Send + Sync + 'static>(
50    search_results: Vec<SearchResultWithReplacement>,
51    cancelled: Arc<AtomicBool>,
52    replacements_completed: Arc<AtomicUsize>,
53    validation_search_config: Option<FileSearcher>,
54    on_completion: T,
55) -> usize {
56    let (included, num_ignored) = split_results(search_results);
57
58    thread::spawn(move || {
59        let path_groups = group_results(included);
60
61        let pool = rayon::ThreadPoolBuilder::new()
62            .num_threads(8)
63            .build()
64            .unwrap();
65
66        pool.install(|| {
67            path_groups
68                .into_par_iter()
69                .for_each(|(_path, mut results)| {
70                    if cancelled.load(Ordering::Relaxed) {
71                        return;
72                    }
73
74                    if let Some(config) = &validation_search_config {
75                        validate_search_result_correctness(config, &results);
76                    }
77                    if let Err(file_err) = replace_in_file(&mut results) {
78                        for res in &mut results {
79                            res.replace_result = Some(ReplaceResult::Error(file_err.to_string()));
80                        }
81                    }
82                    replacements_completed.fetch_add(results.len(), Ordering::Relaxed);
83
84                    for result in results {
85                        on_completion(result);
86                    }
87                });
88        });
89    });
90
91    num_ignored
92}
93
94fn validate_search_result_correctness(
95    validation_search_config: &FileSearcher,
96    results: &[SearchResultWithReplacement],
97) {
98    for res in results {
99        let expected = replacement_if_match(
100            &res.search_result.line,
101            validation_search_config.search(),
102            validation_search_config.replace(),
103        );
104        let actual = &res.replacement;
105        assert_eq!(
106            expected.as_ref(),
107            Some(actual),
108            "Expected replacement does not match actual"
109        );
110    }
111}
112
113#[derive(Clone, Debug, Eq, PartialEq)]
114pub struct ReplaceState {
115    pub num_successes: usize,
116    pub num_ignored: usize,
117    pub errors: Vec<SearchResultWithReplacement>,
118    pub replacement_errors_pos: usize,
119}
120
121impl ReplaceState {
122    #[allow(clippy::needless_pass_by_value)]
123    pub(crate) fn handle_command_results(&mut self, event: CommandResults) -> EventHandlingResult {
124        #[allow(clippy::match_same_arms)]
125        match event {
126            CommandResults::ScrollErrorsDown => {
127                self.scroll_replacement_errors_down();
128                EventHandlingResult::Rerender
129            }
130            CommandResults::ScrollErrorsUp => {
131                self.scroll_replacement_errors_up();
132                EventHandlingResult::Rerender
133            }
134            CommandResults::Quit => EventHandlingResult::Exit(None),
135        }
136    }
137
138    pub fn scroll_replacement_errors_up(&mut self) {
139        if self.replacement_errors_pos == 0 {
140            self.replacement_errors_pos = self.errors.len();
141        }
142        self.replacement_errors_pos = self.replacement_errors_pos.saturating_sub(1);
143    }
144
145    pub fn scroll_replacement_errors_down(&mut self) {
146        if self.replacement_errors_pos >= self.errors.len().saturating_sub(1) {
147            self.replacement_errors_pos = 0;
148        } else {
149            self.replacement_errors_pos += 1;
150        }
151    }
152}
153
154#[derive(Debug)]
155pub struct PerformingReplacementState {
156    pub processing_receiver: UnboundedReceiver<BackgroundProcessingEvent>,
157    pub cancelled: Arc<AtomicBool>,
158    pub replacement_started: Instant,
159    pub num_replacements_completed: Arc<AtomicUsize>,
160    pub total_replacements: usize,
161}
162
163impl PerformingReplacementState {
164    pub fn new(
165        processing_receiver: UnboundedReceiver<BackgroundProcessingEvent>,
166        cancelled: Arc<AtomicBool>,
167        num_replacements_completed: Arc<AtomicUsize>,
168        total_replacements: usize,
169    ) -> Self {
170        Self {
171            processing_receiver,
172            cancelled,
173            replacement_started: Instant::now(),
174            num_replacements_completed,
175            total_replacements,
176        }
177    }
178}
179
180pub fn perform_replacement(
181    search_results: Vec<SearchResultWithReplacement>,
182    background_processing_sender: UnboundedSender<BackgroundProcessingEvent>,
183    cancelled: Arc<AtomicBool>,
184    replacements_completed: Arc<AtomicUsize>,
185    event_sender: UnboundedSender<Event>,
186    validation_search_config: Option<FileSearcher>,
187) -> JoinHandle<()> {
188    tokio::spawn(async move {
189        cancelled.store(false, Ordering::Relaxed);
190
191        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
192        let num_ignored = crate::replace::spawn_replace_included(
193            search_results,
194            cancelled,
195            replacements_completed,
196            validation_search_config,
197            move |result| {
198                let _ = tx.send(result); // Ignore error if receiver is dropped
199            },
200        );
201
202        let mut rerender_interval = tokio::time::interval(Duration::from_millis(92)); // Slightly random duration so that time taken isn't a round number
203
204        let mut replacement_results = Vec::new();
205        loop {
206            tokio::select! {
207                res = rx.recv() => match res {
208                    Some(res) => replacement_results.push(res),
209                    None => break,
210                },
211                _ = rerender_interval.tick() => {
212                    let _ = event_sender.send(Event::Rerender);
213                }
214            }
215        }
216
217        let _ = event_sender.send(Event::Rerender);
218
219        let stats = frep_core::replace::calculate_statistics(replacement_results);
220        // Ignore error: we may have gone back to the previous screen
221        let _ = background_processing_sender.send(BackgroundProcessingEvent::ReplacementCompleted(
222            ReplaceState {
223                num_successes: stats.num_successes,
224                num_ignored,
225                errors: stats.errors,
226                replacement_errors_pos: 0,
227            },
228        ));
229    })
230}
231
232#[cfg(test)]
233mod tests {
234    use std::path::PathBuf;
235
236    use frep_core::{
237        line_reader::LineEnding,
238        replace::ReplaceResult,
239        search::{SearchResult, SearchResultWithReplacement},
240    };
241
242    use crate::{
243        app::EventHandlingResult,
244        commands::CommandResults,
245        replace::{self, ReplaceState},
246    };
247
248    fn create_search_result_with_replacement(
249        path: &str,
250        line_number: usize,
251        line: &str,
252        replacement: &str,
253        included: bool,
254        replace_result: Option<ReplaceResult>,
255    ) -> SearchResultWithReplacement {
256        SearchResultWithReplacement {
257            search_result: SearchResult {
258                path: Some(PathBuf::from(path)),
259                line_number,
260                line: line.to_string(),
261                line_ending: LineEnding::Lf,
262                included,
263            },
264            replacement: replacement.to_string(),
265            replace_result,
266        }
267    }
268
269    #[test]
270    fn test_split_results_all_included() {
271        let result1 =
272            create_search_result_with_replacement("file1.txt", 1, "line1", "repl1", true, None);
273        let result2 =
274            create_search_result_with_replacement("file2.txt", 2, "line2", "repl2", true, None);
275        let result3 =
276            create_search_result_with_replacement("file3.txt", 3, "line3", "repl3", true, None);
277
278        let search_results = vec![result1.clone(), result2.clone(), result3.clone()];
279
280        let (included, num_ignored) = replace::split_results(search_results);
281        assert_eq!(num_ignored, 0);
282        assert_eq!(included, vec![result1, result2, result3]);
283    }
284
285    #[test]
286    fn test_split_results_mixed() {
287        let result1 =
288            create_search_result_with_replacement("file1.txt", 1, "line1", "repl1", true, None);
289        let result2 =
290            create_search_result_with_replacement("file2.txt", 2, "line2", "repl2", false, None);
291        let result3 =
292            create_search_result_with_replacement("file3.txt", 3, "line3", "repl3", true, None);
293        let result4 =
294            create_search_result_with_replacement("file4.txt", 4, "line4", "repl4", false, None);
295
296        let search_results = vec![result1.clone(), result2, result3.clone(), result4];
297
298        let (included, num_ignored) = replace::split_results(search_results);
299        assert_eq!(num_ignored, 2);
300        assert_eq!(included, vec![result1, result3]);
301        assert!(included.iter().all(|r| r.search_result.included));
302    }
303
304    #[test]
305    fn test_replace_state_scroll_replacement_errors_up() {
306        let mut state = ReplaceState {
307            num_successes: 5,
308            num_ignored: 2,
309            errors: vec![
310                create_search_result_with_replacement(
311                    "file1.txt",
312                    1,
313                    "error1",
314                    "repl1",
315                    true,
316                    Some(ReplaceResult::Error("err1".to_string())),
317                ),
318                create_search_result_with_replacement(
319                    "file2.txt",
320                    2,
321                    "error2",
322                    "repl2",
323                    true,
324                    Some(ReplaceResult::Error("err2".to_string())),
325                ),
326                create_search_result_with_replacement(
327                    "file3.txt",
328                    3,
329                    "error3",
330                    "repl3",
331                    true,
332                    Some(ReplaceResult::Error("err3".to_string())),
333                ),
334            ],
335            replacement_errors_pos: 1,
336        };
337
338        state.scroll_replacement_errors_up();
339        assert_eq!(state.replacement_errors_pos, 0);
340
341        state.scroll_replacement_errors_up();
342        assert_eq!(state.replacement_errors_pos, 2);
343
344        state.scroll_replacement_errors_up();
345        assert_eq!(state.replacement_errors_pos, 1);
346    }
347
348    #[test]
349    fn test_replace_state_scroll_replacement_errors_down() {
350        let mut state = ReplaceState {
351            num_successes: 5,
352            num_ignored: 2,
353            errors: vec![
354                create_search_result_with_replacement(
355                    "file1.txt",
356                    1,
357                    "error1",
358                    "repl1",
359                    true,
360                    Some(ReplaceResult::Error("err1".to_string())),
361                ),
362                create_search_result_with_replacement(
363                    "file2.txt",
364                    2,
365                    "error2",
366                    "repl2",
367                    true,
368                    Some(ReplaceResult::Error("err2".to_string())),
369                ),
370                create_search_result_with_replacement(
371                    "file3.txt",
372                    3,
373                    "error3",
374                    "repl3",
375                    true,
376                    Some(ReplaceResult::Error("err3".to_string())),
377                ),
378            ],
379            replacement_errors_pos: 1,
380        };
381
382        state.scroll_replacement_errors_down();
383        assert_eq!(state.replacement_errors_pos, 2);
384
385        state.scroll_replacement_errors_down();
386        assert_eq!(state.replacement_errors_pos, 0);
387
388        state.scroll_replacement_errors_down();
389        assert_eq!(state.replacement_errors_pos, 1);
390    }
391
392    #[test]
393    fn test_replace_state_handle_command_results() {
394        let mut state = ReplaceState {
395            num_successes: 5,
396            num_ignored: 2,
397            errors: vec![
398                create_search_result_with_replacement(
399                    "file1.txt",
400                    1,
401                    "error1",
402                    "repl1",
403                    true,
404                    Some(ReplaceResult::Error("err1".to_string())),
405                ),
406                create_search_result_with_replacement(
407                    "file2.txt",
408                    2,
409                    "error2",
410                    "repl2",
411                    true,
412                    Some(ReplaceResult::Error("err2".to_string())),
413                ),
414            ],
415            replacement_errors_pos: 0,
416        };
417
418        let result = state.handle_command_results(CommandResults::ScrollErrorsDown);
419        assert!(matches!(result, EventHandlingResult::Rerender));
420        assert_eq!(state.replacement_errors_pos, 1);
421
422        let result = state.handle_command_results(CommandResults::ScrollErrorsUp);
423        assert!(matches!(result, EventHandlingResult::Rerender));
424        assert_eq!(state.replacement_errors_pos, 0);
425
426        let result = state.handle_command_results(CommandResults::Quit);
427        assert!(matches!(result, EventHandlingResult::Exit(None)));
428    }
429
430    #[test]
431    fn test_calculate_statistics_all_success() {
432        let results = vec![
433            create_search_result_with_replacement(
434                "file1.txt",
435                1,
436                "line1",
437                "repl1",
438                true,
439                Some(ReplaceResult::Success),
440            ),
441            create_search_result_with_replacement(
442                "file2.txt",
443                2,
444                "line2",
445                "repl2",
446                true,
447                Some(ReplaceResult::Success),
448            ),
449            create_search_result_with_replacement(
450                "file3.txt",
451                3,
452                "line3",
453                "repl3",
454                true,
455                Some(ReplaceResult::Success),
456            ),
457        ];
458
459        let stats = frep_core::replace::calculate_statistics(results);
460        assert_eq!(stats.num_successes, 3);
461        assert_eq!(stats.errors.len(), 0);
462    }
463
464    #[test]
465    fn test_calculate_statistics_with_errors() {
466        let error_result = create_search_result_with_replacement(
467            "file2.txt",
468            2,
469            "line2",
470            "repl2",
471            true,
472            Some(ReplaceResult::Error("test error".to_string())),
473        );
474        let results = vec![
475            create_search_result_with_replacement(
476                "file1.txt",
477                1,
478                "line1",
479                "repl1",
480                true,
481                Some(ReplaceResult::Success),
482            ),
483            error_result.clone(),
484            create_search_result_with_replacement(
485                "file3.txt",
486                3,
487                "line3",
488                "repl3",
489                true,
490                Some(ReplaceResult::Success),
491            ),
492        ];
493
494        let stats = frep_core::replace::calculate_statistics(results);
495        assert_eq!(stats.num_successes, 2);
496        assert_eq!(stats.errors.len(), 1);
497        assert_eq!(
498            stats.errors[0].search_result.path,
499            error_result.search_result.path
500        );
501    }
502
503    #[test]
504    fn test_calculate_statistics_with_none_results() {
505        let results = vec![
506            create_search_result_with_replacement(
507                "file1.txt",
508                1,
509                "line1",
510                "repl1",
511                true,
512                Some(ReplaceResult::Success),
513            ),
514            create_search_result_with_replacement("file2.txt", 2, "line2", "repl2", true, None), // This should be treated as an error
515            create_search_result_with_replacement(
516                "file3.txt",
517                3,
518                "line3",
519                "repl3",
520                true,
521                Some(ReplaceResult::Success),
522            ),
523        ];
524
525        let stats = frep_core::replace::calculate_statistics(results);
526        assert_eq!(stats.num_successes, 2);
527        assert_eq!(stats.errors.len(), 1);
528        assert_eq!(
529            stats.errors[0].search_result.path,
530            Some(PathBuf::from("file2.txt"))
531        );
532        assert_eq!(
533            stats.errors[0].replace_result,
534            Some(ReplaceResult::Error(
535                "Failed to find search result in file".to_owned()
536            ))
537        );
538    }
539}