Skip to main content

buildlog_consultant/
match.rs

1//! Module providing pattern matching functionality for log analysis.
2//!
3//! This module contains tools for matching patterns in logs and extracting problems.
4//! It includes regex-based matchers and a matcher group for combining multiple matchers.
5
6use crate::SingleLineMatch;
7use crate::{Match, Origin, Problem};
8use regex::{Captures, Regex};
9use std::fmt::Display;
10
11/// Type alias for the result of extracting a match and optional problem
12pub type MatchResult = Result<Option<(Box<dyn Match>, Option<Box<dyn Problem>>)>, Error>;
13
14/// Type alias for a callback function that processes regex captures
15pub type RegexCallback =
16    Box<dyn Fn(&Captures) -> Result<Option<Box<dyn Problem>>, Error> + Send + Sync>;
17
18/// Error type for matchers.
19///
20/// Used when pattern matching or problem extraction fails.
21#[derive(Debug)]
22pub struct Error {
23    /// Error message describing what went wrong.
24    pub message: String,
25}
26
27impl Display for Error {
28    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
29        self.message.fmt(f)
30    }
31}
32
33impl std::error::Error for Error {}
34
35/// A matcher that uses regular expressions to find patterns in single lines.
36///
37/// This matcher applies a regex to individual lines and can extract problem information
38/// through a callback function when a match is found.
39pub struct RegexLineMatcher {
40    /// The regular expression to match against each line.
41    regex: Regex,
42    /// Callback function that extracts problem information from regex captures.
43    callback: RegexCallback,
44}
45
46/// Trait for pattern matchers that can extract matches and problems from logs.
47///
48/// Implementors of this trait can search through log lines to find patterns and
49/// extract problem information.
50pub trait Matcher: Sync {
51    /// Extracts a match and optional problem from a specific line in a log.
52    ///
53    /// # Arguments
54    /// * `lines` - The collection of log lines
55    /// * `offset` - The line offset to analyze
56    ///
57    /// # Returns
58    /// * `Ok(Some((match, problem)))` - A match was found along with an optional problem
59    /// * `Ok(None)` - No match was found
60    /// * `Err(error)` - An error occurred during matching
61    fn extract_from_lines(&self, lines: &[&str], offset: usize) -> MatchResult;
62}
63
64impl RegexLineMatcher {
65    /// Creates a new `RegexLineMatcher` with the given regex and callback.
66    ///
67    /// # Arguments
68    /// * `regex` - The regex pattern to match against lines
69    /// * `callback` - Function that processes regex captures and returns an optional problem
70    ///
71    /// # Returns
72    /// A new `RegexLineMatcher` instance
73    pub fn new(regex: Regex, callback: RegexCallback) -> Self {
74        Self { regex, callback }
75    }
76
77    /// Checks if a line matches the regex pattern.
78    ///
79    /// # Arguments
80    /// * `line` - The line to check
81    ///
82    /// # Returns
83    /// `true` if the line matches the pattern, `false` otherwise
84    pub fn matches_line(&self, line: &str) -> bool {
85        self.regex.is_match(line)
86    }
87
88    /// Attempts to extract problem information from a line.
89    ///
90    /// # Arguments
91    /// * `line` - The line to analyze
92    ///
93    /// # Returns
94    /// * `Ok(Some(Some(problem)))` - A match was found and a problem was extracted
95    /// * `Ok(Some(None))` - A match was found but no problem was extracted
96    /// * `Ok(None)` - No match was found
97    /// * `Err(error)` - An error occurred during matching or problem extraction
98    pub fn extract_from_line(&self, line: &str) -> Result<Option<Option<Box<dyn Problem>>>, Error> {
99        let c = self.regex.captures(line);
100        if let Some(c) = c {
101            return Ok(Some((self.callback)(&c)?));
102        }
103        Ok(None)
104    }
105
106    /// Creates an origin identifier for matches from this matcher.
107    ///
108    /// # Returns
109    /// An `Origin` identifying the regex pattern used for matching
110    fn origin(&self) -> Origin {
111        Origin(format!("direct regex ({})", self.regex.as_str()))
112    }
113}
114
115impl Matcher for RegexLineMatcher {
116    fn extract_from_lines(&self, lines: &[&str], offset: usize) -> MatchResult {
117        let line = lines[offset];
118        if let Some(problem) = self.extract_from_line(line)? {
119            let m = SingleLineMatch {
120                offset,
121                line: line.to_string(),
122                origin: self.origin(),
123            };
124            return Ok(Some((Box::new(m), problem)));
125        }
126        Ok(None)
127    }
128}
129
130/// Macro for creating regex-based line matchers.
131///
132/// This macro simplifies the creation of `RegexLineMatcher` instances by automatically
133/// handling regex compilation and callback boxing.
134///
135/// # Examples
136///
137/// ```
138/// # use buildlog_consultant::regex_line_matcher;
139/// # use buildlog_consultant::r#match::RegexLineMatcher;
140/// // With callback
141/// let matcher = regex_line_matcher!(r"error: (.*)", |captures| {
142///     let message = captures.get(1).unwrap().as_str();
143///     // Process the error message
144///     Ok(None)
145/// });
146///
147/// // Without callback (just matches the pattern)
148/// let simple_matcher = regex_line_matcher!(r"warning");
149/// ```
150#[macro_export]
151macro_rules! regex_line_matcher {
152    ($regex:expr, $callback:expr) => {
153        Box::new(RegexLineMatcher::new(
154            regex::Regex::new($regex).unwrap(),
155            Box::new($callback),
156        ))
157    };
158    ($regex: expr) => {
159        Box::new(RegexLineMatcher::new(
160            regex::Regex::new($regex).unwrap(),
161            Box::new(|_| Ok(None)),
162        ))
163    };
164}
165
166/// Macro for creating regex-based paragraph matchers.
167///
168/// This macro is similar to `regex_line_matcher`, but creates matchers that can match
169/// across multiple lines by automatically enabling the "dot matches newline" regex flag (?s).
170///
171/// # Examples
172///
173/// ```
174/// # use buildlog_consultant::regex_para_matcher;
175/// # use buildlog_consultant::r#match::RegexLineMatcher;
176/// // With callback
177/// let matcher = regex_para_matcher!(r"BEGIN(.*?)END", |captures| {
178///     let content = captures.get(1).unwrap().as_str();
179///     // Process the content between BEGIN and END
180///     Ok(None)
181/// });
182///
183/// // Without callback
184/// let simple_matcher = regex_para_matcher!(r"function\s*\{.*?\}");
185/// ```
186#[macro_export]
187macro_rules! regex_para_matcher {
188    ($regex:expr, $callback:expr) => {{
189        Box::new(RegexLineMatcher::new(
190            regex::Regex::new(concat!("(?s)", $regex)).unwrap(),
191            Box::new($callback),
192        ))
193    }};
194    ($regex: expr) => {{
195        Box::new(RegexLineMatcher::new(
196            regex::Regex::new(concat!("(?s)", $regex)).unwrap(),
197            Box::new(|_| Ok(None)),
198        ))
199    }};
200}
201
202/// A group of matchers that can be used to match multiple patterns.
203///
204/// This struct allows combining multiple matchers and trying them in sequence
205/// until a match is found.
206pub struct MatcherGroup(Vec<Box<dyn Matcher>>);
207
208impl MatcherGroup {
209    /// Creates a new `MatcherGroup` with the given matchers.
210    ///
211    /// # Arguments
212    /// * `matchers` - Vector of boxed matchers
213    ///
214    /// # Returns
215    /// A new `MatcherGroup` instance
216    pub fn new(matchers: Vec<Box<dyn Matcher>>) -> Self {
217        Self(matchers)
218    }
219}
220
221impl Default for MatcherGroup {
222    fn default() -> Self {
223        Self::new(vec![])
224    }
225}
226
227impl From<Vec<Box<dyn Matcher>>> for MatcherGroup {
228    fn from(matchers: Vec<Box<dyn Matcher>>) -> Self {
229        Self::new(matchers)
230    }
231}
232
233impl MatcherGroup {
234    /// Tries each matcher in the group until one finds a match.
235    ///
236    /// This method attempts to extract a match and problem from a specific line
237    /// by trying each matcher in the group in sequence until one succeeds.
238    ///
239    /// # Arguments
240    /// * `lines` - The collection of log lines
241    /// * `offset` - The line offset to analyze
242    ///
243    /// # Returns
244    /// * `Ok(Some((match, problem)))` - A match was found by one of the matchers
245    /// * `Ok(None)` - No match was found by any matcher
246    /// * `Err(error)` - An error occurred during matching
247    pub fn extract_from_lines(&self, lines: &[&str], offset: usize) -> MatchResult {
248        for matcher in self.0.iter() {
249            if let Some(p) = matcher.extract_from_lines(lines, offset)? {
250                return Ok(Some(p));
251            }
252        }
253        Ok(None)
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use std::borrow::Cow;
261
262    #[derive(Debug)]
263    struct TestProblem {
264        description: String,
265    }
266
267    impl std::fmt::Display for TestProblem {
268        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269            write!(f, "{}", self.description)
270        }
271    }
272
273    impl Problem for TestProblem {
274        fn kind(&self) -> Cow<'_, str> {
275            Cow::Borrowed("test")
276        }
277
278        fn json(&self) -> serde_json::Value {
279            serde_json::json!({
280                "description": self.description,
281            })
282        }
283
284        fn as_any(&self) -> &dyn std::any::Any {
285            self
286        }
287    }
288
289    #[test]
290    fn test_error_display() {
291        let error = Error {
292            message: "test error".to_string(),
293        };
294        assert_eq!(error.to_string(), "test error");
295    }
296
297    #[test]
298    fn test_regex_line_matcher_new() {
299        let regex = Regex::new(r"test").unwrap();
300        let callback = Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> {
301            Ok(Some(Box::new(TestProblem {
302                description: "test problem".to_string(),
303            })))
304        });
305        let matcher = RegexLineMatcher::new(regex, callback);
306        assert!(matcher.matches_line("test line"));
307        assert!(!matcher.matches_line("other line"));
308    }
309
310    #[test]
311    fn test_regex_line_matcher_matches_line() {
312        let regex = Regex::new(r"test").unwrap();
313        let callback =
314            Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> { Ok(None) });
315        let matcher = RegexLineMatcher::new(regex, callback);
316        assert!(matcher.matches_line("test line"));
317        assert!(!matcher.matches_line("other line"));
318    }
319
320    #[test]
321    fn test_regex_line_matcher_extract_from_line() {
322        let regex = Regex::new(r"test").unwrap();
323        let callback = Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> {
324            Ok(Some(Box::new(TestProblem {
325                description: "test problem".to_string(),
326            })))
327        });
328        let matcher = RegexLineMatcher::new(regex, callback);
329        let result = matcher.extract_from_line("test line").unwrap();
330        assert!(result.is_some());
331        let problem = result.unwrap();
332        assert!(problem.is_some());
333        let problem = problem.unwrap();
334        assert_eq!(problem.kind(), "test");
335    }
336
337    #[test]
338    fn test_regex_line_matcher_extract_from_line_no_match() {
339        let regex = Regex::new(r"test").unwrap();
340        let callback = Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> {
341            Ok(Some(Box::new(TestProblem {
342                description: "test problem".to_string(),
343            })))
344        });
345        let matcher = RegexLineMatcher::new(regex, callback);
346        let result = matcher.extract_from_line("other line").unwrap();
347        assert!(result.is_none());
348    }
349
350    #[test]
351    fn test_regex_line_matcher_extract_from_line_no_problem() {
352        let regex = Regex::new(r"test").unwrap();
353        let callback =
354            Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> { Ok(None) });
355        let matcher = RegexLineMatcher::new(regex, callback);
356        let result = matcher.extract_from_line("test line").unwrap();
357        assert!(result.is_some());
358        let problem = result.unwrap();
359        assert!(problem.is_none());
360    }
361
362    #[test]
363    fn test_regex_line_matcher_extract_from_lines() {
364        let regex = Regex::new(r"test").unwrap();
365        let callback = Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> {
366            Ok(Some(Box::new(TestProblem {
367                description: "test problem".to_string(),
368            })))
369        });
370        let matcher = RegexLineMatcher::new(regex, callback);
371        let lines = vec!["line 1", "test line", "line 3"];
372        let result = matcher.extract_from_lines(&lines, 1).unwrap();
373        assert!(result.is_some());
374        let (m, problem) = result.unwrap();
375        assert_eq!(m.line(), "test line");
376        assert_eq!(m.offset(), 1);
377        assert!(problem.is_some());
378        let problem = problem.unwrap();
379        assert_eq!(problem.kind(), "test");
380    }
381
382    #[test]
383    fn test_regex_line_matcher_extract_from_lines_no_match() {
384        let regex = Regex::new(r"test").unwrap();
385        let callback = Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> {
386            Ok(Some(Box::new(TestProblem {
387                description: "test problem".to_string(),
388            })))
389        });
390        let matcher = RegexLineMatcher::new(regex, callback);
391        let lines = vec!["line 1", "line 2", "line 3"];
392        let result = matcher.extract_from_lines(&lines, 1).unwrap();
393        assert!(result.is_none());
394    }
395
396    #[test]
397    fn test_matcher_group() {
398        let regex1 = Regex::new(r"test1").unwrap();
399        let callback1 = Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> {
400            Ok(Some(Box::new(TestProblem {
401                description: "test problem 1".to_string(),
402            })))
403        });
404        let matcher1 = RegexLineMatcher::new(regex1, callback1);
405
406        let regex2 = Regex::new(r"test2").unwrap();
407        let callback2 = Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> {
408            Ok(Some(Box::new(TestProblem {
409                description: "test problem 2".to_string(),
410            })))
411        });
412        let matcher2 = RegexLineMatcher::new(regex2, callback2);
413
414        let group = MatcherGroup::new(vec![Box::new(matcher1), Box::new(matcher2)]);
415        let lines = vec!["line 1", "test2 line", "line 3"];
416        let result = group.extract_from_lines(&lines, 1).unwrap();
417        assert!(result.is_some());
418        let (m, problem) = result.unwrap();
419        assert_eq!(m.line(), "test2 line");
420        assert_eq!(m.offset(), 1);
421        assert!(problem.is_some());
422        let problem = problem.unwrap();
423        assert_eq!(problem.kind(), "test");
424    }
425
426    #[test]
427    fn test_matcher_group_no_match() {
428        let regex1 = Regex::new(r"test1").unwrap();
429        let callback1 = Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> {
430            Ok(Some(Box::new(TestProblem {
431                description: "test problem 1".to_string(),
432            })))
433        });
434        let matcher1 = RegexLineMatcher::new(regex1, callback1);
435
436        let regex2 = Regex::new(r"test2").unwrap();
437        let callback2 = Box::new(|_: &Captures| -> Result<Option<Box<dyn Problem>>, Error> {
438            Ok(Some(Box::new(TestProblem {
439                description: "test problem 2".to_string(),
440            })))
441        });
442        let matcher2 = RegexLineMatcher::new(regex2, callback2);
443
444        let group = MatcherGroup::new(vec![Box::new(matcher1), Box::new(matcher2)]);
445        let lines = vec!["line 1", "line 2", "line 3"];
446        let result = group.extract_from_lines(&lines, 1).unwrap();
447        assert!(result.is_none());
448    }
449
450    #[test]
451    fn test_regex_line_matcher_macro() {
452        let matcher = regex_line_matcher!(r"test", |_| {
453            Ok(Some(Box::new(TestProblem {
454                description: "test problem".to_string(),
455            })))
456        });
457        let lines = vec!["line 1", "test line", "line 3"];
458        let result = matcher.extract_from_lines(&lines, 1).unwrap();
459        assert!(result.is_some());
460    }
461
462    #[test]
463    fn test_regex_line_matcher_macro_simple() {
464        let matcher = regex_line_matcher!(r"test");
465        let lines = vec!["line 1", "test line", "line 3"];
466        let result = matcher.extract_from_lines(&lines, 1).unwrap();
467        assert!(result.is_some());
468        let (_m, problem) = result.unwrap();
469        assert!(problem.is_none());
470    }
471}