Skip to main content

speccoder_runtime/collection/
listing.rs

1//! The runners' own listings: which tests they would run, asked only when
2//! the static rule and the JUnit report leave a file undecided. Each runs
3//! like a check — sandboxed, scrubbed environment, the check's deadline.
4
5use std::path::Path;
6use std::time::Duration;
7
8use speccoder_core::collection::ListingRun;
9use speccoder_core::verify::CheckStatus;
10
11use crate::verify::process::{CommandRunner, Invocation, OUTPUT_CAP, scrubbed_env};
12
13/// A listing's output, or why there is none.
14pub struct Listed {
15    /// What the runner printed (stdout).
16    pub stdout: String,
17    /// The run, for the evidence.
18    pub run: ListingRun,
19}
20
21/// Runs `argv` in `cwd`; `Err` says why it gave no listing.
22pub fn run(
23    runner: &dyn CommandRunner,
24    cwd: &Path,
25    argv: &[String],
26    timeout: Duration,
27) -> (Result<Listed, String>, ListingRun) {
28    let out = runner.run(&Invocation {
29        argv: argv.to_vec(),
30        cwd: cwd.to_path_buf(),
31        env: scrubbed_env(std::env::vars()),
32        timeout,
33        output_cap: OUTPUT_CAP,
34    });
35    let status = if out.spawn_error.is_some() {
36        CheckStatus::Error
37    } else if out.timed_out {
38        CheckStatus::TimedOut
39    } else if out.exit_code == Some(0) {
40        CheckStatus::Passed
41    } else {
42        CheckStatus::Failed
43    };
44    let run = ListingRun {
45        argv: argv.to_vec(),
46        duration_ms: u64::try_from(out.duration.as_millis()).unwrap_or(u64::MAX),
47        status,
48    };
49    let shown = argv.iter().take(3).cloned().collect::<Vec<_>>().join(" ");
50    let result = if let Some(e) = &out.spawn_error {
51        Err(format!("`{shown}` could not run: {e}"))
52    } else if out.timed_out {
53        Err(format!("`{shown}` timed out"))
54    } else {
55        // pytest exits 5 when it collects nothing: a listing, not a failure.
56        let pytest_empty = out.exit_code == Some(5) && argv.iter().any(|a| a.contains("pytest"));
57        if out.exit_code == Some(0) || pytest_empty {
58            Ok(Listed {
59                stdout: String::from_utf8_lossy(&out.stdout.kept).into_owned(),
60                run: run.clone(),
61            })
62        } else {
63            Err(format!(
64                "`{shown}` failed: exit {}",
65                out.exit_code
66                    .map_or_else(|| "signal".to_owned(), |c| c.to_string())
67            ))
68        }
69    };
70    (result, run)
71}
72
73/// `cargo nextest list --message-format json`: `binary-id::test` for every
74/// test it would run (ignored ones left out).
75#[must_use]
76pub fn nextest_ids(stdout: &str) -> Option<Vec<String>> {
77    let start = stdout.find('{')?;
78    let v: serde_json::Value = serde_json::from_str(stdout[start..].trim()).ok()?;
79    let mut out = Vec::new();
80    for (binary, suite) in v["rust-suites"].as_object()? {
81        for (name, case) in suite["testcases"].as_object().into_iter().flatten() {
82            if case["ignored"] != true && case["filter-match"]["status"] != "mismatch" {
83                out.push(format!("{binary}::{name}"));
84            }
85        }
86    }
87    Some(out)
88}
89
90/// `cargo test … -- --list --format terse`: `name: test` lines.
91#[must_use]
92pub fn libtest_names(stdout: &str) -> Vec<String> {
93    stdout
94        .lines()
95        .filter_map(|l| l.strip_suffix(": test"))
96        .map(str::to_owned)
97        .collect()
98}
99
100/// `pytest --collect-only -q`: node ids (`path::name`).
101#[must_use]
102pub fn pytest_ids(stdout: &str) -> Vec<String> {
103    stdout
104        .lines()
105        .map(str::trim)
106        .filter(|l| l.contains("::"))
107        .map(str::to_owned)
108        .collect()
109}
110
111/// `vitest list --json`: the files of the listed tests, relative to `root`.
112#[must_use]
113pub fn vitest_files(stdout: &str, root: &Path) -> Option<Vec<String>> {
114    let start = stdout.find('[')?;
115    let v: serde_json::Value = serde_json::from_str(stdout[start..].trim()).ok()?;
116    Some(
117        v.as_array()?
118            .iter()
119            .filter_map(|t| t["file"].as_str())
120            .map(|f| relative(f, root))
121            .collect(),
122    )
123}
124
125/// `jest --listTests`: absolute file paths, one per line.
126#[must_use]
127pub fn jest_files(stdout: &str, root: &Path) -> Vec<String> {
128    stdout
129        .lines()
130        .map(str::trim)
131        .filter(|l| !l.is_empty())
132        .map(|f| relative(f, root))
133        .collect()
134}
135
136/// `go test -list .`: test names (the `ok` summary lines left out).
137#[must_use]
138pub fn go_names(stdout: &str) -> Vec<String> {
139    stdout
140        .lines()
141        .map(str::trim)
142        .filter(|l| {
143            !l.is_empty() && !l.starts_with("ok ") && !l.starts_with("?") && !l.contains('\t')
144        })
145        .map(str::to_owned)
146        .collect()
147}
148
149/// `go test -list`: `importpath::Name` per test, the names grouped by the
150/// `ok <importpath>` line that follows them.
151#[must_use]
152pub fn go_ids(stdout: &str) -> Vec<String> {
153    let mut out = Vec::new();
154    let mut names: Vec<&str> = Vec::new();
155    for l in stdout.lines().map(str::trim) {
156        if let Some(rest) = l.strip_prefix("ok") {
157            let pkg = rest.split_whitespace().next().unwrap_or_default();
158            out.extend(names.drain(..).map(|n| format!("{pkg}::{n}")));
159        } else if !l.is_empty() && !l.starts_with('?') && !l.contains('\t') && !l.contains(' ') {
160            names.push(l);
161        }
162    }
163    out
164}
165
166/// A configured `list` command: one test id or file path per line.
167#[must_use]
168pub fn lines(stdout: &str, root: &Path) -> Vec<String> {
169    stdout
170        .lines()
171        .map(str::trim)
172        .filter(|l| !l.is_empty())
173        .map(|l| relative(l, root))
174        .collect()
175}
176
177fn relative(p: &str, root: &Path) -> String {
178    let root = root.to_string_lossy();
179    let canon = std::fs::canonicalize(root.as_ref())
180        .map(|c| c.to_string_lossy().into_owned())
181        .unwrap_or_default();
182    for r in [root.as_ref(), canon.as_str()] {
183        if !r.is_empty()
184            && let Some(rest) = p.strip_prefix(r)
185        {
186            return rest.trim_start_matches('/').to_owned();
187        }
188    }
189    p.trim_start_matches("./").to_owned()
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn listings_parse() {
198        let nx = r#"{"rust-build-meta":{},"rust-suites":{"greet::greet_name":{"testcases":{"a":{"ignored":false,"filter-match":{"status":"matches"}},"b":{"ignored":true,"filter-match":{"status":"matches"}}}}}}"#;
199        assert_eq!(nextest_ids(nx).unwrap(), ["greet::greet_name::a"]);
200        assert_eq!(
201            libtest_names("tests::a: test\nbench: benchmark\n\n1 test, 0 benchmarks\n"),
202            ["tests::a"]
203        );
204        assert_eq!(
205            pytest_ids("tests/test_x.py::test_a\n\n1 test collected in 0.01s\n"),
206            ["tests/test_x.py::test_a"]
207        );
208        let root = Path::new("/w");
209        assert_eq!(
210            vitest_files(r#"[{"name":"g","file":"/w/src/a.test.ts"}]"#, root).unwrap(),
211            ["src/a.test.ts"]
212        );
213        assert_eq!(jest_files("/w/src/a.test.js\n", root), ["src/a.test.js"]);
214        assert_eq!(
215            go_names("TestA\nExampleB\nok  \tex.com/m/g\t0.01s\n"),
216            ["TestA", "ExampleB"]
217        );
218        assert_eq!(
219            go_ids("TestA\nok  \tex.com/m/g\t0.01s\nTestB\nok  \tex.com/m/h\t0.01s\n"),
220            ["ex.com/m/g::TestA", "ex.com/m/h::TestB"]
221        );
222    }
223}