use std::collections::BTreeMap;
use std::process::ExitCode;
use std::sync::LazyLock;
use regex::Regex;
use super::run_parsed_command;
use crate::cmd::{inject_flag_before_separator, user_has_flag};
use crate::output::canonical::BuildResult;
use crate::output::ParseResult;
use crate::runner::CommandOutput;
static CARGO_ERROR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"error\[E\d+\]").expect("valid regex"));
static CARGO_WARNING_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?m)^warning:").expect("valid regex"));
static CARGO_ERROR_LINE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(error\[E\d+\]:.+)").expect("valid regex"));
pub(crate) fn run(args: &[String], show_stats: bool) -> anyhow::Result<ExitCode> {
let mut full_args = vec!["build".to_string()];
full_args.extend_from_slice(args);
if !user_has_flag(&full_args, &["--message-format"]) {
inject_flag_before_separator(&mut full_args, "--message-format=json");
}
run_parsed_command(
"cargo",
&full_args,
&[("CARGO_TERM_COLOR", "never")],
"install Rust from https://rustup.rs",
show_stats,
parse,
)
}
pub(crate) fn run_clippy(args: &[String], show_stats: bool) -> anyhow::Result<ExitCode> {
let mut full_args = vec!["clippy".to_string()];
full_args.extend_from_slice(args);
if !user_has_flag(&full_args, &["--message-format"]) {
inject_flag_before_separator(&mut full_args, "--message-format=json");
}
run_parsed_command(
"cargo",
&full_args,
&[("CARGO_TERM_COLOR", "never")],
"install Rust from https://rustup.rs",
show_stats,
parse,
)
}
fn parse(output: &CommandOutput) -> ParseResult<BuildResult> {
if let Some(result) = try_tier1_json(&output.stdout) {
return result;
}
if let Some(result) = try_tier2_regex(&output.stderr) {
return result;
}
let combined = if output.stderr.is_empty() {
output.stdout.clone()
} else if output.stdout.is_empty() {
output.stderr.clone()
} else {
format!("{}\n{}", output.stdout, output.stderr)
};
ParseResult::Passthrough(combined)
}
fn try_tier1_json(stdout: &str) -> Option<ParseResult<BuildResult>> {
let mut warnings: usize = 0;
let mut errors: usize = 0;
let mut error_messages: Vec<String> = Vec::new();
let mut warning_codes: BTreeMap<String, usize> = BTreeMap::new();
let mut found_build_finished = false;
let mut success = false;
for line in stdout.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let json: serde_json::Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
let reason = json.get("reason").and_then(|v| v.as_str());
match reason {
Some("compiler-message") => {
if let Some(message) = json.get("message") {
let level = message.get("level").and_then(|v| v.as_str()).unwrap_or("");
let msg_text = message
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("");
let code = message
.get("code")
.and_then(|v| v.get("code"))
.and_then(|v| v.as_str())
.unwrap_or("");
let location = message
.get("spans")
.and_then(|v| v.as_array())
.and_then(|spans| spans.first())
.map(|span| {
let file = span
.get("file_name")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
let line = span.get("line_start").and_then(|v| v.as_u64()).unwrap_or(0);
format!("{file}:{line}")
})
.unwrap_or_default();
match level {
"error" => {
errors += 1;
let formatted = if !code.is_empty() && !location.is_empty() {
format!("error[{code}]: {msg_text} in {location}")
} else if !code.is_empty() {
format!("error[{code}]: {msg_text}")
} else if !location.is_empty() {
format!("error: {msg_text} in {location}")
} else {
format!("error: {msg_text}")
};
error_messages.push(formatted);
}
"warning" => {
warnings += 1;
if !code.is_empty() {
*warning_codes.entry(code.to_string()).or_insert(0) += 1;
}
}
_ => {}
}
}
}
Some("build-finished") => {
found_build_finished = true;
success = json
.get("success")
.and_then(|v| v.as_bool())
.unwrap_or(false);
}
_ => {}
}
}
if !found_build_finished {
return None;
}
if !warning_codes.is_empty() {
for (code, count) in &warning_codes {
error_messages.push(format!("{code}: {count} occurrence(s)"));
}
}
let duration_ms = None; let result = BuildResult::new(success, warnings, errors, duration_ms, error_messages);
Some(ParseResult::Full(result))
}
fn try_tier2_regex(stderr: &str) -> Option<ParseResult<BuildResult>> {
if stderr.trim().is_empty() {
return None;
}
let error_count = CARGO_ERROR_RE.find_iter(stderr).count();
let warning_count = CARGO_WARNING_RE.find_iter(stderr).count();
if error_count == 0 && warning_count == 0 {
return None;
}
let error_messages: Vec<String> = CARGO_ERROR_LINE_RE
.captures_iter(stderr)
.filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string()))
.collect();
let success = error_count == 0;
let result = BuildResult::new(success, warning_count, error_count, None, error_messages);
Some(ParseResult::Degraded(
result,
vec!["cargo build: structured parse failed, using regex".to_string()],
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::Duration;
fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cmd/build")
}
fn load_fixture(name: &str) -> String {
std::fs::read_to_string(fixtures_dir().join(name))
.unwrap_or_else(|e| panic!("Failed to load fixture {name}: {e}"))
}
fn make_output(stdout: &str, stderr: &str, exit_code: Option<i32>) -> CommandOutput {
CommandOutput {
stdout: stdout.to_string(),
stderr: stderr.to_string(),
exit_code,
duration: Duration::from_millis(100),
}
}
#[test]
fn test_tier1_build_success() {
let stdout = load_fixture("cargo_build_ok.json");
let output = make_output(&stdout, "", Some(0));
let result = parse(&output);
assert!(
result.is_full(),
"expected Full, got {:?}",
result.tier_name()
);
if let ParseResult::Full(build_result) = &result {
assert!(build_result.success, "expected success");
assert_eq!(build_result.errors, 0);
}
}
#[test]
fn test_tier1_build_failure() {
let stdout = load_fixture("cargo_build_fail.json");
let output = make_output(&stdout, "", Some(101));
let result = parse(&output);
assert!(
result.is_full(),
"expected Full, got {:?}",
result.tier_name()
);
if let ParseResult::Full(build_result) = &result {
assert!(!build_result.success, "expected failure");
assert!(build_result.errors > 0, "expected errors > 0");
}
}
#[test]
fn test_tier1_clippy_warnings() {
let stdout = load_fixture("clippy_warnings.json");
let output = make_output(&stdout, "", Some(0));
let result = parse(&output);
assert!(
result.is_full(),
"expected Full, got {:?}",
result.tier_name()
);
if let ParseResult::Full(build_result) = &result {
assert_eq!(build_result.warnings, 2, "expected 2 warnings");
assert!(build_result.success, "expected success");
}
}
#[test]
fn test_tier1_clippy_warning_codes_grouped() {
let stdout = load_fixture("clippy_warnings.json");
let output = make_output(&stdout, "", Some(0));
let result = parse(&output);
if let ParseResult::Full(build_result) = &result {
assert!(
build_result
.error_messages
.iter()
.any(|m| m.contains("dead_code")),
"expected warning code 'dead_code' in error_messages, got: {:?}",
build_result.error_messages
);
assert!(
build_result
.error_messages
.iter()
.any(|m| m.contains("2 occurrence(s)")),
"expected '2 occurrence(s)' in error_messages, got: {:?}",
build_result.error_messages
);
} else {
panic!("expected Full result");
}
}
#[test]
fn test_flag_injection_skipped() {
let args = vec!["--message-format=json2".to_string()];
assert!(
user_has_flag(&args, &["--message-format"]),
"should detect existing --message-format flag"
);
}
#[test]
fn test_user_message_format_skips_injection_and_falls_through() {
let stderr = "error[E0308]: mismatched types\n --> src/main.rs:10:5\n";
let output = make_output("", stderr, Some(101));
let user_args = vec!["build".to_string(), "--message-format=short".to_string()];
assert!(
user_has_flag(&user_args, &["--message-format"]),
"should detect user's --message-format flag"
);
let result = parse(&output);
assert!(
result.is_degraded(),
"expected Degraded (tier 2) when JSON unavailable, got {:?}",
result.tier_name()
);
if let ParseResult::Degraded(build_result, _) = &result {
assert_eq!(build_result.errors, 1, "expected 1 error from regex tier");
assert!(!build_result.success, "expected failure");
}
}
#[test]
fn test_tier2_regex_errors() {
let stderr = "error[E0308]: mismatched types\n --> src/main.rs:10:5\nerror[E0425]: cannot find value\n";
let output = make_output("", stderr, Some(101));
let result = parse(&output);
assert!(
result.is_degraded(),
"expected Degraded, got {:?}",
result.tier_name()
);
if let ParseResult::Degraded(build_result, markers) = &result {
assert_eq!(build_result.errors, 2, "expected 2 errors from regex");
assert!(!build_result.success, "expected failure");
assert!(
markers.contains(&"cargo build: structured parse failed, using regex".to_string())
);
}
}
#[test]
fn test_tier3_passthrough() {
let output = make_output("some random output", "", Some(0));
let result = parse(&output);
assert!(
result.is_passthrough(),
"expected Passthrough, got {:?}",
result.tier_name()
);
}
#[test]
fn test_inject_flag_before_separator() {
let mut args = vec![
"build".to_string(),
"--release".to_string(),
"--".to_string(),
"-W".to_string(),
"clippy::pedantic".to_string(),
];
inject_flag_before_separator(&mut args, "--message-format=json");
assert_eq!(args[2], "--message-format=json");
assert_eq!(args[3], "--");
}
#[test]
fn test_inject_flag_no_separator() {
let mut args = vec!["build".to_string(), "--release".to_string()];
inject_flag_before_separator(&mut args, "--message-format=json");
assert_eq!(args.last().unwrap(), "--message-format=json");
}
#[test]
fn test_user_has_flag_present() {
let args = vec!["build".to_string(), "--message-format=json2".to_string()];
assert!(user_has_flag(&args, &["--message-format"]));
}
#[test]
fn test_user_has_flag_absent() {
let args = vec!["build".to_string(), "--release".to_string()];
assert!(!user_has_flag(&args, &["--message-format"]));
}
}