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