#![warn(clippy::pedantic)]
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Status {
Pass,
Fail,
Ignore,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TestResult {
pub name: String,
pub status: Status,
pub location: Option<(String, usize)>,
}
#[must_use]
pub fn tally(results: &[TestResult]) -> (usize, usize, usize) {
let mut pass = 0;
let mut fail = 0;
let mut ignore = 0;
for r in results {
match r.status {
Status::Pass => pass += 1,
Status::Fail => fail += 1,
Status::Ignore => ignore += 1,
}
}
(pass, fail, ignore)
}
#[must_use]
pub fn parse(output: &str) -> Vec<TestResult> {
let mut results: Vec<TestResult> = Vec::new();
let mut current_failure: Option<String> = None;
for line in output.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("test ")
&& let Some((name, tail)) = rest.rsplit_once(" ... ")
{
let status = match tail.trim() {
"ok" => Some(Status::Pass),
t if t.starts_with("FAILED") => Some(Status::Fail),
t if t.starts_with("ignored") => Some(Status::Ignore),
_ => None,
};
if let Some(status) = status {
upsert(&mut results, name.trim(), status);
continue;
}
}
if let Some((name, verdict)) = trimmed.rsplit_once(' ') {
let status = match verdict {
"PASSED" => Some(Status::Pass),
"FAILED" => Some(Status::Fail),
"SKIPPED" => Some(Status::Ignore),
_ => None,
};
if let Some(status) = status
&& !name.is_empty()
{
upsert(&mut results, name, status);
continue;
}
}
if let Some(name) = trimmed
.strip_prefix("---- ")
.and_then(|s| s.strip_suffix(" stdout ----"))
{
current_failure = Some(name.trim().to_string());
continue;
}
if let Some(loc) = panic_location(trimmed)
&& let Some(name) = current_failure.clone()
{
attach_location(&mut results, &name, loc);
}
}
results.sort_by(|a, b| a.name.cmp(&b.name));
results.dedup_by(|a, b| a.name == b.name);
results
}
fn upsert(results: &mut Vec<TestResult>, name: &str, status: Status) {
if let Some(r) = results.iter_mut().find(|r| r.name == name) {
r.status = status;
} else {
results.push(TestResult {
name: name.to_string(),
status,
location: None,
});
}
}
fn attach_location(results: &mut [TestResult], name: &str, loc: (String, usize)) {
if let Some(r) = results.iter_mut().find(|r| r.name == name) {
r.location = Some(loc);
}
}
fn panic_location(line: &str) -> Option<(String, usize)> {
let after = line
.rfind("panicked at ")
.map_or(line, |i| &line[i + "panicked at ".len()..]);
let mut parts = after.trim().splitn(3, ':');
let file = parts.next()?.trim();
let line_no: usize = parts.next()?.trim().parse().ok()?;
if file.is_empty() || !file.contains(['/', '.']) {
return None;
}
Some((file.to_string(), line_no))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_cargo_test_output() {
let out = "\
running 3 tests
test mod_a::works ... ok
test mod_a::ignored_one ... ignored
test mod_b::breaks ... FAILED
failures:
---- mod_b::breaks stdout ----
thread 'mod_b::breaks' panicked at src/lib.rs:42:5:
assertion failed
test result: FAILED. 1 passed; 1 failed; 1 ignored;
";
let results = parse(out);
assert_eq!(results.len(), 3);
let (p, f, i) = tally(&results);
assert_eq!((p, f, i), (1, 1, 1));
let broken = results.iter().find(|r| r.name == "mod_b::breaks").unwrap();
assert_eq!(broken.status, Status::Fail);
assert_eq!(broken.location, Some(("src/lib.rs".to_string(), 42)));
}
#[test]
fn parses_pytest_style() {
let out = "tests/test_x.py::test_ok PASSED\ntests/test_x.py::test_bad FAILED\n";
let results = parse(out);
assert_eq!(tally(&results), (1, 1, 0));
}
#[test]
fn empty_output_has_no_results() {
assert!(parse("compiling...\nwarning: unused\n").is_empty());
}
}