Skip to main content

aft/compress/
go.rs

1use crate::compress::generic::GenericCompressor;
2use crate::compress::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) -> String {
18        match go_subcommand(command).as_deref() {
19            Some("test") => compress_test(output),
20            Some("build") => compress_build(output),
21            Some("vet") => compress_vet(output),
22            _ => GenericCompressor::compress_output(output),
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) -> String {
31        compress_test(output)
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) -> String {
45        compress_golangci(output)
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(&sections.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}