Skip to main content

double_o/
pattern.rs

1use std::path::Path;
2use std::sync::LazyLock;
3
4use regex::Regex;
5use serde::Deserialize;
6
7use crate::error::Error;
8
9// ---------------------------------------------------------------------------
10// Types
11// ---------------------------------------------------------------------------
12
13pub struct Pattern {
14    pub command_match: Regex,
15    pub success: Option<SuccessPattern>,
16    pub failure: Option<FailurePattern>,
17}
18
19pub struct SuccessPattern {
20    pub pattern: Regex,
21    pub summary: String, // template with {name} placeholders
22}
23
24pub struct FailurePattern {
25    pub strategy: FailureStrategy,
26}
27
28pub enum FailureStrategy {
29    Tail { lines: usize },
30    Head { lines: usize },
31    Grep { pattern: Regex },
32    Between { start: String, end: String },
33}
34
35// ---------------------------------------------------------------------------
36// Matching & extraction
37// ---------------------------------------------------------------------------
38
39/// Find the first pattern whose `command_match` matches `command`.
40pub fn find_matching<'a>(command: &str, patterns: &'a [Pattern]) -> Option<&'a Pattern> {
41    patterns.iter().find(|p| p.command_match.is_match(command))
42}
43
44/// Like `find_matching` but works with a slice of references.
45pub fn find_matching_ref<'a>(command: &str, patterns: &[&'a Pattern]) -> Option<&'a Pattern> {
46    patterns
47        .iter()
48        .find(|p| p.command_match.is_match(command))
49        .copied()
50}
51
52/// Apply a success pattern to output, returning the formatted summary if it matches.
53pub fn extract_summary(pat: &SuccessPattern, output: &str) -> Option<String> {
54    let caps = pat.pattern.captures(output)?;
55    let mut summary = pat.summary.clone();
56    for name in pat.pattern.capture_names().flatten() {
57        if let Some(m) = caps.name(name) {
58            summary = summary.replace(&format!("{{{name}}}"), m.as_str());
59        }
60    }
61    Some(summary)
62}
63
64/// Apply a failure strategy to extract actionable output.
65pub fn extract_failure(pat: &FailurePattern, output: &str) -> String {
66    match &pat.strategy {
67        FailureStrategy::Tail { lines } => {
68            let all: Vec<&str> = output.lines().collect();
69            let start = all.len().saturating_sub(*lines);
70            all[start..].join("\n")
71        }
72        FailureStrategy::Head { lines } => {
73            let all: Vec<&str> = output.lines().collect();
74            let end = (*lines).min(all.len());
75            all[..end].join("\n")
76        }
77        FailureStrategy::Grep { pattern } => output
78            .lines()
79            .filter(|l| pattern.is_match(l))
80            .collect::<Vec<_>>()
81            .join("\n"),
82        FailureStrategy::Between { start, end } => {
83            let mut capturing = false;
84            let mut lines = Vec::new();
85            for line in output.lines() {
86                if !capturing && line.contains(start.as_str()) {
87                    capturing = true;
88                }
89                if capturing {
90                    lines.push(line);
91                    if line.contains(end.as_str()) {
92                        break;
93                    }
94                }
95            }
96            lines.join("\n")
97        }
98    }
99}
100
101// ---------------------------------------------------------------------------
102// Built-in patterns
103// ---------------------------------------------------------------------------
104
105static BUILTINS: LazyLock<Vec<Pattern>> = LazyLock::new(builtin_patterns);
106
107pub fn builtins() -> &'static [Pattern] {
108    &BUILTINS
109}
110
111fn builtin_patterns() -> Vec<Pattern> {
112    vec![
113        // pytest
114        Pattern {
115            command_match: Regex::new(r"(?:^|\b)pytest\b").unwrap(),
116            success: Some(SuccessPattern {
117                pattern: Regex::new(r"(?P<passed>\d+) passed.*in (?P<time>[\d.]+)s").unwrap(),
118                summary: "{passed} passed, {time}s".into(),
119            }),
120            failure: Some(FailurePattern {
121                strategy: FailureStrategy::Tail { lines: 30 },
122            }),
123        },
124        // cargo test
125        Pattern {
126            command_match: Regex::new(r"\bcargo\s+test\b").unwrap(),
127            success: Some(SuccessPattern {
128                pattern: Regex::new(
129                    r"test result: ok\. (?P<passed>\d+) passed; (?P<failed>\d+) failed.*finished in (?P<time>[\d.]+)s",
130                )
131                .unwrap(),
132                summary: "{passed} passed, {time}s".into(),
133            }),
134            failure: Some(FailurePattern {
135                strategy: FailureStrategy::Tail { lines: 40 },
136            }),
137        },
138        // go test
139        Pattern {
140            command_match: Regex::new(r"\bgo\s+test\b").unwrap(),
141            success: Some(SuccessPattern {
142                pattern: Regex::new(r"ok\s+\S+\s+(?P<time>[\d.]+)s").unwrap(),
143                summary: "ok ({time}s)".into(),
144            }),
145            failure: Some(FailurePattern {
146                strategy: FailureStrategy::Tail { lines: 30 },
147            }),
148        },
149        // jest / vitest
150        Pattern {
151            command_match: Regex::new(r"\b(?:jest|vitest|npx\s+(?:jest|vitest))\b").unwrap(),
152            success: Some(SuccessPattern {
153                pattern: Regex::new(
154                    r"Tests:\s+(?P<passed>\d+) passed.*Time:\s+(?P<time>[\d.]+)\s*s",
155                )
156                .unwrap(),
157                summary: "{passed} passed, {time}s".into(),
158            }),
159            failure: Some(FailurePattern {
160                strategy: FailureStrategy::Tail { lines: 30 },
161            }),
162        },
163        // ruff
164        Pattern {
165            command_match: Regex::new(r"\bruff\s+check\b").unwrap(),
166            success: Some(SuccessPattern {
167                pattern: Regex::new(r"All checks passed").unwrap(),
168                summary: String::new(), // empty = quiet success
169            }),
170            failure: None, // show all violations
171        },
172        // eslint
173        Pattern {
174            command_match: Regex::new(r"\beslint\b").unwrap(),
175            success: Some(SuccessPattern {
176                pattern: Regex::new(r"(?s).*").unwrap(), // always matches
177                summary: String::new(),
178            }),
179            failure: None,
180        },
181        // cargo build
182        Pattern {
183            command_match: Regex::new(r"\bcargo\s+build\b").unwrap(),
184            success: Some(SuccessPattern {
185                pattern: Regex::new(r"(?s).*").unwrap(),
186                summary: String::new(),
187            }),
188            failure: Some(FailurePattern {
189                strategy: FailureStrategy::Head { lines: 20 },
190            }),
191        },
192        // go build
193        Pattern {
194            command_match: Regex::new(r"\bgo\s+build\b").unwrap(),
195            success: Some(SuccessPattern {
196                pattern: Regex::new(r"(?s).*").unwrap(),
197                summary: String::new(),
198            }),
199            failure: Some(FailurePattern {
200                strategy: FailureStrategy::Head { lines: 20 },
201            }),
202        },
203        // tsc
204        Pattern {
205            command_match: Regex::new(r"\btsc\b").unwrap(),
206            success: Some(SuccessPattern {
207                pattern: Regex::new(r"(?s).*").unwrap(),
208                summary: String::new(),
209            }),
210            failure: Some(FailurePattern {
211                strategy: FailureStrategy::Head { lines: 20 },
212            }),
213        },
214        // cargo clippy
215        Pattern {
216            command_match: Regex::new(r"\bcargo\s+clippy\b").unwrap(),
217            success: Some(SuccessPattern {
218                pattern: Regex::new(r"(?s).*").unwrap(),
219                summary: String::new(),
220            }),
221            failure: None,
222        },
223    ]
224}
225
226// ---------------------------------------------------------------------------
227// User patterns (TOML on disk)
228// ---------------------------------------------------------------------------
229
230#[derive(Deserialize)]
231struct PatternFile {
232    command_match: String,
233    success: Option<SuccessSection>,
234    failure: Option<FailureSection>,
235}
236
237#[derive(Deserialize)]
238struct SuccessSection {
239    pattern: String,
240    summary: String,
241}
242
243#[derive(Deserialize)]
244struct FailureSection {
245    strategy: Option<String>,
246    lines: Option<usize>,
247    #[serde(rename = "grep")]
248    grep_pattern: Option<String>,
249    start: Option<String>,
250    end: Option<String>,
251}
252
253/// Load user-defined patterns from a directory of TOML files.
254/// Invalid files are silently skipped.
255pub fn load_user_patterns(dir: &Path) -> Vec<Pattern> {
256    let entries = match std::fs::read_dir(dir) {
257        Ok(e) => e,
258        Err(_) => return Vec::new(),
259    };
260
261    let mut patterns = Vec::new();
262    for entry in entries.flatten() {
263        let path = entry.path();
264        if path.extension().is_some_and(|e| e == "toml") {
265            if let Ok(p) = load_pattern_file(&path) {
266                patterns.push(p);
267            }
268        }
269    }
270    patterns
271}
272
273fn load_pattern_file(path: &Path) -> Result<Pattern, Error> {
274    let content =
275        std::fs::read_to_string(path).map_err(|e| Error::Pattern(format!("{path:?}: {e}")))?;
276    parse_pattern_str(&content)
277}
278
279fn parse_pattern_str(content: &str) -> Result<Pattern, Error> {
280    let pf: PatternFile =
281        toml::from_str(content).map_err(|e| Error::Pattern(format!("TOML parse: {e}")))?;
282
283    let command_match =
284        Regex::new(&pf.command_match).map_err(|e| Error::Pattern(format!("regex: {e}")))?;
285
286    let success = pf
287        .success
288        .map(|s| -> Result<SuccessPattern, Error> {
289            let pattern =
290                Regex::new(&s.pattern).map_err(|e| Error::Pattern(format!("regex: {e}")))?;
291            Ok(SuccessPattern {
292                pattern,
293                summary: s.summary,
294            })
295        })
296        .transpose()?;
297
298    let failure = pf
299        .failure
300        .map(|f| -> Result<FailurePattern, Error> {
301            let strategy = match f.strategy.as_deref().unwrap_or("tail") {
302                "tail" => FailureStrategy::Tail {
303                    lines: f.lines.unwrap_or(30),
304                },
305                "head" => FailureStrategy::Head {
306                    lines: f.lines.unwrap_or(20),
307                },
308                "grep" => {
309                    let pat = f.grep_pattern.ok_or_else(|| {
310                        Error::Pattern("grep strategy requires 'grep' field".into())
311                    })?;
312                    let pattern =
313                        Regex::new(&pat).map_err(|e| Error::Pattern(format!("regex: {e}")))?;
314                    FailureStrategy::Grep { pattern }
315                }
316                "between" => {
317                    let start = f.start.ok_or_else(|| {
318                        Error::Pattern("between strategy requires 'start'".into())
319                    })?;
320                    let end = f
321                        .end
322                        .ok_or_else(|| Error::Pattern("between strategy requires 'end'".into()))?;
323                    FailureStrategy::Between { start, end }
324                }
325                other => {
326                    return Err(Error::Pattern(format!("unknown strategy: {other}")));
327                }
328            };
329            Ok(FailurePattern { strategy })
330        })
331        .transpose()?;
332
333    Ok(Pattern {
334        command_match,
335        success,
336        failure,
337    })
338}
339
340// ---------------------------------------------------------------------------
341// Tests
342// ---------------------------------------------------------------------------
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_builtin_pytest_success() {
350        let patterns = builtins();
351        let pat = find_matching("pytest tests/ -x", patterns).unwrap();
352        let output = "collected 47 items\n\
353                       .................\n\
354                       47 passed in 3.2s\n";
355        let summary = extract_summary(pat.success.as_ref().unwrap(), output).unwrap();
356        assert_eq!(summary, "47 passed, 3.2s");
357    }
358
359    #[test]
360    fn test_builtin_pytest_failure_tail() {
361        let patterns = builtins();
362        let pat = find_matching("pytest -x", patterns).unwrap();
363        let fail_pat = pat.failure.as_ref().unwrap();
364        let lines: String = (0..50).map(|i| format!("line {i}\n")).collect();
365        let result = extract_failure(fail_pat, &lines);
366        // tail 30 lines from 50 → lines 20..49
367        assert!(result.contains("line 20"));
368        assert!(result.contains("line 49"));
369        assert!(!result.contains("line 0\n"));
370    }
371
372    #[test]
373    fn test_builtin_cargo_test_success() {
374        let patterns = builtins();
375        let pat = find_matching("cargo test --release", patterns).unwrap();
376        let output = "running 15 tests\n\
377                       test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 3.45s\n";
378        let summary = extract_summary(pat.success.as_ref().unwrap(), output).unwrap();
379        assert_eq!(summary, "15 passed, 3.45s");
380    }
381
382    #[test]
383    fn test_command_matching() {
384        let patterns = builtins();
385        assert!(find_matching("pytest tests/", patterns).is_some());
386        assert!(find_matching("cargo test", patterns).is_some());
387        assert!(find_matching("cargo build", patterns).is_some());
388        assert!(find_matching("go test ./...", patterns).is_some());
389        assert!(find_matching("ruff check src/", patterns).is_some());
390        assert!(find_matching("eslint .", patterns).is_some());
391        assert!(find_matching("tsc --noEmit", patterns).is_some());
392        assert!(find_matching("cargo clippy", patterns).is_some());
393    }
394
395    #[test]
396    fn test_no_match_unknown_command() {
397        let patterns = builtins();
398        assert!(find_matching("curl https://example.com", patterns).is_none());
399    }
400
401    #[test]
402    fn test_summary_template_formatting() {
403        let pat = SuccessPattern {
404            pattern: Regex::new(r"(?P<a>\d+) things, (?P<b>\d+) items").unwrap(),
405            summary: "{a} things and {b} items".into(),
406        };
407        let result = extract_summary(&pat, "found 5 things, 3 items here").unwrap();
408        assert_eq!(result, "5 things and 3 items");
409    }
410
411    #[test]
412    fn test_failure_strategy_head() {
413        let strat = FailurePattern {
414            strategy: FailureStrategy::Head { lines: 3 },
415        };
416        let output = "line1\nline2\nline3\nline4\nline5\n";
417        let result = extract_failure(&strat, output);
418        assert_eq!(result, "line1\nline2\nline3");
419    }
420
421    #[test]
422    fn test_failure_strategy_grep() {
423        let strat = FailurePattern {
424            strategy: FailureStrategy::Grep {
425                pattern: Regex::new(r"ERROR").unwrap(),
426            },
427        };
428        let output = "INFO ok\nERROR bad\nINFO fine\nERROR worse\n";
429        let result = extract_failure(&strat, output);
430        assert_eq!(result, "ERROR bad\nERROR worse");
431    }
432
433    #[test]
434    fn test_failure_strategy_between() {
435        let strat = FailurePattern {
436            strategy: FailureStrategy::Between {
437                start: "FAILURES".into(),
438                end: "summary".into(),
439            },
440        };
441        let output = "stuff\nFAILURES\nerror 1\nerror 2\nshort test summary\nmore\n";
442        let result = extract_failure(&strat, output);
443        assert_eq!(result, "FAILURES\nerror 1\nerror 2\nshort test summary");
444    }
445
446    #[test]
447    fn test_load_pattern_from_toml() {
448        let toml = r#"
449command_match = "^myapp test"
450
451[success]
452pattern = '(?P<count>\d+) tests passed'
453summary = "{count} tests passed"
454
455[failure]
456strategy = "tail"
457lines = 20
458"#;
459        let pat = parse_pattern_str(toml).unwrap();
460        assert!(pat.command_match.is_match("myapp test --verbose"));
461        let summary = extract_summary(pat.success.as_ref().unwrap(), "42 tests passed").unwrap();
462        assert_eq!(summary, "42 tests passed");
463    }
464
465    #[test]
466    fn test_invalid_toml_returns_error() {
467        let result = parse_pattern_str("not valid toml {{{");
468        assert!(result.is_err());
469    }
470
471    #[test]
472    fn test_invalid_regex_returns_error() {
473        let toml = r#"
474command_match = "[invalid"
475"#;
476        let result = parse_pattern_str(toml);
477        assert!(result.is_err());
478    }
479
480    #[test]
481    fn test_user_patterns_override_builtins() {
482        let user_pat = parse_pattern_str(
483            r#"
484command_match = "^pytest"
485[success]
486pattern = '(?P<n>\d+) ok'
487summary = "{n} ok"
488"#,
489        )
490        .unwrap();
491
492        // User patterns should be checked first
493        let mut all = vec![user_pat];
494        all.extend(builtin_patterns());
495
496        let pat = find_matching("pytest -x", &all).unwrap();
497        let summary = extract_summary(pat.success.as_ref().unwrap(), "10 ok").unwrap();
498        assert_eq!(summary, "10 ok");
499    }
500}