1use std::path::Path;
2use std::sync::LazyLock;
3
4use regex::Regex;
5use serde::Deserialize;
6
7use crate::error::Error;
8
9pub 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, }
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
35pub 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
44pub 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
52pub 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
64pub 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
101static 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 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 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 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 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 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(), }),
170 failure: None, },
172 Pattern {
174 command_match: Regex::new(r"\beslint\b").unwrap(),
175 success: Some(SuccessPattern {
176 pattern: Regex::new(r"(?s).*").unwrap(), summary: String::new(),
178 }),
179 failure: None,
180 },
181 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 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 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 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#[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
253pub 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#[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 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 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}