Skip to main content

aft/compress/
go.rs

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