speccoder_runtime/verify/
summary.rs1use std::sync::LazyLock;
10
11use regex::Regex;
12use speccoder_core::verify::RunnerSummary;
13
14const RUST_UNRESOLVED: &[&str] = &[
16 "E0412", "E0422", "E0423", "E0425", "E0432", "E0433", "E0599",
17];
18const TS_UNRESOLVED: &[&str] = &["TS2304", "TS2305", "TS2307", "TS2339", "TS2552", "TS2724"];
20
21fn re(pattern: &str) -> Option<Regex> {
24 Regex::new(pattern).ok()
25}
26
27static LIBTEST: LazyLock<Option<Regex>> =
28 LazyLock::new(|| re(r"test result: \w+\. (\d+) passed; (\d+) failed"));
29static NEXTEST: LazyLock<Option<Regex>> =
30 LazyLock::new(|| re(r"\d+ tests? run: (\d+) passed(?:[^,\n]*)?(?:, (\d+) failed)?"));
31static RUSTC_CODE: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"(?m)^error\[(E\d{4})\]"));
32static RUSTC_PLAIN: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"(?m)^error: (.*)$"));
33static PYTEST: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"(?m)^=+ (.*) in [\d.]+s.* =+$"));
34static PY_COUNT: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"(\d+) (passed|failed|errors?)"));
35static PY_E_LINE: LazyLock<Option<Regex>> =
36 LazyLock::new(|| re(r"(?m)^E\s+(\w+(?:Error|Exception)):"));
37static JEST: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"(?m)^Tests:\s+(.*)$"));
38static VITEST: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"(?m)^\s*Tests\s+(.*\(\d+\))\s*$"));
39static JS_COUNT: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"(\d+) (passed|failed)"));
40static SUITE_FAILED: LazyLock<Option<Regex>> =
41 LazyLock::new(|| re(r"(?m)^\s*Test (?:Suites|Files):?\s+(\d+) failed"));
42static TS_CODE: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"\berror (TS\d{4})\b"));
43static GO_FAIL: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"(?m)^\s*--- FAIL: "));
44static GO_PASS: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"(?m)^\s*--- PASS: "));
45static GO_COMPILE: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"(?m)^\S+\.go:\d+:\d+: (.*)$"));
46
47fn caps<'t>(r: &'t Option<Regex>, text: &'t str) -> impl Iterator<Item = regex::Captures<'t>> {
48 r.iter().flat_map(move |r| r.captures_iter(text))
49}
50
51fn finds<'t>(r: &'t Option<Regex>, text: &'t str) -> impl Iterator<Item = regex::Match<'t>> {
52 r.iter().flat_map(move |r| r.find_iter(text))
53}
54
55static ANSI: LazyLock<Option<Regex>> = LazyLock::new(|| re(r"\x1b\[[0-9;?]*[ -/]*[@-~]"));
56
57fn plain(text: &str) -> String {
59 match ANSI.as_ref() {
60 Some(r) => r.replace_all(text, "").into_owned(),
61 None => text.to_owned(),
62 }
63}
64
65fn matches(r: &Option<Regex>, text: &str) -> bool {
66 r.as_ref().is_some_and(|r| r.is_match(text))
67}
68
69#[must_use]
72pub fn parse(stdout: &str, stderr: &str, exit_code: Option<i32>) -> Option<RunnerSummary> {
73 let text = plain(&format!("{stdout}\n{stderr}"));
75 let text = text.as_str();
76 rust(text)
77 .or_else(|| python(text, exit_code))
78 .or_else(|| javascript(text))
79 .or_else(|| go(text))
80}
81
82fn rust(text: &str) -> Option<RunnerSummary> {
83 let mut s = RunnerSummary::default();
84 let mut seen = false;
85 for c in caps(&LIBTEST, text) {
86 seen = true;
87 s.passed += num(c.get(1));
88 s.failed += num(c.get(2));
89 }
90 for c in caps(&NEXTEST, text) {
91 seen = true;
92 s.passed += num(c.get(1));
93 s.failed += num(c.get(2));
94 }
95 if text.contains("error: could not compile") {
96 let codes: Vec<&str> = caps(&RUSTC_CODE, text)
97 .filter_map(|c| c.get(1).map(|m| m.as_str()))
98 .collect();
99 let other_plain = caps(&RUSTC_PLAIN, text).any(|c| {
101 let m = c.get(1).map_or("", |m| m.as_str());
102 !(m.starts_with("could not compile")
103 || m.starts_with("aborting due to")
104 || m.starts_with("Recipe")
105 || m.starts_with("test failed"))
106 });
107 s.build_failed = true;
108 s.unresolved_only =
109 !codes.is_empty() && !other_plain && codes.iter().all(|c| RUST_UNRESOLVED.contains(c));
110 return Some(s);
111 }
112 seen.then_some(s)
113}
114
115fn python(text: &str, exit_code: Option<i32>) -> Option<RunnerSummary> {
116 let line = caps(&PYTEST, text).last()?;
117 let mut s = RunnerSummary::default();
118 for c in caps(&PY_COUNT, line.get(1).map_or("", |m| m.as_str())) {
119 match c.get(2).map_or("", |m| m.as_str()) {
120 "passed" => s.passed += num(c.get(1)),
121 "failed" => s.failed += num(c.get(1)),
122 _ => s.errors += num(c.get(1)),
123 }
124 }
125 let collection = text.contains("ERROR collecting") || exit_code == Some(2);
127 if collection && s.failed == 0 {
128 let kinds: Vec<&str> = caps(&PY_E_LINE, text)
129 .filter_map(|c| c.get(1).map(|m| m.as_str()))
130 .collect();
131 let unresolved = |k: &&str| {
132 matches!(
133 *k,
134 "ImportError" | "ModuleNotFoundError" | "AttributeError" | "NameError"
135 )
136 };
137 if !kinds.is_empty() && kinds.iter().all(unresolved) {
138 s.build_failed = true;
139 s.unresolved_only = true;
140 } else if s.errors == 0 {
141 s.errors = 1;
142 }
143 }
144 Some(s)
145}
146
147fn javascript(text: &str) -> Option<RunnerSummary> {
148 let ts: Vec<&str> = caps(&TS_CODE, text)
149 .filter_map(|c| c.get(1).map(|m| m.as_str()))
150 .collect();
151 let line = caps(&JEST, text)
152 .last()
153 .or_else(|| caps(&VITEST, text).last());
154 if line.is_none() && ts.is_empty() {
155 return None;
156 }
157 let mut s = RunnerSummary::default();
158 if let Some(line) = line {
159 for c in caps(&JS_COUNT, line.get(1).map_or("", |m| m.as_str())) {
160 match c.get(2).map_or("", |m| m.as_str()) {
161 "passed" => s.passed += num(c.get(1)),
162 _ => s.failed += num(c.get(1)),
163 }
164 }
165 }
166 if !ts.is_empty() {
167 s.build_failed = true;
168 s.unresolved_only = ts.iter().all(|c| TS_UNRESOLVED.contains(c));
169 } else if s.passed + s.failed == 0 && matches(&SUITE_FAILED, text) {
170 s.errors = 1;
172 }
173 Some(s)
174}
175
176fn go(text: &str) -> Option<RunnerSummary> {
177 let build = text.contains("[build failed]") || text.contains("[setup failed]");
178 let failed = finds(&GO_FAIL, text).count();
179 let passed = finds(&GO_PASS, text).count();
180 if !build && failed + passed == 0 {
181 return None;
182 }
183 let mut s = RunnerSummary {
184 passed,
185 failed,
186 ..RunnerSummary::default()
187 };
188 if build {
189 let diags: Vec<&str> = caps(&GO_COMPILE, text)
190 .filter_map(|c| c.get(1).map(|m| m.as_str()))
191 .collect();
192 s.build_failed = true;
193 s.unresolved_only = !diags.is_empty()
194 && diags
195 .iter()
196 .all(|d| d.starts_with("undefined: ") || d.contains("has no field or method"));
197 }
198 Some(s)
199}
200
201fn num(m: Option<regex::Match<'_>>) -> usize {
202 m.and_then(|m| m.as_str().parse().ok()).unwrap_or(0)
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn s(stdout: &str, code: i32) -> RunnerSummary {
210 parse(stdout, "", Some(code)).unwrap()
211 }
212
213 #[test]
214 fn libtest_assertion_failure() {
215 let out = "running 2 tests\ntest a ... ok\ntest b ... FAILED\n\ntest result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out\n";
216 let r = s(out, 101);
217 assert_eq!((r.passed, r.failed, r.build_failed), (1, 1, false));
218 }
219
220 #[test]
221 fn rustc_missing_function_is_unresolved_only() {
222 let err = "error[E0425]: cannot find function `slugify` in this scope\n --> tests/acceptance_t004.rs:3:5\n\nerror[E0432]: unresolved import `crate::slug`\n\nerror: could not compile `demo` (test \"acceptance_t004\") due to 2 previous errors\n";
223 let r = parse("", err, Some(101)).unwrap();
224 assert!(r.build_failed && r.unresolved_only);
225 }
226
227 #[test]
230 fn coloured_rustc_output_reads_the_same() {
231 let err = "\x1b[0m\x1b[1m\x1b[38;5;9merror[E0425]\x1b[0m\x1b[0m\x1b[1m: cannot find function `slugify` in this scope\x1b[0m\n\x1b[1m\x1b[38;5;9merror\x1b[0m\x1b[1m:\x1b[0m could not compile `demo` (test \"held_out\") due to 1 previous error\n";
232 let r = parse("", err, Some(101)).unwrap();
233 assert!(r.build_failed && r.unresolved_only);
234 }
235
236 #[test]
237 fn rustc_syntax_error_is_a_build_error() {
238 let err = "error: expected one of `,`, `:`, or `}`, found `=`\n --> tests/a.rs:1:9\n\nerror: could not compile `demo` due to 1 previous error\n";
239 let r = parse("", err, Some(101)).unwrap();
240 assert!(r.build_failed && !r.unresolved_only);
241 let typed = "error[E0308]: mismatched types\nerror: could not compile `demo`\n";
242 assert!(!parse("", typed, Some(101)).unwrap().unresolved_only);
243 }
244
245 #[test]
246 fn nextest_summary() {
247 let out = " Summary [ 0.031s] 3 tests run: 1 passed, 2 failed, 0 skipped\n";
248 let r = s(out, 100);
249 assert_eq!((r.passed, r.failed), (1, 2));
250 }
251
252 #[test]
253 fn pytest_outcomes() {
254 let r = s("============ 1 failed, 2 passed in 0.12s =============", 1);
255 assert_eq!((r.passed, r.failed), (2, 1));
256 let import = "==== ERRORS ====\n___ ERROR collecting tests/test_slug.py ___\nE ImportError: cannot import name 'slugify' from 'slug'\n=========== 1 error in 0.05s ===========";
257 let r = s(import, 2);
258 assert!(r.build_failed && r.unresolved_only);
259 let syntax = "___ ERROR collecting tests/test_slug.py ___\nE SyntaxError: invalid syntax\n=========== 1 error in 0.05s ===========";
260 let r = s(syntax, 2);
261 assert!(!r.build_failed && r.errors == 1);
262 let none = s("=========== no tests ran in 0.01s ===========", 5);
263 assert_eq!((none.passed, none.failed, none.errors), (0, 0, 0));
264 }
265
266 #[test]
267 fn jest_vitest_and_typescript() {
268 let r = s(
269 "Test Suites: 1 failed, 1 total\nTests: 1 failed, 3 passed, 4 total\n",
270 1,
271 );
272 assert_eq!((r.passed, r.failed), (3, 1));
273 let r = s(
274 " Test Files 1 failed (1)\n Tests 2 failed | 5 passed (7)\n",
275 1,
276 );
277 assert_eq!((r.passed, r.failed), (5, 2));
278 let ts = "src/a.test.ts:1:10 - error TS2305: Module '\"./slug\"' has no exported member 'slugify'.\nTest Suites: 1 failed, 1 total\nTests: 0 total\n";
279 let r = s(ts, 1);
280 assert!(r.build_failed && r.unresolved_only);
281 let other =
282 "a.test.ts:2:3 - error TS2322: Type 'string' is not assignable to type 'number'.\n";
283 assert!(!s(other, 1).unresolved_only);
284 let suite = "Test Suites: 1 failed, 1 total\nTests: 0 total\n";
285 assert_eq!(s(suite, 1).errors, 1);
286 }
287
288 #[test]
289 fn go_test_outcomes() {
290 let r = s(
291 "--- FAIL: TestSlug (0.00s)\n--- PASS: TestOther (0.00s)\nFAIL\n",
292 1,
293 );
294 assert_eq!((r.passed, r.failed), (1, 1));
295 let undefined = "# demo [demo.test]\n./slug_test.go:6:9: undefined: Slugify\nFAIL\tdemo [build failed]\n";
296 let r = s(undefined, 1);
297 assert!(r.build_failed && r.unresolved_only);
298 let other =
299 "./slug_test.go:6:9: syntax error: unexpected newline\nFAIL\tdemo [build failed]\n";
300 assert!(!s(other, 1).unresolved_only);
301 }
302
303 #[test]
304 fn unknown_output_has_no_summary() {
305 assert!(parse("something happened", "", Some(1)).is_none());
306 }
307}