1#![warn(clippy::pedantic)]
9#![forbid(unsafe_code)]
10#![deny(missing_docs)]
11
12#[derive(Clone, Copy, PartialEq, Eq, Debug)]
14pub enum Status {
15 Pass,
17 Fail,
19 Ignore,
21}
22
23#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct TestResult {
26 pub name: String,
28 pub status: Status,
30 pub location: Option<(String, usize)>,
32}
33
34#[must_use]
36pub fn tally(results: &[TestResult]) -> (usize, usize, usize) {
37 let mut pass = 0;
38 let mut fail = 0;
39 let mut ignore = 0;
40 for r in results {
41 match r.status {
42 Status::Pass => pass += 1,
43 Status::Fail => fail += 1,
44 Status::Ignore => ignore += 1,
45 }
46 }
47 (pass, fail, ignore)
48}
49
50#[must_use]
54pub fn parse(output: &str) -> Vec<TestResult> {
55 let mut results: Vec<TestResult> = Vec::new();
56 let mut current_failure: Option<String> = None;
57
58 for line in output.lines() {
59 let trimmed = line.trim();
60 if let Some(rest) = trimmed.strip_prefix("test ")
62 && let Some((name, tail)) = rest.rsplit_once(" ... ")
63 {
64 let status = match tail.trim() {
65 "ok" => Some(Status::Pass),
66 t if t.starts_with("FAILED") => Some(Status::Fail),
67 t if t.starts_with("ignored") => Some(Status::Ignore),
68 _ => None,
69 };
70 if let Some(status) = status {
71 upsert(&mut results, name.trim(), status);
72 continue;
73 }
74 }
75 if let Some((name, verdict)) = trimmed.rsplit_once(' ') {
77 let status = match verdict {
78 "PASSED" => Some(Status::Pass),
79 "FAILED" => Some(Status::Fail),
80 "SKIPPED" => Some(Status::Ignore),
81 _ => None,
82 };
83 if let Some(status) = status
84 && !name.is_empty()
85 {
86 upsert(&mut results, name, status);
87 continue;
88 }
89 }
90 if let Some(name) = trimmed
92 .strip_prefix("---- ")
93 .and_then(|s| s.strip_suffix(" stdout ----"))
94 {
95 current_failure = Some(name.trim().to_string());
96 continue;
97 }
98 if let Some(loc) = panic_location(trimmed)
99 && let Some(name) = current_failure.clone()
100 {
101 attach_location(&mut results, &name, loc);
102 }
103 }
104
105 results.sort_by(|a, b| a.name.cmp(&b.name));
106 results.dedup_by(|a, b| a.name == b.name);
107 results
108}
109
110fn upsert(results: &mut Vec<TestResult>, name: &str, status: Status) {
113 if let Some(r) = results.iter_mut().find(|r| r.name == name) {
114 r.status = status;
115 } else {
116 results.push(TestResult {
117 name: name.to_string(),
118 status,
119 location: None,
120 });
121 }
122}
123
124fn attach_location(results: &mut [TestResult], name: &str, loc: (String, usize)) {
126 if let Some(r) = results.iter_mut().find(|r| r.name == name) {
127 r.location = Some(loc);
128 }
129}
130
131fn panic_location(line: &str) -> Option<(String, usize)> {
134 let after = line
135 .rfind("panicked at ")
136 .map_or(line, |i| &line[i + "panicked at ".len()..]);
137 let mut parts = after.trim().splitn(3, ':');
138 let file = parts.next()?.trim();
139 let line_no: usize = parts.next()?.trim().parse().ok()?;
140 if file.is_empty() || !file.contains(['/', '.']) {
141 return None;
142 }
143 Some((file.to_string(), line_no))
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn parses_cargo_test_output() {
152 let out = "\
153running 3 tests
154test mod_a::works ... ok
155test mod_a::ignored_one ... ignored
156test mod_b::breaks ... FAILED
157
158failures:
159
160---- mod_b::breaks stdout ----
161thread 'mod_b::breaks' panicked at src/lib.rs:42:5:
162assertion failed
163
164test result: FAILED. 1 passed; 1 failed; 1 ignored;
165";
166 let results = parse(out);
167 assert_eq!(results.len(), 3);
168 let (p, f, i) = tally(&results);
169 assert_eq!((p, f, i), (1, 1, 1));
170 let broken = results.iter().find(|r| r.name == "mod_b::breaks").unwrap();
171 assert_eq!(broken.status, Status::Fail);
172 assert_eq!(broken.location, Some(("src/lib.rs".to_string(), 42)));
173 }
174
175 #[test]
176 fn parses_pytest_style() {
177 let out = "tests/test_x.py::test_ok PASSED\ntests/test_x.py::test_bad FAILED\n";
178 let results = parse(out);
179 assert_eq!(tally(&results), (1, 1, 0));
180 }
181
182 #[test]
183 fn empty_output_has_no_results() {
184 assert!(parse("compiling...\nwarning: unused\n").is_empty());
185 }
186}