lean_ctx/core/patterns/
dotnet.rs1use 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 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}