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_ruff(repo_path: &Path, repo_id: &str) -> Result, CoreError> { let child = Command::new("ruff") .args(["check", ".", "--output-format", "json", "--exit-zero"]) .current_dir(repo_path) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| CoreError::Scanner { scanner: "ruff".to_string(), source: Box::new(e), })?; let output = run_with_timeout(child, "ruff").await?; if output.stdout.is_empty() { return Ok(Vec::new()); } let results: Vec = serde_json::from_slice(&output.stdout).unwrap_or_default(); let findings = results .into_iter() .map(|r| { let severity = if r.code.starts_with('E') || r.code.starts_with('F') { Severity::Medium } else { Severity::Low }; let fingerprint = dedup::compute_fingerprint(&[ repo_id, "ruff", &r.code, &r.filename, &r.location.row.to_string(), ]); let mut finding = Finding::new( repo_id.to_string(), fingerprint, "ruff".to_string(), ScanType::Lint, format!("[ruff] {}: {}", r.code, r.message), r.message, severity, ); finding.rule_id = Some(r.code); finding.file_path = Some(r.filename); finding.line_number = Some(r.location.row); finding }) .collect(); Ok(findings) } #[derive(serde::Deserialize)] struct RuffResult { code: String, message: String, filename: String, location: RuffLocation, } #[derive(serde::Deserialize)] struct RuffLocation { row: u32, } #[cfg(test)] mod tests { use super::*; #[test] fn deserialize_ruff_output() { let json = r#"[ { "code": "E501", "message": "Line too long (120 > 79 characters)", "filename": "src/main.py", "location": {"row": 42} }, { "code": "F401", "message": "`os` imported but unused", "filename": "src/utils.py", "location": {"row": 1} } ]"#; let results: Vec = serde_json::from_str(json).unwrap(); assert_eq!(results.len(), 2); assert_eq!(results[0].code, "E501"); assert_eq!(results[0].filename, "src/main.py"); assert_eq!(results[0].location.row, 42); assert_eq!(results[1].code, "F401"); assert_eq!(results[1].location.row, 1); } #[test] fn deserialize_ruff_empty() { let json = "[]"; let results: Vec = serde_json::from_str(json).unwrap(); assert!(results.is_empty()); } #[test] fn ruff_severity_e_and_f_are_medium() { for code in &["E501", "E302", "F401", "F811"] { let is_medium = code.starts_with('E') || code.starts_with('F'); assert!(is_medium, "Expected {code} to be Medium severity"); } } #[test] fn ruff_severity_others_are_low() { for code in &["W291", "I001", "D100", "C901", "N801"] { let is_medium = code.starts_with('E') || code.starts_with('F'); assert!(!is_medium, "Expected {code} to be Low severity"); } } #[test] fn deserialize_ruff_with_extra_fields() { // Ruff output may contain additional fields we don't use let json = r#"[{ "code": "W291", "message": "Trailing whitespace", "filename": "app.py", "location": {"row": 3, "column": 10}, "end_location": {"row": 3, "column": 11}, "fix": null, "noqa_row": 3 }]"#; let results: Vec = serde_json::from_str(json).unwrap(); assert_eq!(results.len(), 1); assert_eq!(results[0].code, "W291"); } }