use std::collections::BTreeSet;
use serde::Deserialize;
use super::snapshot::{ClippyWarning, TestId};
#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "reason", rename_all = "kebab-case")]
pub enum CargoMessage {
CompilerMessage {
#[serde(default)]
package_id: String,
message: CargoDiagnostic,
},
CompilerArtifact {
#[serde(default)]
package_id: String,
target: CargoTarget,
#[serde(default)]
profile: CargoProfile,
#[serde(default)]
executable: Option<String>,
},
BuildFinished {
success: bool,
},
#[serde(other)]
Other,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CargoTarget {
#[serde(default)]
pub name: String,
#[serde(default)]
pub kind: Vec<String>,
}
impl CargoTarget {
#[must_use]
pub fn primary_kind(&self) -> &str {
self.kind.first().map_or("unknown", String::as_str)
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CargoProfile {
#[serde(default)]
pub test: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CargoDiagnostic {
#[serde(default)]
pub level: String,
#[serde(default)]
pub message: String,
#[serde(default)]
pub code: Option<CargoCode>,
#[serde(default)]
pub spans: Vec<CargoSpan>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CargoCode {
#[serde(default)]
pub code: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CargoSpan {
#[serde(default)]
pub file_name: String,
#[serde(default)]
pub is_primary: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CargoStreamError {
UnparseableLine {
line_no: usize,
snippet: String,
},
}
impl std::fmt::Display for CargoStreamError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CargoStreamError::UnparseableLine { line_no, snippet } => {
write!(f, "unparseable cargo JSON at line {line_no}: {snippet:?}")
}
}
}
}
pub fn parse_cargo_stream(stdout: &str) -> Result<Vec<CargoMessage>, CargoStreamError> {
let mut out = Vec::new();
for (idx, raw) in stdout.lines().enumerate() {
let line = raw.trim();
if line.is_empty() {
continue;
}
let Ok(msg) = serde_json::from_str::<CargoMessage>(line) else {
let snippet: String = line.chars().take(80).collect();
return Err(CargoStreamError::UnparseableLine {
line_no: idx + 1,
snippet,
});
};
out.push(msg);
}
Ok(out)
}
#[must_use]
pub fn has_compile_error(messages: &[CargoMessage]) -> bool {
messages.iter().any(
|m| matches!(m, CargoMessage::CompilerMessage { message, .. } if message.level == "error"),
)
}
#[must_use]
pub fn build_finished(messages: &[CargoMessage]) -> Option<bool> {
messages.iter().rev().find_map(|m| match m {
CargoMessage::BuildFinished { success } => Some(*success),
_ => None,
})
}
#[must_use]
pub fn clippy_warnings(messages: &[CargoMessage]) -> BTreeSet<ClippyWarning> {
let mut set = BTreeSet::new();
for m in messages {
let CargoMessage::CompilerMessage {
package_id,
message,
} = m
else {
continue;
};
if message.level != "warning" {
continue;
}
let Some(code) = message.code.as_ref().map(|c| c.code.clone()) else {
continue;
};
if code.is_empty() {
continue;
}
let file = message
.spans
.iter()
.find(|s| s.is_primary)
.or_else(|| message.spans.first())
.map(|s| s.file_name.clone())
.unwrap_or_default();
set.insert(ClippyWarning {
lint: code,
package: short_package_name(package_id),
file,
message: message.message.clone(),
});
}
set
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TestBinary {
pub package: String,
pub target_kind: String,
pub target: String,
pub executable: String,
}
#[must_use]
pub fn test_binaries(messages: &[CargoMessage]) -> Vec<TestBinary> {
messages
.iter()
.filter_map(|m| match m {
CargoMessage::CompilerArtifact {
package_id,
target,
profile,
executable: Some(exe),
} if profile.test => Some(TestBinary {
package: short_package_name(package_id),
target_kind: target.primary_kind().to_string(),
target: target.name.clone(),
executable: exe.clone(),
}),
_ => None,
})
.collect()
}
#[must_use]
pub fn short_package_name(package_id: &str) -> String {
if let Some((_, after)) = package_id.rsplit_once('#') {
let name = after.split('@').next().unwrap_or(after);
if !name.is_empty() {
return name.to_string();
}
}
package_id
.split_whitespace()
.next()
.unwrap_or(package_id)
.to_string()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct LibtestSummary {
pub passed: usize,
pub failed: usize,
pub ignored: usize,
pub filtered_out: usize,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LibtestReport {
pub passed: Vec<String>,
pub failed: Vec<String>,
pub ignored: Vec<String>,
pub summaries: Vec<LibtestSummary>,
}
#[must_use]
pub fn parse_libtest_report(output: &str) -> LibtestReport {
let mut report = LibtestReport::default();
for raw in output.lines() {
let line = raw.trim();
if let Some(summary) = parse_result_line(line) {
report.summaries.push(summary);
continue;
}
let Some(rest) = line.strip_prefix("test ") else {
continue;
};
let Some((name, outcome)) = rest.split_once(" ... ") else {
continue;
};
let name = name.trim();
if name.is_empty() {
continue;
}
let outcome = outcome.trim();
if outcome == "ok" || outcome.starts_with("ok ") {
report.passed.push(name.to_string());
} else if outcome.starts_with("FAILED") {
report.failed.push(name.to_string());
} else if outcome.starts_with("ignored") {
report.ignored.push(name.to_string());
}
}
report
}
fn parse_result_line(line: &str) -> Option<LibtestSummary> {
let rest = line.strip_prefix("test result:")?;
let mut summary = LibtestSummary::default();
for seg in rest.split(';') {
let seg = seg
.trim()
.trim_start_matches("ok.")
.trim_start_matches("FAILED.");
let mut it = seg.split_whitespace();
let (Some(num), Some(label)) = (it.next(), it.next()) else {
continue;
};
let Ok(n) = num.parse::<usize>() else {
continue;
};
match label {
"passed" => summary.passed = n,
"failed" => summary.failed = n,
"ignored" => summary.ignored = n,
"filtered" => summary.filtered_out = n,
_ => {}
}
}
Some(summary)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LibtestDiscrepancy {
NoSummary,
MultipleSummaries {
count: usize,
},
CountMismatch {
announced: LibtestSummary,
parsed: (usize, usize, usize),
},
Filtered {
count: usize,
},
}
impl std::fmt::Display for LibtestDiscrepancy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LibtestDiscrepancy::NoSummary => f.write_str("no `test result:` summary line"),
LibtestDiscrepancy::MultipleSummaries { count } => {
write!(f, "{count} `test result:` summaries from one binary")
}
LibtestDiscrepancy::CountMismatch { announced, parsed } => write!(
f,
"count mismatch: announced {}p/{}f/{}i, parsed {}p/{}f/{}i",
announced.passed, announced.failed, announced.ignored, parsed.0, parsed.1, parsed.2
),
LibtestDiscrepancy::Filtered { count } => {
write!(f, "{count} test(s) filtered out; capture is only a subset")
}
}
}
}
pub fn reconcile_single_binary(
report: &LibtestReport,
) -> Result<LibtestSummary, LibtestDiscrepancy> {
match report.summaries.as_slice() {
[] => Err(LibtestDiscrepancy::NoSummary),
[summary] => {
if summary.filtered_out > 0 {
return Err(LibtestDiscrepancy::Filtered {
count: summary.filtered_out,
});
}
let parsed = (
report.passed.len(),
report.failed.len(),
report.ignored.len(),
);
let announced = (summary.passed, summary.failed, summary.ignored);
if parsed == announced {
Ok(*summary)
} else {
Err(LibtestDiscrepancy::CountMismatch {
announced: *summary,
parsed,
})
}
}
many => Err(LibtestDiscrepancy::MultipleSummaries { count: many.len() }),
}
}
#[must_use]
pub fn qualify(
package: &str,
target_kind: &str,
target: &str,
names: &[String],
) -> BTreeSet<TestId> {
names
.iter()
.map(|n| TestId::new(package, target_kind, target, n))
.collect()
}
#[must_use]
pub fn count_assert_macros(src: &str) -> usize {
let code = strip_comments_and_strings(src);
let bytes = code.as_bytes();
let mut count = 0;
let mut i = 0;
while i < bytes.len() {
if is_ident_start(bytes[i]) && (i == 0 || !is_ident_char(bytes[i - 1])) {
let start = i;
while i < bytes.len() && is_ident_char(bytes[i]) {
i += 1;
}
let ident = &code[start..i];
if i < bytes.len() && bytes[i] == b'!' && is_assert_macro(ident) {
count += 1;
}
} else {
i += 1;
}
}
count
}
#[must_use]
pub fn strip_comments_and_strings(src: &str) -> String {
let b = src.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(b.len());
let mut i = 0;
let n = b.len();
let prev_is_ident = |out: &[u8]| out.last().is_some_and(|&c| is_ident_char(c));
while i < n {
if b[i] == b'/' && i + 1 < n && b[i + 1] == b'/' {
while i < n && b[i] != b'\n' {
i += 1;
}
continue;
}
if b[i] == b'/' && i + 1 < n && b[i + 1] == b'*' {
let mut depth = 1u32;
i += 2;
while i < n && depth > 0 {
if b[i] == b'/' && i + 1 < n && b[i + 1] == b'*' {
depth += 1;
i += 2;
} else if b[i] == b'*' && i + 1 < n && b[i + 1] == b'/' {
depth -= 1;
i += 2;
} else {
i += 1;
}
}
out.push(b' ');
continue;
}
if (b[i] == b'r' || (b[i] == b'b' && i + 1 < n && b[i + 1] == b'r')) && !prev_is_ident(&out)
{
if let Some(next) = scan_raw_string(b, i) {
out.push(b' ');
i = next;
continue;
}
}
if b[i] == b'"' || (b[i] == b'b' && i + 1 < n && b[i + 1] == b'"' && !prev_is_ident(&out)) {
let mut j = if b[i] == b'"' { i + 1 } else { i + 2 };
while j < n {
if b[j] == b'\\' {
j += 2;
continue;
}
if b[j] == b'"' {
j += 1;
break;
}
j += 1;
}
out.push(b' ');
i = j;
continue;
}
if b[i] == b'\'' {
if let Some(next) = scan_char_literal(b, i) {
out.push(b' ');
i = next;
continue;
}
out.push(b'\'');
i += 1;
continue;
}
out.push(b[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_default()
}
fn scan_raw_string(b: &[u8], i: usize) -> Option<usize> {
let n = b.len();
let mut j = i;
if b[j] == b'b' {
j += 1;
}
if j >= n || b[j] != b'r' {
return None;
}
j += 1;
let mut hashes = 0;
while j < n && b[j] == b'#' {
hashes += 1;
j += 1;
}
if j >= n || b[j] != b'"' {
return None;
}
j += 1;
while j < n {
if b[j] == b'"' {
let close = j + 1;
if close + hashes <= n && b[close..close + hashes].iter().all(|&c| c == b'#') {
return Some(close + hashes);
}
}
j += 1;
}
Some(n) }
fn scan_char_literal(b: &[u8], i: usize) -> Option<usize> {
let n = b.len();
let content_end = if i + 1 < n && b[i + 1] == b'\\' {
i + 3 } else {
i + 2 };
if content_end < n && b[content_end] == b'\'' {
Some(content_end + 1)
} else {
None
}
}
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_'
}
fn is_ident_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn is_assert_macro(ident: &str) -> bool {
ident == "assert"
|| ident == "debug_assert"
|| ident.starts_with("assert_")
|| ident.starts_with("debug_assert_")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_clippy_warnings_from_json_keyed_by_lint_and_file() {
let stream = concat!(
r#"{"reason":"compiler-message","package_id":"path+file:///x#pkg@0.1.0","target":{"name":"pkg","kind":["lib"]},"message":{"level":"warning","message":"unused variable: `x`","code":{"code":"unused_variables"},"spans":[{"file_name":"src/a.rs","line_start":3,"is_primary":true}]}}"#,
"\n",
r#"{"reason":"compiler-message","package_id":"path+file:///x#pkg@0.1.0","target":{"name":"pkg","kind":["lib"]},"message":{"level":"warning","message":"needless return","code":{"code":"clippy::needless_return"},"spans":[{"file_name":"src/b.rs","line_start":10,"is_primary":true}]}}"#,
"\n",
r#"{"reason":"build-finished","success":false}"#,
);
let msgs = parse_cargo_stream(stream).unwrap();
assert_eq!(build_finished(&msgs), Some(false));
assert!(!has_compile_error(&msgs));
let warnings = clippy_warnings(&msgs);
assert_eq!(warnings.len(), 2);
assert!(warnings
.iter()
.any(|w| w.lint == "unused_variables" && w.file == "src/a.rs" && w.package == "pkg"));
assert!(warnings
.iter()
.any(|w| w.lint == "clippy::needless_return" && w.file == "src/b.rs"));
}
#[test]
fn clippy_identity_is_stable_across_line_shifts() {
let mk = |line: u32| {
format!(
r#"{{"reason":"compiler-message","package_id":"p#pkg@0.1.0","target":{{"name":"pkg","kind":["lib"]}},"message":{{"level":"warning","message":"unused variable: `x`","code":{{"code":"unused_variables"}},"spans":[{{"file_name":"src/a.rs","line_start":{line},"is_primary":true}}]}}}}"#
)
};
let a = clippy_warnings(&parse_cargo_stream(&mk(3)).unwrap());
let b = clippy_warnings(&parse_cargo_stream(&mk(47)).unwrap());
assert_eq!(a, b, "a line shift must not change warning identity");
}
#[test]
fn code_less_and_error_diagnostics_are_not_clippy_warnings() {
let stream = concat!(
r#"{"reason":"compiler-message","package_id":"p#pkg@0.1.0","target":{"name":"build-script-build","kind":["custom-build"]},"message":{"level":"warning","message":"forged","code":null,"spans":[]}}"#,
"\n",
r#"{"reason":"compiler-message","package_id":"p#pkg@0.1.0","target":{"name":"pkg","kind":["lib"]},"message":{"level":"error","message":"mismatched types","code":{"code":"E0308"},"spans":[{"file_name":"src/a.rs","is_primary":true}]}}"#,
);
let msgs = parse_cargo_stream(stream).unwrap();
assert!(clippy_warnings(&msgs).is_empty());
assert!(has_compile_error(&msgs));
}
#[test]
fn real_cargo_reasons_including_build_script_parse_as_other() {
let stream = concat!(
r#"{"reason":"build-script-executed","package_id":"registry+https://github.com/rust-lang/crates.io-index#libc@0.2.186","linked_libs":[],"linked_paths":[],"cfgs":["freebsd12"],"env":[],"out_dir":"/x/out"}"#,
"\n",
r#"{"reason":"build-finished","success":true}"#,
);
let msgs = parse_cargo_stream(stream).expect("build-script-executed must not fail closed");
assert_eq!(msgs.len(), 2);
assert!(matches!(msgs[0], CargoMessage::Other));
assert_eq!(build_finished(&msgs), Some(true));
}
#[test]
fn unparseable_line_fails_closed() {
let stream = concat!(
r#"{"reason":"build-finished","success":true}"#,
"\n",
"test injected ... ok\n", );
let err = parse_cargo_stream(stream).unwrap_err();
assert!(matches!(
err,
CargoStreamError::UnparseableLine { line_no: 2, .. }
));
}
#[test]
fn extracts_test_binaries_with_target_metadata() {
let stream = concat!(
r#"{"reason":"compiler-artifact","package_id":"p#octl-cli@0.1.0","target":{"name":"octl-cli","kind":["lib"]},"profile":{"test":true},"executable":"/t/deps/octl_cli-abc"}"#,
"\n",
r#"{"reason":"compiler-artifact","package_id":"p#octl-cli@0.1.0","target":{"name":"e2e","kind":["test"]},"profile":{"test":true},"executable":"/t/deps/e2e-def"}"#,
"\n",
r#"{"reason":"compiler-artifact","package_id":"p#octl-cli@0.1.0","target":{"name":"octl-cli","kind":["lib"]},"profile":{"test":false},"executable":null}"#,
"\n",
r#"{"reason":"build-finished","success":true}"#,
);
let msgs = parse_cargo_stream(stream).unwrap();
let bins = test_binaries(&msgs);
assert_eq!(bins.len(), 2);
assert_eq!(bins[0].target_kind, "lib");
assert_eq!(bins[0].target, "octl-cli");
assert_eq!(bins[1].target_kind, "test");
assert_eq!(bins[1].target, "e2e");
assert_eq!(bins[1].package, "octl-cli");
}
#[test]
fn short_package_name_handles_formats() {
assert_eq!(
short_package_name("path+file:///x#octl-cli@0.1.0"),
"octl-cli"
);
assert_eq!(
short_package_name("registry+https://x#serde@1.0.0"),
"serde"
);
assert_eq!(
short_package_name("octl-cli 0.1.0 (path+file:///x)"),
"octl-cli"
);
assert_eq!(short_package_name("p#bare"), "bare");
}
#[test]
fn parses_mixed_libtest_outcomes_and_summary() {
let out = "\
running 5 tests
test export::csv::roundtrip ... ok
test export::csv::escaping ... FAILED
test routes::account::export_ok ... ok
test slow::network ... ignored
test slow::flaky ... ignored, needs network
test result: FAILED. 2 passed; 1 failed; 2 ignored; 0 measured; 0 filtered out
";
let report = parse_libtest_report(out);
assert_eq!(report.passed.len(), 2);
assert_eq!(report.failed, vec!["export::csv::escaping"]);
assert_eq!(report.ignored.len(), 2);
let summary = reconcile_single_binary(&report).unwrap();
assert_eq!(summary.passed, 2);
assert_eq!(summary.ignored, 2);
}
#[test]
fn forged_ok_line_fails_reconciliation() {
let out = "\
running 1 test
test real::actual ... ok
test forged::injected ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
";
let report = parse_libtest_report(out);
assert_eq!(report.passed.len(), 2, "both lines parse as text");
let err = reconcile_single_binary(&report).unwrap_err();
assert_eq!(
err,
LibtestDiscrepancy::CountMismatch {
announced: LibtestSummary {
passed: 1,
failed: 0,
ignored: 0,
filtered_out: 0,
},
parsed: (2, 0, 0),
}
);
}
#[test]
fn filtered_out_run_fails_closed() {
let out = "\
running 1 test
test kept::one ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 5 filtered out
";
let report = parse_libtest_report(out);
assert_eq!(
reconcile_single_binary(&report).unwrap_err(),
LibtestDiscrepancy::Filtered { count: 5 }
);
}
#[test]
fn forged_summary_line_is_multiple_summaries() {
let out = "\
test real::actual ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
test result: ok. 99 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
";
let report = parse_libtest_report(out);
assert_eq!(
reconcile_single_binary(&report).unwrap_err(),
LibtestDiscrepancy::MultipleSummaries { count: 2 }
);
}
#[test]
fn missing_summary_fails_closed() {
let out = "test a::b ... ok\n";
assert_eq!(
reconcile_single_binary(&parse_libtest_report(out)).unwrap_err(),
LibtestDiscrepancy::NoSummary
);
}
#[test]
fn zero_test_binary_reconciles() {
let out = "\nrunning 0 tests\n\ntest result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\n";
let report = parse_libtest_report(out);
assert_eq!(reconcile_single_binary(&report).unwrap().passed, 0);
}
#[test]
fn qualify_builds_target_qualified_ids() {
let ids = qualify("octl-cli", "test", "e2e", &["a::b".to_string()]);
assert!(ids.contains(&TestId::new("octl-cli", "test", "e2e", "a::b")));
}
#[test]
fn ok_with_trailing_note_is_passed() {
let report = parse_libtest_report(
"test a::b ... ok (fast)\ntest result: ok. 1 passed; 0 failed; 0 ignored\n",
);
assert_eq!(report.passed, vec!["a::b"]);
}
#[test]
fn counts_assert_family_macros() {
let src = r"
fn t() {
assert!(x);
assert_eq!(a, b);
assert_ne!(a, b);
debug_assert!(y);
debug_assert_eq!(a, b);
assert_matches!(v, Some(_));
}
";
assert_eq!(count_assert_macros(src), 6);
}
#[test]
fn does_not_count_non_assert_or_bare_idents() {
let src = "assertion_helper(); let assert = 1; reassert!(z); asserts!(w);";
assert_eq!(count_assert_macros(src), 0);
}
#[test]
fn counts_at_string_boundaries() {
assert_eq!(count_assert_macros("assert!(true)"), 1);
assert_eq!(count_assert_macros("x.assert_eq!(a,b)"), 1);
}
#[test]
fn does_not_count_assertions_in_comments_or_strings() {
let src = r#"
fn t() {
assert!(real); // one real assertion
// assert!(fake); assert_eq!(a,b);
/* assert!(also_fake);
assert_ne!(x, y); */
let s = "assert!(in_string); assert_eq!(q, r)";
let raw = r"assert!(in_raw_string)";
let _c = '"'; // a quote char literal must not desync string parsing
}
"#;
assert_eq!(count_assert_macros(src), 1);
}
#[test]
fn strip_handles_nested_block_comments_and_raw_strings() {
let stripped = strip_comments_and_strings("a /* x /* y */ z */ b");
assert!(stripped.contains('a') && stripped.contains('b'));
assert!(!stripped.contains('x') && !stripped.contains('z'));
let stripped = strip_comments_and_strings(r##"code r#"in "quotes" here"# more"##);
assert!(stripped.contains("code") && stripped.contains("more"));
assert!(!stripped.contains("quotes"));
}
#[test]
fn lifetime_is_not_mistaken_for_a_char_literal() {
let src = "fn f<'a>(x: &'a T) { assert!(x); }";
assert_eq!(count_assert_macros(src), 1);
}
}