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
19fn compress_pytest(output: &str) -> String {
20 let lines: Vec<&str> = output.lines().collect();
21 let mut result = Vec::new();
22 let mut index = 0usize;
23
24 while index < lines.len() {
25 let line = lines[index];
26 let trimmed = line.trim();
27
28 if is_header_line(trimmed) || is_failure_or_error_test_line(trimmed) {
29 result.push(line.to_string());
30 index += 1;
31 continue;
32 }
33
34 if is_section_header(trimmed, "FAILURES") || is_section_header(trimmed, "ERRORS") {
35 while index < lines.len() {
36 let current = lines[index];
37 let current_trimmed = current.trim();
38 if index != 0
39 && index != lines.len() - 1
40 && current_trimmed.starts_with('=')
41 && !is_section_header(current_trimmed, "FAILURES")
42 && !is_section_header(current_trimmed, "ERRORS")
43 {
44 break;
45 }
46 result.push(current.to_string());
47 index += 1;
48 }
49 continue;
50 }
51
52 if is_section_header(trimmed, "warnings summary") {
53 let (warnings, next_index) = compress_warnings(&lines, index);
54 result.extend(warnings);
55 index = next_index;
56 continue;
57 }
58
59 if is_section_header(trimmed, "short test summary info") || is_final_summary(trimmed) {
60 result.push(line.to_string());
61 index += 1;
62 continue;
63 }
64
65 if is_pass_status_line(trimmed) {
66 index += 1;
67 continue;
68 }
69
70 index += 1;
71 }
72
73 trim_trailing_lines(&result.join("\n"))
74}
75
76fn is_header_line(trimmed: &str) -> bool {
77 trimmed.starts_with("platform ")
78 || trimmed.starts_with("rootdir:")
79 || trimmed.starts_with("collected ")
80}
81
82fn is_failure_or_error_test_line(trimmed: &str) -> bool {
83 trimmed.contains(" FAILED")
84 || trimmed.ends_with(" FAILED")
85 || trimmed.contains(" ERROR")
86 || trimmed.ends_with(" ERROR")
87}
88
89fn is_section_header(trimmed: &str, name: &str) -> bool {
90 trimmed.starts_with('=') && trimmed.contains(name) && trimmed.ends_with('=')
91}
92
93fn is_pass_status_line(trimmed: &str) -> bool {
94 !trimmed.is_empty()
95 && (trimmed
96 .chars()
97 .all(|char| matches!(char, '.' | 's' | 'x' | 'X'))
98 || trimmed.ends_with(" PASSED")
99 || trimmed.contains(" PASSED "))
100}
101
102fn is_final_summary(trimmed: &str) -> bool {
103 trimmed.starts_with('=')
104 && (trimmed.contains(" passed")
105 || trimmed.contains(" failed")
106 || trimmed.contains(" error")
107 || trimmed.contains(" skipped")
108 || trimmed.contains(" xfailed"))
109 && trimmed.ends_with('=')
110}
111
112fn compress_warnings(lines: &[&str], start: usize) -> (Vec<String>, usize) {
113 let mut result = vec![lines[start].to_string()];
114 let mut index = start + 1;
115 let mut warnings_seen = 0usize;
116 let mut omitted = 0usize;
117
118 while index < lines.len() {
119 let line = lines[index];
120 let trimmed = line.trim();
121 if trimmed.starts_with('=') && trimmed.ends_with('=') {
122 break;
123 }
124 if is_warning_entry(trimmed) {
125 warnings_seen += 1;
126 if warnings_seen <= 5 {
127 result.push(line.to_string());
128 } else {
129 omitted += 1;
130 }
131 } else if warnings_seen <= 5 {
132 result.push(line.to_string());
133 }
134 index += 1;
135 }
136
137 if omitted > 0 {
138 result.push(format!("... and {omitted} more warnings"));
139 }
140
141 (result, index)
142}
143
144fn is_warning_entry(trimmed: &str) -> bool {
145 trimmed.contains("Warning:") || trimmed.contains("warning:") || trimmed.starts_with("tests/")
146}
147
148fn trim_trailing_lines(input: &str) -> String {
149 input
150 .lines()
151 .map(str::trim_end)
152 .collect::<Vec<_>>()
153 .join("\n")
154}