1use crate::compress::generic::GenericCompressor;
2use crate::compress::{CompressionResult, Compressor, OutputProbe};
3use serde::Deserialize;
4use serde_json::Value;
5use std::collections::BTreeMap;
6
7pub struct GoCompressor;
8
9impl Compressor for GoCompressor {
10 fn matches(&self, command: &str) -> bool {
11 command
12 .split_whitespace()
13 .next()
14 .is_some_and(|head| head == "go")
15 }
16
17 fn compress_with_exit_code(
18 &self,
19 command: &str,
20 output: &str,
21 exit_code: Option<i32>,
22 ) -> CompressionResult {
23 match go_subcommand(command).as_deref() {
24 Some("test") => preserve_go_failure(output, compress_test(output), exit_code).into(),
25 Some("build") => preserve_go_failure(output, compress_build(output), exit_code).into(),
26 Some("vet") => preserve_go_failure(output, compress_vet(output), exit_code).into(),
27 _ => GenericCompressor::compress_output(output).into(),
28 }
29 }
30
31 fn matches_output(&self, output: &str) -> bool {
32 looks_like_go_test_output(output)
33 }
34
35 fn compress_output_match_with_exit_code(
36 &self,
37 output: &str,
38 exit_code: Option<i32>,
39 ) -> CompressionResult {
40 preserve_go_failure(output, compress_test(output), exit_code).into()
41 }
42}
43
44pub struct GolangciLintCompressor;
45
46impl Compressor for GolangciLintCompressor {
47 fn matches(&self, command: &str) -> bool {
48 command
49 .split_whitespace()
50 .any(|token| token == "golangci-lint")
51 }
52
53 fn compress_with_exit_code(
54 &self,
55 _command: &str,
56 output: &str,
57 exit_code: Option<i32>,
58 ) -> CompressionResult {
59 let compressed = compress_golangci(output);
60 if exited_nonzero(exit_code) && compressed.trim() == "golangci-lint: clean" {
61 GenericCompressor::compress_output(output).into()
62 } else {
63 compressed.into()
64 }
65 }
66
67 fn matches_output(&self, output: &str) -> bool {
68 looks_like_golangci_output(output)
69 }
70
71 fn matches_output_probe(&self, probe: &OutputProbe<'_>) -> bool {
72 looks_like_golangci_output_probe(probe)
73 }
74}
75
76fn preserve_go_failure(output: &str, compressed: String, exit_code: Option<i32>) -> String {
77 let compressed_trimmed = compressed.trim();
78 let compressed_has_signal = super::text_has_failure_signal(&compressed);
79 let raw_has_signal = super::text_has_failure_signal(output);
80 let missing_failure_lines = super::missing_raw_failure_signal_lines(output, &compressed);
81 let stripped_failure = output.trim().is_empty()
82 || compressed_trimmed.is_empty()
83 || matches!(compressed_trimmed, "go build: ok" | "go vet: clean")
84 || !compressed_has_signal
85 || !missing_failure_lines.is_empty();
86
87 if stripped_failure && (exited_nonzero(exit_code) || raw_has_signal) {
88 GenericCompressor::compress_output(output)
89 } else {
90 compressed
91 }
92}
93
94fn exited_nonzero(exit_code: Option<i32>) -> bool {
95 matches!(exit_code, Some(code) if code != 0)
96}
97
98fn looks_like_go_test_output(output: &str) -> bool {
99 let mut has_run = false;
100 let mut has_case_result = false;
101 let mut has_final = false;
102
103 for line in output.lines() {
104 let trimmed = line.trim_start();
105 has_run |= trimmed.starts_with("=== RUN");
106 has_case_result |= trimmed.starts_with("--- PASS:") || trimmed.starts_with("--- FAIL:");
107 has_final |= is_go_test_output_final_line(trimmed);
108 }
109
110 has_final || (has_run && has_case_result)
111}
112
113fn looks_like_golangci_output(output: &str) -> bool {
114 let trimmed = output.trim_start();
115 if trimmed.starts_with('{') && looks_like_golangci_json_root(trimmed) {
116 return true;
117 }
118
119 let mut has_summary = false;
120 let mut has_issue = false;
121 for line in output.lines() {
122 let trimmed = line.trim_start();
123 has_summary |= is_golangci_summary_header(trimmed);
124 has_issue |= is_golangci_issue_line(trimmed);
125 }
126 has_summary && has_issue
127}
128
129fn looks_like_golangci_output_probe(probe: &OutputProbe<'_>) -> bool {
130 let output = probe.output();
131 if output.trim_start().starts_with('{')
132 && probe.json().is_some_and(looks_like_golangci_json_value)
133 {
134 return true;
135 }
136
137 let mut has_summary = false;
138 let mut has_issue = false;
139 for line in output.lines() {
140 let trimmed = line.trim_start();
141 has_summary |= is_golangci_summary_header(trimmed);
142 has_issue |= is_golangci_issue_line(trimmed);
143 }
144 has_summary && has_issue
145}
146
147fn looks_like_golangci_json_root(output: &str) -> bool {
148 serde_json::from_str::<Value>(output)
149 .ok()
150 .is_some_and(|value| looks_like_golangci_json_value(&value))
151}
152
153fn looks_like_golangci_json_value(value: &Value) -> bool {
154 value.get("Issues").and_then(Value::as_array).is_some()
155}
156
157fn go_subcommand(command: &str) -> Option<String> {
158 command
159 .split_whitespace()
160 .nth(1)
161 .filter(|s| !crate::compress::is_shell_boundary(s))
162 .map(|s| s.to_string())
163}
164
165fn compress_test(output: &str) -> String {
166 let lines: Vec<&str> = output.lines().collect();
167 let mut kept = Vec::new();
168 let mut index = 0usize;
169
170 while index < lines.len() {
171 let line = lines[index];
172 let trimmed = line.trim_start();
173
174 if is_go_download_chatter(trimmed) || trimmed.starts_with("=== RUN") {
175 index += 1;
176 continue;
177 }
178
179 if trimmed.starts_with("--- FAIL") {
180 let mut block = Vec::new();
181 block.push(line.to_string());
182 index += 1;
183 while index < lines.len() {
184 let next = lines[index];
185 let next_trimmed = next.trim_start();
186 if next_trimmed.starts_with("=== RUN")
187 || next_trimmed.starts_with("--- PASS")
188 || next_trimmed.starts_with("--- FAIL")
189 || is_final_go_test_line(next_trimmed)
190 || is_go_download_chatter(next_trimmed)
191 {
192 break;
193 }
194 block.push(next.to_string());
195 index += 1;
196 }
197 kept.extend(block);
198 continue;
199 }
200
201 if trimmed.starts_with("--- PASS") {
202 index += 1;
203 continue;
204 }
205
206 if is_panic_or_stack_line(trimmed) || is_final_go_test_line(trimmed) {
207 kept.push(line.to_string());
208 }
209
210 index += 1;
211 }
212
213 trim_trailing_lines(&kept.join("\n"))
214}
215
216fn compress_build(output: &str) -> String {
217 let errors: Vec<String> = output
218 .lines()
219 .filter_map(|line| {
220 let trimmed = line.trim_start();
221 if is_go_download_chatter(trimmed) {
222 return None;
223 }
224 if is_go_file_location_line(trimmed) {
225 Some(line.to_string())
226 } else {
227 None
228 }
229 })
230 .collect();
231
232 if errors.is_empty() {
233 "go build: ok".to_string()
234 } else {
235 trim_trailing_lines(&errors.join("\n"))
236 }
237}
238
239fn compress_vet(output: &str) -> String {
240 let warnings: Vec<String> = output
241 .lines()
242 .filter_map(|line| {
243 let trimmed = line.trim_start();
244 if is_go_file_location_line(trimmed) && trimmed.contains(": vet: ") {
245 Some(line.to_string())
246 } else {
247 None
248 }
249 })
250 .collect();
251
252 if warnings.is_empty() {
253 "go vet: clean".to_string()
254 } else {
255 trim_trailing_lines(&warnings.join("\n"))
256 }
257}
258
259fn compress_golangci(output: &str) -> String {
260 if output.trim().is_empty() {
261 return "golangci-lint: clean".to_string();
262 }
263
264 if looks_like_golangci_json(output) {
265 return compress_golangci_json(output);
266 }
267
268 compress_golangci_text(output)
269}
270
271#[derive(Debug, Deserialize)]
272struct GolangciJsonOutput {
273 #[serde(rename = "Issues", default)]
274 issues: Vec<GolangciIssue>,
275}
276
277#[derive(Debug, Deserialize)]
278struct GolangciIssue {
279 #[serde(rename = "FromLinter")]
280 from_linter: String,
281 #[serde(rename = "Text")]
282 text: String,
283 #[serde(rename = "Pos")]
284 pos: GolangciPosition,
285}
286
287#[derive(Debug, Deserialize)]
288struct GolangciPosition {
289 #[serde(rename = "Filename")]
290 filename: String,
291 #[serde(rename = "Line")]
292 line: usize,
293 #[serde(rename = "Column")]
294 column: usize,
295}
296
297fn compress_golangci_json(output: &str) -> String {
298 let parsed = match serde_json::from_str::<GolangciJsonOutput>(output) {
299 Ok(parsed) => parsed,
300 Err(_) => return GenericCompressor::compress_output(output),
301 };
302
303 if parsed.issues.is_empty() {
304 return "golangci-lint: clean".to_string();
305 }
306
307 let mut by_linter: BTreeMap<String, Vec<GolangciIssue>> = BTreeMap::new();
308 for issue in parsed.issues {
309 by_linter
310 .entry(issue.from_linter.clone())
311 .or_default()
312 .push(issue);
313 }
314
315 let total: usize = by_linter.values().map(Vec::len).sum();
316 let mut sections = vec![format!("golangci-lint: {total} issues")];
317 for (linter, issues) in by_linter {
318 sections.push(format!("{linter} ({}):", issues.len()));
319 for issue in issues {
320 sections.push(format!(
321 " {}:{}:{}: {}",
322 issue.pos.filename, issue.pos.line, issue.pos.column, issue.text
323 ));
324 }
325 }
326
327 trim_trailing_lines(§ions.join("\n"))
328}
329
330fn compress_golangci_text(output: &str) -> String {
331 let lines: Vec<&str> = output.lines().collect();
332 let mut kept = Vec::new();
333 let mut in_summary = false;
334
335 for line in lines {
336 let trimmed = line.trim_start();
337 if in_summary {
338 if trimmed.starts_with('*') || trimmed.is_empty() {
339 kept.push(line.to_string());
340 } else {
341 in_summary = false;
342 }
343 continue;
344 }
345
346 if is_golangci_issue_line(trimmed) {
347 kept.push(line.to_string());
348 continue;
349 }
350
351 if is_golangci_summary_header(trimmed) {
352 kept.push(line.to_string());
353 in_summary = true;
354 }
355 }
356
357 if kept.is_empty() {
358 "golangci-lint: clean".to_string()
359 } else {
360 trim_trailing_lines(&kept.join("\n"))
361 }
362}
363
364fn looks_like_golangci_json(output: &str) -> bool {
365 let trimmed = output.trim_start();
366 trimmed.starts_with('{')
367 && trimmed
368 .chars()
369 .take(200)
370 .collect::<String>()
371 .contains("\"Issues\"")
372}
373
374fn is_go_download_chatter(trimmed: &str) -> bool {
375 trimmed.starts_with("go: downloading ")
376 || trimmed.starts_with("go: finding ")
377 || trimmed.starts_with("go: extracting ")
378}
379
380fn is_go_test_output_final_line(trimmed: &str) -> bool {
381 trimmed == "PASS"
382 || trimmed == "FAIL"
383 || trimmed.starts_with("ok ")
384 || trimmed.starts_with("ok ")
385 || trimmed.starts_with("FAIL ")
386 || trimmed.starts_with("FAIL ")
387}
388
389fn is_final_go_test_line(trimmed: &str) -> bool {
390 trimmed == "PASS"
391 || trimmed == "FAIL"
392 || trimmed.starts_with("ok ")
393 || trimmed.starts_with("ok\t")
394 || trimmed.starts_with("FAIL ")
395 || trimmed.starts_with("FAIL\t")
396 || trimmed.starts_with("? ")
397 || trimmed.starts_with("?\t")
398 || trimmed.starts_with("exit status ")
399}
400
401fn is_panic_or_stack_line(trimmed: &str) -> bool {
402 trimmed.starts_with("panic:")
403 || trimmed.starts_with("fatal error:")
404 || trimmed.starts_with("goroutine ")
405 || trimmed.starts_with("created by ")
406 || trimmed.starts_with("runtime.")
407}
408
409fn is_go_file_location_line(trimmed: &str) -> bool {
410 let Some(pos) = trimmed.find(".go:") else {
411 return false;
412 };
413 let rest = &trimmed[pos + 4..];
414 let Some((line, rest)) = rest.split_once(':') else {
415 return false;
416 };
417 if line.is_empty() || !line.chars().all(|c| c.is_ascii_digit()) {
418 return false;
419 }
420 let Some((column, _message)) = rest.split_once(':') else {
421 return false;
422 };
423 !column.is_empty() && column.chars().all(|c| c.is_ascii_digit())
424}
425
426fn is_golangci_issue_line(trimmed: &str) -> bool {
427 is_go_file_location_line(trimmed)
428}
429
430fn is_golangci_summary_header(trimmed: &str) -> bool {
431 let Some(count) = trimmed.strip_suffix(" issues:") else {
432 return false;
433 };
434 !count.is_empty() && count.chars().all(|c| c.is_ascii_digit())
435}
436
437fn trim_trailing_lines(input: &str) -> String {
438 input
439 .lines()
440 .map(str::trim_end)
441 .collect::<Vec<_>>()
442 .join("\n")
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 fn go_compress(command: &str, output: &str) -> CompressionResult {
450 GoCompressor.compress(command, output)
451 }
452
453 fn golangci_compress(command: &str, output: &str) -> CompressionResult {
454 GolangciLintCompressor.compress(command, output)
455 }
456
457 #[test]
458 fn matches_go_head_token_and_golangci_token_anywhere() {
459 let go = GoCompressor;
460 assert!(go.matches("go test ./..."));
461 assert!(go.matches("go"));
462 assert!(!go.matches("goimports ./..."));
463 assert!(!go.matches("gomod tidy"));
464 assert!(!go.matches("pingo go"));
465
466 let golangci = GolangciLintCompressor;
467 assert!(golangci.matches("golangci-lint run ./..."));
468 assert!(golangci.matches("go tool golangci-lint run ./..."));
469 assert!(golangci.matches("xargs golangci-lint"));
470 assert!(!golangci.matches("golangci-lint-wrapper run"));
471 assert!(!golangci.matches("go test ./..."));
472 }
473
474 #[test]
475 fn go_test_failure_block_preserves_fail_block_and_stack_trace() {
476 let output = r#"=== RUN TestFoo
477--- PASS: TestFoo (0.00s)
478=== RUN TestBar
479--- FAIL: TestBar (0.01s)
480 bar_test.go:25: expected 5, got 3
481panic: boom
482
483goroutine 7 [running]:
484example.com/pkg/bar.TestBar()
485 /tmp/bar_test.go:26 +0x55
486FAIL
487exit status 1
488FAIL example.com/pkg/bar 0.123s"#;
489
490 let compressed = go_compress("go test ./...", output);
491 assert!(compressed.contains("--- FAIL: TestBar (0.01s)"));
492 assert!(compressed.contains(" bar_test.go:25: expected 5, got 3"));
493 assert!(compressed.contains("panic: boom"));
494 assert!(compressed.contains("goroutine 7 [running]:"));
495 assert!(compressed.contains("FAIL\texample.com/pkg/bar\t0.123s"));
496 assert!(!compressed.contains("--- PASS: TestFoo"));
497 }
498
499 #[test]
500 fn go_test_happy_path_drops_download_and_pass_noise() {
501 let output = r#"go: downloading github.com/foo/bar v1.2.3
502=== RUN TestFoo
503--- PASS: TestFoo (0.00s)
504=== RUN TestBar
505--- PASS: TestBar (0.01s)
506PASS
507ok example.com/pkg/foo 0.123s"#;
508
509 let compressed = go_compress("go test ./...", output);
510 assert_eq!(compressed, "PASS\nok \texample.com/pkg/foo\t0.123s");
511 assert!(!compressed.contains("downloading"));
512 assert!(!compressed.contains("TestFoo"));
513 assert!(!compressed.contains("--- PASS"));
514 }
515
516 #[test]
517 fn go_build_keeps_error_lines_and_reports_ok_when_clean() {
518 let output = r#"go: downloading github.com/foo/bar v1.2.3
519# example.com/pkg
520main.go:10:5: undefined: missingFunc
521internal/lib.go:22:12: cannot use x as string"#;
522
523 let compressed = go_compress("go build ./...", output);
524 assert_eq!(
525 compressed,
526 "main.go:10:5: undefined: missingFunc\ninternal/lib.go:22:12: cannot use x as string"
527 );
528 assert_eq!(go_compress("go build ./...", ""), "go build: ok");
529 assert_eq!(
530 go_compress(
531 "go build ./...",
532 "go: downloading github.com/pkg/errors v0.9.1"
533 ),
534 "go build: ok"
535 );
536 }
537
538 #[test]
539 fn go_build_linker_error_does_not_report_ok_when_exit_is_unknown() {
540 let output = "# example.com/pkg
541/usr/bin/ld: error: undefined reference to `missing_symbol'
542collect2: error: ld returned 1 exit status
543";
544
545 let compressed = go_compress("go build ./...", output);
546
547 assert_ne!(compressed.text, "go build: ok");
548 assert!(compressed.text.contains("undefined reference"));
549 assert!(compressed.text.contains("collect2: error"));
550 }
551
552 #[test]
553 fn go_test_compile_error_preserves_diagnostic_even_with_unknown_exit() {
554 let output = "# example.com/pkg [example.com/pkg.test]
555./main_test.go:8:2: undefined: missingSymbol
556FAIL example.com/pkg [build failed]
557";
558
559 let compressed = go_compress("go test ./...", output);
560
561 assert!(compressed.text.contains("undefined: missingSymbol"));
562 assert!(compressed.text.contains("FAIL example.com/pkg"));
563 }
564
565 #[test]
566 fn go_vet_syntax_error_does_not_report_clean_when_exit_is_unknown() {
567 let output = "# example.com/pkg
568vet: ./main.go:9:1: expected declaration, found '}'
569ERROR: vet failed
570";
571
572 let compressed = go_compress("go vet ./...", output);
573
574 assert_ne!(compressed.text, "go vet: clean");
575 assert!(compressed.text.contains("ERROR: vet failed"));
576 }
577
578 #[test]
579 fn golangci_json_groups_by_linter_and_text_keeps_verbatim_lines() {
580 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":[]}}"#;
581
582 let compressed = golangci_compress("golangci-lint run --out-format json", json);
583 assert!(compressed.contains("golangci-lint: 3 issues"));
584 assert!(
585 compressed.contains("golint (1):\n src/foo.go:25:1: variable `Foo` should be `foo`")
586 );
587 assert!(compressed.contains("unused (2):"));
588 assert!(compressed.contains("src/foo.go:10:5: unused variable `x`"));
589 assert!(compressed.contains("src/bar.go:3:8: unused variable `y`"));
590
591 let text = r#"src/foo.go:10:5: unused variable `x` (unused)
592src/foo.go:25:1: variable `Foo` should be `foo` (golint)
593src/bar.go:3:8: ineffectual assignment (ineffassign)
5943 issues:
595* unused: 1
596* golint: 1
597* ineffassign: 1"#;
598 assert_eq!(golangci_compress("golangci-lint run", text), text);
599 assert_eq!(
600 golangci_compress("golangci-lint run", ""),
601 "golangci-lint: clean"
602 );
603 }
604
605 #[test]
606 fn go_vet_keeps_vet_warnings_and_reports_clean() {
607 let output = "# example.com/pkg\nmain.go:42:2: vet: Printf format %d has arg x of wrong type string\nother output";
608 assert_eq!(
609 go_compress("go vet ./...", output),
610 "main.go:42:2: vet: Printf format %d has arg x of wrong type string"
611 );
612 assert_eq!(go_compress("go vet ./...", ""), "go vet: clean");
613 }
614
615 #[test]
616 fn large_input_compresses_noisy_go_test_output() {
617 let mut raw = String::new();
618 for idx in 0..500 {
619 raw.push_str(&format!("go: downloading example.com/pkg{idx} v1.0.0\n"));
620 raw.push_str(&format!("=== RUN TestPass{idx}\n"));
621 raw.push_str(&format!("--- PASS: TestPass{idx} (0.00s)\n"));
622 }
623 raw.push_str("=== RUN TestFail\n");
624 raw.push_str("--- FAIL: TestFail (0.01s)\n");
625 raw.push_str(" fail_test.go:10: expected true\n");
626 raw.push_str("FAIL\nFAIL\texample.com/pkg\t0.999s\n");
627
628 let compressed = go_compress("go test ./...", &raw);
629 assert!(compressed.contains("--- FAIL: TestFail (0.01s)"));
630 assert!(compressed.contains("fail_test.go:10"));
631 assert!(compressed.contains("FAIL\texample.com/pkg\t0.999s"));
632 assert!(compressed.len() * 10 < raw.len());
633 }
634
635 #[test]
636 fn go_subcommand_returns_none_for_pipe_as_second_token() {
637 assert_eq!(go_subcommand("go | grep x"), None);
638 }
639
640 #[test]
641 fn go_subcommand_returns_subcommand_when_before_pipe() {
642 assert_eq!(
643 go_subcommand("go test | grep FAIL").as_deref(),
644 Some("test")
645 );
646 }
647
648 #[test]
649 fn go_subcommand_unaffected_without_metacharacters() {
650 assert_eq!(go_subcommand("go build ./...").as_deref(), Some("build"));
651 }
652}