Skip to main content

double_o/pattern/
mod.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4// Public API re-exports
5pub use self::builtins::builtin_patterns;
6pub use self::toml::{FailureSection, PatternFile, load_user_patterns, parse_pattern_str};
7
8/// Get a reference to the static built-in patterns.
9pub fn builtins() -> &'static [Pattern] {
10    &BUILTINS
11}
12
13// ---------------------------------------------------------------------------
14// Types
15// ---------------------------------------------------------------------------
16
17/// A pattern for matching and extracting information from command output.
18///
19/// Patterns define how to compress command output using regex matching.
20/// When a command matches the `command_match` regex, the pattern's
21/// success or failure logic is applied to extract compressed output.
22pub struct Pattern {
23    /// Regex that matches the command line (e.g., `r"cargo test"`).
24    pub command_match: Regex,
25
26    /// Optional pattern for extracting a summary from successful command output.
27    pub success: Option<SuccessPattern>,
28
29    /// Optional strategy for filtering failed command output.
30    pub failure: Option<FailurePattern>,
31}
32
33/// Pattern for extracting a summary from successful command output.
34///
35/// The `pattern` field contains a regex with named capture groups.
36/// The `summary` field is a template string with placeholders like `{name}`
37/// that are replaced with captured values.
38pub struct SuccessPattern {
39    /// Regex with named capture groups for extracting values.
40    pub pattern: Regex,
41
42    /// Template string with `{name}` placeholders for summary formatting.
43    pub summary: String,
44}
45
46/// Strategy for filtering failed command output.
47///
48/// When a command exits with a non-zero status, the failure strategy
49/// extracts relevant error information (e.g., tail N lines, head N lines,
50/// grep for error keywords, or extract text between delimiters).
51pub struct FailurePattern {
52    /// The strategy to apply for extracting error information.
53    pub strategy: FailureStrategy,
54}
55
56/// Strategy for extracting error information from failed command output.
57///
58/// Each variant defines a different approach to identifying and extracting
59/// the most relevant error information from command output.
60pub enum FailureStrategy {
61    /// Keep the last N lines of output (tail).
62    Tail {
63        /// Number of lines to keep from the end.
64        lines: usize,
65    },
66
67    /// Keep the first N lines of output (head).
68    Head {
69        /// Number of lines to keep from the start.
70        lines: usize,
71    },
72
73    /// Filter lines matching a regex pattern.
74    Grep {
75        /// Regex pattern to match error lines.
76        pattern: Regex,
77    },
78
79    /// Extract text between two delimiter strings.
80    Between {
81        /// Starting delimiter string.
82        start: String,
83
84        /// Ending delimiter string.
85        end: String,
86    },
87}
88
89// ---------------------------------------------------------------------------
90// Matching & extraction
91// ---------------------------------------------------------------------------
92
93/// Find the first pattern whose `command_match` matches `command`.
94pub fn find_matching<'a>(command: &str, patterns: &'a [Pattern]) -> Option<&'a Pattern> {
95    patterns.iter().find(|p| p.command_match.is_match(command))
96}
97
98/// Like `find_matching` but works with a slice of references.
99///
100/// Useful when you have a slice of pattern references rather than values.
101pub fn find_matching_ref<'a>(command: &str, patterns: &[&'a Pattern]) -> Option<&'a Pattern> {
102    patterns
103        .iter()
104        .find(|p| p.command_match.is_match(command))
105        .copied()
106}
107
108/// Apply a success pattern to output, returning the formatted summary if it matches.
109pub fn extract_summary(pat: &SuccessPattern, output: &str) -> Option<String> {
110    let caps = pat.pattern.captures(output)?;
111    let mut summary = pat.summary.clone();
112    for name in pat.pattern.capture_names().flatten() {
113        if let Some(m) = caps.name(name) {
114            summary = summary.replace(&format!("{{{name}}}"), m.as_str());
115        }
116    }
117    Some(summary)
118}
119
120/// Apply a failure strategy to extract actionable output.
121pub fn extract_failure(pat: &FailurePattern, output: &str) -> String {
122    match &pat.strategy {
123        FailureStrategy::Tail { lines } => {
124            let all: Vec<&str> = output.lines().collect();
125            let start = all.len().saturating_sub(*lines);
126            all[start..].join("\n")
127        }
128        FailureStrategy::Head { lines } => {
129            let all: Vec<&str> = output.lines().collect();
130            let end = (*lines).min(all.len());
131            all[..end].join("\n")
132        }
133        FailureStrategy::Grep { pattern } => output
134            .lines()
135            .filter(|l| pattern.is_match(l))
136            .collect::<Vec<_>>()
137            .join("\n"),
138        FailureStrategy::Between { start, end } => {
139            let mut capturing = false;
140            let mut lines = Vec::new();
141            for line in output.lines() {
142                if !capturing && line.contains(start.as_str()) {
143                    capturing = true;
144                }
145                if capturing {
146                    lines.push(line);
147                    if line.contains(end.as_str()) {
148                        break;
149                    }
150                }
151            }
152            lines.join("\n")
153        }
154    }
155}
156
157// Submodules
158mod builtins;
159mod toml;
160
161// Static builtin patterns
162static BUILTINS: LazyLock<Vec<Pattern>> = LazyLock::new(builtin_patterns);
163
164// ---------------------------------------------------------------------------
165// Tests
166// ---------------------------------------------------------------------------
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_builtin_pytest_success() {
174        let patterns = builtins();
175        let pat = find_matching("pytest tests/ -x", patterns).unwrap();
176        let output = "collected 47 items\n\
177                       .................\n\
178                       47 passed in 3.2s\n";
179        let summary = extract_summary(pat.success.as_ref().unwrap(), output).unwrap();
180        assert_eq!(summary, "47 passed, 3.2s");
181    }
182
183    #[test]
184    fn test_builtin_pytest_failure_tail() {
185        let patterns = builtins();
186        let pat = find_matching("pytest -x", patterns).unwrap();
187        let fail_pat = pat.failure.as_ref().unwrap();
188        let lines: String = (0..50).map(|i| format!("line {i}\n")).collect();
189        let result = extract_failure(fail_pat, &lines);
190        // tail 30 lines from 50 → lines 20..49
191        assert!(result.contains("line 20"));
192        assert!(result.contains("line 49"));
193        assert!(!result.contains("line 0\n"));
194    }
195
196    #[test]
197    fn test_builtin_cargo_test_success() {
198        let patterns = builtins();
199        let pat = find_matching("cargo test --release", patterns).unwrap();
200        let output = "running 15 tests\n\
201                       test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.45s\n";
202        let summary = extract_summary(pat.success.as_ref().unwrap(), output).unwrap();
203        assert_eq!(summary, "15 passed, 3.45s");
204    }
205
206    #[test]
207    fn test_command_matching() {
208        let patterns = builtins();
209        assert!(find_matching("pytest tests/", patterns).is_some());
210        assert!(find_matching("cargo test", patterns).is_some());
211        assert!(find_matching("cargo build", patterns).is_some());
212        assert!(find_matching("go test ./...", patterns).is_some());
213        assert!(find_matching("ruff check src/", patterns).is_some());
214        assert!(find_matching("eslint .", patterns).is_some());
215        assert!(find_matching("tsc --noEmit", patterns).is_some());
216        assert!(find_matching("cargo clippy", patterns).is_some());
217    }
218
219    #[test]
220    fn test_no_match_unknown_command() {
221        let patterns = builtins();
222        assert!(find_matching("curl https://example.com", patterns).is_none());
223    }
224
225    #[test]
226    fn test_summary_template_formatting() {
227        let pat = SuccessPattern {
228            pattern: Regex::new(r"(?P<a>\d+) things, (?P<b>\d+) items").unwrap(),
229            summary: "{a} things and {b} items".into(),
230        };
231        let result = extract_summary(&pat, "found 5 things, 3 items here").unwrap();
232        assert_eq!(result, "5 things and 3 items");
233    }
234
235    #[test]
236    fn test_failure_strategy_head() {
237        let strat = FailurePattern {
238            strategy: FailureStrategy::Head { lines: 3 },
239        };
240        let output = "line1\nline2\nline3\nline4\nline5\n";
241        let result = extract_failure(&strat, output);
242        assert_eq!(result, "line1\nline2\nline3");
243    }
244
245    #[test]
246    fn test_failure_strategy_grep() {
247        let strat = FailurePattern {
248            strategy: FailureStrategy::Grep {
249                pattern: Regex::new(r"ERROR").unwrap(),
250            },
251        };
252        let output = "INFO ok\nERROR bad\nINFO fine\nERROR worse\n";
253        let result = extract_failure(&strat, output);
254        assert_eq!(result, "ERROR bad\nERROR worse");
255    }
256
257    #[test]
258    fn test_failure_strategy_between() {
259        let strat = FailurePattern {
260            strategy: FailureStrategy::Between {
261                start: "FAILURES".into(),
262                end: "summary".into(),
263            },
264        };
265        let output = "stuff\nFAILURES\nerror 1\nerror 2\nshort test summary\nmore\n";
266        let result = extract_failure(&strat, output);
267        assert_eq!(result, "FAILURES\nerror 1\nerror 2\nshort test summary");
268    }
269
270    #[test]
271    fn test_load_pattern_from_toml() {
272        let toml = r#"
273command_match = "^myapp test"
274
275[success]
276pattern = '(?P<count>\d+) tests passed'
277summary = "{count} tests passed"
278
279[failure]
280strategy = "tail"
281lines = 20
282"#;
283        let pat = parse_pattern_str(toml).unwrap();
284        assert!(pat.command_match.is_match("myapp test --verbose"));
285        let summary = extract_summary(pat.success.as_ref().unwrap(), "42 tests passed").unwrap();
286        assert_eq!(summary, "42 tests passed");
287    }
288
289    #[test]
290    fn test_invalid_toml_returns_error() {
291        let result = parse_pattern_str("not valid toml {{{");
292        assert!(result.is_err());
293    }
294
295    #[test]
296    fn test_invalid_regex_returns_error() {
297        let toml = r#"
298command_match = "[invalid"
299"#;
300        let result = parse_pattern_str(toml);
301        assert!(result.is_err());
302    }
303
304    #[test]
305    fn test_user_patterns_override_builtins() {
306        let user_pat = parse_pattern_str(
307            r#"
308command_match = "^pytest"
309[success]
310pattern = '(?P<n>\d+) ok'
311summary = "{n} ok"
312"#,
313        )
314        .unwrap();
315
316        // User patterns should be checked first
317        let mut all = vec![user_pat];
318        all.extend(builtin_patterns());
319
320        let pat = find_matching("pytest -x", &all).unwrap();
321        let summary = extract_summary(pat.success.as_ref().unwrap(), "10 ok").unwrap();
322        assert_eq!(summary, "10 ok");
323    }
324}