mod joiner;
mod junit_parser;
mod report_printer;
mod test_parser;
mod test_producer_serde;
mod valgrind_parser;
use crate::joiner::StringJoiner;
use crate::junit_parser::JUnitTestParser;
use crate::report_printer::print_report;
use crate::test_parser::{CargoTestParser, CargoTestParserOptions, ParsedTestSuite, TestParser};
use crate::test_producer_serde::{RenderedSuite, TestProducer};
use crate::valgrind_parser::ValgrindTestParser;
use anyhow::Context;
use clap::Parser;
use clap_derive::{ArgEnum, Parser};
use regex::Regex;
use std::fmt::{Debug, Display, Formatter};
use std::fs::OpenOptions;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[clap(version, author = "Jonathan H. R. Lopes <jhrldev@gmail.com>")]
struct Opts {
#[clap(short, long, help = "Output directory for produced .xml files.")]
out: Option<String>,
#[clap(short, long, help = "Mirror stdin in stdout.")]
mirror: bool,
#[clap(short, long, help = "Report the errors to console.")]
report: bool,
#[clap(
short,
long,
help = "Ignore parse errors when the input is malformed. Useful when the program mixes its test report output with test SysErr/SysOut outputs."
)]
ignore_parse_errors: bool,
#[clap(
short = 'z',
long,
help = "Exit with non-zero code when there are errors in tests."
)]
non_zero_on_errors: bool,
#[clap(
short,
long,
help = "File to read from (default: stdin)",
multiple_values = true
)]
file: Option<Vec<String>>,
#[clap(arg_enum, short, long, help = "Kind of report to transform into JUnit XML.", default_value_t = ReportKind::default())]
kind: ReportKind,
}
#[derive(Debug, ArgEnum, Clone)]
enum ReportKind {
Cargo,
Valgrind,
Junit,
}
impl Display for ReportKind {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", format!("{:?}", self).to_lowercase())
}
}
impl Default for ReportKind {
fn default() -> Self {
ReportKind::Cargo
}
}
#[derive(Debug)]
enum AppError {
SerdeAnyhow(anyhow::Error),
Render(anyhow::Error),
IOAnyhow(anyhow::Error),
FailedTests(Vec<String>),
GenericApp(anyhow::Error),
}
fn main() -> Result<(), AppError> {
let opts: Opts = Opts::parse();
let ignore_parse_errors = opts.ignore_parse_errors;
let (suites, render) = if let Some(file) = opts.file {
match opts.kind {
ReportKind::Valgrind => read_from_file(file, ValgrindTestParser::new(), opts.mirror)?,
ReportKind::Cargo => read_from_file(
file,
CargoTestParser::new(create_cargo_parser_opts(ignore_parse_errors)),
opts.mirror,
)?,
ReportKind::Junit => read_from_file(file, JUnitTestParser::new(), opts.mirror)?,
}
} else {
match opts.kind {
ReportKind::Valgrind => render_from_stdin(ValgrindTestParser::new(), opts.mirror)?,
ReportKind::Cargo => render_from_stdin(
CargoTestParser::new(create_cargo_parser_opts(ignore_parse_errors)),
opts.mirror,
)?,
ReportKind::Junit => render_from_stdin(JUnitTestParser::new(), opts.mirror)?,
}
};
let re = Regex::new(r"[/:]")
.with_context(|| "Failed to compile `[/:]` regex")
.map_err(|e| AppError::GenericApp(e))?;
if let Some(ref out) = opts.out {
write_to_file(&out, render, &re)?;
} else {
write_to_stdout(render)?;
}
if opts.report {
print_report(&suites);
}
if opts.non_zero_on_errors {
let any_error = suites
.iter()
.filter(|s| s.errors > 0 || s.failed > 0)
.map(|s| s.suite_name.clone())
.collect::<Vec<_>>();
if any_error.len() > 0 {
eprintln!("Total of {} tests failed!", any_error.len());
eprintln!("Failed tests:");
eprintln!(" - {}", any_error.join("\n - "));
return Err(AppError::FailedTests(any_error));
}
}
Ok(())
}
fn create_cargo_parser_opts(ignore_parse_errors: bool) -> CargoTestParserOptions {
CargoTestParserOptions {
ignore_parse_errors,
}
}
fn write_to_stdout(render: Vec<RenderedSuite>) -> Result<(), AppError> {
for r in render {
println!("<{}>", r.name);
println!("{}", r.rendered);
println!("</{}>", r.name);
}
Ok(())
}
fn write_to_file(
out: &String,
render: Vec<RenderedSuite>,
invalid_name_regex: &Regex,
) -> Result<(), AppError> {
let root = PathBuf::from(out);
if !root.exists() {
std::fs::create_dir(root.clone())
.with_context(|| format!("Failed to create directory {}", root.display()))
.map_err(|e| AppError::IOAnyhow(e))?;
}
for i in 0..render.len() {
let rendered = render.get(i).unwrap();
let path = root.join(create_rendered_suite_filename(
&rendered,
i,
invalid_name_regex,
));
let mut file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&path)
.with_context(|| format!("Failed to open file {}", path.display()))
.map_err(|e| AppError::IOAnyhow(e))?;
file.write_all(rendered.rendered.as_bytes())
.with_context(|| format!("Failed to write to file {}", path.display()))
.map_err(|e| AppError::IOAnyhow(e))?;
}
Ok(())
}
fn create_rendered_suite_filename(
suite: &RenderedSuite,
i: usize,
invalid_name_regex: &Regex,
) -> String {
create_rendered_suite_name_filename(&suite.name, i, invalid_name_regex)
}
fn create_rendered_suite_name_filename(name: &str, i: usize, invalid_name_regex: &Regex) -> String {
let n = invalid_name_regex.replace_all(name, "_");
format!("{}-{}.xml", n, i)
}
fn render_from_stdin<P: TestParser>(
mut parser: P,
mirror: bool,
) -> Result<(Vec<ParsedTestSuite>, Vec<RenderedSuite>), AppError> {
let mut buffer = String::new();
let stdin = io::stdin();
let mut suites: Vec<ParsedTestSuite> = vec![];
let mut line;
loop {
line = stdin.read_line(&mut buffer);
let has_more = read_from_buffer(&mut parser, line, &mut buffer, &mut suites, mirror)?;
if !has_more {
break;
}
}
TestProducer::produce(suites.clone())
.map(move |v| (suites, v))
.map_err(|e| AppError::Render(e))
}
fn read_from_file<P, PA: TestParser>(
paths: Vec<P>,
mut parser: PA,
mirror: bool,
) -> Result<(Vec<ParsedTestSuite>, Vec<RenderedSuite>), AppError>
where
P: AsRef<Path>,
{
let mut suites: Vec<ParsedTestSuite> = vec![];
for path in paths {
let mut buffer = String::new();
let mut open = OpenOptions::new()
.read(true)
.open(&path)
.with_context(|| format!("Failed to open file {}", path.as_ref().display()))
.map_err(|e| AppError::IOAnyhow(e))?;
if parser.multi_line() {
let mut buf = BufReader::new(open);
let mut line;
loop {
line = buf.read_line(&mut buffer);
let has_more =
read_from_buffer(&mut parser, line, &mut buffer, &mut suites, mirror)?;
if !has_more {
break;
}
}
} else {
open.read_to_string(&mut buffer)
.with_context(|| format!("Failed to read file {}", path.as_ref().display()))
.map_err(|e| AppError::IOAnyhow(e))?;
read_from_buffer(&mut parser, Ok(1), &mut buffer, &mut suites, mirror)?;
}
}
TestProducer::produce(suites.clone())
.map(move |v| (suites, v))
.map_err(|e| AppError::Render(e))
}
fn read_from_buffer<P: TestParser>(
parser: &mut P,
read: Result<usize, std::io::Error>,
buffer: &mut String,
suites: &mut Vec<ParsedTestSuite>,
mirror: bool,
) -> Result<bool, AppError> {
if let Ok(l) = read {
if l > 0 && buffer.len() > 0 {
if mirror {
println!("{}", buffer)
}
let parse = parser.parse(&buffer);
if let Ok(p) = parse {
if let Some(suite) = p {
suites.push(suite);
parser.reset();
}
buffer.clear();
return Ok(true);
} else if let Err(e) = parse {
return Err(AppError::SerdeAnyhow(e.into()));
}
} else {
return Ok(false);
}
} else {
return Ok(false);
}
return Ok(false);
}
#[cfg(test)]
mod tests {
use crate::test_parser::{parse_test, Test};
use crate::{
create_rendered_suite_name_filename, read_from_file, CargoTestParser,
CargoTestParserOptions, JUnitTestParser, ValgrindTestParser,
};
use regex::Regex;
#[test]
fn suite() {
let suite = r#"{ "type": "suite", "event": "started", "test_count": 14 }"#;
let result = parse_test(suite, false).map_err(|e| e.to_string());
assert_eq!(
Ok(Some(Test::new_suite_started("started".to_string(), 14))),
result
);
}
#[test]
fn test() {
let suite =
r#"{ "type": "test", "event": "started", "name": "tests::bench_1mb_tar_detect" }"#;
let result = parse_test(suite, false).map_err(|e| e.to_string());
assert_eq!(
Ok(Some(Test::Test {
event: "started".to_string(),
name: "tests::bench_1mb_tar_detect".to_string(),
stdout: None,
exec_time: None,
})),
result
);
}
#[test]
fn test_with_time() {
let suite = r#"{ "type": "test", "name": "tests::test_rar_sfx_detect", "event": "ok", "exec_time": 0.07352109 }"#;
let result = parse_test(suite, false).map_err(|e| e.to_string());
assert_eq!(
Ok(Some(Test::Test {
event: "ok".to_string(),
name: "tests::test_rar_sfx_detect".to_string(),
stdout: None,
exec_time: Some(0.07352109f64),
})),
result
);
}
#[test]
fn test_ignored() {
let suite = r#"{ "type": "test", "name": "tests::test_rar_sfx_detect", "event": "ignored"}"#;
let result = parse_test(suite, false).map_err(|e| e.to_string());
assert_eq!(
Ok(Some(Test::Test {
event: "ignored".to_string(),
name: "tests::test_rar_sfx_detect".to_string(),
stdout: None,
exec_time: Option::None,
})),
result
);
}
#[test]
fn suite_ok() {
let suite = r#"{ "type": "suite", "event": "ok", "passed": 1, "failed": 0, "allowed_fail": 0, "ignored": 0, "measured": 0, "filtered_out": 0, "exec_time": 0.279672772 }"#;
let result = parse_test(suite, false).map_err(|e| e.to_string());
assert_eq!(
Ok(Some(Test::new_suite_ok(
"ok".to_string(),
1,
0,
None,
0,
0,
0,
0,
0.279672772f64,
))),
result
);
}
#[test]
fn suite_with_ignored() {
let suite = r#"{ "type": "suite", "event": "ok", "passed": 1, "failed": 0, "allowed_fail": 0, "ignored": 1, "measured": 0, "filtered_out": 0, "exec_time": 0.279672772 }"#;
let result = parse_test(suite, false).map_err(|e| e.to_string());
assert_eq!(
Ok(Some(Test::new_suite_ok(
"ok".to_string(),
1,
0,
None,
0,
1,
0,
0,
0.279672772f64,
))),
result
);
}
#[test]
fn test_read_case_1() {
let result = read_from_file(
vec!["test/test-case-1"],
CargoTestParser::new(Default::default()),
false,
);
assert_eq!(result.is_ok(), true);
}
#[test]
fn test_read_case_2() {
let result = read_from_file(
vec!["test/test-case-2"],
CargoTestParser::new(Default::default()),
false,
);
assert_eq!(result.is_ok(), true);
}
#[test]
fn test_read_case_3() {
let result = read_from_file(
vec!["test/test-case-3"],
CargoTestParser::new(Default::default()),
false,
);
assert_eq!(result.is_ok(), true);
}
#[test]
fn test_read_mdxbook_case() {
let result = read_from_file(
vec!["test/mdxbook-test-case"],
CargoTestParser::new(CargoTestParserOptions {
ignore_parse_errors: true,
}),
false,
);
assert_eq!(result.is_ok(), true);
}
#[test]
fn valgrind_test_read_case_1() {
let result = read_from_file(
vec!["test/valgrind-test-case-1.xml"],
ValgrindTestParser::new(),
false,
);
assert_eq!(result.is_ok(), true);
}
#[test]
fn valgrind_test_read_case_2() {
let result = read_from_file(
vec!["test/valgrind-test-case-2.xml"],
ValgrindTestParser::new(),
false,
);
assert!(matches!(result, Ok(_)));
assert_eq!(result.is_ok(), true);
}
#[test]
fn junit_test_read_case_1() {
let result = read_from_file(
vec!["test/junit-test-case-1.xml"],
JUnitTestParser::new(),
false,
);
assert!(matches!(result, Ok(_)));
assert_eq!(result.is_ok(), true);
}
#[test]
fn junit_test_read_case_2() {
let result = read_from_file(
vec!["test/junit-test-case-2.xml"],
JUnitTestParser::new(),
false,
);
assert!(matches!(result, Ok(_)));
assert_eq!(result.is_ok(), true);
}
#[test]
fn test_invalid_name_replace() {
let re = Regex::new(r"[/:]").unwrap();
assert_eq!(
"src_book_mod.rs - book__MDBook__iter (line 202)-0.xml",
create_rendered_suite_name_filename(
"src/book/mod.rs - book::MDBook::iter (line 202)",
0,
&re,
)
);
}
}