use klasp_core::{Finding, Severity};
use serde_json::Value;
use super::verdict::finding;
use super::MAX_FINDINGS;
pub(super) fn collect_compiler_diagnostics(check_name: &str, stdout: &str) -> Vec<Finding> {
let mut out: Vec<Finding> = Vec::new();
for line in stdout.lines() {
if out.len() >= MAX_FINDINGS {
break;
}
let trimmed = line.trim();
if trimmed.is_empty() || !trimmed.starts_with('{') {
continue;
}
let Ok(value) = serde_json::from_str::<Value>(trimmed) else {
continue;
};
if value.get("reason").and_then(Value::as_str) != Some("compiler-message") {
continue;
}
let Some(message) = value.get("message") else {
continue;
};
let Some(finding) = build_diagnostic_finding(check_name, message) else {
continue;
};
out.push(finding);
}
out
}
fn build_diagnostic_finding(check_name: &str, message: &Value) -> Option<Finding> {
let level = message.get("level").and_then(Value::as_str)?;
let severity = severity_from_level(level)?;
let text = message.get("message").and_then(Value::as_str)?.to_string();
let code = message
.get("code")
.and_then(|c| c.get("code"))
.and_then(Value::as_str);
let primary_span = message
.get("spans")
.and_then(Value::as_array)
.and_then(|spans| {
spans
.iter()
.find(|s| s.get("is_primary").and_then(Value::as_bool) == Some(true))
.or_else(|| spans.first())
});
let file = primary_span
.and_then(|s| s.get("file_name"))
.and_then(Value::as_str)
.map(str::to_string);
let line = primary_span
.and_then(|s| s.get("line_start"))
.and_then(Value::as_u64)
.map(|n| n as u32);
let suffix = code.unwrap_or(level);
let detail = match code {
Some(c) => format!("{level}[{c}]: {text}"),
None => format!("{level}: {text}"),
};
Some(finding(check_name, suffix, &detail, file, line, severity))
}
fn severity_from_level(level: &str) -> Option<Severity> {
match level {
"error" | "error: internal compiler error" => Some(Severity::Error),
"warning" => Some(Severity::Warn),
"help" | "note" => Some(Severity::Info),
_ => None,
}
}
pub(super) fn summarise_diagnostics(subcommand: &str, findings: &[Finding]) -> String {
let errors = findings
.iter()
.filter(|f| matches!(f.severity, Severity::Error))
.count();
let warnings = findings
.iter()
.filter(|f| matches!(f.severity, Severity::Warn))
.count();
let info = findings
.iter()
.filter(|f| matches!(f.severity, Severity::Info))
.count();
match (errors, warnings) {
(0, 0) => format!(
"cargo {subcommand} reported {info} info note{}",
if info == 1 { "" } else { "s" }
),
(e, 0) => format!(
"cargo {subcommand} reported {e} error{}",
if e == 1 { "" } else { "s" }
),
(0, w) => format!(
"cargo {subcommand} reported {w} warning{}",
if w == 1 { "" } else { "s" }
),
(e, w) => format!(
"cargo {subcommand} reported {e} error{}, {w} warning{}",
if e == 1 { "" } else { "s" },
if w == 1 { "" } else { "s" }
),
}
}
pub(super) fn summarise_test_output(stdout: &str) -> Option<String> {
let line = stdout
.lines()
.rev()
.find(|l| l.trim_start().starts_with("test result:"))?;
let passed = parse_count(line, "passed");
let failed = parse_count(line, "failed");
let ignored = parse_count(line, "ignored");
Some(match (passed, failed, ignored) {
(Some(p), Some(f), _) if f > 0 => {
format!("cargo test failed ({p} passed, {f} failed)")
}
(Some(p), Some(0), Some(i)) if i > 0 => {
format!("cargo test passed ({p} passed, {i} ignored)")
}
_ => line.trim().to_string(),
})
}
fn parse_count(line: &str, kind: &str) -> Option<u64> {
let mut tokens = line.split_whitespace().peekable();
while let Some(tok) = tokens.next() {
if let Ok(n) = tok.parse::<u64>() {
if let Some(next) = tokens.peek() {
let stripped = next.trim_end_matches(';').trim_end_matches(',');
if stripped == kind {
return Some(n);
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_stdout_yields_no_findings() {
assert!(collect_compiler_diagnostics("build", "").is_empty());
}
#[test]
fn non_compiler_message_lines_skipped() {
let stdout = concat!(
r#"{"reason":"compiler-artifact","package_id":"foo","target":{}}"#,
"\n",
r#"{"reason":"build-finished","success":true}"#,
"\n"
);
assert!(collect_compiler_diagnostics("build", stdout).is_empty());
}
#[test]
fn compiler_message_with_error_yields_finding() {
let stdout = concat!(
r#"{"reason":"compiler-message","message":{"message":"cannot find value `x`","code":{"code":"E0425"},"level":"error","spans":[{"file_name":"src/lib.rs","line_start":7,"is_primary":true}]}}"#,
"\n"
);
let findings = collect_compiler_diagnostics("build", stdout);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Error);
assert!(findings[0].message.contains("E0425"));
assert_eq!(findings[0].file.as_deref(), Some("src/lib.rs"));
assert_eq!(findings[0].line, Some(7));
assert!(findings[0].rule.contains("E0425"));
}
#[test]
fn compiler_message_with_warning_uses_warn_severity() {
let stdout = concat!(
r#"{"reason":"compiler-message","message":{"message":"unused variable","level":"warning","spans":[{"file_name":"a.rs","line_start":1,"is_primary":true}]}}"#,
"\n"
);
let findings = collect_compiler_diagnostics("build", stdout);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].severity, Severity::Warn);
}
#[test]
fn compiler_message_skipped_when_no_level() {
let stdout = r#"{"reason":"compiler-message","message":{"message":"odd","spans":[]}}"#;
assert!(collect_compiler_diagnostics("build", stdout).is_empty());
}
#[test]
fn malformed_json_lines_silently_skipped() {
let stdout = concat!(
"garbage\n",
r#"{"reason":"compiler-message","message":{"message":"a","level":"error","spans":[]}}"#,
"\n",
"more garbage\n",
);
let findings = collect_compiler_diagnostics("build", stdout);
assert_eq!(findings.len(), 1);
}
#[test]
fn first_span_used_when_no_primary_marker() {
let stdout = concat!(
r#"{"reason":"compiler-message","message":{"message":"x","level":"error","spans":[{"file_name":"a.rs","line_start":2,"is_primary":false},{"file_name":"b.rs","line_start":9,"is_primary":false}]}}"#,
"\n"
);
let findings = collect_compiler_diagnostics("b", stdout);
assert_eq!(findings[0].file.as_deref(), Some("a.rs"));
assert_eq!(findings[0].line, Some(2));
}
#[test]
fn cap_limits_diagnostic_count() {
let mut stdout = String::new();
for _ in 0..70 {
stdout.push_str(
r#"{"reason":"compiler-message","message":{"message":"x","level":"error","spans":[]}}"#,
);
stdout.push('\n');
}
let findings = collect_compiler_diagnostics("b", &stdout);
assert_eq!(findings.len(), MAX_FINDINGS);
}
#[test]
fn summarise_diagnostics_pluralisation() {
let one_err = vec![mk_finding(Severity::Error)];
assert_eq!(
summarise_diagnostics("clippy", &one_err),
"cargo clippy reported 1 error"
);
let many = vec![
mk_finding(Severity::Error),
mk_finding(Severity::Error),
mk_finding(Severity::Warn),
];
let s = summarise_diagnostics("clippy", &many);
assert!(s.contains("2 errors"));
assert!(s.contains("1 warning"));
}
#[test]
fn summarise_test_output_extracts_failed_count() {
let stdout = concat!(
"running 5 tests\n",
"test foo ... ok\n",
"test bar ... FAILED\n",
"test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.04s\n",
);
let summary = summarise_test_output(stdout).expect("summary line should parse");
assert!(summary.contains("4 passed"));
assert!(summary.contains("1 failed"));
}
#[test]
fn summarise_test_output_returns_none_when_summary_missing() {
assert!(summarise_test_output("running 5 tests\nthread 'main' panicked").is_none());
}
fn mk_finding(severity: Severity) -> Finding {
Finding {
rule: "r".into(),
message: "m".into(),
file: None,
line: None,
severity,
}
}
}