Skip to main content

aft/compress/
go.rs

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(&sections.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}