1use std::path::PathBuf;
31use std::process::Command;
32
33use dev_report::{CheckResult, Evidence, Producer, Report, Severity};
34use serde::Deserialize;
35
36pub struct CargoTestProducer {
45 subject: String,
46 subject_version: String,
47 workdir: Option<PathBuf>,
48}
49
50impl CargoTestProducer {
51 pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
54 self.workdir = Some(dir.into());
55 self
56 }
57}
58
59impl Producer for CargoTestProducer {
60 fn produce(&self) -> Report {
61 let mut report =
62 Report::new(&self.subject, &self.subject_version).with_producer("cargo-test");
63
64 let output = match run_cargo(&self.workdir, &["test", "--no-fail-fast"]) {
65 Ok(o) => o,
66 Err(c) => {
67 report.push(*c);
68 report.finish();
69 return report;
70 }
71 };
72
73 for c in parse_cargo_test_output(&output.combined) {
74 report.push(c);
75 }
76 report.finish();
77 report
78 }
79}
80
81pub fn cargo_test_producer(
95 subject: impl Into<String>,
96 subject_version: impl Into<String>,
97) -> CargoTestProducer {
98 CargoTestProducer {
99 subject: subject.into(),
100 subject_version: subject_version.into(),
101 workdir: None,
102 }
103}
104
105fn parse_cargo_test_output(text: &str) -> Vec<CheckResult> {
106 let mut results = Vec::new();
107 for line in text.lines() {
108 let rest = match line.strip_prefix("test ") {
113 Some(r) => r,
114 None => continue,
115 };
116 let (name, outcome) = match rest.rsplit_once(" ... ") {
117 Some(pair) => pair,
118 None => continue,
119 };
120 let trimmed_outcome = outcome.split_whitespace().next().unwrap_or("");
121 let check = match trimmed_outcome {
122 "ok" => CheckResult::pass(name),
123 "FAILED" => CheckResult::fail(name, Severity::Error),
124 "ignored" => CheckResult::skip(name),
125 _ => continue,
126 };
127 results.push(check);
128 }
129 results
130}
131
132pub struct ClippyProducer {
141 subject: String,
142 subject_version: String,
143 workdir: Option<PathBuf>,
144}
145
146impl ClippyProducer {
147 pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
149 self.workdir = Some(dir.into());
150 self
151 }
152}
153
154impl Producer for ClippyProducer {
155 fn produce(&self) -> Report {
156 let mut report =
157 Report::new(&self.subject, &self.subject_version).with_producer("cargo-clippy");
158 run_message_format_json(&self.workdir, "clippy", &mut report);
159 report
160 }
161}
162
163pub fn clippy_producer(
182 subject: impl Into<String>,
183 subject_version: impl Into<String>,
184) -> ClippyProducer {
185 ClippyProducer {
186 subject: subject.into(),
187 subject_version: subject_version.into(),
188 workdir: None,
189 }
190}
191
192pub struct CargoCheckProducer {
197 subject: String,
198 subject_version: String,
199 workdir: Option<PathBuf>,
200}
201
202impl CargoCheckProducer {
203 pub fn in_dir(mut self, dir: impl Into<PathBuf>) -> Self {
205 self.workdir = Some(dir.into());
206 self
207 }
208}
209
210impl Producer for CargoCheckProducer {
211 fn produce(&self) -> Report {
212 let mut report =
213 Report::new(&self.subject, &self.subject_version).with_producer("cargo-check");
214 run_message_format_json(&self.workdir, "check", &mut report);
215 report
216 }
217}
218
219pub fn cargo_check_producer(
234 subject: impl Into<String>,
235 subject_version: impl Into<String>,
236) -> CargoCheckProducer {
237 CargoCheckProducer {
238 subject: subject.into(),
239 subject_version: subject_version.into(),
240 workdir: None,
241 }
242}
243
244fn run_message_format_json(workdir: &Option<PathBuf>, subcommand: &str, report: &mut Report) {
245 let output = match run_cargo(workdir, &[subcommand, "--message-format=json"]) {
246 Ok(o) => o,
247 Err(c) => {
248 report.push(*c);
249 report.finish();
250 return;
251 }
252 };
253 for line in output.stdout.lines() {
254 if let Some(c) = parse_cargo_message_line(line) {
255 report.push(c);
256 }
257 }
258 report.finish();
259}
260
261#[derive(Deserialize)]
262struct CargoMessage {
263 reason: String,
264 message: Option<CompilerMessage>,
265}
266
267#[derive(Deserialize)]
268struct CompilerMessage {
269 level: String,
270 message: String,
271 spans: Vec<CompilerSpan>,
272 code: Option<DiagnosticCode>,
273 rendered: Option<String>,
274}
275
276#[derive(Deserialize)]
277struct CompilerSpan {
278 file_name: String,
279 line_start: u32,
280 line_end: u32,
281 is_primary: bool,
282}
283
284#[derive(Deserialize)]
285struct DiagnosticCode {
286 code: String,
287}
288
289fn parse_cargo_message_line(line: &str) -> Option<CheckResult> {
290 let msg: CargoMessage = serde_json::from_str(line).ok()?;
291 if msg.reason != "compiler-message" {
292 return None;
293 }
294 let compiler_msg = msg.message?;
295 let (verdict_kind, severity) = match compiler_msg.level.as_str() {
296 "warning" => (Verdict::Warn, Severity::Warning),
297 "error" | "error: internal compiler error" => (Verdict::Fail, Severity::Error),
298 _ => return None, };
300
301 let name = compiler_msg
302 .code
303 .as_ref()
304 .map(|c| c.code.clone())
305 .unwrap_or_else(|| short_name_from_message(&compiler_msg.message));
306
307 let mut check = match verdict_kind {
308 Verdict::Warn => CheckResult::warn(name, severity),
309 Verdict::Fail => CheckResult::fail(name, severity),
310 _ => return None,
311 };
312 check = check.with_detail(compiler_msg.message.clone());
313
314 let primary_span = compiler_msg
315 .spans
316 .iter()
317 .find(|s| s.is_primary)
318 .or_else(|| compiler_msg.spans.first());
319 if let Some(span) = primary_span {
320 check = check.with_evidence(Evidence::file_ref_lines(
321 "site",
322 span.file_name.clone(),
323 span.line_start,
324 span.line_end,
325 ));
326 }
327 if let Some(rendered) = compiler_msg.rendered {
328 check = check.with_evidence(Evidence::snippet("rendered", rendered));
329 }
330 Some(check)
331}
332
333fn short_name_from_message(msg: &str) -> String {
334 let first_line = msg.lines().next().unwrap_or("diagnostic").trim();
337 if first_line.len() <= 80 {
338 first_line.to_string()
339 } else {
340 format!("{}...", &first_line[..77])
341 }
342}
343
344use dev_report::Verdict;
346
347struct CapturedOutput {
352 stdout: String,
353 #[allow(dead_code)]
354 stderr: String,
355 combined: String,
356}
357
358fn run_cargo(workdir: &Option<PathBuf>, args: &[&str]) -> Result<CapturedOutput, Box<CheckResult>> {
359 let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
360 let mut cmd = Command::new(&cargo);
361 cmd.args(args);
362 if let Some(dir) = workdir.as_ref() {
363 cmd.current_dir(dir);
364 }
365
366 let output = match cmd.output() {
367 Ok(o) => o,
368 Err(e) => {
369 return Err(Box::new(
370 CheckResult::fail("subprocess::spawn", Severity::Critical)
371 .with_detail(format!("failed to spawn cargo: {}", e)),
372 ));
373 }
374 };
375
376 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
377 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
378 let combined = format!("{}\n{}", stdout, stderr);
379 Ok(CapturedOutput {
380 stdout,
381 stderr,
382 combined,
383 })
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389
390 #[test]
391 fn parse_cargo_test_output_recognizes_ok_failed_ignored() {
392 let stdout = "\
393running 4 tests
394test foo::bar ... ok
395test foo::baz ... FAILED
396test foo::qux ... ignored
397test foo::quux ... ok (0.01s)
398
399failures:
400foo::baz
401";
402 let results = parse_cargo_test_output(stdout);
403 let names: Vec<&str> = results.iter().map(|c| c.name.as_str()).collect();
404 assert_eq!(names, vec!["foo::bar", "foo::baz", "foo::qux", "foo::quux"]);
405 assert_eq!(results[0].verdict, Verdict::Pass);
406 assert_eq!(results[1].verdict, Verdict::Fail);
407 assert_eq!(results[1].severity, Some(Severity::Error));
408 assert_eq!(results[2].verdict, Verdict::Skip);
409 assert_eq!(results[3].verdict, Verdict::Pass);
410 }
411
412 #[test]
413 fn parse_cargo_test_output_ignores_unrelated_lines() {
414 let stdout = "\
415 Compiling foo v0.1.0
416running 1 test
417test test_a ... ok
418
419test result: ok. 1 passed; 0 failed; 0 ignored
420";
421 let results = parse_cargo_test_output(stdout);
422 assert_eq!(results.len(), 1);
423 assert_eq!(results[0].name, "test_a");
424 }
425
426 #[test]
427 fn parse_cargo_message_line_maps_warning() {
428 let line = r#"{"reason":"compiler-message","package_id":"x","manifest_path":"x","target":{},"message":{"level":"warning","message":"unused variable: `x`","spans":[{"file_name":"src/lib.rs","line_start":10,"line_end":10,"is_primary":true,"byte_start":0,"byte_end":1,"column_start":1,"column_end":2,"text":[]}],"code":{"code":"unused_variables"},"rendered":"warning: unused variable: `x`\n --> src/lib.rs:10:1"}}"#;
429 let check = parse_cargo_message_line(line).expect("should parse");
430 assert_eq!(check.name, "unused_variables");
431 assert_eq!(check.verdict, Verdict::Warn);
432 assert_eq!(check.severity, Some(Severity::Warning));
433 assert_eq!(check.detail.as_deref(), Some("unused variable: `x`"));
434 assert_eq!(check.evidence.len(), 2);
435 }
436
437 #[test]
438 fn parse_cargo_message_line_maps_error() {
439 let line = r#"{"reason":"compiler-message","message":{"level":"error","message":"cannot find type `Foo`","spans":[{"file_name":"src/main.rs","line_start":3,"line_end":3,"is_primary":true,"byte_start":0,"byte_end":1,"column_start":1,"column_end":2,"text":[]}],"code":{"code":"E0412"},"rendered":"error[E0412]: cannot find type `Foo`"}}"#;
440 let check = parse_cargo_message_line(line).expect("should parse");
441 assert_eq!(check.name, "E0412");
442 assert_eq!(check.verdict, Verdict::Fail);
443 assert_eq!(check.severity, Some(Severity::Error));
444 }
445
446 #[test]
447 fn parse_cargo_message_line_ignores_non_diagnostic_reasons() {
448 for line in [
449 r#"{"reason":"compiler-artifact","package_id":"x"}"#,
450 r#"{"reason":"build-finished","success":true}"#,
451 r#"{"reason":"build-script-executed","package_id":"x"}"#,
452 ] {
453 assert!(parse_cargo_message_line(line).is_none());
454 }
455 }
456
457 #[test]
458 fn parse_cargo_message_line_handles_diagnostic_without_code() {
459 let line = r#"{"reason":"compiler-message","message":{"level":"warning","message":"this is a long warning that has no diagnostic code attached","spans":[],"code":null,"rendered":""}}"#;
460 let check = parse_cargo_message_line(line).expect("should parse");
461 assert_eq!(
462 check.name,
463 "this is a long warning that has no diagnostic code attached"
464 );
465 }
466
467 #[test]
468 fn parse_cargo_message_line_truncates_very_long_message_for_name() {
469 let long = "a".repeat(200);
470 let line = format!(
471 r#"{{"reason":"compiler-message","message":{{"level":"warning","message":"{}","spans":[],"code":null,"rendered":""}}}}"#,
472 long
473 );
474 let check = parse_cargo_message_line(&line).expect("should parse");
475 assert!(check.name.ends_with("..."));
476 assert!(check.name.len() <= 80);
477 }
478
479 #[test]
480 fn parse_cargo_message_line_skips_unrecognized_levels() {
481 for level in ["note", "help", "failure-note"] {
482 let line = format!(
483 r#"{{"reason":"compiler-message","message":{{"level":"{}","message":"x","spans":[],"code":null,"rendered":""}}}}"#,
484 level
485 );
486 assert!(parse_cargo_message_line(&line).is_none(), "level {}", level);
487 }
488 }
489
490 #[test]
491 fn parse_cargo_message_line_ignores_malformed_json() {
492 assert!(parse_cargo_message_line("not json").is_none());
493 assert!(parse_cargo_message_line("").is_none());
494 }
495}