frep_core/
validation.rs

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
34/// Collects errors into an array
35pub 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        // No conversion required
134        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            // Validate the regex without transformation
148            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        // Shouldn't fail as we have already verified that the regex is valid, so `unwrap` here is fine.
160        // (Any issues will likely be with the padding we are doing in this function.)
161        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, // forces regex wrapping
407                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, // forces regex wrapping
421                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}