1#![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#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, ValueEnum)]
33#[non_exhaustive]
34pub enum Mode {
35 GithubSummary,
37 GithubPrAnnotation,
39 Human,
41}
42
43#[derive(Debug)]
45#[non_exhaustive]
46pub enum Error {
47 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
73pub 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}