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