Skip to main content

lean_ctx/core/patterns/
dotnet.rs

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