cargo_pretty_test/
parsing.rs

1use crate::{regex::re, Result};
2use colored::{ColoredString, Colorize};
3use indexmap::IndexMap;
4use std::{
5    path::{Component, Path},
6    time::Duration,
7};
8
9/// The core parsing function that extracts all the information from `cargo test`
10/// but filters out empty tests.
11pub fn parse_cargo_test<'s>(stderr: &'s str, stdout: &'s str) -> Result<TestRunners<'s>> {
12    use TestType::*;
13
14    let mut pkg = None;
15    Ok(TestRunners::new(
16        parse_cargo_test_with_empty_ones(stderr, stdout)?
17            .filter_map(|(runner, info)| {
18                match runner.ty {
19                    UnitLib | UnitBin => pkg = Some(runner.src.bin_name),
20                    Doc => pkg = Some("Doc Tests"),
21                    _ => (),
22                }
23                if info.stats.total == 0 {
24                    // don't show test types that have no tests
25                    None
26                } else {
27                    Some((pkg, runner, info))
28                }
29            })
30            .collect(),
31    ))
32}
33
34/// The core parsing function that extracts all the information from `cargo test`.
35pub fn parse_cargo_test_with_empty_ones<'s>(
36    stderr: &'s str,
37    stdout: &'s str,
38) -> Result<impl Iterator<Item = (TestRunner<'s>, TestInfo<'s>)>> {
39    let parsed_stderr = parse_stderr(stderr)?;
40    let parsed_stdout = parse_stdout(stdout)?;
41    let err_len = parsed_stderr.len();
42    let out_len = parsed_stdout.len();
43    if err_len != out_len {
44        return Err(format!(
45            "{err_len} (the amount of test runners from stderr) should \
46         equal to {out_len} (that from stdout)\n\
47         stderr = {stderr:?}\nstdout = {stdout:?}"
48        ));
49    }
50    Ok(parsed_stderr.into_iter().zip(parsed_stdout))
51}
52
53/// Pkg/crate name determined by the unittests.
54/// It's possible to be None because unittests can be omitted in `cargo test`
55/// and we can't determine which crate emits the tests.
56/// This mainly affacts how the project structure looks like specifically the root node.
57pub type Pkg<'s> = Option<Text<'s>>;
58
59/// All the test runners with original display order but filtering empty types out.
60#[derive(Debug, Default)]
61pub struct TestRunners<'s> {
62    pub pkgs: IndexMap<Pkg<'s>, PkgTest<'s>>,
63}
64
65impl<'s> TestRunners<'s> {
66    pub fn new(v: Vec<(Pkg<'s>, TestRunner<'s>, TestInfo<'s>)>) -> TestRunners<'s> {
67        let mut runners = TestRunners::default();
68        for (pkg, runner, info) in v {
69            match runners.pkgs.entry(pkg) {
70                indexmap::map::Entry::Occupied(mut item) => {
71                    item.get_mut().push(runner, info);
72                }
73                indexmap::map::Entry::Vacant(empty) => {
74                    empty.insert(PkgTest::new(runner, info));
75                }
76            }
77        }
78        runners
79    }
80}
81
82/// The raw output from `cargo test`.
83pub type Text<'s> = &'s str;
84
85/// Tests information in a pkg/crate.
86/// For doc test type, tests from multiple crates are considered
87/// to be under a presumed Doc pkg.
88#[derive(Debug, Default)]
89pub struct PkgTest<'s> {
90    pub inner: Vec<Data<'s>>,
91    pub stats: Stats,
92}
93
94impl<'s> PkgTest<'s> {
95    pub fn new(runner: TestRunner<'s>, info: TestInfo<'s>) -> PkgTest<'s> {
96        let stats = info.stats.clone();
97        PkgTest {
98            inner: vec![Data { runner, info }],
99            stats,
100        }
101    }
102    pub fn push(&mut self, runner: TestRunner<'s>, info: TestInfo<'s>) {
103        self.stats += &info.stats;
104        self.inner.push(Data { runner, info });
105    }
106}
107
108/// Information extracted from stdout & stderr.
109#[derive(Debug)]
110pub struct Data<'s> {
111    pub runner: TestRunner<'s>,
112    pub info: TestInfo<'s>,
113}
114
115/// A test runner determined by the type and binary & source path.
116#[derive(Debug, Hash, PartialEq, Eq)]
117pub struct TestRunner<'s> {
118    pub ty: TestType,
119    pub src: Src<'s>,
120}
121
122/// All the information reported by a test runner.
123#[derive(Debug)]
124pub struct TestInfo<'s> {
125    /// Raw test information from stdout.
126    pub raw: Text<'s>,
127    pub stats: Stats,
128    pub parsed: ParsedCargoTestOutput<'s>,
129}
130
131/// Types of a test.
132#[derive(Debug, Hash, PartialEq, Eq, Clone, Copy)]
133pub enum TestType {
134    UnitLib,
135    UnitBin,
136    Doc,
137    Tests,
138    Examples,
139    Benches,
140}
141
142/// Source location and binary name for a test runner.
143#[derive(Debug, Hash, PartialEq, Eq, Clone, Copy)]
144pub struct Src<'s> {
145    /// Path of source code (except Doc type) which is relative to its crate
146    /// rather than root of project.
147    ///
148    /// This means it's possible to see same path from different crates.
149    pub src_path: Text<'s>,
150    /// Name from the path of test runner binary. The path usually starts with `target/`.
151    ///
152    /// But this field doesn't contain neither the `target/...` prefix nor hash postfix,
153    /// so it's possible to see same name from different crates.
154    pub bin_name: Text<'s>,
155}
156
157/// Statistics of test.
158#[derive(Debug, PartialEq, Eq, Clone)]
159pub struct Stats {
160    pub ok: bool,
161    pub total: u32,
162    pub passed: u32,
163    pub failed: u32,
164    pub ignored: u32,
165    pub measured: u32,
166    pub filtered_out: u32,
167    pub finished_in: Duration,
168}
169
170/// Summary text on the bottom.
171impl std::fmt::Display for Stats {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        let Stats {
174            ok,
175            total,
176            passed,
177            failed,
178            ignored,
179            measured,
180            filtered_out,
181            finished_in,
182        } = *self;
183        let time = finished_in.as_secs_f32();
184        let fail = if failed == 0 {
185            format!("{failed} failed")
186        } else {
187            format!("{failed} failed").red().bold().to_string()
188        };
189        write!(
190            f,
191            "Status: {}; total {total} tests in {time:.2}s: \
192            {passed} passed; {fail}; {ignored} ignored; \
193            {measured} measured; {filtered_out} filtered out",
194            status(ok)
195        )
196    }
197}
198
199fn status(ok: bool) -> ColoredString {
200    if ok {
201        "OK".green().bold()
202    } else {
203        "FAIL".red().bold()
204    }
205}
206
207impl Stats {
208    /// Summary text at the end of root node.
209    /// If the metric is zero, it won't be shown.
210    pub fn inlay_summary_string(&self) -> String {
211        let Stats {
212            total,
213            passed,
214            failed,
215            ignored,
216            filtered_out,
217            finished_in,
218            ..
219        } = *self;
220        let time = finished_in.as_secs_f32();
221        let mut metrics = Vec::with_capacity(4);
222        if passed != 0 {
223            metrics.push(format!("✅ {passed}"));
224        };
225        if failed != 0 {
226            metrics.push(format!("❌ {failed}").red().to_string());
227        };
228        if ignored != 0 {
229            metrics.push(format!("🔕 {ignored}"));
230        };
231        if filtered_out != 0 {
232            metrics.push(format!("✂️ {filtered_out}"));
233        };
234        format!("{total} tests in {time:.2}s: {}", metrics.join("; "))
235    }
236
237    /// Root of test tree node depending on the test type.
238    pub fn root_string(&self, pkg_name: Text) -> String {
239        format!(
240            "({}) {:} ... ({})",
241            status(self.ok),
242            pkg_name.blue().bold(),
243            self.inlay_summary_string().bold()
244        )
245    }
246
247    /// Subroot of test tree node depending on the test type.
248    /// Compared with `Stats::root_string`, texts except status are non-bold.
249    pub fn subroot_string(&self, runner_name: Text) -> String {
250        format!(
251            "({}) {} ... ({})",
252            status(self.ok),
253            runner_name,
254            self.inlay_summary_string()
255        )
256    }
257}
258
259impl Default for Stats {
260    fn default() -> Self {
261        Stats {
262            ok: true,
263            total: 0,
264            passed: 0,
265            failed: 0,
266            ignored: 0,
267            measured: 0,
268            filtered_out: 0,
269            finished_in: Duration::from_secs(0),
270        }
271    }
272}
273
274impl std::ops::Add<&Stats> for &Stats {
275    type Output = Stats;
276
277    fn add(self, rhs: &Stats) -> Self::Output {
278        Stats {
279            ok: self.ok && rhs.ok,
280            total: self.total + rhs.total,
281            passed: self.passed + rhs.passed,
282            failed: self.failed + rhs.failed,
283            ignored: self.ignored + rhs.ignored,
284            measured: self.measured + rhs.measured,
285            filtered_out: self.filtered_out + rhs.filtered_out,
286            finished_in: self.finished_in + rhs.finished_in,
287        }
288    }
289}
290
291impl std::ops::AddAssign<&Stats> for Stats {
292    fn add_assign(&mut self, rhs: &Stats) {
293        *self = &*self + rhs;
294    }
295}
296
297/// Output from one test runner.
298#[derive(Debug)]
299pub struct ParsedCargoTestOutput<'s> {
300    pub head: Text<'s>,
301    pub tree: Vec<Text<'s>>,
302    pub detail: Text<'s>,
303}
304
305pub fn parse_stderr(stderr: &str) -> Result<Vec<TestRunner>> {
306    fn parse_stderr_inner<'s>(cap: &regex_lite::Captures<'s>) -> Result<TestRunner<'s>> {
307        if let Some((path, pkg)) = cap.name("path").zip(cap.name("pkg")) {
308            let path = path.as_str();
309            let path_norm = Path::new(path);
310            let ty = if cap.name("is_unit").is_some() {
311                if path_norm
312                    .components()
313                    .take(2)
314                    .map(Component::as_os_str)
315                    .eq(["src", "lib.rs"])
316                {
317                    TestType::UnitLib
318                } else {
319                    TestType::UnitBin
320                }
321            } else {
322                let Some(base_dir) = path_norm
323                    .components()
324                    .next()
325                    .and_then(|p| p.as_os_str().to_str())
326                else {
327                    return Err(format!("failed to parse the type of test: {path:?}"));
328                };
329                match base_dir {
330                    "tests" => TestType::Tests,
331                    "examples" => TestType::Examples,
332                    "benches" => TestType::Benches,
333                    _ => return Err(format!("failed to parse the type of test: {path:?}")),
334                }
335            };
336
337            // e.g. target/debug/deps/cargo_pretty_test-xxxxxxxxxxxxxxxx
338            let mut pkg_comp = Path::new(pkg.as_str()).components();
339            match pkg_comp.next().map(|p| p.as_os_str() == "target") {
340                Some(true) => (),
341                _ => return Err(format!("failed to parse the location of test: {pkg:?}")),
342            }
343            let pkg = pkg_comp
344                .nth(2)
345                .ok_or_else(|| format!("can't get the third component in {pkg:?}"))?
346                .as_os_str()
347                .to_str()
348                .ok_or_else(|| format!("can't turn os_str into str in {pkg:?}"))?;
349            let pkg = &pkg[..pkg
350                .find('-')
351                .ok_or_else(|| format!("pkg `{pkg}` should be of `pkgname-hash` pattern"))?];
352            Ok(TestRunner {
353                ty,
354                src: Src {
355                    src_path: path,
356                    bin_name: pkg,
357                },
358            })
359        } else if let Some(s) = cap.name("doc").map(|m| m.as_str()) {
360            Ok(TestRunner {
361                ty: TestType::Doc,
362                src: Src {
363                    src_path: s,
364                    bin_name: s,
365                },
366            })
367        } else {
368            Err(format!("{cap:?} is not supported to be parsed"))
369        }
370    }
371    re().ty
372        .captures_iter(stderr)
373        .map(|cap| parse_stderr_inner(&cap))
374        .collect::<Result<Vec<_>>>()
375}
376
377#[allow(clippy::too_many_lines)]
378pub fn parse_stdout(stdout: &str) -> Result<Vec<TestInfo>> {
379    fn parse_stdout_except_head(raw: &str) -> Result<(Vec<Text>, Text, Stats, Text)> {
380        fn parse_tree_detail(text: &str) -> (Vec<Text>, Text) {
381            let line: Vec<_> = re().tree.find_iter(text).collect();
382            let tree_end = line.last().map_or(0, |cap| cap.end() + 1);
383            let mut tree: Vec<_> = line.into_iter().map(|cap| cap.as_str()).collect();
384            tree.sort_unstable();
385            (tree, text[tree_end..].trim())
386        }
387
388        if raw.is_empty() {
389            Err("raw stdout is empty".into())
390        } else {
391            let (tree, detail) = parse_tree_detail(raw);
392            let cap = re()
393                .stats
394                .captures(detail)
395                .ok_or_else(|| format!("`stats` is not found in {raw:?}"))?;
396            let stats = Stats {
397                ok: cap
398                    .name("ok")
399                    .ok_or_else(|| format!("`ok` is not found in {raw:?}"))?
400                    .as_str()
401                    == "ok",
402                total: u32::try_from(tree.len()).map_err(|err| err.to_string())?,
403                passed: cap
404                    .name("passed")
405                    .ok_or_else(|| format!("`passed` is not found in {raw:?}"))?
406                    .as_str()
407                    .parse::<u32>()
408                    .map_err(|err| err.to_string())?,
409                failed: cap
410                    .name("failed")
411                    .ok_or_else(|| format!("`failed` is not found in {raw:?}"))?
412                    .as_str()
413                    .parse::<u32>()
414                    .map_err(|err| err.to_string())?,
415                ignored: cap
416                    .name("ignored")
417                    .ok_or_else(|| format!("`ignored` is not found in {raw:?}"))?
418                    .as_str()
419                    .parse::<u32>()
420                    .map_err(|err| err.to_string())?,
421                measured: cap
422                    .name("measured")
423                    .ok_or_else(|| format!("`measured` is not found in {raw:?}"))?
424                    .as_str()
425                    .parse::<u32>()
426                    .map_err(|err| err.to_string())?,
427                filtered_out: cap
428                    .name("filtered")
429                    .ok_or_else(|| format!("`filtered` is not found in {raw:?}"))?
430                    .as_str()
431                    .parse::<u32>()
432                    .map_err(|err| err.to_string())?,
433                finished_in: Duration::from_secs_f32(
434                    cap.name("time")
435                        .ok_or_else(|| format!("`time` is not found in {raw:?}"))?
436                        .as_str()
437                        .parse::<f32>()
438                        .map_err(|err| err.to_string())?,
439                ),
440            };
441            let stats_start = cap
442                .get(0)
443                .ok_or_else(|| format!("can't get stats start in {raw:?}"))?
444                .start();
445            Ok((tree, detail[..stats_start].trim(), stats, raw))
446        }
447    }
448
449    let split: Vec<_> = re()
450        .head
451        .captures_iter(stdout)
452        .filter_map(|cap| {
453            let full = cap.get(0)?;
454            Some((
455                full.start(),
456                full.as_str(),
457                cap.name("amount")?.as_str().parse::<u32>().ok()?,
458            ))
459        })
460        .collect();
461    if split.is_empty() {
462        return Err(format!(
463            "{stdout:?} should contain `running (?P<amount>\\d+) tests?` pattern"
464        ));
465    }
466    let parsed_stdout = if split.len() == 1 {
467        vec![parse_stdout_except_head(stdout)?]
468    } else {
469        let start = split.iter().map(|v| v.0);
470        let end = start.clone().skip(1).chain([stdout.len()]);
471        start
472            .zip(end)
473            .map(|(a, b)| {
474                let src = &stdout[a..b];
475                parse_stdout_except_head(src)
476            })
477            .collect::<Result<Vec<_>>>()?
478    };
479
480    // check the amount of tests
481    let parsed_amount_from_head: Vec<_> = split.iter().map(|v| v.2).collect();
482    let stats_total: Vec<_> = parsed_stdout.iter().map(|v| v.2.total).collect();
483    if parsed_amount_from_head != stats_total {
484        return Err(format!(
485            "the parsed amount of running tests {parsed_amount_from_head:?} \
486             should equal to the number in stats.total {stats_total:?}\n\
487             split = {split:#?}\nparsed_stdout = {parsed_stdout:#?}"
488        ));
489    }
490
491    Ok(split
492        .iter()
493        .zip(parsed_stdout)
494        .map(|(head_info, v)| TestInfo {
495            parsed: ParsedCargoTestOutput {
496                head: head_info.1,
497                tree: v.0,
498                detail: v.1,
499            },
500            stats: v.2,
501            raw: v.3,
502        })
503        .collect())
504}