Skip to main content

max / makenotwork

7.8 KB · 268 lines History Blame Raw
1 //! Parsers for CI test output. Turns raw `cargo test` text into a structured
2 //! `TestSummary` and per-test `TestDetail` records.
3
4 use crate::types::{StepResult, TestDetail, TestSummary};
5
6 /// Parse remote test-command output into a structured TestSummary.
7 ///
8 /// The command is whatever a target configures (today, `cargo test` over SSH).
9 /// Looks for:
10 /// - `PASS <step name>` / `FAIL <step name>` step-summary lines
11 /// - `test result: ok. N passed; M failed` lines from cargo test
12 /// - `test <name> ... ok` / `test <name> ... FAILED` individual test lines
13 pub fn parse_ci_output(output: &str) -> TestSummary {
14 let mut steps = Vec::new();
15 let mut total_passed: i64 = 0;
16 let mut total_failed: i64 = 0;
17 let mut found_test_results = false;
18
19 for line in output.lines() {
20 let trimmed = line.trim();
21
22 // Parse PASS/FAIL step-summary lines (if the command emits them)
23 if let Some(name) = trimmed.strip_prefix("PASS ") {
24 steps.push(StepResult {
25 name: name.trim().to_string(),
26 passed: true,
27 });
28 } else if let Some(name) = trimmed.strip_prefix("FAIL ") {
29 steps.push(StepResult {
30 name: name.trim().to_string(),
31 passed: false,
32 });
33 }
34
35 // Parse cargo test result lines
36 if trimmed.starts_with("test result:") {
37 found_test_results = true;
38 // "test result: ok. 42 passed; 0 failed; 0 ignored; ..."
39 if let Some(counts) = parse_test_result_line(trimmed) {
40 total_passed += counts.0;
41 total_failed += counts.1;
42 }
43 }
44 }
45
46 let details = parse_individual_tests(output);
47
48 TestSummary {
49 steps,
50 total_passed: if found_test_results {
51 Some(total_passed)
52 } else {
53 None
54 },
55 total_failed: if found_test_results {
56 Some(total_failed)
57 } else {
58 None
59 },
60 details,
61 }
62 }
63
64 /// Parse individual test result lines from cargo test output.
65 ///
66 /// Matches lines like:
67 /// - `test workflows::endorsements::toggle ... ok`
68 /// - `test markdown::tests::bold_and_italic ... FAILED`
69 pub fn parse_individual_tests(output: &str) -> Vec<TestDetail> {
70 let mut details = Vec::new();
71
72 for line in output.lines() {
73 let trimmed = line.trim();
74
75 // Match: "test <name> ... ok" or "test <name> ... FAILED"
76 if let Some(rest) = trimmed.strip_prefix("test ") {
77 if let Some(name) = rest.strip_suffix(" ... ok") {
78 details.push(TestDetail {
79 test_name: name.to_string(),
80 passed: true,
81 });
82 } else if let Some(name) = rest.strip_suffix(" ... FAILED") {
83 details.push(TestDetail {
84 test_name: name.to_string(),
85 passed: false,
86 });
87 }
88 }
89 }
90
91 details
92 }
93
94 fn parse_test_result_line(line: &str) -> Option<(i64, i64)> {
95 // "test result: ok. 42 passed; 3 failed; 0 ignored; 0 measured; 0 filtered out"
96 let mut passed = 0i64;
97 let mut failed = 0i64;
98
99 for part in line.split(';') {
100 let part = part.trim();
101 if part.ends_with("passed") {
102 // "42 passed" or "ok. 42 passed"
103 let num_str = part
104 .rsplit_once(". ")
105 .map_or(part, |(_, r)| r)
106 .trim()
107 .strip_suffix(" passed")?
108 .trim();
109 passed = num_str.parse().ok()?;
110 } else if part.ends_with("failed") {
111 let num_str = part.trim().strip_suffix(" failed")?.trim();
112 failed = num_str.parse().ok()?;
113 }
114 }
115
116 Some((passed, failed))
117 }
118
119 #[cfg(test)]
120 mod tests {
121 use super::*;
122
123 #[test]
124 fn parse_full_ci_output() {
125 let output = r"
126 ========================================
127 cargo check
128 ========================================
129
130 Finished `dev` profile
131
132 ========================================
133 cargo test --lib
134 ========================================
135
136 running 45 tests
137 test result: ok. 45 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 2.3s
138
139 ========================================
140 cargo test --test integration
141 ========================================
142
143 running 714 tests
144 test result: ok. 714 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 89.2s
145
146 ========================================
147 CI Summary
148 ========================================
149
150 PASS cargo check
151 PASS cargo test --lib
152 PASS cargo test --test integration
153 PASS cargo clippy
154 PASS cargo audit
155
156 All steps passed.
157 ";
158 let summary = parse_ci_output(output);
159 assert_eq!(summary.steps.len(), 5);
160 assert!(summary.steps.iter().all(|s| s.passed));
161 assert_eq!(summary.total_passed, Some(759));
162 assert_eq!(summary.total_failed, Some(0));
163 }
164
165 #[test]
166 fn parse_failed_ci_output() {
167 let output = r"
168 ========================================
169 CI Summary
170 ========================================
171
172 PASS cargo check
173 FAIL cargo test --lib
174 PASS cargo clippy
175
176 1 step(s) failed.
177 ";
178 let summary = parse_ci_output(output);
179 assert_eq!(summary.steps.len(), 3);
180 assert!(!summary.steps[1].passed);
181 assert_eq!(summary.steps[1].name, "cargo test --lib");
182 }
183
184 #[test]
185 fn parse_individual_tests_ok_and_failed() {
186 let output = r"
187 running 4 tests
188 test workflows::endorsements::toggle_endorsement_removes ... ok
189 test workflows::endorsements::self_endorse_rejected ... ok
190 test markdown::tests::bold_and_italic ... ok
191 test workflows::endorsements::endorse_post_happy_path ... FAILED
192
193 test result: FAILED. 3 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
194 ";
195 let details = parse_individual_tests(output);
196 assert_eq!(details.len(), 4);
197 assert!(details[0].passed);
198 assert_eq!(
199 details[0].test_name,
200 "workflows::endorsements::toggle_endorsement_removes"
201 );
202 assert!(details[1].passed);
203 assert!(!details[3].passed);
204 assert_eq!(
205 details[3].test_name,
206 "workflows::endorsements::endorse_post_happy_path"
207 );
208 }
209
210 #[test]
211 fn parse_individual_tests_empty_when_no_individual_lines() {
212 let output = r"
213 ========================================
214 CI Summary
215 ========================================
216
217 PASS cargo check
218 PASS cargo test --lib
219 ";
220 let details = parse_individual_tests(output);
221 assert!(details.is_empty());
222 }
223
224 #[test]
225 fn parse_ci_output_includes_details() {
226 let output = r"
227 ========================================
228 cargo test --lib
229 ========================================
230
231 running 2 tests
232 test foo::bar ... ok
233 test foo::baz ... FAILED
234
235 test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
236
237 ========================================
238 CI Summary
239 ========================================
240
241 FAIL cargo test --lib
242 ";
243 let summary = parse_ci_output(output);
244 assert_eq!(summary.details.len(), 2);
245 assert!(summary.details[0].passed);
246 assert!(!summary.details[1].passed);
247 assert_eq!(summary.total_passed, Some(1));
248 assert_eq!(summary.total_failed, Some(1));
249 }
250
251 #[test]
252 fn parse_test_result_line_ok() {
253 let line = "test result: ok. 42 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out";
254 let (p, f) = parse_test_result_line(line).unwrap();
255 assert_eq!(p, 42);
256 assert_eq!(f, 0);
257 }
258
259 #[test]
260 fn parse_test_result_line_failed() {
261 let line =
262 "test result: FAILED. 40 passed; 2 failed; 0 ignored; 0 measured; 0 filtered out";
263 let (p, f) = parse_test_result_line(line).unwrap();
264 assert_eq!(p, 40);
265 assert_eq!(f, 2);
266 }
267 }
268