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