Skip to main content

lean_ctx/core/patterns/
cargo_diagnostics.rs

1//! Compact parsing and presentation for Cargo compiler diagnostics.
2
3use 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/// Counts and grouped details extracted from rustc or Clippy output.
16#[derive(Debug, Default, Eq, PartialEq)]
17pub struct DiagnosticSummary {
18    /// Total error diagnostics, including repeated messages.
19    pub error_count: u32,
20    /// Total warning diagnostics, including repeated warning groups.
21    pub warning_count: u32,
22    /// Unique error messages, including error codes when available.
23    pub error_messages: Vec<String>,
24    /// Warning rule names with occurrence counts, sorted by count descending.
25    pub warning_groups: Vec<(String, u32)>,
26}
27
28/// Separates Cargo output into its progress, diagnostic, and result sections.
29#[derive(Debug, Default, Eq, PartialEq)]
30pub struct BuildPhases {
31    /// Leading `Compiling`, `Checking`, and `Downloading` progress lines.
32    pub compile_phase: String,
33    /// Lines after progress and before Cargo reports a final result.
34    pub diagnostic_phase: String,
35    /// Lines beginning with `Finished` or `test result:`.
36    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
55/// Parses rustc and Clippy diagnostics into error messages and warning groups.
56pub 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
120/// Formats a diagnostic summary as compact, human-readable output.
121pub 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
172/// Collapses long contiguous Cargo progress runs while preserving other output.
173pub 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
250/// Splits Cargo output into compile progress, diagnostics, and final result phases.
251pub 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}