Skip to main content

dev_tools/
producers.rs

1//! Reusable [`Producer`] implementations for common `cargo` subcommands.
2//!
3//! Each producer spawns a `cargo` subprocess, captures its output, and
4//! converts the result into a [`Report`]. Subprocess failures (missing
5//! `cargo`, non-zero exit, parse errors) become a [`CheckResult::fail`]
6//! inside the produced report rather than panicking.
7//!
8//! ### Available producers
9//!
10//! | Function                | Subcommand        | Mapping                                                                 |
11//! |-------------------------|-------------------|-------------------------------------------------------------------------|
12//! | [`cargo_test_producer`] | `cargo test`      | Each test → one `CheckResult` (pass / fail+Error / skip for ignored).   |
13//! | [`clippy_producer`]     | `cargo clippy`    | Each diagnostic → one `CheckResult` (warning → warn, error → fail).     |
14//! | [`cargo_check_producer`]| `cargo check`     | Each diagnostic → one `CheckResult` (same mapping as clippy).           |
15//!
16//! Both `clippy` and `cargo check` parse `--message-format=json`. The
17//! producers do NOT escalate warnings to errors (no `-D warnings`); the
18//! distinction is preserved in the produced `CheckResult` verdicts.
19//!
20//! ### Environment
21//!
22//! `CARGO_TARGET_DIR`, `CARGO`, and the rest of the parent environment
23//! are inherited by the subprocess. Callers that need a clean environment
24//! should configure one before constructing the producer.
25//!
26//! [`Producer`]: dev_report::Producer
27//! [`Report`]: dev_report::Report
28//! [`CheckResult::fail`]: dev_report::CheckResult::fail
29
30use std::path::PathBuf;
31use std::process::Command;
32
33use dev_report::{CheckResult, Evidence, Producer, Report, Severity};
34use serde::Deserialize;
35
36// ---------------------------------------------------------------------------
37// cargo test
38// ---------------------------------------------------------------------------
39
40/// Producer that runs `cargo test --no-fail-fast` and maps libtest's
41/// human-readable output to one [`CheckResult`] per test.
42///
43/// Constructed via [`cargo_test_producer`].
44pub struct CargoTestProducer {
45    subject: String,
46    subject_version: String,
47    workdir: Option<PathBuf>,
48}
49
50impl CargoTestProducer {
51    /// Set the working directory to invoke `cargo` in. Defaults to the
52    /// current process CWD.
53    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
81/// Build a producer that runs `cargo test --no-fail-fast` and emits one
82/// [`CheckResult`] per test.
83///
84/// # Example
85///
86/// ```no_run
87/// use dev_tools::producers::cargo_test_producer;
88/// use dev_tools::report::Producer;
89///
90/// let producer = cargo_test_producer("my-crate", "0.1.0");
91/// let report = producer.produce();
92/// println!("{}", report.to_json().unwrap());
93/// ```
94pub 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        // libtest emits one line per test, of the shape:
109        //   test some::name ... ok
110        //   test some::name ... FAILED
111        //   test some::name ... ignored
112        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
132// ---------------------------------------------------------------------------
133// cargo clippy / cargo check
134// ---------------------------------------------------------------------------
135
136/// Producer that runs `cargo clippy --message-format=json` and maps each
137/// compiler diagnostic to one [`CheckResult`].
138///
139/// Constructed via [`clippy_producer`].
140pub struct ClippyProducer {
141    subject: String,
142    subject_version: String,
143    workdir: Option<PathBuf>,
144}
145
146impl ClippyProducer {
147    /// Set the working directory to invoke `cargo` in.
148    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
163/// Build a producer that runs `cargo clippy --message-format=json`.
164///
165/// Each diagnostic emitted by clippy becomes a `CheckResult`. Warnings
166/// map to `Verdict::Warn` + `Severity::Warning`; errors map to
167/// `Verdict::Fail` + `Severity::Error`. Source locations propagate as
168/// `Evidence::FileRef`; the rendered diagnostic text propagates as
169/// `Evidence::Snippet`.
170///
171/// # Example
172///
173/// ```no_run
174/// use dev_tools::producers::clippy_producer;
175/// use dev_tools::report::Producer;
176///
177/// let producer = clippy_producer("my-crate", "0.1.0");
178/// let report = producer.produce();
179/// println!("{}", report.to_json().unwrap());
180/// ```
181pub 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
192/// Producer that runs `cargo check --message-format=json` and maps each
193/// compiler diagnostic to one [`CheckResult`].
194///
195/// Constructed via [`cargo_check_producer`].
196pub struct CargoCheckProducer {
197    subject: String,
198    subject_version: String,
199    workdir: Option<PathBuf>,
200}
201
202impl CargoCheckProducer {
203    /// Set the working directory to invoke `cargo` in.
204    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
219/// Build a producer that runs `cargo check --message-format=json`.
220///
221/// Same diagnostic-to-CheckResult mapping as [`clippy_producer`].
222///
223/// # Example
224///
225/// ```no_run
226/// use dev_tools::producers::cargo_check_producer;
227/// use dev_tools::report::Producer;
228///
229/// let producer = cargo_check_producer("my-crate", "0.1.0");
230/// let report = producer.produce();
231/// println!("{}", report.to_json().unwrap());
232/// ```
233pub 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, // ignore notes, helps, ICE notes, etc.
299    };
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    // Diagnostics without a code (e.g. some internal ones) still need a
335    // stable-ish name. Take the first line, trim, and cap at 80 chars.
336    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
344// Local alias so we don't need to `use dev_report::Verdict` only for matching.
345use dev_report::Verdict;
346
347// ---------------------------------------------------------------------------
348// Subprocess plumbing
349// ---------------------------------------------------------------------------
350
351struct 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}