Skip to main content

lean_ctx_patterns/
dotnet.rs

1// SPDX-License-Identifier: Apache-2.0
2
3macro_rules! static_regex {
4    ($pattern:expr_2021) => {{
5        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6        RE.get_or_init(|| {
7            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
8        })
9    }};
10}
11
12fn build_summary_err_re() -> &'static regex::Regex {
13    static_regex!(r"(?i)^\s*(\d+)\s+Error\(s\)\s*$")
14}
15fn build_summary_warn_re() -> &'static regex::Regex {
16    static_regex!(r"(?i)^\s*(\d+)\s+Warning\(s\)\s*$")
17}
18fn build_result_re() -> &'static regex::Regex {
19    static_regex!(r"(?i)^(Build succeeded\.|Build FAILED\.)")
20}
21fn restored_proj_re() -> &'static regex::Regex {
22    static_regex!(r"(?i)^\s*Restored\s+(.+\.csproj[^(\n]*)(?:\s*\([^)]*\))?\s*\.?\s*$")
23}
24fn restored_pkg_re() -> &'static regex::Regex {
25    static_regex!(r"(?i)Restored\s+(\d+)\s+package")
26}
27fn test_total_re() -> &'static regex::Regex {
28    static_regex!(r"(?i)^\s*Total tests:\s*(\d+)\s*$")
29}
30fn publish_arrow_re() -> &'static regex::Regex {
31    static_regex!(r"\s+->\s+")
32}
33
34fn is_msbuild_noise(line: &str) -> bool {
35    let t = line.trim_start();
36    let tl = t.to_ascii_lowercase();
37    if tl.starts_with("microsoft (r) build engine")
38        || tl.starts_with("copyright (c) microsoft")
39        || tl.contains("version ") && tl.contains("msbuild")
40    {
41        return true;
42    }
43    if tl.starts_with("verbosity:") || tl == "build started." {
44        return true;
45    }
46    // Progress / target spam (typical minimal+ still has some)
47    if tl.starts_with("time elapsed") && tl.contains("00:00:") {
48        return true;
49    }
50    false
51}
52
53fn is_dotnet_restore_noise(line: &str) -> bool {
54    let tl = line.trim().to_ascii_lowercase();
55    tl.starts_with("determining projects to restore")
56        || tl.contains("assets file has not changed")
57        || tl.starts_with("writing assets file")
58}
59
60fn looks_like_build_error_line(line: &str) -> bool {
61    let t = line.trim();
62    let tl = t.to_ascii_lowercase();
63    if tl.contains(": error ") || tl.starts_with("error ") {
64        return true;
65    }
66    if tl.contains("msbuild : error") || tl.contains("error msb") {
67        return true;
68    }
69    if tl.contains(": error cs") || tl.contains(": error mc") {
70        return true;
71    }
72    false
73}
74
75fn looks_like_build_warning_line(line: &str) -> bool {
76    let t = line.trim();
77    let tl = t.to_ascii_lowercase();
78    (tl.contains(": warning ") || tl.starts_with("warning ")) && !tl.contains("warning(s)")
79}
80
81pub fn compress(command: &str, output: &str) -> Option<String> {
82    let cl = command.trim().to_ascii_lowercase();
83    if !cl.starts_with("dotnet ") {
84        return None;
85    }
86
87    let sub = cl
88        .split_whitespace()
89        .nth(1)
90        .unwrap_or("")
91        .trim_start_matches('-');
92    match sub {
93        "build" | "msbuild" => return Some(compress_build(output)),
94        "test" | "vstest" => return Some(compress_test(output)),
95        "restore" => return Some(compress_restore(output)),
96        "publish" => return Some(compress_publish(output)),
97        _ => {}
98    }
99
100    None
101}
102
103fn compress_build(output: &str) -> String {
104    let mut parts = Vec::new();
105    let mut errors = Vec::new();
106    let mut warnings = Vec::new();
107    let mut result_line: Option<String> = None;
108    let mut summary_errors: Option<String> = None;
109    let mut summary_warnings: Option<String> = None;
110
111    for line in output.lines() {
112        let t = line.trim_end();
113        if t.trim().is_empty() || is_msbuild_noise(t) {
114            continue;
115        }
116        let trim = t.trim();
117        if build_result_re().is_match(trim) {
118            result_line = Some(trim.to_string());
119            continue;
120        }
121        if let Some(caps) = build_summary_err_re().captures(trim) {
122            summary_errors = Some(format!("{} Error(s)", &caps[1]));
123            continue;
124        }
125        if let Some(caps) = build_summary_warn_re().captures(trim) {
126            summary_warnings = Some(format!("{} Warning(s)", &caps[1]));
127            continue;
128        }
129        if looks_like_build_error_line(trim) {
130            errors.push(trim.to_string());
131            continue;
132        }
133        if looks_like_build_warning_line(trim) {
134            warnings.push(trim.to_string());
135        }
136    }
137
138    if let Some(r) = result_line {
139        parts.push(r);
140    }
141    if let Some(s) = summary_warnings {
142        parts.push(s);
143    }
144    if let Some(s) = summary_errors {
145        parts.push(s);
146    }
147    if !errors.is_empty() {
148        parts.push(format!("{} error lines:", errors.len()));
149        parts.extend(errors.into_iter().map(|e| format!("  {e}")));
150    }
151    if !warnings.is_empty() && warnings.len() <= 20 {
152        parts.push(format!("{} warning lines:", warnings.len()));
153        parts.extend(warnings.into_iter().map(|w| format!("  {w}")));
154    } else if !warnings.is_empty() {
155        parts.push(format!("{} warnings (omitted detail)", warnings.len()));
156    }
157
158    if parts.is_empty() {
159        compact_or_ok(output, 8)
160    } else {
161        parts.join("\n")
162    }
163}
164
165fn compress_test(output: &str) -> String {
166    let mut parts = Vec::new();
167    let mut in_failure = false;
168    let mut failure_block: Vec<String> = Vec::new();
169
170    for line in output.lines() {
171        let t = line.trim_end();
172        let trim = t.trim();
173        let tl = trim.to_ascii_lowercase();
174
175        if tl.contains("test run failed") || tl == "failed!" {
176            parts.push(trim.to_string());
177        }
178        if tl.starts_with("passed!") || tl.starts_with("failed!") {
179            parts.push(trim.to_string());
180        }
181        if test_total_re().is_match(trim) {
182            parts.push(trim.to_string());
183        }
184        if tl.starts_with("passed:")
185            || tl.starts_with("failed:")
186            || tl.starts_with("skipped:")
187            || tl.starts_with("total:")
188        {
189            parts.push(trim.to_string());
190        }
191        if tl.contains("error message:") || tl.contains("stack trace:") {
192            in_failure = true;
193        }
194        if looks_like_build_error_line(trim) && tl.contains("error") {
195            parts.push(trim.to_string());
196        }
197
198        if in_failure && !trim.is_empty() {
199            failure_block.push(trim.to_string());
200            if failure_block.len() > 40 {
201                in_failure = false;
202            }
203        }
204    }
205
206    if !failure_block.is_empty() {
207        parts.push("failure detail:".to_string());
208        parts.extend(failure_block.into_iter().take(25).map(|l| format!("  {l}")));
209    }
210
211    if parts.is_empty() {
212        compact_or_ok(output, 12)
213    } else {
214        parts.join("\n")
215    }
216}
217
218fn compress_restore(output: &str) -> String {
219    let mut restored_projects = Vec::new();
220    let mut pkg_summary: Option<String> = None;
221
222    for line in output.lines() {
223        let t = line.trim_end();
224        if t.trim().is_empty() || is_dotnet_restore_noise(t) {
225            continue;
226        }
227        let trim = t.trim();
228        if let Some(caps) = restored_proj_re().captures(trim) {
229            restored_projects.push(caps[1].trim().to_string());
230            continue;
231        }
232        if let Some(caps) = restored_pkg_re().captures(trim) {
233            pkg_summary = Some(format!("Restored {} packages (summary line)", &caps[1]));
234        }
235        if looks_like_build_error_line(trim) {
236            restored_projects.push(format!("ERR: {trim}"));
237        }
238    }
239
240    let mut parts = Vec::new();
241    if !restored_projects.is_empty() {
242        parts.push(format!("Restored {} project(s):", restored_projects.len()));
243        for p in restored_projects {
244            parts.push(format!("  {p}"));
245        }
246    }
247    if let Some(s) = pkg_summary {
248        parts.push(s);
249    }
250
251    if parts.is_empty() {
252        compact_or_ok(output, 10)
253    } else {
254        parts.join("\n")
255    }
256}
257
258fn compress_publish(output: &str) -> String {
259    let mut parts = Vec::new();
260
261    for line in output.lines() {
262        let t = line.trim_end();
263        if t.trim().is_empty() || is_msbuild_noise(t) {
264            continue;
265        }
266        let trim = t.trim();
267        if publish_arrow_re().is_match(trim) {
268            parts.push(trim.to_string());
269            continue;
270        }
271        if trim.to_ascii_lowercase().contains("published to")
272            || trim.to_ascii_lowercase().contains("output path")
273        {
274            parts.push(trim.to_string());
275        }
276        if build_result_re().is_match(trim) {
277            parts.push(trim.to_string());
278        }
279        if looks_like_build_error_line(trim) {
280            parts.push(trim.to_string());
281        }
282    }
283
284    if parts.is_empty() {
285        compact_or_ok(output, 10)
286    } else {
287        parts.join("\n")
288    }
289}
290
291fn compact_or_ok(output: &str, max: usize) -> String {
292    let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect();
293    if lines.is_empty() {
294        return "ok".to_string();
295    }
296    if lines.len() <= max {
297        return lines.join("\n");
298    }
299    format!(
300        "{}\n{}",
301        lines[..max].join("\n"),
302        super::elision_marker(lines.len() - max)
303    )
304}