1#[cfg(feature = "term")]
2use crossterm::style::Stylize;
3use fancy_regex::Regex as FancyRegex;
4use ignore::overrides::OverrideBuilder;
5use regex::Regex;
6use std::path::PathBuf;
7
8use crate::{
9 replace::interpret_escapes,
10 search::{ParsedDirConfig, ParsedSearchConfig, SearchType},
11 utils,
12};
13
14#[derive(Clone, Debug, Eq, PartialEq)]
15#[allow(clippy::struct_excessive_bools)]
16pub struct SearchConfig<'a> {
17 pub search_text: &'a str,
18 pub replacement_text: &'a str,
19 pub fixed_strings: bool,
20 pub advanced_regex: bool,
21 pub match_whole_word: bool,
22 pub match_case: bool,
23 pub multiline: bool,
24 pub interpret_escape_sequences: bool,
25}
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct DirConfig<'a> {
29 pub include_globs: Option<&'a str>,
30 pub exclude_globs: Option<&'a str>,
31 pub directory: PathBuf,
32 pub include_hidden: bool,
33 pub include_git_folders: bool,
34}
35pub trait ValidationErrorHandler {
36 fn handle_search_text_error(&mut self, error: &str, detail: &str);
37 fn handle_include_files_error(&mut self, error: &str, detail: &str);
38 fn handle_exclude_files_error(&mut self, error: &str, detail: &str);
39}
40
41pub struct SimpleErrorHandler {
43 pub errors: Vec<String>,
44}
45
46impl SimpleErrorHandler {
47 pub fn new() -> Self {
48 Self { errors: Vec::new() }
49 }
50
51 pub fn errors_str(&self) -> Option<String> {
52 if self.errors.is_empty() {
53 None
54 } else {
55 Some(format!("Validation errors:\n{}", self.errors.join("\n")))
56 }
57 }
58
59 fn push_error(&mut self, err_msg: &str, detail: &str) {
60 #[cfg(feature = "term")]
61 let title = err_msg.red();
62 #[cfg(not(feature = "term"))]
63 let title = err_msg;
64
65 self.errors.push(format!("\n{title}:\n{detail}"));
66 }
67}
68
69impl Default for SimpleErrorHandler {
70 fn default() -> Self {
71 Self::new()
72 }
73}
74
75impl ValidationErrorHandler for SimpleErrorHandler {
76 fn handle_search_text_error(&mut self, _error: &str, detail: &str) {
77 self.push_error("Failed to parse search text", detail);
78 }
79
80 fn handle_include_files_error(&mut self, _error: &str, detail: &str) {
81 self.push_error("Failed to parse include globs", detail);
82 }
83
84 fn handle_exclude_files_error(&mut self, _error: &str, detail: &str) {
85 self.push_error("Failed to parse exclude globs", detail);
86 }
87}
88
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub enum ValidationResult<T> {
91 Success(T),
92 ValidationErrors,
93}
94
95impl<T> ValidationResult<T> {
96 fn map<U, F>(self, f: F) -> ValidationResult<U>
97 where
98 F: FnOnce(T) -> U,
99 Self: Sized,
100 {
101 match self {
102 ValidationResult::Success(t) => ValidationResult::Success(f(t)),
103 ValidationResult::ValidationErrors => ValidationResult::ValidationErrors,
104 }
105 }
106}
107
108#[allow(clippy::needless_pass_by_value)]
109pub fn validate_search_configuration<H: ValidationErrorHandler>(
110 search_config: SearchConfig<'_>,
111 dir_config: Option<DirConfig<'_>>,
112 error_handler: &mut H,
113) -> anyhow::Result<ValidationResult<(ParsedSearchConfig, Option<ParsedDirConfig>)>> {
114 let search_pattern = parse_search_text_with_error_handler(&search_config, error_handler)?;
115
116 let parsed_dir_config = match dir_config {
117 Some(dir_config) => {
118 let overrides = parse_overrides(dir_config, error_handler)?;
119 overrides.map(Some)
120 }
121 None => ValidationResult::Success(None),
122 };
123
124 if let (
125 ValidationResult::Success(search_pattern),
126 ValidationResult::Success(parsed_dir_config),
127 ) = (search_pattern, parsed_dir_config)
128 {
129 let replace = if search_config.interpret_escape_sequences {
130 interpret_escapes(search_config.replacement_text)
131 } else {
132 search_config.replacement_text.to_owned()
133 };
134 let parsed_search_config = ParsedSearchConfig {
135 search: search_pattern,
136 replace,
137 multiline: search_config.multiline,
138 };
139 Ok(ValidationResult::Success((
140 parsed_search_config,
141 parsed_dir_config,
142 )))
143 } else {
144 Ok(ValidationResult::ValidationErrors)
145 }
146}
147
148pub fn parse_search_text(config: &SearchConfig<'_>) -> anyhow::Result<SearchType> {
149 if !config.match_whole_word && config.match_case {
150 let search = if config.fixed_strings {
152 SearchType::Fixed(config.search_text.to_string())
153 } else if config.advanced_regex {
154 SearchType::PatternAdvanced(FancyRegex::new(config.search_text)?)
155 } else {
156 SearchType::Pattern(Regex::new(config.search_text)?)
157 };
158 Ok(search)
159 } else {
160 let mut search_regex_str = if config.fixed_strings {
161 regex::escape(config.search_text)
162 } else {
163 let search = config.search_text.to_owned();
164 FancyRegex::new(&search)?;
166 search
167 };
168
169 if config.match_whole_word {
170 search_regex_str = format!(r"(?<![a-zA-Z0-9_]){search_regex_str}(?![a-zA-Z0-9_])");
171 }
172 if !config.match_case {
173 search_regex_str = format!(r"(?i){search_regex_str}");
174 }
175
176 let fancy_regex = FancyRegex::new(&search_regex_str).unwrap();
179 Ok(SearchType::PatternAdvanced(fancy_regex))
180 }
181}
182
183fn parse_search_text_with_error_handler<H: ValidationErrorHandler>(
184 config: &SearchConfig<'_>,
185 error_handler: &mut H,
186) -> anyhow::Result<ValidationResult<SearchType>> {
187 match parse_search_text(config) {
188 Ok(pattern) => Ok(ValidationResult::Success(pattern)),
189 Err(e) => {
190 if utils::is_regex_error(&e) {
191 error_handler.handle_search_text_error("Couldn't parse regex", &e.to_string());
192 Ok(ValidationResult::ValidationErrors)
193 } else {
194 Err(e)
195 }
196 }
197 }
198}
199
200fn parse_overrides<H: ValidationErrorHandler>(
201 dir_config: DirConfig<'_>,
202 error_handler: &mut H,
203) -> anyhow::Result<ValidationResult<ParsedDirConfig>> {
204 let mut overrides = OverrideBuilder::new(&dir_config.directory);
205 let mut success = true;
206
207 if let Some(include_globs) = dir_config.include_globs
208 && let Err(e) = utils::add_overrides(&mut overrides, include_globs, "")
209 {
210 error_handler.handle_include_files_error("Couldn't parse glob pattern", &e.to_string());
211 success = false;
212 }
213 if let Some(exclude_globs) = dir_config.exclude_globs
214 && let Err(e) = utils::add_overrides(&mut overrides, exclude_globs, "!")
215 {
216 error_handler.handle_exclude_files_error("Couldn't parse glob pattern", &e.to_string());
217 success = false;
218 }
219 if !dir_config.include_git_folders {
220 overrides
221 .add("!.git")
222 .expect("Failed to add `!.git` exclusion");
223 }
224 if !success {
225 return Ok(ValidationResult::ValidationErrors);
226 }
227
228 Ok(ValidationResult::Success(ParsedDirConfig {
229 overrides: overrides.build()?,
230 root_dir: dir_config.directory,
231 include_hidden: dir_config.include_hidden,
232 }))
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 fn create_search_test_config<'a>() -> SearchConfig<'a> {
240 SearchConfig {
241 search_text: "test",
242 replacement_text: "replacement",
243 fixed_strings: false,
244 advanced_regex: false,
245 match_whole_word: false,
246 match_case: false,
247 multiline: false,
248 interpret_escape_sequences: false,
249 }
250 }
251
252 #[test]
253 fn test_valid_configuration() {
254 let config = create_search_test_config();
255 let mut error_handler = SimpleErrorHandler::new();
256
257 let result = validate_search_configuration(config, None, &mut error_handler);
258
259 assert!(result.is_ok());
260 assert!(matches!(result.unwrap(), ValidationResult::Success(_)));
261 assert!(error_handler.errors_str().is_none());
262 }
263
264 #[test]
265 fn test_invalid_regex() {
266 let mut config = create_search_test_config();
267 config.search_text = "[invalid regex";
268 let mut error_handler = SimpleErrorHandler::new();
269
270 let result = validate_search_configuration(config, None, &mut error_handler);
271
272 assert!(result.is_ok());
273 assert!(matches!(
274 result.unwrap(),
275 ValidationResult::ValidationErrors
276 ));
277 assert!(error_handler.errors_str().is_some());
278 assert!(error_handler.errors[0].contains("Failed to parse search text"));
279 }
280
281 #[test]
282 fn test_invalid_include_glob() {
283 let search_config = create_search_test_config();
284 let dir_config = DirConfig {
285 include_globs: Some("[invalid"),
286 exclude_globs: None,
287 directory: std::env::temp_dir(),
288 include_hidden: false,
289 include_git_folders: false,
290 };
291 let mut error_handler = SimpleErrorHandler::new();
292
293 let result =
294 validate_search_configuration(search_config, Some(dir_config), &mut error_handler);
295
296 assert!(result.is_ok());
297 assert!(matches!(
298 result.unwrap(),
299 ValidationResult::ValidationErrors
300 ));
301 assert!(error_handler.errors_str().is_some());
302 assert!(error_handler.errors[0].contains("Failed to parse include globs"));
303 }
304
305 #[test]
306 fn test_fixed_strings_mode() {
307 let mut config = create_search_test_config();
308 config.search_text = "[this would be invalid regex]";
309 config.fixed_strings = true;
310 let mut error_handler = SimpleErrorHandler::new();
311
312 let result = validate_search_configuration(config, None, &mut error_handler);
313
314 assert!(result.is_ok());
315 assert!(matches!(result.unwrap(), ValidationResult::Success(_)));
316 assert!(error_handler.errors_str().is_none());
317 }
318
319 mod parse_search_text_tests {
320 use super::*;
321
322 mod test_helpers {
323 use super::*;
324
325 pub fn assert_pattern_contains(search_type: &SearchType, expected_parts: &[&str]) {
326 if let SearchType::PatternAdvanced(regex) = search_type {
327 let pattern = regex.as_str();
328 for part in expected_parts {
329 assert!(
330 pattern.contains(part),
331 "Pattern '{pattern}' should contain '{part}'"
332 );
333 }
334 } else {
335 panic!("Expected PatternAdvanced, got {search_type:?}");
336 }
337 }
338 }
339
340 #[test]
341 fn test_convert_regex_whole_word() {
342 let search_config = SearchConfig {
343 search_text: "test",
344 replacement_text: "",
345 fixed_strings: true,
346 match_whole_word: true,
347 match_case: true,
348 multiline: false,
349 advanced_regex: false,
350 interpret_escape_sequences: false,
351 };
352 let converted = parse_search_text(&search_config).unwrap();
353
354 test_helpers::assert_pattern_contains(
355 &converted,
356 &["(?<![a-zA-Z0-9_])", "(?![a-zA-Z0-9_])", "test"],
357 );
358 }
359
360 #[test]
361 fn test_convert_regex_case_insensitive() {
362 let search_config = SearchConfig {
363 search_text: "Test",
364 replacement_text: "",
365 fixed_strings: true,
366 match_whole_word: false,
367 match_case: false,
368 multiline: false,
369 advanced_regex: false,
370 interpret_escape_sequences: false,
371 };
372 let converted = parse_search_text(&search_config).unwrap();
373
374 test_helpers::assert_pattern_contains(&converted, &["(?i)", "Test"]);
375 }
376
377 #[test]
378 fn test_convert_regex_whole_word_and_case_insensitive() {
379 let search_config = SearchConfig {
380 search_text: "Test",
381 replacement_text: "",
382 fixed_strings: true,
383 match_whole_word: true,
384 match_case: false,
385 multiline: false,
386 advanced_regex: false,
387 interpret_escape_sequences: false,
388 };
389 let converted = parse_search_text(&search_config).unwrap();
390
391 test_helpers::assert_pattern_contains(
392 &converted,
393 &["(?<![a-zA-Z0-9_])", "(?![a-zA-Z0-9_])", "(?i)", "Test"],
394 );
395 }
396
397 #[test]
398 fn test_convert_regex_escapes_special_chars() {
399 let search_config = SearchConfig {
400 search_text: "test.regex*",
401 replacement_text: "",
402 fixed_strings: true,
403 match_whole_word: true,
404 match_case: true,
405 multiline: false,
406 advanced_regex: false,
407 interpret_escape_sequences: false,
408 };
409 let converted = parse_search_text(&search_config).unwrap();
410
411 test_helpers::assert_pattern_contains(&converted, &[r"test\.regex\*"]);
412 }
413
414 #[test]
415 fn test_convert_regex_from_existing_pattern() {
416 let search_config = SearchConfig {
417 search_text: r"\d+",
418 replacement_text: "",
419 fixed_strings: false,
420 match_whole_word: true,
421 match_case: false,
422 multiline: false,
423 advanced_regex: false,
424 interpret_escape_sequences: false,
425 };
426 let converted = parse_search_text(&search_config).unwrap();
427
428 test_helpers::assert_pattern_contains(
429 &converted,
430 &["(?<![a-zA-Z0-9_])", "(?![a-zA-Z0-9_])", "(?i)", r"\d+"],
431 );
432 }
433
434 #[test]
435 fn test_fixed_string_with_unbalanced_paren_in_case_insensitive_mode() {
436 let search_config = SearchConfig {
437 search_text: "(foo",
438 replacement_text: "",
439 fixed_strings: true,
440 match_whole_word: false,
441 match_case: false, advanced_regex: false,
443 multiline: false,
444 interpret_escape_sequences: false,
445 };
446 let converted = parse_search_text(&search_config).unwrap();
447 test_helpers::assert_pattern_contains(&converted, &[r"\(foo", "(?i)"]);
448 }
449
450 #[test]
451 fn test_fixed_string_with_regex_chars_case_insensitive() {
452 let search_config = SearchConfig {
453 search_text: "test.regex*+?[chars]",
454 replacement_text: "",
455 fixed_strings: true,
456 match_whole_word: false,
457 match_case: false, advanced_regex: false,
459 multiline: false,
460 interpret_escape_sequences: false,
461 };
462 let converted = parse_search_text(&search_config).unwrap();
463 test_helpers::assert_pattern_contains(
464 &converted,
465 &[r"test\.regex\*\+\?\[chars\]", "(?i)"],
466 );
467 }
468 }
469}