Skip to main content

scooter_core/
validation.rs

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
41/// Collects errors into an array
42pub 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        // No conversion required
151        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            // Validate the regex without transformation
165            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        // Shouldn't fail as we have already verified that the regex is valid, so `unwrap` here is fine.
177        // (Any issues will likely be with the padding we are doing in this function.)
178        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, // forces regex wrapping
442                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, // forces regex wrapping
458                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}