From 2728de26e4b2c706e5f57880903fe0a7f50cf4e0 Mon Sep 17 00:00:00 2001 From: Jake Shadle Date: Sun, 10 Dec 2023 01:08:25 +0100 Subject: [PATCH] Add libtest json output option (#1086) Add support for machine-readable outputs for nextest's test runs. --- cargo-nextest/src/dispatch.rs | 56 +- cargo-nextest/src/errors.rs | 12 +- nextest-runner/src/errors.rs | 1 + nextest-runner/src/reporter.rs | 18 +- nextest-runner/src/reporter/structured.rs | 70 +++ .../src/reporter/structured/libtest.rs | 535 ++++++++++++++++++ 6 files changed, 687 insertions(+), 5 deletions(-) create mode 100644 nextest-runner/src/reporter/structured.rs create mode 100644 nextest-runner/src/reporter/structured/libtest.rs diff --git a/cargo-nextest/src/dispatch.rs b/cargo-nextest/src/dispatch.rs index 89cf051bcb6..299062f28e7 100644 --- a/cargo-nextest/src/dispatch.rs +++ b/cargo-nextest/src/dispatch.rs @@ -28,7 +28,7 @@ use nextest_runner::{ }, partition::PartitionerBuilder, platform::BuildPlatforms, - reporter::{FinalStatusLevel, StatusLevel, TestOutputDisplay, TestReporterBuilder}, + reporter::{structured, FinalStatusLevel, StatusLevel, TestOutputDisplay, TestReporterBuilder}, reuse_build::{archive_to_file, ArchiveReporter, MetadataOrPath, PathMapper, ReuseBuildInfo}, runner::{configure_handle_inheritance, RunStatsFailureKind, TestRunnerBuilder}, show_config::{ShowNextestVersion, ShowTestGroupSettings, ShowTestGroups, ShowTestGroupsMode}, @@ -771,6 +771,18 @@ enum IgnoreOverridesOpt { All, } +#[derive(Clone, Copy, Debug, ValueEnum, Default)] +enum MessageFormat { + /// The default output format + #[default] + Human, + /// Output test information in the same format as libtest itself + LibtestJson, + /// Output test information in the same format as libtest itself, with an + /// `nextest` subobject that includes additional metadata + LibtestJsonPlus, +} + #[derive(Debug, Default, Args)] #[command(next_help_heading = "Reporter options")] struct TestReporterOpts { @@ -818,6 +830,32 @@ struct TestReporterOpts { /// Do not display the progress bar #[arg(long, env = "NEXTEST_HIDE_PROGRESS_BAR")] hide_progress_bar: bool, + + /// The format to use for outputting test results + #[arg( + long, + name = "message-format", + value_enum, + default_value_t, + conflicts_with = "no-run", + value_name = "FORMAT", + env = "NEXTEST_MESSAGE_FORMAT" + )] + message_format: MessageFormat, + + /// The specific version of the `message-format` to use + /// + /// This version string allows the machine-readable formats to use a stable + /// structure for consistent consumption across changes to nextest. If not + /// specified the latest version for the selected message format is used + #[arg( + long, + conflicts_with = "no-run", + requires = "message-format", + value_name = "VERSION", + env = "NEXTEST_MESSAGE_FORMAT_VERSION" + )] + message_format_version: Option, } impl TestReporterOpts { @@ -1504,10 +1542,24 @@ impl App { let output = output_writer.reporter_output(); let profile = profile.apply_build_platforms(&build_platforms); + let structured_reporter = match reporter_opts.message_format { + MessageFormat::Human => structured::StructuredReporter::Disabled, + MessageFormat::LibtestJson | MessageFormat::LibtestJsonPlus => { + structured::StructuredReporter::Libtest(structured::LibtestReporter::new( + reporter_opts.message_format_version.as_deref(), + if matches!(reporter_opts.message_format, MessageFormat::LibtestJsonPlus) { + structured::EmitNextestObject::Yes + } else { + structured::EmitNextestObject::No + }, + )?) + } + }; + let mut reporter = reporter_opts .to_builder(no_capture) .set_verbose(self.base.output.verbose) - .build(&test_list, &profile, output); + .build(&test_list, &profile, output, structured_reporter); if self .base .output diff --git a/cargo-nextest/src/errors.rs b/cargo-nextest/src/errors.rs index 82670e90a7b..d1c6a317bd1 100644 --- a/cargo-nextest/src/errors.rs +++ b/cargo-nextest/src/errors.rs @@ -242,6 +242,11 @@ pub enum ExpectedError { #[source] err: std::io::Error, }, + #[error("message format version is not valid")] + InvalidMessageFormatVersion { + #[from] + err: FormatVersionError, + }, } impl ExpectedError { @@ -394,7 +399,8 @@ impl ExpectedError { | Self::TestBinaryArgsParseError { .. } | Self::DialoguerError { .. } | Self::SignalHandlerSetupError { .. } - | Self::ShowTestGroupsError { .. } => NextestExitCode::SETUP_ERROR, + | Self::ShowTestGroupsError { .. } + | Self::InvalidMessageFormatVersion { .. } => NextestExitCode::SETUP_ERROR, Self::ConfigParseError { err } => { // Experimental features not being enabled are their own error. match err.kind() { @@ -835,6 +841,10 @@ impl ExpectedError { log::error!("[double-spawn] failed to exec `{command:?}`"); Some(err as &dyn Error) } + Self::InvalidMessageFormatVersion { err } => { + log::error!("error parsing message format version`"); + Some(err as &dyn Error) + } }; while let Some(err) = next_error { diff --git a/nextest-runner/src/errors.rs b/nextest-runner/src/errors.rs index f4bc547c66a..cef0952c59e 100644 --- a/nextest-runner/src/errors.rs +++ b/nextest-runner/src/errors.rs @@ -1411,5 +1411,6 @@ mod self_update_errors { } } +pub use crate::reporter::structured::FormatVersionError; #[cfg(feature = "self-update")] pub use self_update_errors::*; diff --git a/nextest-runner/src/reporter.rs b/nextest-runner/src/reporter.rs index c42318aa604..17e7a7a22a6 100644 --- a/nextest-runner/src/reporter.rs +++ b/nextest-runner/src/reporter.rs @@ -6,6 +6,8 @@ //! The main structure in this module is [`TestReporter`]. mod aggregator; +pub mod structured; + use crate::{ config::{NextestProfile, ScriptId}, errors::WriteEventError, @@ -161,6 +163,7 @@ pub struct TestReporterBuilder { success_output: Option, status_level: Option, final_status_level: Option, + verbose: bool, hide_progress_bar: bool, } @@ -220,6 +223,7 @@ impl TestReporterBuilder { test_list: &TestList, profile: &NextestProfile<'a>, output: ReporterStderr<'a>, + structured_reporter: structured::StructuredReporter<'a>, ) -> TestReporter<'a> { let styles = Box::default(); let binary_id_width = test_list @@ -319,6 +323,7 @@ impl TestReporterBuilder { final_outputs: DebugIgnore(vec![]), }, stderr, + structured_reporter, metadata_reporter: aggregator, } } @@ -330,11 +335,15 @@ enum ReporterStderrImpl<'a> { Buffer(&'a mut Vec), } -/// Functionality to report test results to stderr and JUnit +/// Functionality to report test results to stderr, JUnit, and/or structured, +/// machine-readable results to stdout pub struct TestReporter<'a> { inner: TestReporterImpl<'a>, stderr: ReporterStderrImpl<'a>, + /// Used to aggregate events for JUnit reports written to disk metadata_reporter: EventAggregator<'a>, + /// Used to emit test events in machine-readable format(s) to stdout + structured_reporter: structured::StructuredReporter<'a>, } impl<'a> TestReporter<'a> { @@ -383,6 +392,8 @@ impl<'a> TestReporter<'a> { .map_err(WriteEventError::Io)?; } } + + self.structured_reporter.write_event(&event)?; self.metadata_reporter.write_event(event)?; Ok(()) } @@ -1780,7 +1791,9 @@ impl Styles { #[cfg(test)] mod tests { use super::*; - use crate::{config::NextestConfig, platform::BuildPlatforms}; + use crate::{ + config::NextestConfig, platform::BuildPlatforms, reporter::structured::StructuredReporter, + }; #[test] fn no_capture_settings() { @@ -1802,6 +1815,7 @@ mod tests { &test_list, &profile.apply_build_platforms(&build_platforms), output, + StructuredReporter::Disabled, ); assert!(reporter.inner.no_capture, "no_capture is true"); assert_eq!( diff --git a/nextest-runner/src/reporter/structured.rs b/nextest-runner/src/reporter/structured.rs new file mode 100644 index 00000000000..e0e97020efc --- /dev/null +++ b/nextest-runner/src/reporter/structured.rs @@ -0,0 +1,70 @@ +//! Functionality for emitting structured, machine readable output in different +//! formats + +mod libtest; + +use super::*; +pub use libtest::{EmitNextestObject, LibtestReporter}; + +/// Error returned when a user-supplied format version fails to be parsed to a +/// valid and supported version +#[derive(Clone, Debug, thiserror::Error)] +#[error("invalid format version: {input}")] +pub struct FormatVersionError { + /// The input that failed to parse. + pub input: String, + /// The underlying error + #[source] + pub err: FormatVersionErrorInner, +} + +/// The different errors that can occur when parsing and validating a format version +#[derive(Clone, Debug, thiserror::Error)] +pub enum FormatVersionErrorInner { + /// The input did not have a valid syntax + #[error("expected format version in form of `{expected}`")] + InvalidFormat { + /// The expected pseudo format + expected: &'static str, + }, + /// A decimal integer was expected but could not be parsed + #[error("version component `{which}` could not be parsed as an integer")] + InvalidInteger { + /// Which component was invalid + which: &'static str, + /// The parse failure + #[source] + err: std::num::ParseIntError, + }, + /// The version component was not within th expected range + #[error("version component `{which}` value {value} is out of range {range:?}")] + InvalidValue { + /// The component which was out of range + which: &'static str, + /// The value that was parsed + value: u8, + /// The range of valid values for the component + range: std::ops::Range, + }, +} + +/// A reporter for structured, machine readable, output based on the user's +/// preference +pub enum StructuredReporter<'a> { + /// Libtest compatible output + Libtest(LibtestReporter<'a>), + // TODO: make a custom format that is easier to consume than libtest's + //Json(json::JsonReporter<'a>), + /// Variant that doesn't actually emit anything + Disabled, +} + +impl<'a> StructuredReporter<'a> { + #[inline] + pub(super) fn write_event(&mut self, event: &TestEvent<'a>) -> Result<(), WriteEventError> { + match self { + Self::Disabled => Ok(()), + Self::Libtest(ltr) => ltr.write_event(event), + } + } +} diff --git a/nextest-runner/src/reporter/structured/libtest.rs b/nextest-runner/src/reporter/structured/libtest.rs new file mode 100644 index 00000000000..e345d29940a --- /dev/null +++ b/nextest-runner/src/reporter/structured/libtest.rs @@ -0,0 +1,535 @@ +// Copyright (c) The nextest Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! libtest compatible output support +//! +//! Before 1.70.0 it was possible to send `--format json` to test executables and +//! they would print out a JSON line to stdout for various events. This format +//! was however not intended to be stabilized, so 1.70.0 made it nightly only as +//! intended. However, machine readable output is immensely useful to other +//! tooling that can much more easily consume it than parsing the output meant +//! for humans. +//! +//! Since there already existed tooling using the libtest output format, this +//! event aggregator replicates that format so that projects can seamlessly +//! integrate cargo-nextest into their project, as well as get the benefit of +//! running their tests on stable instead of being forced to use nightly. +//! +//! This implementation will attempt to follow the libtest format as it changes, +//! but the rate of changes is quite low (see ) +//! so this should not be a big issue to users, however, if the format is changed, +//! the changes will be replicated in this file with a new minor version allowing +//! users to move to the new format or stick to the format version(s) they were +//! using before + +use super::{ + FormatVersionError, FormatVersionErrorInner, TestEvent, TestEventKind, WriteEventError, +}; +use crate::runner::ExecutionResult; +use nextest_metadata::MismatchReason; +use std::collections::BTreeMap; + +/// To support pinning the version of the output, we just use this simple enum +/// to document changes as libtest output changes +#[derive(Copy, Clone)] +#[repr(u8)] +enum FormatMinorVersion { + /// The libtest output as of `rustc 1.75.0-nightly (aa1a71e9e 2023-10-26)` with `--format json --report-time` + /// + /// * `{ "type": "suite", "event": "started", "test_count": }` - Start of a test binary run, always printed + /// * `{ "type": "test", "event": "started", "name": "" }` - Start of a single test, always printed + /// * `{ "type": "test", "name": "", "event": "ignored" }` - Printed if a test is ignored + /// * Will have an additional `"message" = ""` field if the there is a message in the ignore attribute eg. `#[ignore = "not yet implemented"]` + /// * `{ "type": "test", "name": "", "event": "ok", "exec_time": }` - Printed if a test runs successfully + /// * `{ "type": "test", "name": "", "event": "failed", "exec_time": , "stdout": "" }` - Printed if a test fails, note the stdout field actually contains both stdout and stderr despite the name + /// * If `--ensure-time` is passed, libtest will add `"reason": "time limit exceeded"` if the test passes, but exceeds the time limit. + /// * If `#[should_panic = ""]` is used and message doesn't match, an additional `"message": "panic did not contain expected string\n"` field is added + /// * `{ "type": "suite", "event": "", "passed": , "failed": , "ignored": , "measured": , "filtered_out": , "exec_time": }` + /// * `event` will be `"ok"` if no failures occurred, or `"failed"` if `"failed" > 0` + /// * `ignored` will be > 0 if there are `#[ignore]` tests and `--ignored` was not passed + /// * `filtered_out` with be > 0 if there were tests not marked `#[ignore]` and `--ignored` was passed OR a test filter was passed and 1 or more tests were not executed + /// * `measured` is only > 0 if running benchmarks + First = 1, + #[doc(hidden)] + _Max, +} + +/// If libtest output is ever stabilized, this would most likely become the single +/// version and we could get rid of the minor version, but who knows if that +/// will ever happen +#[derive(Copy, Clone)] +#[repr(u8)] +enum FormatMajorVersion { + /// The libtest output is unstable + Unstable = 0, + #[doc(hidden)] + _Max, +} + +/// The accumulated stats for a single test binary +struct LibtestSuite { + /// The number of tests that failed + failed: usize, + /// The number of tests that succeeded + succeeded: usize, + /// The number of tests that were ignored + ignored: usize, + /// The number of tests that were not executed due to filters + filtered: usize, + /// The number of tests in this suite that are still running + running: usize, + /// The accumulated duration of every test that has been executed + total: std::time::Duration, + /// Libtest outputs outputs a `started` event for every test that isn't + /// filtered, including ignored tests, then outputs `ignored` events after + /// all the started events, so we just mimic that with a temporary buffer + ignore_block: Option, + /// The single block of output accumulated for all tests executed in the binary, + /// this needs to be emitted as a single block to emulate how cargo test works, + /// executing each test binary serially and outputting a json line for each + /// event, as otherwise consumers would not be able to associate a single test + /// with its parent suite + output_block: bytes::BytesMut, +} + +/// Determines whether the `nextest` subobject is added with additional metadata +/// to events +#[derive(Copy, Clone, Debug)] +pub enum EmitNextestObject { + /// The `nextest` subobject is added + Yes, + /// The `nextest` subobject is not added + No, +} + +/// A reporter that reports test runs in the same line-by-line JSON format as +/// libtest itself +pub struct LibtestReporter<'cfg> { + _minor: FormatMinorVersion, + _major: FormatMajorVersion, + test_suites: BTreeMap<&'cfg str, LibtestSuite>, + /// If true, we emit a `nextest` subobject with additional metadata in it + /// that consumers can use for easier integration if they wish + emit_nextest_obj: bool, +} + +impl<'cfg> LibtestReporter<'cfg> { + /// Creates a new libtest reporter + /// + /// The version string is used to allow the reporter to evolve along with + /// libtest, but still be able to output a stable format for consumers. If + /// it is not specified the latest version of the format will be produced. + /// + /// If [`EmitNextestObject::Yes`] is passed, an additional `nextest` subobject + /// will be added to some events that includes additional metadata not produced + /// by libtest, but most consumers should still be able to consume them as + /// the base format itself is not changed + pub fn new( + version: Option<&str>, + emit_nextest_obj: EmitNextestObject, + ) -> Result { + let emit_nextest_obj = matches!(emit_nextest_obj, EmitNextestObject::Yes); + + let Some(version) = version else { + return Ok(Self { + _minor: FormatMinorVersion::First, + _major: FormatMajorVersion::Unstable, + test_suites: BTreeMap::new(), + emit_nextest_obj, + }); + }; + let Some((major, minor)) = version.split_once('.') else { + return Err(FormatVersionError { + input: version.into(), + err: FormatVersionErrorInner::InvalidFormat { + expected: ".", + }, + }); + }; + + let major: u8 = major.parse().map_err(|err| FormatVersionError { + input: version.into(), + err: FormatVersionErrorInner::InvalidInteger { + which: "major", + err, + }, + })?; + + let minor: u8 = minor.parse().map_err(|err| FormatVersionError { + input: version.into(), + err: FormatVersionErrorInner::InvalidInteger { + which: "minor", + err, + }, + })?; + + let major = match major { + 0 => FormatMajorVersion::Unstable, + o => { + return Err(FormatVersionError { + input: version.into(), + err: FormatVersionErrorInner::InvalidValue { + which: "major", + value: o, + range: (FormatMajorVersion::Unstable as u8) + ..(FormatMajorVersion::_Max as u8), + }, + }); + } + }; + + let minor = match minor { + 0 => FormatMinorVersion::First, + o => { + return Err(FormatVersionError { + input: version.into(), + err: FormatVersionErrorInner::InvalidValue { + which: "minor", + value: o, + range: (FormatMinorVersion::First as u8)..(FormatMinorVersion::_Max as u8), + }, + }); + } + }; + + Ok(Self { + _major: major, + _minor: minor, + test_suites: BTreeMap::new(), + emit_nextest_obj, + }) + } + + pub(crate) fn write_event(&mut self, event: &TestEvent<'cfg>) -> Result<(), WriteEventError> { + use std::fmt::Write as _; + + const KIND_TEST: &str = "test"; + const KIND_SUITE: &str = "suite"; + + const EVENT_STARTED: &str = "started"; + const EVENT_IGNORED: &str = "ignored"; + const EVENT_OK: &str = "ok"; + const EVENT_FAILED: &str = "failed"; + + let mut retries = None; + + // Write the pieces of data that are the same across all events + let (kind, eve, test_instance) = match &event.kind { + TestEventKind::TestStarted { test_instance, .. } => { + (KIND_TEST, EVENT_STARTED, test_instance) + } + TestEventKind::TestSkipped { + test_instance, + reason: MismatchReason::Ignored, + } => { + // Note: unfortunately, libtest does not expose the message test in `#[ignore = ""]` + // so we can't replicate the behavior of libtest exactly by emitting + // that message as additional metadata + (KIND_TEST, EVENT_STARTED, test_instance) + } + TestEventKind::TestFinished { + test_instance, + run_statuses, + .. + } => { + if run_statuses.len() > 1 { + retries = Some(run_statuses.len()); + } + + ( + KIND_TEST, + match run_statuses.last_status().result { + ExecutionResult::Pass | ExecutionResult::Leak => EVENT_OK, + ExecutionResult::Fail { .. } + | ExecutionResult::ExecFail + | ExecutionResult::Timeout => EVENT_FAILED, + }, + test_instance, + ) + } + _ => return Ok(()), + }; + + #[inline] + fn fmt_err(err: std::fmt::Error) -> WriteEventError { + WriteEventError::Io(std::io::Error::new(std::io::ErrorKind::OutOfMemory, err)) + } + + let suite_info = test_instance.suite_info; + let crate_name = suite_info.package.name(); + let binary_name = &suite_info.binary_name; + + // Emit the suite start if this is the first test of the suite + let test_suite = match self.test_suites.entry(suite_info.binary_id.as_str()) { + std::collections::btree_map::Entry::Vacant(e) => { + let mut out = bytes::BytesMut::with_capacity(1024); + write!( + &mut out, + r#"{{"type":"{KIND_SUITE}","event":"{EVENT_STARTED}","test_count":{}"#, + suite_info.status.test_count() + ) + .map_err(fmt_err)?; + + if self.emit_nextest_obj { + write!( + &mut out, + r#","nextest":{{"crate":"{crate_name}","test_binary":"{binary_name}","kind":"{}"}}"#, + suite_info.kind, + ) + .map_err(fmt_err)?; + } + + out.extend_from_slice(b"}\n"); + + e.insert(LibtestSuite { + running: suite_info.status.test_count(), + failed: 0, + succeeded: 0, + ignored: 0, + filtered: 0, + total: std::time::Duration::new(0, 0), + ignore_block: None, + output_block: out, + }) + } + std::collections::btree_map::Entry::Occupied(e) => e.into_mut(), + }; + + let out = &mut test_suite.output_block; + + // After all the tests have been started or ignored, put the block of + // tests that were ignored just as libtest does + if matches!(event.kind, TestEventKind::TestFinished { .. }) { + if let Some(ib) = test_suite.ignore_block.take() { + out.extend_from_slice(&ib); + } + } + + // This is one place where we deviate from the behavior of libtest, by + // always prefixing the test name with both the crate and the binary name, + // as this information is quite important to distinguish tests from each + // other when testing inside a large workspace with hundreds or thousands + // of tests + // + // Additionally, a `#` is used as a suffix if the test was retried, + // as libtest does not support that functionality + write!( + out, + r#"{{"type":"{kind}","event":"{eve}","name":"{}::{}${}"#, + suite_info.package.name(), + suite_info.binary_name, + test_instance.name, + ) + .map_err(fmt_err)?; + + if let Some(retry_count) = retries { + write!(out, "#{retry_count}\"").map_err(fmt_err)?; + } else { + out.extend_from_slice(b"\""); + } + + match &event.kind { + TestEventKind::TestFinished { run_statuses, .. } => { + let last_status = run_statuses.last_status(); + + test_suite.total += last_status.time_taken; + test_suite.running -= 1; + + // libtest actually requires an additional `--report-time` flag to be + // passed for the exec_time information to be written. This doesn't + // really make sense when outputting structured output so we emit it + // unconditionally + write!( + out, + r#","exec_time":{}"#, + last_status.time_taken.as_secs_f64() + ) + .map_err(fmt_err)?; + + match last_status.result { + ExecutionResult::Fail { .. } | ExecutionResult::ExecFail => { + test_suite.failed += 1; + let stdout = String::from_utf8_lossy(&last_status.stdout); + let stderr = String::from_utf8_lossy(&last_status.stderr); + + // TODO: Get the combined stdout and stderr streams, in the order they + // are supposed to be, to accurately replicate libtest's output + + // TODO: Strip libtest stdout output + // libtest outputs various things when _not_ using the + // unstable json format that we need to strip to emulate + // that json output, eg. + // + // ``` + // running tests + // + // test ... FAILED + // \n\nfailures:\n\nfailures:\n \n\ntest result: FAILED + // ``` + + write!( + out, + r#","stdout":"{}{}""#, + EscapedString(&stdout), + EscapedString(&stderr) + ) + .map_err(fmt_err)?; + } + ExecutionResult::Timeout => { + test_suite.failed += 1; + out.extend_from_slice(br#","reason":"time limit exceeded""#); + } + _ => { + test_suite.succeeded += 1; + } + } + } + TestEventKind::TestSkipped { reason, .. } => { + if matches!(reason, MismatchReason::Ignored) { + test_suite.ignored += 1; + } else { + test_suite.filtered += 1; + } + + test_suite.running -= 1; + + if test_suite.ignore_block.is_none() { + test_suite.ignore_block = Some(bytes::BytesMut::with_capacity(1024)); + } + + let ib = test_suite + .ignore_block + .get_or_insert_with(|| bytes::BytesMut::with_capacity(1024)); + + writeln!( + ib, + r#"{{"type":"{kind}","event":"{EVENT_IGNORED}","name":"{}::{}${}"}}"#, + suite_info.package.name(), + suite_info.binary_name, + test_instance.name, + ) + .map_err(fmt_err)?; + } + _ => {} + }; + + out.extend_from_slice(b"}\n"); + + // If this is the last test of the suite, emit the test suite summary + // before emitting the entire block + if test_suite.running > 0 { + return Ok(()); + } + + let event = if test_suite.failed > 0 { + EVENT_FAILED + } else { + EVENT_OK + }; + + write!( + out, + r#"{{"type":"{KIND_SUITE}","event":"{event}","passed":{},"failed":{},"ignored":{},"measured":0,"filtered_out":{},"exec_time":{}"#, + test_suite.succeeded, + test_suite.failed, + test_suite.ignored, + test_suite.filtered, + test_suite.total.as_secs_f64(), + ) + .map_err(fmt_err)?; + + if self.emit_nextest_obj { + write!( + out, + r#","nextest":{{"crate":"{crate_name}","test_binary":"{binary_name}","kind":"{}"}}"#, + suite_info.kind, + ) + .map_err(fmt_err)?; + } + + out.extend_from_slice(b"}\n"); + + { + use std::io::Write as _; + + let mut stdout = std::io::stdout().lock(); + stdout.write_all(out).map_err(WriteEventError::Io)?; + stdout.flush().map_err(WriteEventError::Io)?; + } + + // Once we've emitted the output block we can remove the suite accumulator + // to free up memory since we won't use it again + self.test_suites.remove(suite_info.binary_id.as_str()); + + Ok(()) + } +} + +/// Copy of the same string escaper used in libtest +/// +/// +struct EscapedString<'s>(&'s str); + +impl<'s> std::fmt::Display for EscapedString<'s> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> ::std::fmt::Result { + let mut start = 0; + let s = self.0; + + for (i, byte) in s.bytes().enumerate() { + let escaped = match byte { + b'"' => "\\\"", + b'\\' => "\\\\", + b'\x00' => "\\u0000", + b'\x01' => "\\u0001", + b'\x02' => "\\u0002", + b'\x03' => "\\u0003", + b'\x04' => "\\u0004", + b'\x05' => "\\u0005", + b'\x06' => "\\u0006", + b'\x07' => "\\u0007", + b'\x08' => "\\b", + b'\t' => "\\t", + b'\n' => "\\n", + b'\x0b' => "\\u000b", + b'\x0c' => "\\f", + b'\r' => "\\r", + b'\x0e' => "\\u000e", + b'\x0f' => "\\u000f", + b'\x10' => "\\u0010", + b'\x11' => "\\u0011", + b'\x12' => "\\u0012", + b'\x13' => "\\u0013", + b'\x14' => "\\u0014", + b'\x15' => "\\u0015", + b'\x16' => "\\u0016", + b'\x17' => "\\u0017", + b'\x18' => "\\u0018", + b'\x19' => "\\u0019", + b'\x1a' => "\\u001a", + b'\x1b' => "\\u001b", + b'\x1c' => "\\u001c", + b'\x1d' => "\\u001d", + b'\x1e' => "\\u001e", + b'\x1f' => "\\u001f", + b'\x7f' => "\\u007f", + _ => { + continue; + } + }; + + if start < i { + f.write_str(&s[start..i])?; + } + + f.write_str(escaped)?; + + start = i + 1; + } + + if start != self.0.len() { + f.write_str(&s[start..])?; + } + + Ok(()) + } +}