use std::collections::{BTreeSet, VecDeque};
use std::io::{self, BufRead, BufReader, IsTerminal};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde_json::{Value, json};
use super::select::Package;
use super::shim;
use crate::invoke;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct TestBin {
pub package: String,
pub target: String,
pub kinds: Vec<String>,
pub doctest: bool,
pub exe: PathBuf,
}
impl TestBin {
pub fn label(&self) -> String {
format!(
"{} {} ({})",
self.package,
self.target,
self.kinds.join(",")
)
}
pub fn has_doc_tests(&self) -> bool {
self.doctest
}
pub fn target_flags(&self) -> Vec<String> {
match self.kinds.first().map(String::as_str) {
Some(kind @ ("bin" | "test" | "bench" | "example")) => {
vec![format!("--{kind}"), self.target.clone()]
}
_ => vec!["--lib".into()],
}
}
}
fn cargo_test(
lead: &[&str],
names: &[String],
cargo_args: &[String],
extra: &[String],
run_args: &[String],
) -> Command {
let mut cmd = Command::new("cargo");
cmd.arg("test").args(lead);
for n in names {
cmd.args(["-p", n]);
}
cmd.args(cargo_args).args(extra);
if !run_args.is_empty() {
cmd.arg("--").args(run_args);
}
cmd
}
pub fn build(names: &[String], cargo_args: &[String]) -> io::Result<Vec<Value>> {
let mut cmd = cargo_test(
&["--no-run", "--message-format=json"],
names,
cargo_args,
&[],
&[],
);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::inherit());
let out = cmd.output()?;
let messages: Vec<Value> = invoke::cargo_messages(&out.stdout).collect();
for m in messages
.iter()
.filter(|m| m["reason"] == "compiler-message")
{
if let Some(rendered) = m["message"]["rendered"].as_str() {
eprint!("{rendered}");
}
}
if !out.status.success() {
return Err(io::Error::other("cargo test --no-run failed"));
}
Ok(messages)
}
pub struct RunStep<'a> {
pub names: &'a [String],
pub cargo_args: &'a [String],
pub run_args: &'a [String],
pub triples: &'a [String],
pub plan: &'a Path,
pub shim: &'a Path,
pub rustdoc: Option<&'a Path>,
}
pub struct RunOutcome {
pub success: bool,
pub code: Option<i32>,
pub doc_failed: bool,
pub tail: Vec<String>,
}
const TAIL: usize = 20;
pub fn run_step(step: &RunStep) -> io::Result<RunOutcome> {
let runner = json!([step.shim, "soothfast", shim::SUBCOMMAND]).to_string();
let mut extra: Vec<String> = step
.triples
.iter()
.flat_map(|t| ["--config".into(), format!("target.\"{t}\".runner={runner}")])
.collect();
if std::io::stderr().is_terminal() && !step.cargo_args.iter().any(|a| a.starts_with("--color"))
{
extra.push("--color=always".into());
}
let mut cmd = cargo_test(
&["--no-fail-fast"],
step.names,
step.cargo_args,
&extra,
step.run_args,
);
cmd.env(shim::PLAN_ENV, step.plan);
if let Some(rustdoc) = step.rustdoc {
cmd.env("RUSTDOC", rustdoc);
}
let mut child = cmd.stderr(Stdio::piped()).spawn()?;
let mut tail = VecDeque::with_capacity(TAIL);
let mut doc_failed = false;
if let Some(stderr) = child.stderr.take() {
for line in BufReader::new(stderr).lines() {
let line = line?;
eprintln!("{line}");
doc_failed |= names_failed_doc_test(&line);
if tail.len() == TAIL {
tail.pop_front();
}
tail.push_back(line);
}
}
let status = child.wait()?;
Ok(RunOutcome {
success: status.success(),
code: status.code(),
doc_failed,
tail: tail.into(),
})
}
fn names_failed_doc_test(line: &str) -> bool {
line.contains("doctest failed")
|| (line.trim_start().starts_with('`') && line.contains("--doc`"))
}
pub fn run_target(bin: &TestBin, cargo_args: &[String], run_args: &[String]) -> io::Result<bool> {
let names = std::slice::from_ref(&bin.package);
let mut cmd = cargo_test(&[], names, cargo_args, &bin.target_flags(), run_args);
Ok(cmd.status()?.success())
}
pub fn doc_tests(names: &[String], cargo_args: &[String], run_args: &[String]) -> io::Result<bool> {
let mut cmd = cargo_test(&["--doc"], names, cargo_args, &[], run_args);
Ok(cmd.status()?.success())
}
pub fn narrows_targets(cargo_args: &[String]) -> bool {
const FLAGS: &[&str] = &[
"--lib",
"--bin",
"--bins",
"--test",
"--tests",
"--example",
"--examples",
"--bench",
"--benches",
"--all-targets",
];
cargo_args
.iter()
.any(|a| FLAGS.contains(&a.split_once('=').map_or(a.as_str(), |(f, _)| f)))
}
#[derive(Debug, Default, PartialEq)]
pub struct Built {
pub tests: Vec<TestBin>,
pub siblings: Vec<TestBin>,
}
pub fn collect(messages: &[Value], pkgs: &[Package]) -> Built {
let mut tests = BTreeSet::new();
let mut siblings = BTreeSet::new();
for m in messages
.iter()
.filter(|m| m["reason"] == "compiler-artifact")
{
let Some(bin) = executable(m, pkgs) else {
continue;
};
if m["profile"]["test"] == true {
tests.insert(bin);
} else if bin.kinds.iter().any(|k| k == "bin" || k == "example") {
siblings.insert(bin);
}
}
Built {
tests: tests.into_iter().collect(),
siblings: siblings.into_iter().collect(),
}
}
fn executable(m: &Value, pkgs: &[Package]) -> Option<TestBin> {
let exe = PathBuf::from(m["executable"].as_str()?);
let manifest = PathBuf::from(m["manifest_path"].as_str()?);
let package = pkgs
.iter()
.find(|p| manifest.parent() == Some(p.manifest_dir.as_path()))?;
let kinds = m["target"]["kind"]
.as_array()?
.iter()
.filter_map(|k| k.as_str().map(String::from))
.collect();
Some(TestBin {
package: package.name.clone(),
target: m["target"]["name"].as_str()?.to_string(),
kinds,
doctest: m["target"]["doctest"] == true,
exe,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cargo_lines_naming_a_failed_doc_test_are_recognized() {
assert!(names_failed_doc_test(
"error: doctest failed, to rerun pass `-p a --doc`"
));
assert!(names_failed_doc_test(" `-p a --doc`"));
assert!(!names_failed_doc_test(" `-p a --test it`"));
assert!(!names_failed_doc_test(
"error: could not compile `a` (lib test)"
));
}
fn pkg(name: &str) -> Package {
Package {
name: name.into(),
dir: name.into(),
manifest_dir: PathBuf::from("/ws").join(name),
deps: Vec::new(),
}
}
fn artifact(
manifest: &str,
target: &str,
kinds: &[&str],
test: bool,
exe: Option<&str>,
) -> String {
let doctest = kinds
.iter()
.any(|k| ["lib", "rlib", "proc-macro"].contains(k));
json!({ "reason": "compiler-artifact", "manifest_path": manifest,
"target": { "name": target, "kind": kinds, "doctest": doctest },
"profile": { "test": test }, "executable": exe })
.to_string()
}
fn collected(lines: &[String], pkgs: &[Package]) -> Built {
let stdout = lines.join("\n");
let messages: Vec<Value> = invoke::cargo_messages(stdout.as_bytes()).collect();
collect(&messages, pkgs)
}
#[test]
fn test_executables_are_collected_with_their_package_and_kind() {
let out = [
artifact("/ws/a/Cargo.toml", "a", &["lib"], true, Some("/t/a-1")),
artifact("/ws/a/Cargo.toml", "it", &["test"], true, Some("/t/it-2")),
artifact("/ws/b/Cargo.toml", "b", &["bin"], true, Some("/t/b-3")),
];
let bins = collected(&out, &[pkg("a"), pkg("b")]).tests;
let labels: Vec<String> = bins.iter().map(TestBin::label).collect();
assert_eq!(labels, ["a a (lib)", "a it (test)", "b b (bin)"]);
assert_eq!(bins[1].exe, PathBuf::from("/t/it-2"));
}
#[test]
fn non_test_and_non_executable_artifacts_are_skipped() {
let out = [
artifact("/ws/a/Cargo.toml", "a", &["lib"], false, Some("/t/a-lib")),
artifact("/ws/a/Cargo.toml", "a", &["lib"], true, None),
artifact(
"/ws/a/Cargo.toml",
"build-script",
&["custom-build"],
false,
None,
),
];
assert_eq!(collected(&out, &[pkg("a")]), Built::default());
}
#[test]
fn plain_bins_and_examples_are_siblings_not_tests() {
let out = [
artifact("/ws/a/Cargo.toml", "a", &["bin"], false, Some("/t/a")),
artifact(
"/ws/a/Cargo.toml",
"demo",
&["example"],
false,
Some("/t/demo"),
),
artifact(
"/ws/a/Cargo.toml",
"a",
&["lib"],
false,
Some("/t/liba.rlib"),
),
];
let built = collected(&out, &[pkg("a")]);
assert!(built.tests.is_empty());
let labels: Vec<String> = built.siblings.iter().map(TestBin::label).collect();
assert_eq!(labels, ["a a (bin)", "a demo (example)"]);
}
#[test]
fn a_repeated_artifact_is_one_binary() {
let line = artifact("/ws/a/Cargo.toml", "a", &["lib"], true, Some("/t/a-1"));
let out = [line.clone(), line, "not json".to_string()];
assert_eq!(collected(&out, &[pkg("a")]).tests.len(), 1);
}
#[test]
fn a_dependency_outside_the_workspace_is_not_collected() {
let out = [artifact(
"/reg/serde/Cargo.toml",
"serde",
&["lib"],
true,
Some("/t/s"),
)];
assert_eq!(collected(&out, &[pkg("a")]), Built::default());
}
#[test]
fn a_dual_crate_type_lib_keeps_its_doc_tests_and_selects_with_lib() {
let out = [artifact(
"/ws/a/Cargo.toml",
"a",
&["cdylib", "rlib"],
true,
Some("/t/a"),
)];
let bin = collected(&out, &[pkg("a")]).tests.remove(0);
assert!(bin.has_doc_tests());
assert_eq!(bin.target_flags(), ["--lib"]);
}
#[test]
fn named_targets_select_by_kind_and_name() {
let kinds = |k: &str, name: &str| TestBin {
package: "p".into(),
target: name.into(),
kinds: vec![k.into()],
doctest: false,
exe: PathBuf::new(),
};
assert_eq!(kinds("test", "golden").target_flags(), ["--test", "golden"]);
assert_eq!(kinds("bin", "cli").target_flags(), ["--bin", "cli"]);
assert_eq!(kinds("bench", "b").target_flags(), ["--bench", "b"]);
assert!(!kinds("test", "golden").has_doc_tests());
}
fn args(list: &[&str]) -> Vec<String> {
list.iter().map(|s| s.to_string()).collect()
}
#[test]
fn target_selecting_flags_are_detected() {
assert!(narrows_targets(&args(&["--release", "--lib"])));
assert!(narrows_targets(&args(&["--test=golden"])));
assert!(narrows_targets(&args(&["--bin=cli"])));
assert!(!narrows_targets(&args(&["--all-features", "--release"])));
}
}