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//! run(Mode::GithubSummary, io::stdin().lock(), io::stdout().lock(), io::stderr().lock())?;
13//! # Ok(())
14//! # }
15//! ```
16
17#![warn(missing_docs)]
18#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::needless_collect))]
19
20use clap::ValueEnum;
21use std::collections::BTreeSet;
22use std::io::{BufRead, Write};
23
24mod level;
25mod output;
26mod print;
27
28use level::Level;
29use output::Output;
30
31/// Output mode selected at the CLI.
32#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, ValueEnum)]
33#[non_exhaustive]
34pub enum Mode {
35    /// Markdown table for `$GITHUB_STEP_SUMMARY`.
36    GithubSummary,
37    /// GitHub workflow commands for inline PR annotations.
38    GithubPrAnnotation,
39    /// Plain rendered diagnostics.
40    Human,
41}
42
43/// Errors returned by [`run`].
44#[derive(Debug)]
45#[non_exhaustive]
46pub enum Error {
47    /// An I/O error while reading input or writing output.
48    Io(std::io::Error),
49}
50
51impl std::fmt::Display for Error {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        match self {
54            Self::Io(e) => write!(f, "I/O error: {e}"),
55        }
56    }
57}
58
59impl std::error::Error for Error {
60    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
61        match self {
62            Self::Io(e) => Some(e),
63        }
64    }
65}
66
67impl From<std::io::Error> for Error {
68    fn from(e: std::io::Error) -> Self {
69        Self::Io(e)
70    }
71}
72
73/// Reads clippy JSON from `reader`, formats according to `mode`, writes to `writer`.
74///
75/// Per-line JSON parse errors are logged to `error_writer` and the run continues;
76/// only I/O failures on `reader` or `writer` are fatal.
77///
78/// # Errors
79/// Returns [`Error::Io`] if reading from `reader`, writing to `writer`, or
80/// writing to `error_writer` fails.
81pub fn run<R, W, E>(mode: Mode, reader: R, mut writer: W, mut error_writer: E) -> Result<(), Error>
82where
83    R: BufRead,
84    W: Write,
85    E: Write,
86{
87    let (diagnostics, any_success) = parse(reader, &mut error_writer)?;
88    let text = match mode {
89        Mode::GithubSummary => print::github_summary(&diagnostics, any_success),
90        Mode::GithubPrAnnotation => print::github_pr_annotation(&diagnostics),
91        Mode::Human => print::human(&diagnostics),
92    };
93    writeln!(writer, "{text}")?;
94    Ok(())
95}
96
97fn parse<R, E>(reader: R, error_writer: &mut E) -> Result<(Vec<Output>, bool), Error>
98where
99    R: BufRead,
100    E: Write,
101{
102    let mut any_success = false;
103    let mut set: BTreeSet<Output> = BTreeSet::new();
104    for (idx, line) in reader.lines().enumerate() {
105        let line = line?;
106        match serde_json::from_str::<Output>(&line) {
107            Ok(output) => {
108                if output.success() {
109                    any_success = true;
110                }
111                if output.is_level(&Level::Error) || output.is_level(&Level::Warning) {
112                    set.insert(output);
113                }
114            }
115            Err(e) => {
116                writeln!(error_writer, "rust-rapport: line {}: invalid JSON: {e}", idx + 1)?;
117            }
118        }
119    }
120    Ok((set.into_iter().collect(), any_success))
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use std::io::Cursor;
127
128    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"}}"#;
129    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"}}"#;
130    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"}}"#;
131    const BUILD_OK: &str = r#"{"reason":"build-finished","success":true}"#;
132    const BUILD_KO: &str = r#"{"reason":"build-finished","success":false}"#;
133
134    fn run_capture(mode: Mode, input: &str) -> (String, String) {
135        let mut out = Vec::new();
136        let mut err = Vec::new();
137        run(mode, Cursor::new(input), &mut out, &mut err).expect("run should succeed");
138        (String::from_utf8(out).expect("utf8 stdout"), String::from_utf8(err).expect("utf8 stderr"))
139    }
140
141    #[test]
142    fn summary_happy_when_build_success_and_no_diagnostics() {
143        let input = format!("{BUILD_OK}\n");
144        let (out, err) = run_capture(Mode::GithubSummary, &input);
145        assert!(out.contains("Cargo is Happy"), "got: {out}");
146        assert!(err.is_empty());
147    }
148
149    #[test]
150    fn summary_sad_when_no_build_success_and_no_diagnostics() {
151        let input = format!("{BUILD_KO}\n");
152        let (out, _) = run_capture(Mode::GithubSummary, &input);
153        assert!(out.contains("Cargo is Sad"), "got: {out}");
154    }
155
156    #[test]
157    fn summary_table_has_header_in_returned_text() {
158        let input = format!("{WARN_LINE}\n{BUILD_KO}\n");
159        let (out, _) = run_capture(Mode::GithubSummary, &input);
160        assert!(out.contains("| Type | Message |"), "missing header: {out}");
161        assert!(out.contains("| ---- | ------- |"), "missing separator: {out}");
162        assert!(out.contains("warning"), "missing row: {out}");
163    }
164
165    #[test]
166    fn diagnostics_are_deduplicated_and_deterministic() {
167        let input = format!("{WARN_LINE}\n{WARN_LINE}\n{ERROR_LINE}\n");
168        let (out, _) = run_capture(Mode::GithubPrAnnotation, &input);
169        let lines: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect();
170        assert_eq!(lines.len(), 2, "expected 2 unique annotations, got: {out}");
171        let (out2, _) = run_capture(Mode::GithubPrAnnotation, &input);
172        assert_eq!(out, out2, "output must be deterministic");
173    }
174
175    #[test]
176    fn note_level_is_filtered_out() {
177        let input = format!("{NOTE_LINE}\n{BUILD_KO}\n");
178        let (out, _) = run_capture(Mode::GithubPrAnnotation, &input);
179        assert!(out.trim().is_empty(), "notes should be filtered: {out}");
180    }
181
182    #[test]
183    fn malformed_json_is_reported_on_stderr_and_skipped() {
184        let input = format!("{WARN_LINE}\nnot json\n{ERROR_LINE}\n{BUILD_KO}\n");
185        let (out, err) = run_capture(Mode::GithubPrAnnotation, &input);
186        assert!(err.contains("line 2"), "expected line number in stderr: {err}");
187        assert!(err.contains("invalid JSON"), "expected diagnostic: {err}");
188        let non_empty: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect();
189        assert_eq!(non_empty.len(), 2, "valid lines should still render: {out}");
190    }
191
192    #[test]
193    fn human_mode_emits_rendered_text() {
194        let input = format!("{WARN_LINE}\n{ERROR_LINE}\n");
195        let (out, _) = run_capture(Mode::Human, &input);
196        assert!(out.contains("warning: unused"));
197        assert!(out.contains("error: boom"));
198    }
199
200    #[test]
201    fn error_display_includes_source() {
202        let io_err = std::io::Error::other("broken pipe");
203        let e = Error::from(io_err);
204        assert!(e.to_string().contains("I/O error"));
205        assert!(e.to_string().contains("broken pipe"));
206    }
207}