1use crate::compress::generic::GenericCompressor;
2use crate::compress::Compressor;
3use serde::Deserialize;
4use std::collections::BTreeMap;
5
6pub struct GoCompressor;
7
8impl Compressor for GoCompressor {
9 fn matches(&self, command: &str) -> bool {
10 command
11 .split_whitespace()
12 .next()
13 .is_some_and(|head| head == "go")
14 }
15
16 fn compress(&self, command: &str, output: &str) -> String {
17 match go_subcommand(command).as_deref() {
18 Some("test") => compress_test(output),
19 Some("build") => compress_build(output),
20 Some("vet") => compress_vet(output),
21 _ => GenericCompressor::compress_output(output),
22 }
23 }
24}
25
26pub struct GolangciLintCompressor;
27
28impl Compressor for GolangciLintCompressor {
29 fn matches(&self, command: &str) -> bool {
30 command
31 .split_whitespace()
32 .any(|token| token == "golangci-lint")
33 }
34
35 fn compress(&self, _command: &str, output: &str) -> String {
36 compress_golangci(output)
37 }
38}
39
40fn go_subcommand(command: &str) -> Option<String> {
41 command.split_whitespace().nth(1).map(|s| s.to_string())
42}
43
44fn compress_test(output: &str) -> String {
45 let lines: Vec<&str> = output.lines().collect();
46 let mut kept = Vec::new();
47 let mut index = 0usize;
48
49 while index < lines.len() {
50 let line = lines[index];
51 let trimmed = line.trim_start();
52
53 if is_go_download_chatter(trimmed) || trimmed.starts_with("=== RUN") {
54 index += 1;
55 continue;
56 }
57
58 if trimmed.starts_with("--- FAIL") {
59 let mut block = Vec::new();
60 block.push(line.to_string());
61 index += 1;
62 while index < lines.len() {
63 let next = lines[index];
64 let next_trimmed = next.trim_start();
65 if next_trimmed.starts_with("=== RUN")
66 || next_trimmed.starts_with("--- PASS")
67 || next_trimmed.starts_with("--- FAIL")
68 || is_final_go_test_line(next_trimmed)
69 || is_go_download_chatter(next_trimmed)
70 {
71 break;
72 }
73 block.push(next.to_string());
74 index += 1;
75 }
76 kept.extend(block);
77 continue;
78 }
79
80 if trimmed.starts_with("--- PASS") {
81 index += 1;
82 continue;
83 }
84
85 if is_panic_or_stack_line(trimmed) || is_final_go_test_line(trimmed) {
86 kept.push(line.to_string());
87 }
88
89 index += 1;
90 }
91
92 trim_trailing_lines(&kept.join("\n"))
93}
94
95fn compress_build(output: &str) -> String {
96 let errors: Vec<String> = output
97 .lines()
98 .filter_map(|line| {
99 let trimmed = line.trim_start();
100 if is_go_download_chatter(trimmed) {
101 return None;
102 }
103 if is_go_file_location_line(trimmed) {
104 Some(line.to_string())
105 } else {
106 None
107 }
108 })
109 .collect();
110
111 if errors.is_empty() {
112 "go build: ok".to_string()
113 } else {
114 trim_trailing_lines(&errors.join("\n"))
115 }
116}
117
118fn compress_vet(output: &str) -> String {
119 let warnings: Vec<String> = output
120 .lines()
121 .filter_map(|line| {
122 let trimmed = line.trim_start();
123 if is_go_file_location_line(trimmed) && trimmed.contains(": vet: ") {
124 Some(line.to_string())
125 } else {
126 None
127 }
128 })
129 .collect();
130
131 if warnings.is_empty() {
132 "go vet: clean".to_string()
133 } else {
134 trim_trailing_lines(&warnings.join("\n"))
135 }
136}
137
138fn compress_golangci(output: &str) -> String {
139 if output.trim().is_empty() {
140 return "golangci-lint: clean".to_string();
141 }
142
143 if looks_like_golangci_json(output) {
144 return compress_golangci_json(output);
145 }
146
147 compress_golangci_text(output)
148}
149
150#[derive(Debug, Deserialize)]
151struct GolangciJsonOutput {
152 #[serde(rename = "Issues", default)]
153 issues: Vec<GolangciIssue>,
154}
155
156#[derive(Debug, Deserialize)]
157struct GolangciIssue {
158 #[serde(rename = "FromLinter")]
159 from_linter: String,
160 #[serde(rename = "Text")]
161 text: String,
162 #[serde(rename = "Pos")]
163 pos: GolangciPosition,
164}
165
166#[derive(Debug, Deserialize)]
167struct GolangciPosition {
168 #[serde(rename = "Filename")]
169 filename: String,
170 #[serde(rename = "Line")]
171 line: usize,
172 #[serde(rename = "Column")]
173 column: usize,
174}
175
176fn compress_golangci_json(output: &str) -> String {
177 let parsed = match serde_json::from_str::<GolangciJsonOutput>(output) {
178 Ok(parsed) => parsed,
179 Err(_) => return GenericCompressor::compress_output(output),
180 };
181
182 if parsed.issues.is_empty() {
183 return "golangci-lint: clean".to_string();
184 }
185
186 let mut by_linter: BTreeMap<String, Vec<GolangciIssue>> = BTreeMap::new();
187 for issue in parsed.issues {
188 by_linter
189 .entry(issue.from_linter.clone())
190 .or_default()
191 .push(issue);
192 }
193
194 let total: usize = by_linter.values().map(Vec::len).sum();
195 let mut sections = vec![format!("golangci-lint: {total} issues")];
196 for (linter, issues) in by_linter {
197 sections.push(format!("{linter} ({}):", issues.len()));
198 for issue in issues {
199 sections.push(format!(
200 " {}:{}:{}: {}",
201 issue.pos.filename, issue.pos.line, issue.pos.column, issue.text
202 ));
203 }
204 }
205
206 trim_trailing_lines(§ions.join("\n"))
207}
208
209fn compress_golangci_text(output: &str) -> String {
210 let lines: Vec<&str> = output.lines().collect();
211 let mut kept = Vec::new();
212 let mut in_summary = false;
213
214 for line in lines {
215 let trimmed = line.trim_start();
216 if in_summary {
217 if trimmed.starts_with('*') || trimmed.is_empty() {
218 kept.push(line.to_string());
219 } else {
220 in_summary = false;
221 }
222 continue;
223 }
224
225 if is_golangci_issue_line(trimmed) {
226 kept.push(line.to_string());
227 continue;
228 }
229
230 if is_golangci_summary_header(trimmed) {
231 kept.push(line.to_string());
232 in_summary = true;
233 }
234 }
235
236 if kept.is_empty() {
237 "golangci-lint: clean".to_string()
238 } else {
239 trim_trailing_lines(&kept.join("\n"))
240 }
241}
242
243fn looks_like_golangci_json(output: &str) -> bool {
244 let trimmed = output.trim_start();
245 trimmed.starts_with('{')
246 && trimmed
247 .chars()
248 .take(200)
249 .collect::<String>()
250 .contains("\"Issues\"")
251}
252
253fn is_go_download_chatter(trimmed: &str) -> bool {
254 trimmed.starts_with("go: downloading ")
255 || trimmed.starts_with("go: finding ")
256 || trimmed.starts_with("go: extracting ")
257}
258
259fn is_final_go_test_line(trimmed: &str) -> bool {
260 trimmed == "PASS"
261 || trimmed == "FAIL"
262 || trimmed.starts_with("ok ")
263 || trimmed.starts_with("ok\t")
264 || trimmed.starts_with("FAIL ")
265 || trimmed.starts_with("FAIL\t")
266 || trimmed.starts_with("? ")
267 || trimmed.starts_with("?\t")
268 || trimmed.starts_with("exit status ")
269}
270
271fn is_panic_or_stack_line(trimmed: &str) -> bool {
272 trimmed.starts_with("panic:")
273 || trimmed.starts_with("fatal error:")
274 || trimmed.starts_with("goroutine ")
275 || trimmed.starts_with("created by ")
276 || trimmed.starts_with("runtime.")
277}
278
279fn is_go_file_location_line(trimmed: &str) -> bool {
280 let Some(pos) = trimmed.find(".go:") else {
281 return false;
282 };
283 let rest = &trimmed[pos + 4..];
284 let Some((line, rest)) = rest.split_once(':') else {
285 return false;
286 };
287 if line.is_empty() || !line.chars().all(|c| c.is_ascii_digit()) {
288 return false;
289 }
290 let Some((column, _message)) = rest.split_once(':') else {
291 return false;
292 };
293 !column.is_empty() && column.chars().all(|c| c.is_ascii_digit())
294}
295
296fn is_golangci_issue_line(trimmed: &str) -> bool {
297 is_go_file_location_line(trimmed)
298}
299
300fn is_golangci_summary_header(trimmed: &str) -> bool {
301 let Some(count) = trimmed.strip_suffix(" issues:") else {
302 return false;
303 };
304 !count.is_empty() && count.chars().all(|c| c.is_ascii_digit())
305}
306
307fn trim_trailing_lines(input: &str) -> String {
308 input
309 .lines()
310 .map(str::trim_end)
311 .collect::<Vec<_>>()
312 .join("\n")
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn matches_go_head_token_and_golangci_token_anywhere() {
321 let go = GoCompressor;
322 assert!(go.matches("go test ./..."));
323 assert!(go.matches("go"));
324 assert!(!go.matches("goimports ./..."));
325 assert!(!go.matches("gomod tidy"));
326 assert!(!go.matches("pingo go"));
327
328 let golangci = GolangciLintCompressor;
329 assert!(golangci.matches("golangci-lint run ./..."));
330 assert!(golangci.matches("go tool golangci-lint run ./..."));
331 assert!(golangci.matches("xargs golangci-lint"));
332 assert!(!golangci.matches("golangci-lint-wrapper run"));
333 assert!(!golangci.matches("go test ./..."));
334 }
335
336 #[test]
337 fn go_test_failure_block_preserves_fail_block_and_stack_trace() {
338 let output = r#"=== RUN TestFoo
339--- PASS: TestFoo (0.00s)
340=== RUN TestBar
341--- FAIL: TestBar (0.01s)
342 bar_test.go:25: expected 5, got 3
343panic: boom
344
345goroutine 7 [running]:
346example.com/pkg/bar.TestBar()
347 /tmp/bar_test.go:26 +0x55
348FAIL
349exit status 1
350FAIL example.com/pkg/bar 0.123s"#;
351
352 let compressed = GoCompressor.compress("go test ./...", output);
353 assert!(compressed.contains("--- FAIL: TestBar (0.01s)"));
354 assert!(compressed.contains(" bar_test.go:25: expected 5, got 3"));
355 assert!(compressed.contains("panic: boom"));
356 assert!(compressed.contains("goroutine 7 [running]:"));
357 assert!(compressed.contains("FAIL\texample.com/pkg/bar\t0.123s"));
358 assert!(!compressed.contains("--- PASS: TestFoo"));
359 }
360
361 #[test]
362 fn go_test_happy_path_drops_download_and_pass_noise() {
363 let output = r#"go: downloading github.com/foo/bar v1.2.3
364=== RUN TestFoo
365--- PASS: TestFoo (0.00s)
366=== RUN TestBar
367--- PASS: TestBar (0.01s)
368PASS
369ok example.com/pkg/foo 0.123s"#;
370
371 let compressed = GoCompressor.compress("go test ./...", output);
372 assert_eq!(compressed, "PASS\nok \texample.com/pkg/foo\t0.123s");
373 assert!(!compressed.contains("downloading"));
374 assert!(!compressed.contains("TestFoo"));
375 assert!(!compressed.contains("--- PASS"));
376 }
377
378 #[test]
379 fn go_build_keeps_error_lines_and_reports_ok_when_clean() {
380 let output = r#"go: downloading github.com/foo/bar v1.2.3
381# example.com/pkg
382main.go:10:5: undefined: missingFunc
383internal/lib.go:22:12: cannot use x as string"#;
384
385 let compressed = GoCompressor.compress("go build ./...", output);
386 assert_eq!(
387 compressed,
388 "main.go:10:5: undefined: missingFunc\ninternal/lib.go:22:12: cannot use x as string"
389 );
390 assert_eq!(GoCompressor.compress("go build ./...", ""), "go build: ok");
391 assert_eq!(
392 GoCompressor.compress(
393 "go build ./...",
394 "go: downloading github.com/pkg/errors v0.9.1"
395 ),
396 "go build: ok"
397 );
398 }
399
400 #[test]
401 fn golangci_json_groups_by_linter_and_text_keeps_verbatim_lines() {
402 let json = r#"{"Issues":[{"FromLinter":"unused","Text":"unused variable `x`","Pos":{"Filename":"src/foo.go","Line":10,"Column":5}},{"FromLinter":"golint","Text":"variable `Foo` should be `foo`","Pos":{"Filename":"src/foo.go","Line":25,"Column":1}},{"FromLinter":"unused","Text":"unused variable `y`","Pos":{"Filename":"src/bar.go","Line":3,"Column":8}}],"Report":{"Linters":[]}}"#;
403
404 let compressed =
405 GolangciLintCompressor.compress("golangci-lint run --out-format json", json);
406 assert!(compressed.contains("golangci-lint: 3 issues"));
407 assert!(
408 compressed.contains("golint (1):\n src/foo.go:25:1: variable `Foo` should be `foo`")
409 );
410 assert!(compressed.contains("unused (2):"));
411 assert!(compressed.contains("src/foo.go:10:5: unused variable `x`"));
412 assert!(compressed.contains("src/bar.go:3:8: unused variable `y`"));
413
414 let text = r#"src/foo.go:10:5: unused variable `x` (unused)
415src/foo.go:25:1: variable `Foo` should be `foo` (golint)
416src/bar.go:3:8: ineffectual assignment (ineffassign)
4173 issues:
418* unused: 1
419* golint: 1
420* ineffassign: 1"#;
421 assert_eq!(
422 GolangciLintCompressor.compress("golangci-lint run", text),
423 text
424 );
425 assert_eq!(
426 GolangciLintCompressor.compress("golangci-lint run", ""),
427 "golangci-lint: clean"
428 );
429 }
430
431 #[test]
432 fn go_vet_keeps_vet_warnings_and_reports_clean() {
433 let output = "# example.com/pkg\nmain.go:42:2: vet: Printf format %d has arg x of wrong type string\nother output";
434 assert_eq!(
435 GoCompressor.compress("go vet ./...", output),
436 "main.go:42:2: vet: Printf format %d has arg x of wrong type string"
437 );
438 assert_eq!(GoCompressor.compress("go vet ./...", ""), "go vet: clean");
439 }
440
441 #[test]
442 fn large_input_compresses_noisy_go_test_output() {
443 let mut raw = String::new();
444 for idx in 0..500 {
445 raw.push_str(&format!("go: downloading example.com/pkg{idx} v1.0.0\n"));
446 raw.push_str(&format!("=== RUN TestPass{idx}\n"));
447 raw.push_str(&format!("--- PASS: TestPass{idx} (0.00s)\n"));
448 }
449 raw.push_str("=== RUN TestFail\n");
450 raw.push_str("--- FAIL: TestFail (0.01s)\n");
451 raw.push_str(" fail_test.go:10: expected true\n");
452 raw.push_str("FAIL\nFAIL\texample.com/pkg\t0.999s\n");
453
454 let compressed = GoCompressor.compress("go test ./...", &raw);
455 assert!(compressed.contains("--- FAIL: TestFail (0.01s)"));
456 assert!(compressed.contains("fail_test.go:10"));
457 assert!(compressed.contains("FAIL\texample.com/pkg\t0.999s"));
458 assert!(compressed.len() * 10 < raw.len());
459 }
460}