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