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