Skip to main content

vix_test_runner/
lib.rs

1//! Parse test-runner output into a pass/fail list with jump-to-failure
2//! locations. Supports `cargo test`'s libtest format and the common
3//! `name PASSED/FAILED/SKIPPED` shape (pytest `-v`, many others).
4//!
5//! Pure text → data, so it is unit-testable; the host runs the command (via the
6//! async pipeline) and renders the [`TestResult`]s.
7
8#![warn(clippy::pedantic)]
9#![forbid(unsafe_code)]
10#![deny(missing_docs)]
11
12/// Outcome of a single test.
13#[derive(Clone, Copy, PartialEq, Eq, Debug)]
14pub enum Status {
15    /// The test passed.
16    Pass,
17    /// The test failed.
18    Fail,
19    /// The test was ignored / skipped.
20    Ignore,
21}
22
23/// One parsed test result.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct TestResult {
26    /// Test name / path (e.g. `module::it_works`).
27    pub name: String,
28    /// Pass / fail / ignore.
29    pub status: Status,
30    /// Source location of a failure, if found: `(file, 1-based line)`.
31    pub location: Option<(String, usize)>,
32}
33
34/// Counts of passed / failed / ignored in a result set.
35#[must_use]
36pub fn tally(results: &[TestResult]) -> (usize, usize, usize) {
37    let mut pass = 0;
38    let mut fail = 0;
39    let mut ignore = 0;
40    for r in results {
41        match r.status {
42            Status::Pass => pass += 1,
43            Status::Fail => fail += 1,
44            Status::Ignore => ignore += 1,
45        }
46    }
47    (pass, fail, ignore)
48}
49
50/// Parse test-runner `output` lines into results, attaching failure locations
51/// found in libtest failure blocks. Results are sorted by name (which groups
52/// them by module prefix).
53#[must_use]
54pub fn parse(output: &str) -> Vec<TestResult> {
55    let mut results: Vec<TestResult> = Vec::new();
56    let mut current_failure: Option<String> = None;
57
58    for line in output.lines() {
59        let trimmed = line.trim();
60        // libtest: `test module::name ... ok | FAILED | ignored`
61        if let Some(rest) = trimmed.strip_prefix("test ")
62            && let Some((name, tail)) = rest.rsplit_once(" ... ")
63        {
64            let status = match tail.trim() {
65                "ok" => Some(Status::Pass),
66                t if t.starts_with("FAILED") => Some(Status::Fail),
67                t if t.starts_with("ignored") => Some(Status::Ignore),
68                _ => None,
69            };
70            if let Some(status) = status {
71                upsert(&mut results, name.trim(), status);
72                continue;
73            }
74        }
75        // Generic: `path::name PASSED | FAILED | SKIPPED` (pytest -v, etc.).
76        if let Some((name, verdict)) = trimmed.rsplit_once(' ') {
77            let status = match verdict {
78                "PASSED" => Some(Status::Pass),
79                "FAILED" => Some(Status::Fail),
80                "SKIPPED" => Some(Status::Ignore),
81                _ => None,
82            };
83            if let Some(status) = status
84                && !name.is_empty()
85            {
86                upsert(&mut results, name, status);
87                continue;
88            }
89        }
90        // libtest failure detail: `---- name stdout ----` then a panic location.
91        if let Some(name) = trimmed
92            .strip_prefix("---- ")
93            .and_then(|s| s.strip_suffix(" stdout ----"))
94        {
95            current_failure = Some(name.trim().to_string());
96            continue;
97        }
98        if let Some(loc) = panic_location(trimmed)
99            && let Some(name) = current_failure.clone()
100        {
101            attach_location(&mut results, &name, loc);
102        }
103    }
104
105    results.sort_by(|a, b| a.name.cmp(&b.name));
106    results.dedup_by(|a, b| a.name == b.name);
107    results
108}
109
110/// Insert or update a result by name (a later status wins, e.g. the failure
111/// summary after the run).
112fn upsert(results: &mut Vec<TestResult>, name: &str, status: Status) {
113    if let Some(r) = results.iter_mut().find(|r| r.name == name) {
114        r.status = status;
115    } else {
116        results.push(TestResult {
117            name: name.to_string(),
118            status,
119            location: None,
120        });
121    }
122}
123
124/// Attach a failure location to the named test.
125fn attach_location(results: &mut [TestResult], name: &str, loc: (String, usize)) {
126    if let Some(r) = results.iter_mut().find(|r| r.name == name) {
127        r.location = Some(loc);
128    }
129}
130
131/// Extract a `<file>:<line>` from a `thread '…' panicked at <file>:<line>:<col>`
132/// line (or a bare `<file>:<line>:<col>` prefix).
133fn panic_location(line: &str) -> Option<(String, usize)> {
134    let after = line
135        .rfind("panicked at ")
136        .map_or(line, |i| &line[i + "panicked at ".len()..]);
137    let mut parts = after.trim().splitn(3, ':');
138    let file = parts.next()?.trim();
139    let line_no: usize = parts.next()?.trim().parse().ok()?;
140    if file.is_empty() || !file.contains(['/', '.']) {
141        return None;
142    }
143    Some((file.to_string(), line_no))
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn parses_cargo_test_output() {
152        let out = "\
153running 3 tests
154test mod_a::works ... ok
155test mod_a::ignored_one ... ignored
156test mod_b::breaks ... FAILED
157
158failures:
159
160---- mod_b::breaks stdout ----
161thread 'mod_b::breaks' panicked at src/lib.rs:42:5:
162assertion failed
163
164test result: FAILED. 1 passed; 1 failed; 1 ignored;
165";
166        let results = parse(out);
167        assert_eq!(results.len(), 3);
168        let (p, f, i) = tally(&results);
169        assert_eq!((p, f, i), (1, 1, 1));
170        let broken = results.iter().find(|r| r.name == "mod_b::breaks").unwrap();
171        assert_eq!(broken.status, Status::Fail);
172        assert_eq!(broken.location, Some(("src/lib.rs".to_string(), 42)));
173    }
174
175    #[test]
176    fn parses_pytest_style() {
177        let out = "tests/test_x.py::test_ok PASSED\ntests/test_x.py::test_bad FAILED\n";
178        let results = parse(out);
179        assert_eq!(tally(&results), (1, 1, 0));
180    }
181
182    #[test]
183    fn empty_output_has_no_results() {
184        assert!(parse("compiling...\nwarning: unused\n").is_empty());
185    }
186}