Skip to main content

rust_rapport/
lib.rs

1//! Formats `cargo clippy --message-format json` output for GitHub Actions.
2//!
3//! Read diagnostics from a [`BufRead`] stream, filter errors and warnings,
4//! and write the result to a [`Write`] sink using one of the three [`Mode`]
5//! variants.
6//!
7//! ```no_run
8//! use std::io;
9//! use rust_rapport::{Mode, run};
10//!
11//! # fn main() -> Result<(), rust_rapport::Error> {
12//! let mut stdout = io::stdout().lock();
13//! let mut stderr = io::stderr().lock();
14//! let report = run(Mode::Github, io::stdin().lock(), &mut stdout, &mut stderr)?;
15//! std::process::exit(if report.is_failure() { 1 } else { 0 });
16//! # }
17//! ```
18
19#![warn(missing_docs)]
20#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::needless_collect))]
21
22use clap::ValueEnum;
23use std::collections::BTreeSet;
24use std::io::{BufRead, Write};
25
26mod level;
27mod output;
28mod print;
29
30use level::Level;
31use output::Output;
32
33/// Output mode selected at the CLI.
34#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, ValueEnum)]
35#[non_exhaustive]
36pub enum Mode {
37    /// Convenience mode for GitHub Actions: appends the Markdown summary to
38    /// `$GITHUB_STEP_SUMMARY`, emits PR workflow commands on stdout, and lets
39    /// the caller set the process exit status based on the [`RunReport`]
40    /// returned by [`run`] (non-zero when clippy reported errors or the build
41    /// failed). Equivalent to the legacy `tee >(github-summary) >(github-pr-annotation)`
42    /// bash incantation, without the `PIPESTATUS` dance.
43    Github,
44    /// Markdown table for `$GITHUB_STEP_SUMMARY`.
45    GithubSummary,
46    /// GitHub workflow commands for inline PR annotations.
47    GithubPrAnnotation,
48    /// Plain rendered diagnostics.
49    Human,
50}
51
52/// Summary of what [`run`] observed in the clippy stream.
53///
54/// Returned so the caller can choose an exit code that mirrors clippy's
55/// (non-zero when the build failed or at least one error-level diagnostic
56/// was reported).
57#[derive(Debug, Default, Clone, Copy)]
58pub struct RunReport {
59    /// Number of unique error-level diagnostics.
60    pub errors: usize,
61    /// Number of unique warning-level diagnostics.
62    pub warnings: usize,
63    /// At least one `build-finished` message reported `success: true`.
64    pub any_success: bool,
65    /// At least one `build-finished` message reported `success: false`.
66    pub any_failure: bool,
67}
68
69impl RunReport {
70    /// `true` if clippy would have exited non-zero: either the build was
71    /// marked as failed or at least one error-level diagnostic was emitted.
72    #[must_use]
73    pub const fn is_failure(&self) -> bool {
74        self.errors > 0 || self.any_failure
75    }
76}
77
78/// Errors returned by [`run`].
79#[derive(Debug)]
80#[non_exhaustive]
81pub enum Error {
82    /// An I/O error while reading input or writing output.
83    Io(std::io::Error),
84}
85
86impl std::fmt::Display for Error {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Self::Io(e) => write!(f, "I/O error: {e}"),
90        }
91    }
92}
93
94impl std::error::Error for Error {
95    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
96        match self {
97            Self::Io(e) => Some(e),
98        }
99    }
100}
101
102impl From<std::io::Error> for Error {
103    fn from(e: std::io::Error) -> Self {
104        Self::Io(e)
105    }
106}
107
108/// Reads clippy JSON from `reader`, formats according to `mode`, writes to `writer`.
109///
110/// Per-line JSON parse errors are logged to `error_writer` and the run continues;
111/// only I/O failures on `reader` or `writer` are fatal. In [`Mode::Github`] the
112/// summary is appended to the file at `$GITHUB_STEP_SUMMARY` (if set) instead
113/// of being written to `writer`; annotations still go to `writer`.
114///
115/// # Errors
116/// Returns [`Error::Io`] if reading from `reader`, writing to `writer`, or
117/// writing to `error_writer` / `$GITHUB_STEP_SUMMARY` fails.
118pub fn run<R, W, E>(
119    mode: Mode,
120    reader: R,
121    writer: &mut W,
122    error_writer: &mut E,
123) -> Result<RunReport, Error>
124where
125    R: BufRead,
126    W: Write + ?Sized,
127    E: Write + ?Sized,
128{
129    let (diagnostics, report) = parse(reader, error_writer)?;
130    match mode {
131        Mode::Github => {
132            write_github_summary(
133                &print::github_summary(&diagnostics, report.any_success),
134                error_writer,
135            )?;
136            let annot = print::github_pr_annotation(&diagnostics);
137            if !annot.is_empty() {
138                writeln!(writer, "{annot}")?;
139            }
140        }
141        Mode::GithubSummary => {
142            writeln!(writer, "{}", print::github_summary(&diagnostics, report.any_success))?;
143        }
144        Mode::GithubPrAnnotation => {
145            writeln!(writer, "{}", print::github_pr_annotation(&diagnostics))?;
146        }
147        Mode::Human => writeln!(writer, "{}", print::human(&diagnostics))?,
148    }
149    Ok(report)
150}
151
152/// Append `summary` to the file pointed to by `$GITHUB_STEP_SUMMARY`. Falls
153/// back to `error_writer` when the environment variable is unset — useful for
154/// local previews of the GitHub rendering.
155fn write_github_summary<E: Write + ?Sized>(
156    summary: &str,
157    error_writer: &mut E,
158) -> Result<(), Error> {
159    if let Some(path) = std::env::var_os("GITHUB_STEP_SUMMARY") {
160        writeln!(std::fs::OpenOptions::new().append(true).create(true).open(path)?, "{summary}")?;
161    } else {
162        writeln!(error_writer, "{summary}")?;
163    }
164    Ok(())
165}
166
167fn parse<R, E>(reader: R, error_writer: &mut E) -> Result<(Vec<Output>, RunReport), Error>
168where
169    R: BufRead,
170    E: Write + ?Sized,
171{
172    let lines = reader.lines().collect::<Result<Vec<_>, _>>()?;
173    let (diagnostics, report) = lines.into_iter().enumerate().try_fold(
174        (BTreeSet::<Output>::new(), RunReport::default()),
175        |(mut set, mut report), (idx, line)| -> Result<_, Error> {
176            match serde_json::from_str::<Output>(&line) {
177                Ok(output) => {
178                    match output.build_success() {
179                        Some(true) => report.any_success = true,
180                        Some(false) => report.any_failure = true,
181                        None => {}
182                    }
183                    if output.is_level(&Level::Error) || output.is_level(&Level::Warning) {
184                        set.insert(output);
185                    }
186                }
187                Err(e) => {
188                    writeln!(error_writer, "rust-rapport: line {}: invalid JSON: {e}", idx + 1)?;
189                }
190            }
191            Ok((set, report))
192        },
193    )?;
194    // Count distinct findings by level from the deduplicated set.
195    let errors = diagnostics.iter().filter(|o| o.is_level(&Level::Error)).count();
196    let warnings = diagnostics.iter().filter(|o| o.is_level(&Level::Warning)).count();
197    Ok((diagnostics.into_iter().collect(), RunReport { errors, warnings, ..report }))
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use std::io::Cursor;
204
205    const WARN_LINE: &str = r#"{"reason":"compiler-message","manifest_path":"/x/Cargo.toml","message":{"code":{"code":"unused"},"level":"warning","message":"unused variable: x","spans":[{"file_name":"src/main.rs","line_start":1,"line_end":1,"column_start":5,"column_end":6}],"rendered":"warning: unused"}}"#;
206    const ERROR_LINE: &str = r#"{"reason":"compiler-message","manifest_path":"/x/Cargo.toml","message":{"code":{"code":"E0001"},"level":"error","message":"boom","spans":[{"file_name":"src/lib.rs","line_start":2,"line_end":2,"column_start":1,"column_end":2}],"rendered":"error: boom"}}"#;
207    const NOTE_LINE: &str = r#"{"reason":"compiler-message","manifest_path":"/x/Cargo.toml","message":{"code":null,"level":"note","message":"a note","spans":[],"rendered":"note: a note"}}"#;
208    const BUILD_OK: &str = r#"{"reason":"build-finished","success":true}"#;
209    const BUILD_KO: &str = r#"{"reason":"build-finished","success":false}"#;
210
211    fn run_capture(mode: Mode, input: &str) -> (String, String) {
212        let mut out = Vec::new();
213        let mut err = Vec::new();
214        run(mode, Cursor::new(input), &mut out, &mut err).expect("run should succeed");
215        (String::from_utf8(out).expect("utf8 stdout"), String::from_utf8(err).expect("utf8 stderr"))
216    }
217
218    #[test]
219    fn summary_happy_when_build_success_and_no_diagnostics() {
220        let input = format!("{BUILD_OK}\n");
221        let (out, err) = run_capture(Mode::GithubSummary, &input);
222        assert!(out.contains("Cargo is Happy"), "got: {out}");
223        assert!(err.is_empty());
224    }
225
226    #[test]
227    fn summary_sad_when_no_build_success_and_no_diagnostics() {
228        let input = format!("{BUILD_KO}\n");
229        let (out, _) = run_capture(Mode::GithubSummary, &input);
230        assert!(out.contains("Cargo is Sad"), "got: {out}");
231    }
232
233    #[test]
234    fn summary_table_has_header_in_returned_text() {
235        let input = format!("{WARN_LINE}\n{BUILD_KO}\n");
236        let (out, _) = run_capture(Mode::GithubSummary, &input);
237        assert!(out.contains("| Level | Location | Rule | Message |"), "missing header: {out}");
238        assert!(out.contains("| --- | --- | --- | --- |"), "missing separator: {out}");
239        assert!(out.contains("⚠️ warning"), "missing row: {out}");
240    }
241
242    #[test]
243    fn diagnostics_are_deduplicated_and_deterministic() {
244        let input = format!("{WARN_LINE}\n{WARN_LINE}\n{ERROR_LINE}\n");
245        let (out, _) = run_capture(Mode::GithubPrAnnotation, &input);
246        let lines: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect();
247        assert_eq!(lines.len(), 2, "expected 2 unique annotations, got: {out}");
248        let (out2, _) = run_capture(Mode::GithubPrAnnotation, &input);
249        assert_eq!(out, out2, "output must be deterministic");
250    }
251
252    #[test]
253    fn note_level_is_filtered_out() {
254        let input = format!("{NOTE_LINE}\n{BUILD_KO}\n");
255        let (out, _) = run_capture(Mode::GithubPrAnnotation, &input);
256        assert!(out.trim().is_empty(), "notes should be filtered: {out}");
257    }
258
259    #[test]
260    fn malformed_json_is_reported_on_stderr_and_skipped() {
261        let input = format!("{WARN_LINE}\nnot json\n{ERROR_LINE}\n{BUILD_KO}\n");
262        let (out, err) = run_capture(Mode::GithubPrAnnotation, &input);
263        assert!(err.contains("line 2"), "expected line number in stderr: {err}");
264        assert!(err.contains("invalid JSON"), "expected diagnostic: {err}");
265        let non_empty: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect();
266        assert_eq!(non_empty.len(), 2, "valid lines should still render: {out}");
267    }
268
269    #[test]
270    fn human_mode_emits_rendered_text() {
271        let input = format!("{WARN_LINE}\n{ERROR_LINE}\n");
272        let (out, _) = run_capture(Mode::Human, &input);
273        assert!(out.contains("warning: unused"));
274        assert!(out.contains("error: boom"));
275    }
276
277    #[test]
278    fn error_display_includes_source() {
279        let io_err = std::io::Error::other("broken pipe");
280        let e = Error::from(io_err);
281        assert!(e.to_string().contains("I/O error"));
282        assert!(e.to_string().contains("broken pipe"));
283    }
284
285    /// Returns the `RunReport` built from `input` via a non-Github mode
286    /// (avoids touching `$GITHUB_STEP_SUMMARY` in unit tests — that behaviour
287    /// is covered by the integration tests in `tests/cli.rs`).
288    fn run_report(input: &str) -> RunReport {
289        let mut out = Vec::new();
290        let mut err = Vec::new();
291        run(Mode::GithubPrAnnotation, Cursor::new(input), &mut out, &mut err).expect("run")
292    }
293
294    #[test]
295    fn report_counts_errors_and_warnings_distinctly() {
296        let input = format!("{WARN_LINE}\n{ERROR_LINE}\n");
297        let r = run_report(&input);
298        assert_eq!(r.warnings, 1);
299        assert_eq!(r.errors, 1);
300        assert!(r.is_failure(), "any error must flip is_failure");
301    }
302
303    #[test]
304    fn report_is_not_failure_when_only_warnings() {
305        let r = run_report(&format!("{WARN_LINE}\n{BUILD_OK}\n"));
306        assert!(!r.is_failure());
307    }
308
309    #[test]
310    fn report_picks_up_build_success_flag() {
311        let r = run_report(&format!("{BUILD_OK}\n"));
312        assert!(r.any_success);
313        assert!(!r.any_failure);
314    }
315
316    #[test]
317    fn report_flags_failure_on_build_finished_false_without_diagnostics() {
318        let r = run_report(&format!("{BUILD_KO}\n"));
319        assert!(r.any_failure);
320        assert!(r.is_failure());
321    }
322}