1use regex::Regex;
2use std::sync::LazyLock;
3
4pub use self::builtins::builtin_patterns;
6pub use self::toml::{FailureSection, PatternFile, load_user_patterns, parse_pattern_str};
7
8pub fn builtins() -> &'static [Pattern] {
10 &BUILTINS
11}
12
13pub struct Pattern {
23 pub command_match: Regex,
25
26 pub success: Option<SuccessPattern>,
28
29 pub failure: Option<FailurePattern>,
31}
32
33pub struct SuccessPattern {
39 pub pattern: Regex,
41
42 pub summary: String,
44}
45
46pub struct FailurePattern {
52 pub strategy: FailureStrategy,
54}
55
56pub enum FailureStrategy {
61 Tail {
63 lines: usize,
65 },
66
67 Head {
69 lines: usize,
71 },
72
73 Grep {
75 pattern: Regex,
77 },
78
79 Between {
81 start: String,
83
84 end: String,
86 },
87}
88
89pub 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
98pub 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
108pub 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
120pub 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
157mod builtins;
159mod toml;
160
161static BUILTINS: LazyLock<Vec<Pattern>> = LazyLock::new(builtin_patterns);
163
164#[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 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 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}