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); },
200 );
201
202 let mut rerender_interval = tokio::time::interval(Duration::from_millis(92)); 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 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), 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}