Skip to main content

aft/compress/
pytest.rs

1use crate::compress::Compressor;
2
3pub struct PytestCompressor;
4
5impl Compressor for PytestCompressor {
6    fn matches(&self, command: &str) -> bool {
7        let tokens: Vec<&str> = command.split_whitespace().collect();
8        tokens.first().is_some_and(|head| *head == "pytest")
9            || tokens
10                .windows(3)
11                .any(|window| matches!(window, ["python" | "python3", "-m", "pytest"]))
12    }
13
14    fn compress(&self, _command: &str, output: &str) -> String {
15        compress_pytest(output)
16    }
17
18    fn matches_output(&self, output: &str) -> bool {
19        output.lines().any(|line| {
20            let trimmed = line.trim();
21            is_section_header(trimmed, "FAILURES")
22                || is_section_header(trimmed, "ERRORS")
23                || is_section_header(trimmed, "short test summary info")
24                || is_pytest_final_summary_signature(trimmed)
25        })
26    }
27}
28
29fn compress_pytest(output: &str) -> String {
30    let lines: Vec<&str> = output.lines().collect();
31    let mut result = Vec::new();
32    let mut index = 0usize;
33
34    while index < lines.len() {
35        let line = lines[index];
36        let trimmed = line.trim();
37
38        if is_header_line(trimmed) || is_failure_or_error_test_line(trimmed) {
39            result.push(line.to_string());
40            index += 1;
41            continue;
42        }
43
44        if is_section_header(trimmed, "FAILURES") || is_section_header(trimmed, "ERRORS") {
45            while index < lines.len() {
46                let current = lines[index];
47                let current_trimmed = current.trim();
48                if index != 0
49                    && index != lines.len() - 1
50                    && current_trimmed.starts_with('=')
51                    && !is_section_header(current_trimmed, "FAILURES")
52                    && !is_section_header(current_trimmed, "ERRORS")
53                {
54                    break;
55                }
56                result.push(current.to_string());
57                index += 1;
58            }
59            continue;
60        }
61
62        if is_section_header(trimmed, "warnings summary") {
63            let (warnings, next_index) = compress_warnings(&lines, index);
64            result.extend(warnings);
65            index = next_index;
66            continue;
67        }
68
69        if is_section_header(trimmed, "short test summary info") || is_final_summary(trimmed) {
70            result.push(line.to_string());
71            index += 1;
72            continue;
73        }
74
75        if is_pass_status_line(trimmed) {
76            index += 1;
77            continue;
78        }
79
80        index += 1;
81    }
82
83    trim_trailing_lines(&result.join("\n"))
84}
85
86fn is_header_line(trimmed: &str) -> bool {
87    trimmed.starts_with("platform ")
88        || trimmed.starts_with("rootdir:")
89        || trimmed.starts_with("collected ")
90}
91
92fn is_failure_or_error_test_line(trimmed: &str) -> bool {
93    trimmed.contains(" FAILED")
94        || trimmed.ends_with(" FAILED")
95        || trimmed.contains(" ERROR")
96        || trimmed.ends_with(" ERROR")
97}
98
99fn is_section_header(trimmed: &str, name: &str) -> bool {
100    trimmed.starts_with('=') && trimmed.contains(name) && trimmed.ends_with('=')
101}
102
103fn is_pass_status_line(trimmed: &str) -> bool {
104    !trimmed.is_empty()
105        && (trimmed
106            .chars()
107            .all(|char| matches!(char, '.' | 's' | 'x' | 'X'))
108            || trimmed.ends_with(" PASSED")
109            || trimmed.contains(" PASSED "))
110}
111
112fn is_pytest_final_summary_signature(trimmed: &str) -> bool {
113    if !trimmed.starts_with('=') || !trimmed.ends_with('=') {
114        return false;
115    }
116    let body = trimmed.trim_matches('=').trim();
117    let has_status = body
118        .split(|ch: char| !ch.is_ascii_alphabetic())
119        .any(|word| matches!(word, "passed" | "failed" | "error" | "errors"));
120    if !has_status {
121        return false;
122    }
123    let Some((_, after_in)) = body.rsplit_once(" in ") else {
124        return false;
125    };
126    let Some(duration) = after_in.split_whitespace().next() else {
127        return false;
128    };
129    let Some(seconds) = duration.strip_suffix('s') else {
130        return false;
131    };
132    !seconds.is_empty() && seconds.chars().all(|ch| ch.is_ascii_digit() || ch == '.')
133}
134
135fn is_final_summary(trimmed: &str) -> bool {
136    trimmed.starts_with('=')
137        && (trimmed.contains(" passed")
138            || trimmed.contains(" failed")
139            || trimmed.contains(" error")
140            || trimmed.contains(" skipped")
141            || trimmed.contains(" xfailed"))
142        && trimmed.ends_with('=')
143}
144
145fn compress_warnings(lines: &[&str], start: usize) -> (Vec<String>, usize) {
146    let mut result = vec![lines[start].to_string()];
147    let mut index = start + 1;
148    let mut warnings_seen = 0usize;
149    let mut omitted = 0usize;
150
151    while index < lines.len() {
152        let line = lines[index];
153        let trimmed = line.trim();
154        if trimmed.starts_with('=') && trimmed.ends_with('=') {
155            break;
156        }
157        if is_warning_entry(trimmed) {
158            warnings_seen += 1;
159            if warnings_seen <= 5 {
160                result.push(line.to_string());
161            } else {
162                omitted += 1;
163            }
164        } else if warnings_seen <= 5 {
165            result.push(line.to_string());
166        }
167        index += 1;
168    }
169
170    if omitted > 0 {
171        result.push(format!("... and {omitted} more warnings"));
172    }
173
174    (result, index)
175}
176
177fn is_warning_entry(trimmed: &str) -> bool {
178    trimmed.contains("Warning:") || trimmed.contains("warning:") || trimmed.starts_with("tests/")
179}
180
181fn trim_trailing_lines(input: &str) -> String {
182    input
183        .lines()
184        .map(str::trim_end)
185        .collect::<Vec<_>>()
186        .join("\n")
187}