Skip to main content

aft/compress/
go.rs

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