1use std::cmp::Reverse;
4use std::collections::BTreeMap;
5
6macro_rules! static_regex {
7 ($pattern:expr_2021) => {{
8 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
9 RE.get_or_init(|| {
10 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
11 })
12 }};
13}
14
15#[derive(Debug, Default, Eq, PartialEq)]
17pub struct DiagnosticSummary {
18 pub error_count: u32,
20 pub warning_count: u32,
22 pub error_messages: Vec<String>,
24 pub warning_groups: Vec<(String, u32)>,
26}
27
28#[derive(Debug, Default, Eq, PartialEq)]
30pub struct BuildPhases {
31 pub compile_phase: String,
33 pub diagnostic_phase: String,
35 pub result_phase: String,
37}
38
39fn error_re() -> &'static regex::Regex {
40 static_regex!(r"^error(?:\[([A-Z]\d+)\])?:\s*(.+)$")
41}
42
43fn clippy_warning_re() -> &'static regex::Regex {
44 static_regex!(r"^warning\[([^\]]+)\]:\s*(.+)$")
45}
46
47fn plain_warning_re() -> &'static regex::Regex {
48 static_regex!(r"^warning:\s*(.+)$")
49}
50
51fn warning_summary_re() -> &'static regex::Regex {
52 static_regex!(r"^(?:\d+ warnings? (?:emitted|generated)|.* generated \d+ warnings?)$")
53}
54
55pub fn parse_and_summarize(output: &str) -> DiagnosticSummary {
57 let mut summary = DiagnosticSummary::default();
58 let mut warning_counts = BTreeMap::new();
59
60 for line in output.lines() {
61 let trimmed = line.trim_start();
62 if let Some(captures) = error_re().captures(trimmed) {
63 summary.error_count += 1;
64 let message = match captures.get(1) {
65 Some(code) => format!("{} {}", code.as_str(), &captures[2]),
66 None => captures[2].to_string(),
67 };
68 if !summary.error_messages.contains(&message) {
69 summary.error_messages.push(message);
70 }
71 continue;
72 }
73
74 if let Some(captures) = clippy_warning_re().captures(trimmed) {
75 summary.warning_count += 1;
76 let rule = captures[1].rsplit("::").next().unwrap_or(&captures[1]);
77 *warning_counts
78 .entry(normalize_warning_group(rule))
79 .or_insert(0) += 1;
80 continue;
81 }
82
83 if let Some(captures) = plain_warning_re().captures(trimmed) {
84 let message = &captures[1];
85 if warning_summary_re().is_match(message) {
86 continue;
87 }
88 summary.warning_count += 1;
89 *warning_counts
90 .entry(normalize_warning_group(message))
91 .or_insert(0) += 1;
92 }
93 }
94
95 summary.warning_groups = warning_counts.into_iter().collect();
96 summary
97 .warning_groups
98 .sort_unstable_by_key(|(rule, count)| (Reverse(*count), rule.clone()));
99 summary
100}
101
102fn normalize_warning_group(message: &str) -> String {
103 let prefix = message.split(':').next().unwrap_or(message).trim();
104 let mut group = String::with_capacity(prefix.len());
105 let mut previous_separator = false;
106
107 for character in prefix.chars() {
108 if character.is_ascii_alphanumeric() || character == '_' {
109 group.push(character.to_ascii_lowercase());
110 previous_separator = false;
111 } else if !previous_separator && !group.is_empty() {
112 group.push('_');
113 previous_separator = true;
114 }
115 }
116
117 group.trim_end_matches('_').to_string()
118}
119
120pub fn format_summary(summary: &DiagnosticSummary) -> String {
122 let mut sections = Vec::new();
123
124 if summary.error_count > 0 {
125 let errors = summary.error_messages.join(", ");
126 let label = if summary.error_count == 1 {
127 "error"
128 } else {
129 "errors"
130 };
131 if errors.is_empty() {
132 sections.push(format!("{} {label}", summary.error_count));
133 } else {
134 sections.push(format!("{} {label}: {errors}", summary.error_count));
135 }
136 }
137
138 if summary.warning_count > 0 {
139 let label = if summary.warning_count == 1 {
140 "warning"
141 } else {
142 "warnings"
143 };
144 let mut groups = summary
145 .warning_groups
146 .iter()
147 .take(5)
148 .map(|(rule, count)| format!("{rule} ×{count}"))
149 .collect::<Vec<_>>();
150 let other_count = summary.warning_groups.len().saturating_sub(5);
151 if other_count > 0 {
152 groups.push(format!("+{other_count} others"));
153 }
154 if groups.is_empty() {
155 sections.push(format!("{} {label}", summary.warning_count));
156 } else {
157 sections.push(format!(
158 "{} {label} ({})",
159 summary.warning_count,
160 groups.join(", ")
161 ));
162 }
163 }
164
165 if sections.is_empty() {
166 "clean".to_string()
167 } else {
168 sections.join("\n")
169 }
170}
171
172pub fn fold_compile_progress(output: &str) -> String {
174 let mut result = Vec::new();
175 let lines = output.lines().collect::<Vec<_>>();
176 let mut index = 0;
177
178 while index < lines.len() {
179 let line = lines[index];
180 if is_ignored_progress_line(line) {
181 index += 1;
182 continue;
183 }
184
185 if progress_kind(line).is_none() {
186 result.push(line.to_string());
187 index += 1;
188 continue;
189 }
190
191 let start = index;
192 let mut compiled = 0;
193 let mut checked = 0;
194 let mut downloaded = 0;
195 while index < lines.len() {
196 match progress_kind(lines[index]) {
197 Some(ProgressKind::Compiling) => compiled += 1,
198 Some(ProgressKind::Checking) => checked += 1,
199 Some(ProgressKind::Downloading) => downloaded += 1,
200 None => break,
201 }
202 index += 1;
203 }
204
205 if compiled >= 3 || checked >= 3 || downloaded >= 3 {
206 result.push(format_progress_summary(compiled, checked, downloaded));
207 } else {
208 result.extend(lines[start..index].iter().map(ToString::to_string));
209 }
210 }
211
212 result.join("\n")
213}
214
215#[derive(Copy, Clone)]
216enum ProgressKind {
217 Compiling,
218 Checking,
219 Downloading,
220}
221
222fn progress_kind(line: &str) -> Option<ProgressKind> {
223 match line.trim_start() {
224 line if line.starts_with("Compiling ") => Some(ProgressKind::Compiling),
225 line if line.starts_with("Checking ") => Some(ProgressKind::Checking),
226 line if line.starts_with("Downloading ") => Some(ProgressKind::Downloading),
227 _ => None,
228 }
229}
230
231fn is_ignored_progress_line(line: &str) -> bool {
232 let trimmed = line.trim_start();
233 trimmed.starts_with("Fresh ") || trimmed.starts_with("Blocking waiting for file lock")
234}
235
236fn format_progress_summary(compiled: u32, checked: u32, downloaded: u32) -> String {
237 let mut parts = Vec::new();
238 if compiled > 0 {
239 parts.push(format!("compiled {compiled}"));
240 }
241 if checked > 0 {
242 parts.push(format!("checked {checked}"));
243 }
244 if downloaded > 0 {
245 parts.push(format!("downloaded {downloaded}"));
246 }
247 format!("[{} crates]", parts.join(", "))
248}
249
250pub fn split_build_phases(output: &str) -> BuildPhases {
252 let lines = output.lines().collect::<Vec<_>>();
253 let compile_end = lines
254 .iter()
255 .position(|line| progress_kind(line).is_none())
256 .unwrap_or(lines.len());
257 let result_start = lines
258 .iter()
259 .enumerate()
260 .skip(compile_end)
261 .find_map(|(index, line)| is_result_line(line).then_some(index))
262 .unwrap_or(lines.len());
263
264 BuildPhases {
265 compile_phase: lines[..compile_end].join("\n"),
266 diagnostic_phase: lines[compile_end..result_start].join("\n"),
267 result_phase: lines[result_start..].join("\n"),
268 }
269}
270
271fn is_result_line(line: &str) -> bool {
272 let trimmed = line.trim_start();
273 trimmed.starts_with("Finished ") || trimmed.starts_with("test result:")
274}
275
276#[cfg(test)]
277mod tests {
278 use super::{
279 DiagnosticSummary, fold_compile_progress, format_summary, parse_and_summarize,
280 split_build_phases,
281 };
282
283 #[test]
284 fn test_clean_build() {
285 let summary = parse_and_summarize(
286 "Finished dev profile [unoptimized + debuginfo] target(s) in 0.21s",
287 );
288
289 assert_eq!(summary, DiagnosticSummary::default());
290 }
291
292 #[test]
293 fn test_warnings_only() {
294 let output = "warning: unused import: `foo`\nwarning: unused import: `bar`\nwarning: unused import: `baz`\nwarning[dead_code]: function `old` is never used\nwarning[dead_code]: function `older` is never used";
295 let summary = parse_and_summarize(output);
296
297 assert_eq!(summary.warning_count, 5);
298 assert_eq!(
299 summary.warning_groups,
300 vec![
301 ("unused_import".to_string(), 3),
302 ("dead_code".to_string(), 2)
303 ]
304 );
305 }
306
307 #[test]
308 fn test_errors_only() {
309 let output = "error[E0308]: mismatched types\n --> src/main.rs:4:5\nerror[E0308]: expected `u32`, found `String`";
310 let summary = parse_and_summarize(output);
311
312 assert_eq!(summary.error_count, 2);
313 assert_eq!(
314 summary.error_messages,
315 vec![
316 "E0308 mismatched types",
317 "E0308 expected `u32`, found `String`"
318 ]
319 );
320 }
321
322 #[test]
323 fn test_mixed() {
324 let output = "warning: unused variable: `item`\nhelp: prefix it with an underscore\nerror[E0277]: the trait bound `Thing: Copy` is not satisfied";
325 let summary = parse_and_summarize(output);
326
327 assert_eq!(summary.error_count, 1);
328 assert_eq!(summary.warning_count, 1);
329 assert_eq!(
330 summary.error_messages,
331 vec!["E0277 the trait bound `Thing: Copy` is not satisfied"]
332 );
333 assert_eq!(
334 summary.warning_groups,
335 vec![("unused_variable".to_string(), 1)]
336 );
337 }
338
339 #[test]
340 fn test_clippy_rules() {
341 let summary = parse_and_summarize(
342 "warning[clippy::needless_borrow]: this expression creates a reference which is immediately dereferenced",
343 );
344
345 assert_eq!(summary.warning_count, 1);
346 assert_eq!(
347 summary.warning_groups,
348 vec![("needless_borrow".to_string(), 1)]
349 );
350 }
351
352 #[test]
353 fn test_summary_format_warnings() {
354 let summary = DiagnosticSummary {
355 error_count: 0,
356 warning_count: 21,
357 error_messages: Vec::new(),
358 warning_groups: vec![
359 ("unused_import".to_string(), 6),
360 ("dead_code".to_string(), 5),
361 ("needless_borrow".to_string(), 4),
362 ("unused_variable".to_string(), 3),
363 ("redundant_clone".to_string(), 2),
364 ("missing_docs".to_string(), 1),
365 ],
366 };
367
368 assert_eq!(
369 format_summary(&summary),
370 "21 warnings (unused_import ×6, dead_code ×5, needless_borrow ×4, unused_variable ×3, redundant_clone ×2, +1 others)"
371 );
372 }
373
374 #[test]
375 fn test_fold_progress() {
376 let compiling = (1..=10)
377 .map(|number| format!(" Compiling crate-{number} v1.0.0"))
378 .collect::<Vec<_>>();
379 let checking = (1..=5)
380 .map(|number| format!(" Checking crate-{number} v1.0.0"))
381 .collect::<Vec<_>>();
382 let mut lines = compiling;
383 lines.extend(checking);
384 lines.push("warning: unused import: `thing`".to_string());
385
386 assert_eq!(
387 fold_compile_progress(&lines.join("\n")),
388 "[compiled 10, checked 5 crates]\nwarning: unused import: `thing`"
389 );
390 }
391
392 #[test]
393 fn test_split_phases() {
394 let output = " Compiling app v0.1.0\n Checking dep v1.2.0\nwarning: unused import: `value`\n --> src/lib.rs:1:5\n Finished dev profile [unoptimized + debuginfo] target(s) in 0.22s\ntest result: ok. 4 passed; 0 failed; 0 ignored";
395 let phases = split_build_phases(output);
396
397 assert_eq!(
398 phases.compile_phase,
399 " Compiling app v0.1.0\n Checking dep v1.2.0"
400 );
401 assert_eq!(
402 phases.diagnostic_phase,
403 "warning: unused import: `value`\n --> src/lib.rs:1:5"
404 );
405 assert!(phases.result_phase.starts_with(" Finished dev profile"));
406 }
407}