use std::path::Path; use compliance_core::models::{Finding, ScanType, Severity}; use compliance_core::CoreError; use tokio::process::Command; use crate::pipeline::dedup; use super::run_with_timeout; pub(super) async fn run_clippy(repo_path: &Path, repo_id: &str) -> Result, CoreError> { let child = Command::new("cargo") .args([ "clippy", "--message-format=json", "--quiet", "--", "-W", "clippy::all", ]) .current_dir(repo_path) .env("RUSTC_WRAPPER", "") .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| CoreError::Scanner { scanner: "clippy".to_string(), source: Box::new(e), })?; let output = run_with_timeout(child, "clippy").await?; let stdout = String::from_utf8_lossy(&output.stdout); let mut findings = Vec::new(); for line in stdout.lines() { let msg: serde_json::Value = match serde_json::from_str(line) { Ok(v) => v, Err(_) => continue, }; if msg.get("reason").and_then(|v| v.as_str()) != Some("compiler-message") { continue; } let message = match msg.get("message") { Some(m) => m, None => continue, }; let level = message.get("level").and_then(|v| v.as_str()).unwrap_or(""); if level != "warning" && level != "error" { continue; } let text = message .get("message") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let code = message .get("code") .and_then(|v| v.get("code")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); if text.starts_with("aborting due to") || code.is_empty() { continue; } let (file_path, line_number) = extract_primary_span(message); let severity = if level == "error" { Severity::High } else { Severity::Low }; let fingerprint = dedup::compute_fingerprint(&[ repo_id, "clippy", &code, &file_path, &line_number.to_string(), ]); let mut finding = Finding::new( repo_id.to_string(), fingerprint, "clippy".to_string(), ScanType::Lint, format!("[clippy] {text}"), text, severity, ); finding.rule_id = Some(code); if !file_path.is_empty() { finding.file_path = Some(file_path); } if line_number > 0 { finding.line_number = Some(line_number); } findings.push(finding); } Ok(findings) } fn extract_primary_span(message: &serde_json::Value) -> (String, u32) { let spans = match message.get("spans").and_then(|v| v.as_array()) { Some(s) => s, None => return (String::new(), 0), }; for span in spans { if span.get("is_primary").and_then(|v| v.as_bool()) == Some(true) { let file = span .get("file_name") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let line = span.get("line_start").and_then(|v| v.as_u64()).unwrap_or(0) as u32; return (file, line); } } (String::new(), 0) } #[cfg(test)] mod tests { use super::*; #[test] fn extract_primary_span_with_primary() { let msg = serde_json::json!({ "spans": [ { "file_name": "src/lib.rs", "line_start": 42, "is_primary": true } ] }); let (file, line) = extract_primary_span(&msg); assert_eq!(file, "src/lib.rs"); assert_eq!(line, 42); } #[test] fn extract_primary_span_no_primary() { let msg = serde_json::json!({ "spans": [ { "file_name": "src/lib.rs", "line_start": 42, "is_primary": false } ] }); let (file, line) = extract_primary_span(&msg); assert_eq!(file, ""); assert_eq!(line, 0); } #[test] fn extract_primary_span_multiple_spans() { let msg = serde_json::json!({ "spans": [ { "file_name": "src/other.rs", "line_start": 10, "is_primary": false }, { "file_name": "src/main.rs", "line_start": 99, "is_primary": true } ] }); let (file, line) = extract_primary_span(&msg); assert_eq!(file, "src/main.rs"); assert_eq!(line, 99); } #[test] fn extract_primary_span_no_spans() { let msg = serde_json::json!({}); let (file, line) = extract_primary_span(&msg); assert_eq!(file, ""); assert_eq!(line, 0); } #[test] fn extract_primary_span_empty_spans() { let msg = serde_json::json!({ "spans": [] }); let (file, line) = extract_primary_span(&msg); assert_eq!(file, ""); assert_eq!(line, 0); } #[test] fn parse_clippy_compiler_message_line() { let line = r#"{"reason":"compiler-message","message":{"level":"warning","message":"unused variable","code":{"code":"unused_variables"},"spans":[{"file_name":"src/main.rs","line_start":5,"is_primary":true}]}}"#; let msg: serde_json::Value = serde_json::from_str(line).unwrap(); assert_eq!( msg.get("reason").and_then(|v| v.as_str()), Some("compiler-message") ); let message = msg.get("message").unwrap(); assert_eq!( message.get("level").and_then(|v| v.as_str()), Some("warning") ); assert_eq!( message.get("message").and_then(|v| v.as_str()), Some("unused variable") ); assert_eq!( message .get("code") .and_then(|v| v.get("code")) .and_then(|v| v.as_str()), Some("unused_variables") ); let (file, line_num) = extract_primary_span(message); assert_eq!(file, "src/main.rs"); assert_eq!(line_num, 5); } #[test] fn skip_non_compiler_message() { let line = r#"{"reason":"build-script-executed","package_id":"foo 0.1.0"}"#; let msg: serde_json::Value = serde_json::from_str(line).unwrap(); assert_ne!( msg.get("reason").and_then(|v| v.as_str()), Some("compiler-message") ); } #[test] fn skip_aborting_message() { let text = "aborting due to 3 previous errors"; assert!(text.starts_with("aborting due to")); } }