use serde::Serialize;
#[derive(Debug, PartialEq, Serialize)]
pub struct TestRun {
pub passed: u32,
pub failed: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub suites: Option<Vec<Suite>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<TestError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub coverage: Option<Coverage>,
}
#[derive(Debug, PartialEq, Serialize)]
pub struct Coverage {
pub covered: u32,
pub lines: u32,
pub percent: u32,
pub files: Vec<FileCoverage>,
}
#[derive(Debug, PartialEq, Serialize)]
pub struct FileCoverage {
pub path: String,
pub covered: u32,
pub lines: u32,
pub percent: u32,
pub uncovered: Vec<u32>,
}
impl From<&crate::coverage::CoverageReport> for Coverage {
fn from(r: &crate::coverage::CoverageReport) -> Self {
Coverage {
covered: r.total_covered(),
lines: r.total_lines(),
percent: r.total_percent(),
files: r
.files
.iter()
.map(|f| FileCoverage {
path: f.path.clone(),
covered: f.covered,
lines: f.total,
percent: crate::coverage::percent(f.covered, f.total),
uncovered: f.uncovered.clone(),
})
.collect(),
}
}
}
#[derive(Debug, PartialEq, Serialize)]
pub struct Suite {
pub name: String,
pub kind: String,
pub cases: Vec<Case>,
}
#[derive(Debug, PartialEq, Serialize)]
pub struct Case {
pub name: String,
pub outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<Location>,
}
#[derive(Debug, PartialEq, Serialize)]
pub struct Location {
pub path: String,
pub line: u32,
pub col: u32,
}
#[derive(Debug, PartialEq, Serialize)]
pub struct TestError {
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub diagnostics: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stderr: Option<String>,
}
impl TestRun {
pub fn empty() -> Self {
TestRun {
passed: 0,
failed: 0,
suites: Some(Vec::new()),
error: None,
coverage: None,
}
}
pub fn with_coverage(mut self, report: &crate::coverage::CoverageReport) -> Self {
if !report.is_empty() {
self.coverage = Some(Coverage::from(report));
}
self
}
pub fn discovered(suites: Vec<Suite>) -> Self {
TestRun {
passed: 0,
failed: 0,
suites: Some(suites),
error: None,
coverage: None,
}
}
pub fn runtime_error(message: impl Into<String>, stderr: Option<String>) -> Self {
TestRun {
passed: 0,
failed: 0,
suites: None,
error: Some(TestError {
kind: "runtime".to_string(),
message: Some(message.into()),
diagnostics: Vec::new(),
stderr: stderr.filter(|s| !s.trim().is_empty()),
}),
coverage: None,
}
}
pub fn compile_error(diagnostics: Vec<String>) -> Self {
TestRun {
passed: 0,
failed: 0,
suites: None,
error: Some(TestError {
kind: "compile".to_string(),
message: None,
diagnostics,
stderr: None,
}),
coverage: None,
}
}
pub fn render(&self) -> String {
let mut s = serde_json::to_string_pretty(self).expect("TestRun serialises");
s.push('\n');
s
}
}
#[derive(Debug, Default, PartialEq)]
pub struct ParsedRun {
pub passed: u32,
pub failed: u32,
pub suites: Vec<Suite>,
pub complete: bool,
}
impl ParsedRun {
pub fn into_document(self, stderr: &str) -> TestRun {
if self.complete {
TestRun {
passed: self.passed,
failed: self.failed,
suites: Some(self.suites),
error: None,
coverage: None,
}
} else {
let trimmed = stderr.trim();
TestRun {
passed: self.passed,
failed: self.failed,
suites: Some(self.suites),
error: Some(TestError {
kind: "runtime".to_string(),
message: Some("the test runner exited before completing".to_string()),
diagnostics: Vec::new(),
stderr: (!trimmed.is_empty()).then(|| trimmed.to_string()),
}),
coverage: None,
}
}
}
}
pub fn parse_ndjson(stdout: &str) -> ParsedRun {
let mut run = ParsedRun::default();
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
match value.get("type").and_then(|t| t.as_str()) {
Some("suite-begin") => {
run.suites.push(Suite {
name: str_field(&value, "name"),
kind: str_field(&value, "kind"),
cases: Vec::new(),
});
}
Some("case") => {
let outcome = str_field(&value, "outcome");
if outcome == "pass" {
run.passed += 1;
} else {
run.failed += 1;
}
let message = value
.get("message")
.and_then(|m| m.as_str())
.map(str::to_string);
let location = value
.get("location")
.and_then(|l| l.as_str())
.and_then(parse_location);
let case = Case {
name: str_field(&value, "name"),
outcome,
message,
location,
};
if let Some(suite) = run.suites.last_mut() {
suite.cases.push(case);
}
}
Some("run-end") => {
run.complete = true;
}
_ => {}
}
}
run
}
fn str_field(value: &serde_json::Value, key: &str) -> String {
value
.get(key)
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string()
}
fn parse_location(s: &str) -> Option<Location> {
let (rest, col) = s.rsplit_once(':')?;
let (path, line) = rest.rsplit_once(':')?;
let line: u32 = line.parse().ok()?;
let col: u32 = col.parse().ok()?;
if path.is_empty() {
return None;
}
Some(Location {
path: path.to_string(),
line,
col,
})
}