Skip to main content

speccoder_runtime/collection/
runner.rs

1//! The listing a check's own run implies: built from the argv of
2//! a fast check that runs the toolchain — its selectors, filters and
3//! packages kept — so a test counts only if a check the verdict rests on
4//! would run it. No check runs the toolchain: there is no listing, and the
5//! file is unknown.
6
7/// Whether a check's argv runs `toolchain`'s tests.
8#[must_use]
9pub fn runs(toolchain: speccoder_core::collection::Toolchain, argv: &[String]) -> bool {
10    use speccoder_core::collection::Toolchain;
11    let has = |w: &str| argv.iter().any(|a| a == w || a.ends_with(&format!("/{w}")));
12    let word = |i: usize| argv.get(i).map(String::as_str);
13    match toolchain {
14        Toolchain::Rust => {
15            // `cargo test` or `cargo nextest run`: a cargo that runs tests.
16            let cargo = argv
17                .iter()
18                .position(|a| a == "cargo" || a.ends_with("/cargo"));
19            match cargo {
20                Some(i) => {
21                    matches!(word(i + 1), Some("test"))
22                        || matches!((word(i + 1), word(i + 2)), (Some("nextest"), Some("run")))
23                }
24                None => has("cargo-nextest"),
25            }
26        }
27        Toolchain::Python => argv.iter().any(|a| a.contains("pytest")),
28        Toolchain::JavaScript => argv
29            .iter()
30            .any(|a| a.contains("vitest") || a.contains("jest")),
31        Toolchain::Go => {
32            has("gotestsum")
33                || argv
34                    .iter()
35                    .position(|a| a == "go" || a.ends_with("/go"))
36                    .is_some_and(|i| word(i + 1) == Some("test"))
37        }
38    }
39}
40
41/// `cargo nextest run …` as `cargo nextest list … --message-format json`,
42/// its filters and package selection kept.
43#[must_use]
44pub fn nextest_list(argv: &[String]) -> Option<Vec<String>> {
45    let i = argv.iter().position(|a| a == "nextest")?;
46    if argv.get(i + 1).map(String::as_str) != Some("run") {
47        return None;
48    }
49    // Options only `run` takes: dropped, never changing the selection.
50    const RUN_ONLY: &[&str] = &[
51        "--no-fail-fast",
52        "--fail-fast",
53        "--no-capture",
54        "--nocapture",
55        "--retries",
56        "--success-output",
57        "--failure-output",
58        "--status-level",
59        "--final-status-level",
60        "--no-tests",
61        "--test-threads",
62        "-j",
63        "--profile",
64        "-P",
65        "--max-fail",
66    ];
67    let mut out: Vec<String> = argv[..=i].to_vec();
68    out.push("list".to_owned());
69    let mut k = i + 2;
70    while k < argv.len() {
71        let a = &argv[k];
72        let flag = a.split('=').next().unwrap_or(a);
73        if RUN_ONLY.contains(&flag) {
74            // A separate value goes with it.
75            if !a.contains('=')
76                && !matches!(
77                    flag,
78                    "--no-fail-fast" | "--fail-fast" | "--no-capture" | "--nocapture"
79                )
80            {
81                k += 1;
82            }
83            k += 1;
84            continue;
85        }
86        out.push(a.clone());
87        k += 1;
88    }
89    out.extend(["--message-format", "json"].map(str::to_owned));
90    Some(out)
91}
92
93/// A `cargo test` check's parts: options before `--`, and the test
94/// binary's arguments after it.
95fn split_cargo(argv: &[String]) -> (Vec<String>, Vec<String>) {
96    match argv.iter().position(|a| a == "--") {
97        Some(i) => (argv[..i].to_vec(), argv[i + 1..].to_vec()),
98        None => (argv.to_vec(), Vec::new()),
99    }
100}
101
102/// The target a `cargo test` check runs: whether it runs `binary` of
103/// package `pkg` (`kind`: `lib`, `bin <name>`, `test <name>`).
104#[must_use]
105pub fn cargo_runs(argv: &[String], pkg: &str, root_package: bool, kind: &(String, String)) -> bool {
106    let (opts, _) = split_cargo(argv);
107    let value = |flag: &str| -> Vec<String> {
108        let mut v = Vec::new();
109        for (i, a) in opts.iter().enumerate() {
110            if a == flag {
111                if let Some(n) = opts.get(i + 1) {
112                    v.push(n.clone());
113                }
114            } else if let Some(x) = a.strip_prefix(&format!("{flag}=")) {
115                v.push(x.to_owned());
116            }
117        }
118        v
119    };
120    let has = |f: &str| opts.iter().any(|a| a == f);
121    let packages: Vec<String> = value("-p").into_iter().chain(value("--package")).collect();
122    let package_ok = if !packages.is_empty() {
123        packages.iter().any(|p| p == pkg)
124    } else {
125        root_package || has("--workspace") || has("--all")
126    };
127    if !package_ok {
128        return false;
129    }
130    let tests = value("--test");
131    let bins = value("--bin");
132    let selected = has("--lib")
133        || has("--tests")
134        || has("--bins")
135        || has("--doc")
136        || has("--all-targets")
137        || !tests.is_empty()
138        || !bins.is_empty()
139        || has("--examples")
140        || has("--benches");
141    if !selected || has("--all-targets") {
142        return kind.0 != "bench" && kind.0 != "example";
143    }
144    match kind.0.as_str() {
145        "lib" => has("--lib"),
146        "bin" => has("--bins") || bins.contains(&kind.1),
147        "test" => has("--tests") || tests.contains(&kind.1),
148        _ => false,
149    }
150}
151
152/// The listing of one target as the `cargo test` check would run it: its
153/// package and feature options and its test filters kept, the target
154/// selected, `--list` added. The second argv lists the ignored tests
155/// (subtracted), unless the check runs them too.
156#[must_use]
157pub fn cargo_list(
158    argv: &[String],
159    pkg: &str,
160    kind: &(String, String),
161) -> (Vec<String>, Option<Vec<String>>) {
162    let (opts, test_args) = split_cargo(argv);
163    // Drop the check's own target selection; keep everything else.
164    let mut kept = Vec::new();
165    let mut i = 0;
166    while i < opts.len() {
167        let a = &opts[i];
168        let flag = a.split('=').next().unwrap_or(a);
169        match flag {
170            "--lib" | "--tests" | "--bins" | "--doc" | "--all-targets" | "--examples"
171            | "--benches" | "--no-fail-fast" => {}
172            "--test" | "--bin" | "-p" | "--package" => {
173                if !a.contains('=') {
174                    i += 1;
175                }
176            }
177            _ => kept.push(a.clone()),
178        }
179        i += 1;
180    }
181    let mut base = kept;
182    base.extend(["-p".to_owned(), pkg.to_owned()]);
183    match kind.0.as_str() {
184        "lib" => base.push("--lib".to_owned()),
185        "bin" => base.extend(["--bin".to_owned(), kind.1.clone()]),
186        _ => base.extend(["--test".to_owned(), kind.1.clone()]),
187    }
188    base.push("--".to_owned());
189    // Output-only flags of the test binary are dropped; filters stay.
190    let filters: Vec<String> = test_args
191        .iter()
192        .filter(|a| {
193            !matches!(
194                a.as_str(),
195                "--nocapture" | "--no-capture" | "--show-output" | "-q" | "--quiet"
196            )
197        })
198        .cloned()
199        .collect();
200    let runs_ignored = filters
201        .iter()
202        .any(|a| a == "--ignored" || a == "--include-ignored");
203    let mut list = base.clone();
204    list.extend(
205        filters
206            .iter()
207            .filter(|a| *a != "--include-ignored")
208            .cloned(),
209    );
210    list.extend(["--list", "--format", "terse"].map(str::to_owned));
211    let ignored = (!runs_ignored).then(|| {
212        let mut l = base;
213        l.extend(filters.iter().cloned());
214        l.extend(["--list", "--format", "terse", "--ignored"].map(str::to_owned));
215        l
216    });
217    (list, ignored)
218}
219
220fn drop_flags(argv: &[String], flags: &[&str]) -> Vec<String> {
221    let mut out = Vec::new();
222    let mut i = 0;
223    while i < argv.len() {
224        let a = &argv[i];
225        let flag = a.split('=').next().unwrap_or(a);
226        if flags.contains(&flag) {
227            if !a.contains('=') && argv.get(i + 1).is_some_and(|n| !n.starts_with('-')) {
228                i += 1;
229            }
230            i += 1;
231            continue;
232        }
233        out.push(a.clone());
234        i += 1;
235    }
236    out
237}
238
239/// The pytest check with `--collect-only -q`: its paths, `-k`, `-m` kept.
240#[must_use]
241pub fn pytest_list(argv: &[String]) -> Vec<String> {
242    let mut out = drop_flags(
243        argv,
244        &[
245            "--junitxml",
246            "--junit-xml",
247            "--cov",
248            "--cov-report",
249            "-x",
250            "--exitfirst",
251            "-v",
252            "--verbose",
253        ],
254    );
255    out.extend(["--collect-only", "-q"].map(str::to_owned));
256    out
257}
258
259/// The vitest check as `vitest list --json`, its filters and projects kept.
260#[must_use]
261pub fn vitest_list(argv: &[String]) -> Option<Vec<String>> {
262    let i = argv.iter().position(|a| a.ends_with("vitest"))?;
263    let mut out: Vec<String> = argv[..=i].to_vec();
264    out.push("list".to_owned());
265    let rest = &argv[i + 1..];
266    let rest = match rest.first().map(String::as_str) {
267        Some("run" | "watch" | "dev") => &rest[1..],
268        _ => rest,
269    };
270    out.extend(drop_flags(
271        rest,
272        &[
273            "--reporter",
274            "--outputFile",
275            "--coverage",
276            "--run",
277            "--silent",
278        ],
279    ));
280    out.push("--json".to_owned());
281    Some(out)
282}
283
284/// The jest check with `--listTests`, its path patterns and projects kept.
285#[must_use]
286pub fn jest_list(argv: &[String]) -> Vec<String> {
287    let mut out = drop_flags(
288        argv,
289        &[
290            "--reporters",
291            "--ci",
292            "--coverage",
293            "--silent",
294            "--json",
295            "--outputFile",
296        ],
297    );
298    out.push("--listTests".to_owned());
299    out
300}
301
302/// The go check as `go test -list <its -run, else .> <its packages>`.
303#[must_use]
304pub fn go_list(argv: &[String]) -> Vec<String> {
305    // gotestsum: the go arguments follow `--`.
306    let go_args: Vec<String> = if argv.iter().any(|a| a.ends_with("gotestsum")) {
307        argv.iter()
308            .skip_while(|a| *a != "--")
309            .skip(1)
310            .cloned()
311            .collect()
312    } else {
313        argv.iter()
314            .skip_while(|a| !(a.as_str() == "go" || a.ends_with("/go")))
315            .skip(2)
316            .cloned()
317            .collect()
318    };
319    let mut pattern = ".".to_owned();
320    let mut rest = Vec::new();
321    let mut i = 0;
322    while i < go_args.len() {
323        let a = &go_args[i];
324        if a == "-run" {
325            if let Some(p) = go_args.get(i + 1) {
326                pattern = p.clone();
327            }
328            i += 2;
329            continue;
330        }
331        if let Some(p) = a.strip_prefix("-run=") {
332            pattern = p.to_owned();
333        } else if matches!(a.as_str(), "-v" | "-json" | "-race" | "-short")
334            || a.starts_with("-count")
335            || a.starts_with("-cover")
336            || a.starts_with("-timeout")
337            || a.starts_with("-coverprofile")
338        {
339        } else {
340            rest.push(a.clone());
341        }
342        i += 1;
343    }
344    let mut out = vec![
345        "go".to_owned(),
346        "test".to_owned(),
347        "-list".to_owned(),
348        pattern,
349    ];
350    if rest.iter().all(|a| a.starts_with('-')) {
351        rest.push("./...".to_owned());
352    }
353    out.extend(rest);
354    out
355}
356
357/// A skip that a listing cannot see (the listing names the test, the run
358/// skips it): pytest, JS and Go files with one are decided by a report only.
359#[must_use]
360pub fn skips_unseen_by_listing(
361    toolchain: speccoder_core::collection::Toolchain,
362    source: &str,
363) -> bool {
364    use speccoder_core::collection::Toolchain;
365    let code = speccoder_core::collection::strip_comments(source);
366    match toolchain {
367        Toolchain::Python => [
368            "pytest.mark.skip",
369            "pytest.skip(",
370            "pytest.mark.xfail",
371            "unittest.skip",
372            "pytest.importorskip",
373        ]
374        .iter()
375        .any(|m| code.contains(m)),
376        Toolchain::JavaScript => [
377            ".skip(",
378            ".todo(",
379            "xit(",
380            "xdescribe(",
381            "xtest(",
382            ".skipIf(",
383            ".runIf(",
384        ]
385        .iter()
386        .any(|m| code.contains(m)),
387        Toolchain::Go => ["t.Skip", "testing.Short()"]
388            .iter()
389            .any(|m| code.contains(m)),
390        // Ignored tests are listed separately (`--ignored`); `cfg` is compiled out.
391        Toolchain::Rust => false,
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use speccoder_core::collection::Toolchain;
399
400    fn v(s: &[&str]) -> Vec<String> {
401        s.iter().map(|x| (*x).to_owned()).collect()
402    }
403
404    #[test]
405    fn only_a_check_that_runs_the_tests_counts() {
406        assert!(runs(Toolchain::Rust, &v(&["cargo", "test", "--lib"])));
407        assert!(runs(Toolchain::Rust, &v(&["cargo", "nextest", "run"])));
408        assert!(!runs(Toolchain::Rust, &v(&["cargo", "fmt", "--check"])));
409        assert!(!runs(Toolchain::Rust, &v(&["cargo", "clippy"])));
410        assert!(!runs(Toolchain::Go, &v(&["go", "vet", "./..."])));
411        assert!(runs(Toolchain::Go, &v(&["go", "test", "./..."])));
412    }
413
414    #[test]
415    fn a_cargo_check_runs_only_what_it_selects() {
416        let lib = ("lib".to_owned(), String::new());
417        let greet = ("test".to_owned(), "greet".to_owned());
418        assert!(cargo_runs(&v(&["cargo", "test", "--lib"]), "g", true, &lib));
419        assert!(!cargo_runs(
420            &v(&["cargo", "test", "--lib"]),
421            "g",
422            true,
423            &greet
424        ));
425        assert!(cargo_runs(&v(&["cargo", "test"]), "g", true, &greet));
426        assert!(!cargo_runs(
427            &v(&["cargo", "test", "-p", "other"]),
428            "g",
429            true,
430            &greet
431        ));
432        assert!(cargo_runs(
433            &v(&["cargo", "test", "--test", "greet"]),
434            "g",
435            true,
436            &greet
437        ));
438        assert!(
439            !cargo_runs(&v(&["cargo", "test"]), "g", false, &greet),
440            "not the root package, no -p"
441        );
442    }
443
444    #[test]
445    fn listings_keep_the_check_s_selection() {
446        let (l, ignored) = cargo_list(
447            &v(&["cargo", "test", "--offline", "--", "greet", "--nocapture"]),
448            "g",
449            &("test".to_owned(), "greet".to_owned()),
450        );
451        assert_eq!(
452            l,
453            v(&[
454                "cargo",
455                "test",
456                "--offline",
457                "-p",
458                "g",
459                "--test",
460                "greet",
461                "--",
462                "greet",
463                "--list",
464                "--format",
465                "terse"
466            ])
467        );
468        assert!(ignored.unwrap().ends_with(&v(&["--ignored"])));
469        assert_eq!(
470            nextest_list(&v(&[
471                "cargo",
472                "nextest",
473                "run",
474                "--profile",
475                "speccoder",
476                "--no-tests=warn",
477                "-E",
478                "test(a)"
479            ]))
480            .unwrap(),
481            v(&[
482                "cargo",
483                "nextest",
484                "list",
485                "-E",
486                "test(a)",
487                "--message-format",
488                "json"
489            ])
490        );
491        assert_eq!(
492            pytest_list(&v(&[
493                "python3",
494                "-m",
495                "pytest",
496                "tests/unit",
497                "-k",
498                "greet",
499                "--junitxml=r.xml"
500            ])),
501            v(&[
502                "python3",
503                "-m",
504                "pytest",
505                "tests/unit",
506                "-k",
507                "greet",
508                "--collect-only",
509                "-q"
510            ])
511        );
512        assert_eq!(
513            vitest_list(&v(&["npx", "vitest", "run", "src/a", "--reporter=junit"])).unwrap(),
514            v(&["npx", "vitest", "list", "src/a", "--json"])
515        );
516        assert_eq!(
517            go_list(&v(&["go", "test", "-run", "TestA", "./pkg/..."])),
518            v(&["go", "test", "-list", "TestA", "./pkg/..."])
519        );
520        assert_eq!(
521            go_list(&v(&[
522                "gotestsum",
523                "--junitfile",
524                "r.xml",
525                "--",
526                "-tags=it",
527                "./..."
528            ])),
529            v(&["go", "test", "-list", ".", "-tags=it", "./..."])
530        );
531    }
532
533    #[test]
534    fn skips_a_listing_cannot_see_are_found() {
535        assert!(skips_unseen_by_listing(
536            Toolchain::Python,
537            "@pytest.mark.skip\ndef test_a(): ..."
538        ));
539        assert!(skips_unseen_by_listing(
540            Toolchain::JavaScript,
541            "it.skip('a', () => {})"
542        ));
543        assert!(skips_unseen_by_listing(
544            Toolchain::Go,
545            "func TestA(t *testing.T) { t.Skip() }"
546        ));
547        assert!(!skips_unseen_by_listing(
548            Toolchain::Python,
549            "def test_a(): ..."
550        ));
551    }
552}