use std::collections::HashSet;
use std::io::{self, BufRead, Write};
use std::process;
use termcolor::StandardStream;
pub(crate) const MESSAGE_FORMAT: &str = "--message-format=json-diagnostic-rendered-ansi";
#[derive(serde::Deserialize)]
pub(crate) struct CargoMessage {
pub reason: String,
#[serde(default)]
pub message: Option<Diagnostic>,
}
#[derive(serde::Deserialize)]
pub(crate) struct Diagnostic {
#[serde(default)]
pub rendered: Option<String>,
pub level: String,
}
struct DiagnosticCounts {
warnings: usize,
errors: usize,
suppressed: usize,
}
fn process_lines(
reader: impl BufRead,
writer: &mut impl Write,
dedupe: bool,
seen: &mut HashSet<String>,
) -> DiagnosticCounts {
let mut warnings: usize = 0;
let mut errors: usize = 0;
let mut suppressed: usize = 0;
for line in reader.lines() {
let Ok(line) = line else { break };
match serde_json::from_str::<CargoMessage>(&line) {
Ok(msg) if msg.reason == "compiler-message" => {
if let Some(ref diag) = msg.message {
match diag.level.as_str() {
"warning" => warnings += 1,
"error" => errors += 1,
_ => {}
}
if let Some(ref rendered) = diag.rendered {
if dedupe && !seen.insert(rendered.clone()) {
suppressed += 1;
} else {
let _ = writer.write_all(rendered.as_bytes());
}
}
}
}
Ok(_) => {
}
Err(_) => {
let _ = writeln!(writer, "{line}");
}
}
}
DiagnosticCounts {
warnings,
errors,
suppressed,
}
}
pub(crate) fn process_output(
child: &mut process::Child,
summary_only: bool,
dedupe: bool,
seen: &mut HashSet<String>,
stdout: &mut StandardStream,
) -> io::Result<crate::runner::ProcessResult> {
let mut output_buf = Vec::<u8>::new();
let proc_stderr = child.stderr.take();
let proc_stdout = child.stdout.take();
let mut counts = DiagnosticCounts {
warnings: 0,
errors: 0,
suppressed: 0,
};
std::thread::scope(|scope| {
let stderr_handle = scope.spawn(move || -> io::Result<Vec<u8>> {
let mut stderr_buf = Vec::new();
if let Some(stderr) = proc_stderr {
io::copy(
&mut io::BufReader::new(stderr),
&mut io::Cursor::new(&mut stderr_buf),
)?;
}
Ok(stderr_buf)
});
if let Some(proc_stdout) = proc_stdout {
let reader = io::BufReader::new(proc_stdout);
if summary_only {
counts = process_lines(reader, &mut output_buf, dedupe, seen);
} else {
counts = process_lines(reader, stdout, dedupe, seen);
let _ = stdout.flush();
}
}
if let Ok(Ok(stderr_buf)) = stderr_handle.join()
&& counts.errors == 0
&& !stderr_buf.is_empty()
{
output_buf.extend(&stderr_buf);
}
io::Result::Ok(())
})?;
Ok(crate::runner::ProcessResult {
num_warnings: counts.warnings,
num_errors: counts.errors,
num_suppressed: counts.suppressed,
output: output_buf,
})
}
#[cfg(test)]
mod test {
use super::{DiagnosticCounts, process_lines};
use indoc::indoc;
use similar_asserts::assert_eq as sim_assert_eq;
use std::collections::HashSet;
#[allow(
clippy::expect_used,
reason = "test helper — serialization of a static shape cannot fail"
)]
fn diag_json(level: &str, rendered: &str) -> String {
serde_json::to_string(&serde_json::json!({
"reason": "compiler-message",
"message": {
"rendered": rendered,
"level": level,
}
}))
.expect("serializing diagnostic JSON")
}
#[allow(
clippy::expect_used,
reason = "test helper — serialization of a static shape cannot fail"
)]
fn artifact_json() -> String {
serde_json::to_string(&serde_json::json!({
"reason": "compiler-artifact",
"package_id": "foo",
"target": { "kind": ["lib"], "name": "foo" }
}))
.expect("serializing artifact JSON")
}
fn run_lines(
input: &str,
dedupe: bool,
seen: &mut HashSet<String>,
) -> (DiagnosticCounts, String) {
let reader = std::io::BufReader::new(input.as_bytes());
let mut writer = Vec::new();
let counts = process_lines(reader, &mut writer, dedupe, seen);
let written = String::from_utf8(writer).unwrap_or_default();
(counts, written)
}
#[test]
fn counts_warnings_and_errors() {
let input = include_str!("../test-data/diagnostics_only_json_output.txt");
let mut seen = HashSet::new();
let (counts, _) = run_lines(input, false, &mut seen);
sim_assert_eq!(counts.warnings, 2);
sim_assert_eq!(counts.errors, 1);
sim_assert_eq!(counts.suppressed, 0);
}
#[test]
fn rendered_diagnostics_are_written() {
let input = include_str!("../test-data/diagnostics_only_json_output.txt");
let mut seen = HashSet::new();
let (_, written) = run_lines(input, false, &mut seen);
assert!(written.contains("unused variable"));
assert!(written.contains("unused import"));
assert!(written.contains("cannot find value"));
}
#[test]
fn non_diagnostic_json_is_skipped() {
let input = include_str!("../test-data/diagnostics_only_json_output.txt");
let mut seen = HashSet::new();
let (_, written) = run_lines(input, false, &mut seen);
assert!(!written.contains("compiler-artifact"));
assert!(!written.contains("build-finished"));
}
#[test]
fn non_json_lines_are_passed_through() {
let input = indoc! {"
running 5 tests
test foo::bar ... ok
test result: ok
"};
let mut seen = HashSet::new();
let (counts, written) = run_lines(input, false, &mut seen);
assert!(written.contains("running 5 tests"));
assert!(written.contains("test foo::bar ... ok"));
assert!(written.contains("test result: ok"));
sim_assert_eq!(counts.warnings, 0);
sim_assert_eq!(counts.errors, 0);
}
#[test]
fn mixed_json_and_non_json_lines() {
let input = format!(
"{}\nrunning 1 test\n{}\ntest bar ... ok\n",
diag_json("warning", "warning: foo\n"),
artifact_json(),
);
let mut seen = HashSet::new();
let (counts, written) = run_lines(&input, false, &mut seen);
sim_assert_eq!(counts.warnings, 1);
assert!(written.contains("warning: foo"));
assert!(written.contains("running 1 test"));
assert!(written.contains("test bar ... ok"));
assert!(!written.contains("compiler-artifact"));
}
#[test]
fn dedupe_suppresses_duplicate_diagnostics() {
let line = diag_json("warning", "warning: duplicate\n");
let input = format!("{line}\n{line}\n{line}\n");
let mut seen = HashSet::new();
let (counts, written) = run_lines(&input, true, &mut seen);
sim_assert_eq!(counts.warnings, 3);
sim_assert_eq!(counts.suppressed, 2);
sim_assert_eq!(written.matches("warning: duplicate").count(), 1);
}
#[test]
fn dedupe_preserves_distinct_diagnostics() {
let input = format!(
"{}\n{}\n",
diag_json("warning", "warning: first\n"),
diag_json("warning", "warning: second\n"),
);
let mut seen = HashSet::new();
let (counts, written) = run_lines(&input, true, &mut seen);
sim_assert_eq!(counts.warnings, 2);
sim_assert_eq!(counts.suppressed, 0);
assert!(written.contains("warning: first"));
assert!(written.contains("warning: second"));
}
#[test]
fn dedupe_works_across_multiple_calls() {
let input = diag_json("warning", "warning: shared\n");
let mut seen = HashSet::new();
let (c1, o1) = run_lines(&input, true, &mut seen);
sim_assert_eq!(c1.warnings, 1);
sim_assert_eq!(c1.suppressed, 0);
assert!(o1.contains("warning: shared"));
let (c2, o2) = run_lines(&input, true, &mut seen);
sim_assert_eq!(c2.warnings, 1);
sim_assert_eq!(c2.suppressed, 1);
assert!(!o2.contains("warning: shared"));
}
#[test]
fn empty_input_produces_zero_counts() {
let mut seen = HashSet::new();
let (counts, written) = run_lines("", false, &mut seen);
sim_assert_eq!(counts.warnings, 0);
sim_assert_eq!(counts.errors, 0);
sim_assert_eq!(counts.suppressed, 0);
assert!(written.is_empty());
}
#[test]
fn cargo_level_error_lines_are_not_silently_swallowed() {
let input = indoc! {r"
error: failed to parse manifest at `/tmp/foo/Cargo.toml`
Caused by:
duplicate key `dependencies` in table `package`
"};
let mut seen = HashSet::new();
let (counts, written) = run_lines(input, false, &mut seen);
sim_assert_eq!(counts.warnings, 0);
sim_assert_eq!(counts.errors, 0);
assert!(written.contains("failed to parse manifest"));
assert!(written.contains("duplicate key"));
}
#[test]
fn rendered_text_with_special_characters_survives_roundtrip() {
let rendered = indoc! {"
error[E0308]: mismatched types
--> src/lib.rs:1:1
|
1 | fn foo() -> &'static str { 42 }
| expected `&str`, found `i32`
"};
let input = diag_json("error", rendered);
let mut seen = HashSet::new();
let (counts, written) = run_lines(&input, false, &mut seen);
sim_assert_eq!(counts.errors, 1);
assert!(written.contains("mismatched types"));
assert!(written.contains("expected `&str`, found `i32`"));
}
}