cargo_testdox/
lib.rs

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]
7/// Runs `cargo test` with any supplied extra arguments, and returns the
8/// resulting standard output.
9///
10/// # Panics
11///
12/// If executing the `cargo test` command fails.
13pub 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]
26/// Parses the standard output of `cargo test` into a vec of `TestResult`.
27///
28/// Results are returned sorted by module, name, and status for better readability.
29pub 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
35/// Parses a line from the standard output of `cargo test`.
36///
37/// If the line represents the result of a test, returns `Some(TestResult)`,
38/// otherwise returns `None`.
39pub 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]
58/// Formats the name of a test function as a sentence.
59///
60/// Underscores are replaced with spaces. To retain the underscores in a function name, put `_fn_` after it. For example:
61///
62/// ```text
63/// parse_line_fn_parses_a_line
64/// ```
65///
66/// becomes:
67///
68/// ```text
69/// parse_line parses a line
70/// ```
71pub 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)]
98/// The (prettified) name and pass/fail status of a given test.
99pub 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)]
121/// The status of a given test, as reported by `cargo test`.
122pub 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 results sort by module, name, and status.
286    #[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}