1#![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#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, ValueEnum)]
35#[non_exhaustive]
36pub enum Mode {
37 Github,
44 GithubSummary,
46 GithubPrAnnotation,
48 Human,
50}
51
52#[derive(Debug, Default, Clone, Copy)]
58pub struct RunReport {
59 pub errors: usize,
61 pub warnings: usize,
63 pub any_success: bool,
65 pub any_failure: bool,
67}
68
69impl RunReport {
70 #[must_use]
73 pub const fn is_failure(&self) -> bool {
74 self.errors > 0 || self.any_failure
75 }
76}
77
78#[derive(Debug)]
80#[non_exhaustive]
81pub enum Error {
82 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
108pub 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
152fn 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 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 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}