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