Skip to main content

lean_ctx/core/patterns/
dotnet.rs

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