1#![doc = include_str!("../README.md")]
2use anyhow::{anyhow, Context};
3use colored::Colorize;
4use std::{fmt::Display, process::Command, str::FromStr};
5
6#[must_use]
7pub fn get_cargo_test_output(extra_args: Vec<String>) -> String {
14 let mut cargo = Command::new("cargo");
15 cargo.arg("test");
16 cargo.args(extra_args);
17 let raw_output = cargo
18 .output()
19 .context(format!("{cargo:?}"))
20 .expect("executing command should succeed")
21 .stdout;
22 String::from_utf8_lossy(&raw_output).to_string()
23}
24
25#[must_use]
26pub fn parse_test_results(test_output: &str) -> Vec<TestResult> {
30 let mut results: Vec<TestResult> = test_output.lines().filter_map(parse_line).collect();
31 results.sort();
32 results
33}
34
35pub fn parse_line(line: impl AsRef<str>) -> Option<TestResult> {
40 let line = line.as_ref().strip_prefix("test ")?;
41 if line.starts_with("result") || line.contains("(line ") {
42 return None;
43 }
44
45 let (test, status) = line.split_once(" ... ")?;
46 let (module, name) = match test.rsplit_once("::") {
47 Some((module, name)) => (prettify_module(module), name),
48 None => (None, test),
49 };
50 Some(TestResult {
51 module,
52 name: prettify(name),
53 status: status.parse().ok()?,
54 })
55}
56
57#[must_use]
58pub fn prettify(input: impl AsRef<str>) -> String {
72 if let Some((fn_name, sentence)) = input.as_ref().split_once("_fn_") {
73 format!("{} {}", fn_name, humanize(sentence))
74 } else {
75 humanize(input)
76 }
77}
78
79fn humanize(input: impl AsRef<str>) -> String {
80 input
81 .as_ref()
82 .replace('_', " ")
83 .split_whitespace()
84 .collect::<Vec<&str>>()
85 .join(" ")
86}
87
88fn prettify_module(module: &str) -> Option<String> {
89 let mut parts = module.split("::").collect::<Vec<_>>();
90 parts.pop_if(|&mut s| s == "tests" || s == "test");
91 if parts.is_empty() {
92 return None;
93 }
94 Some(parts.join("::"))
95}
96
97#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
98pub struct TestResult {
100 pub module: Option<String>,
101 pub name: String,
102 pub status: Status,
103}
104
105impl Display for TestResult {
106 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 match &self.module {
108 Some(module) => write!(
109 f,
110 "{} {} – {}",
111 self.status,
112 module.bright_blue(),
113 self.name
114 ),
115 None => write!(f, "{} {}", self.status, self.name),
116 }
117 }
118}
119
120#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
121pub enum Status {
123 Pass,
124 Fail,
125 Ignored(Option<String>),
126}
127
128impl FromStr for Status {
129 type Err = anyhow::Error;
130
131 fn from_str(status: &str) -> Result<Self, Self::Err> {
132 match status {
133 "ok" => Ok(Status::Pass),
134 "FAILED" => Ok(Status::Fail),
135 "ignored" => Ok(Status::Ignored(None)),
136 _ => {
137 if let Some((_, reason)) = status.split_once(", ") {
138 Ok(Status::Ignored(Some(reason.to_string())))
139 } else {
140 Err(anyhow!("unhandled test status {status:?}"))
141 }
142 }
143 }
144 }
145}
146
147impl Display for Status {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 let status = match self {
150 Status::Pass => "✔".bright_green(),
151 Status::Fail => "x".bright_red(),
152 Status::Ignored(None) => "?".bright_yellow(),
153 Status::Ignored(Some(reason)) => format!("? [{reason}]").bright_yellow(),
154 };
155 write!(f, "{status}")
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn prettify_returns_expected_results() {
165 struct Case {
166 input: &'static str,
167 want: String,
168 }
169 let cases = Vec::from([
170 Case {
171 input: "anagrams_must_use_all_letters_exactly_once",
172 want: "anagrams must use all letters exactly once".into(),
173 },
174 Case {
175 input: "no_matches",
176 want: "no matches".into(),
177 },
178 Case {
179 input: "single",
180 want: "single".into(),
181 },
182 Case {
183 input: "parse_line_fn_does_stuff",
184 want: "parse_line does stuff".into(),
185 },
186 Case {
187 input: "prettify__handles_multiple_underscores",
188 want: "prettify handles multiple underscores".into(),
189 },
190 Case {
191 input: "prettify_fn__handles_multiple_underscores",
192 want: "prettify handles multiple underscores".into(),
193 },
194 ]);
195 for case in cases {
196 assert_eq!(case.want, prettify(case.input));
197 }
198 }
199
200 #[test]
201 fn parse_line_fn_returns_expected_result() {
202 struct Case {
203 line: &'static str,
204 want: Option<TestResult>,
205 }
206 let cases = Vec::from([
207 Case {
208 line: " Finished `test` profile [unoptimized + debuginfo] target(s) in 0.20s",
209 want: None,
210 },
211 Case {
212 line: "test foo ... ok",
213 want: Some(TestResult {
214 module: None,
215 name: "foo".into(),
216 status: Status::Pass,
217 }),
218 },
219 Case {
220 line: "test foo::tests::does_foo_stuff ... ok",
221 want: Some(TestResult {
222 module: Some("foo".into()),
223 name: "does foo stuff".into(),
224 status: Status::Pass,
225 }),
226 },
227 Case {
228 line: "test tests::urls_correctly_extracts_valid_urls ... FAILED",
229 want: Some(TestResult {
230 module: None,
231 name: "urls correctly extracts valid urls".into(),
232 status: Status::Fail,
233 }),
234 },
235 Case {
236 line: "test files::test::files_can_be_sorted_in_descending_order ... ignored",
237 want: Some(TestResult {
238 module: Some("files".into()),
239 name: "files can be sorted in descending order".into(),
240 status: Status::Ignored(None),
241 }),
242 },
243 Case {
244 line: "test files::test::foo::tests::files_can_be_sorted_in_descending_order ... ignored",
245 want: Some(TestResult {
246 module: Some("files::test::foo".into()),
247 name: "files can be sorted in descending order".into(),
248 status: Status::Ignored(None),
249 }),
250 },
251 Case {
252 line: "test files::test_foo::files_can_be_sorted_in_descending_order ... ignored",
253 want: Some(TestResult {
254 module: Some("files::test_foo".into()),
255 name: "files can be sorted in descending order".into(),
256 status: Status::Ignored(None),
257 }),
258 },
259 Case {
260 line: "test tests::pi_to_1m_digits ... ignored, expensive test",
261 want: Some(TestResult {
262 module: None,
263 name: "pi to 1m digits".into(),
264 status: Status::Ignored(Some("expensive test".into())),
265 }),
266 },
267 Case {
268 line: "test src/lib.rs - find_top_n_largest_files (line 17) ... ok",
269 want: None,
270 },
271 Case {
272 line: "test output_format::_concise_expects ... ok",
273 want: Some(TestResult {
274 module: Some("output_format".into()),
275 name: "concise expects".into(),
276 status: Status::Pass,
277 }),
278 },
279 ]);
280 for case in cases {
281 assert_eq!(case.want, parse_line(case.line));
282 }
283 }
284
285 #[test]
287 fn test_results_sort_by_module_name_and_status() {
288 let mut results = vec![
289 TestResult {
290 module: Some("zeta".into()),
291 name: "zebra".into(),
292 status: Status::Pass,
293 },
294 TestResult {
295 module: None,
296 name: "alpha".into(),
297 status: Status::Fail,
298 },
299 TestResult {
300 module: None,
301 name: "alpha".into(),
302 status: Status::Pass,
303 },
304 TestResult {
305 module: Some("alpha".into()),
306 name: "beta".into(),
307 status: Status::Ignored(None),
308 },
309 TestResult {
310 module: Some("alpha".into()),
311 name: "alpha".into(),
312 status: Status::Pass,
313 },
314 ];
315 results.sort();
316 let expected = vec![
317 TestResult {
318 module: None,
319 name: "alpha".into(),
320 status: Status::Pass,
321 },
322 TestResult {
323 module: None,
324 name: "alpha".into(),
325 status: Status::Fail,
326 },
327 TestResult {
328 module: Some("alpha".into()),
329 name: "alpha".into(),
330 status: Status::Pass,
331 },
332 TestResult {
333 module: Some("alpha".into()),
334 name: "beta".into(),
335 status: Status::Ignored(None),
336 },
337 TestResult {
338 module: Some("zeta".into()),
339 name: "zebra".into(),
340 status: Status::Pass,
341 },
342 ];
343 assert_eq!(results, expected, "wrong order");
344 }
345}