use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::path::Path;
use serde::Deserialize;
use xshell::Shell;
use crate::environment::{
discover_features, get_workspace_packages, git_commit_id, CmdExt, Package, PackageManifest,
ProgressGuard,
};
use crate::git;
use crate::lock::LockFile;
use crate::toolchain::{prepare_toolchain, Toolchain};
#[derive(Debug, Default)]
struct PackageSummary {
name: String,
examples: Vec<String>,
individual_features: Vec<String>,
sampled_subsets: Vec<Vec<String>>,
exact_sets: Vec<Vec<String>>,
no_std_checked: bool,
}
impl fmt::Display for PackageSummary {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let fmt_list = |list: &[String]| -> String {
if list.is_empty() {
"(none)".to_string()
} else {
list.join(", ")
}
};
let fmt_sets = |sets: &[Vec<String>]| -> String {
if sets.is_empty() {
return "(none)".to_string();
}
sets.iter().map(|s| format!("[{}]", fmt_list(s))).collect::<Vec<_>>().join(", ")
};
let rows: &[(&str, String)] = &[
("Examples", fmt_list(&self.examples)),
("Individual features", fmt_list(&self.individual_features)),
("Sampled subsets", fmt_sets(&self.sampled_subsets)),
("Exact sets", fmt_sets(&self.exact_sets)),
("No-std check", if self.no_std_checked { "ran" } else { "skipped" }.to_string()),
];
let width = rows.iter().map(|(label, _)| label.len()).max().unwrap_or(0);
writeln!(f, " Package: {}", self.name)?;
for (label, value) in rows {
writeln!(f, " {label:<width$}: {value}")?;
}
Ok(())
}
}
#[derive(Debug, Default)]
struct TestSummary {
commits: Vec<(String, Vec<PackageSummary>)>,
}
impl TestSummary {
fn print(&self) {
println!("Test Summary");
for (sha, packages) in &self.commits {
println!("Commit: {}", sha);
for pkg in packages {
print!("{}", pkg);
}
}
}
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
struct TestConfig {
examples: Vec<String>,
exclude_features: Vec<String>,
exact_features: Vec<Vec<String>>,
release: bool,
}
impl TestConfig {
fn load(crate_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
#[derive(serde::Deserialize, Default)]
struct RbmtTable {
#[serde(default)]
test: TestConfig,
}
let path = crate_dir.join("Cargo.toml");
if !path.exists() {
return Ok(Self::default());
}
let contents = std::fs::read_to_string(&path)?;
Ok(toml::from_str::<PackageManifest<RbmtTable>>(&contents)?.package.metadata.rbmt.test)
}
}
fn test_features(
sh: &Shell,
features: &[impl AsRef<str>],
release: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let features_str = features.iter().map(AsRef::as_ref).collect::<Vec<_>>().join(" ");
rbmt_cmd!(sh, "cargo --locked build --no-default-features --features={features_str}")
.set_release(release)
.run_verbose()?;
rbmt_cmd!(sh, "cargo --locked test --no-default-features --features={features_str}")
.set_release(release)
.run_verbose()?;
Ok(())
}
pub fn run(
sh: &Shell,
lockfile: LockFile,
toolchain: Toolchain,
debug_assertions: bool,
release: bool,
baseline: Option<&str>,
packages: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
let mut progress = ProgressGuard::new();
let mut summary = TestSummary::default();
let _rustflags_guard = sh.push_env(
"RUSTFLAGS",
if debug_assertions { "-C debug-assertions=on" } else { "-C debug-assertions=off" },
);
if let Some(baseline) = baseline {
let commits = git::list_commits(sh, baseline)?;
if commits.is_empty() {
rbmt_eprintln!("No commits found between '{}' and HEAD.", baseline);
return Ok(());
}
rbmt_eprintln!("Testing {} commit(s) against baseline '{}'", commits.len(), baseline);
for sha in &commits {
rbmt_eprintln!("Testing commit {}...", &sha[..12]);
let _git_guard = git::GitSwitchGuard::new(sh, sha)?;
let _lockfile_guard = lockfile.activate(sh)?;
let packages = get_workspace_packages(sh, packages)?;
let pkg_summaries = test_commit(sh, toolchain, release, &packages)?;
summary.commits.push((sha.clone(), pkg_summaries));
}
} else {
let packages = get_workspace_packages(sh, packages)?;
let _lockfile_guard = lockfile.activate(sh)?;
let sha = git_commit_id(sh).unwrap_or_else(|| "unknown".to_owned());
let pkg_summaries = test_commit(sh, toolchain, release, &packages)?;
summary.commits.push((sha, pkg_summaries));
}
rbmt_eprintln!("Tests complete.");
progress.disable();
summary.print();
Ok(())
}
fn test_commit(
sh: &Shell,
toolchain: Toolchain,
release: bool,
packages: &[Package],
) -> Result<Vec<PackageSummary>, Box<dyn std::error::Error>> {
rbmt_eprintln!("Testing {} crate(s)", packages.len());
let mut pkg_summaries = Vec::new();
for package in packages {
rbmt_eprintln!("Testing package: {}", package.name);
let _dir = sh.push_dir(&package.dir);
prepare_toolchain(sh, toolchain)?;
let config = TestConfig::load(Path::new(&package.dir))?;
let release = release || config.release;
let mut pkg_summary = PackageSummary { name: package.name.clone(), ..Default::default() };
do_test(sh, &config, release, &mut pkg_summary)?;
do_feature_matrix(sh, package, &config, release, &mut pkg_summary)?;
do_no_std_check(sh, &package.dir, &mut pkg_summary)?;
pkg_summaries.push(pkg_summary);
}
Ok(pkg_summaries)
}
fn do_test(
sh: &Shell,
config: &TestConfig,
release: bool,
summary: &mut PackageSummary,
) -> Result<(), Box<dyn std::error::Error>> {
rbmt_eprintln!("Running default tests on {}", summary.name);
rbmt_cmd!(sh, "cargo --locked build").set_release(release).run_verbose()?;
rbmt_cmd!(sh, "cargo --locked test").set_release(release).run_verbose()?;
for example in &config.examples {
let parts: Vec<&str> = example.split(':').collect();
match parts.len() {
1 => {
let name = parts[0];
rbmt_eprintln!(
"Running example {} with default features in {}",
name,
summary.name
);
rbmt_cmd!(sh, "cargo --locked run --example {name}")
.set_release(release)
.run_verbose()?;
}
2 => {
let name = parts[0];
let features = parts[1];
if features == "-" {
rbmt_eprintln!(
"Running example {} with no default features in {}",
name,
summary.name
);
rbmt_cmd!(sh, "cargo --locked run --no-default-features --example {name}")
.set_release(release)
.run_verbose()?;
} else {
rbmt_eprintln!(
"Running example {} with features {} in {}",
name,
features,
summary.name
);
rbmt_cmd!(sh, "cargo --locked run --example {name} --features={features}")
.set_release(release)
.run_verbose()?;
}
}
_ => {
return Err(format!(
"Invalid example format: {}, expected 'name', 'name:-', or 'name:features'",
example
)
.into());
}
}
summary.examples.push(example.clone());
}
Ok(())
}
fn do_feature_matrix(
sh: &Shell,
package: &Package,
config: &TestConfig,
release: bool,
summary: &mut PackageSummary,
) -> Result<(), Box<dyn std::error::Error>> {
rbmt_eprintln!("Running feature matrix tests in {}", package.name);
rbmt_eprintln!("Testing all features in {}", package.name);
rbmt_cmd!(sh, "cargo --locked build --all-features").set_release(release).run_verbose()?;
rbmt_cmd!(sh, "cargo --locked test --all-features").set_release(release).run_verbose()?;
rbmt_eprintln!("Testing no features in {}", package.name);
rbmt_cmd!(sh, "cargo --locked build --no-default-features")
.set_release(release)
.run_verbose()?;
rbmt_cmd!(sh, "cargo --locked test --no-default-features")
.set_release(release)
.run_verbose()?;
let features: Vec<String> = discover_features(sh, package)?
.into_iter()
.filter(|f| !config.exclude_features.contains(f))
.collect();
if !features.is_empty() {
rbmt_eprintln!(
"Discovered {} feature(s) in {} to test: {:?}",
features.len(),
package.name,
features
);
sampled_feature_matrix(sh, &features, release, summary)?;
}
for features in &config.exact_features {
rbmt_eprintln!("Testing exact feature set in {}: {:?}", package.name, features);
test_features(sh, features, release)?;
summary.exact_sets.push(features.clone());
}
Ok(())
}
fn sampled_feature_matrix(
sh: &Shell,
features: &[String],
release: bool,
summary: &mut PackageSummary,
) -> Result<(), Box<dyn std::error::Error>> {
fn num_subsets(n: usize) -> u32 {
if n <= 1 {
0
} else {
n.ilog2() + u32::from(!n.is_power_of_two())
}
}
for feature in features {
rbmt_eprintln!("Testing individual feature in {}: {}", summary.name, feature);
test_features(sh, &[feature], release)?;
summary.individual_features.push(feature.clone());
}
if let Some(commit) = git_commit_id(sh) {
for subset_index in 0..num_subsets(features.len()) {
let subset: Vec<&String> = features
.iter()
.filter(|f| {
let mut hasher = DefaultHasher::new();
commit.hash(&mut hasher);
subset_index.hash(&mut hasher);
f.hash(&mut hasher);
hasher.finish() & 1 == 1
})
.collect();
if subset.is_empty() {
continue;
}
rbmt_eprintln!("Testing sampled feature set in {}: {:?}", summary.name, subset);
test_features(sh, &subset, release)?;
summary.sampled_subsets.push(subset.into_iter().cloned().collect());
}
}
Ok(())
}
fn is_no_std_package(sh: &Shell, package_dir: &Path) -> Result<bool, Box<dyn std::error::Error>> {
let metadata = rbmt_cmd!(sh, "cargo metadata --format-version 1 --no-deps").read()?;
let json: serde_json::Value = serde_json::from_str(&metadata)?;
let packages =
json["packages"].as_array().ok_or("Missing 'packages' field in cargo metadata")?;
let current_manifest = package_dir.join("Cargo.toml");
let package = packages
.iter()
.find(|p| {
p["manifest_path"].as_str().is_some_and(|path| Path::new(path) == current_manifest)
})
.ok_or("Could not find package in metadata")?;
let targets = package["targets"].as_array().ok_or("Missing 'targets' field")?;
let lib_target = targets
.iter()
.find(|t| t["kind"].as_array().is_some_and(|kinds| kinds.iter().any(|k| k == "lib")));
let Some(lib_target) = lib_target else {
return Ok(false);
};
let lib_path = lib_target["src_path"].as_str().ok_or("Missing src_path in lib target")?;
let contents = std::fs::read_to_string(lib_path)?;
Ok(contents.lines().any(|line| line.trim() == "#![no_std]"))
}
fn do_no_std_check(
sh: &Shell,
package_dir: &Path,
summary: &mut PackageSummary,
) -> Result<(), Box<dyn std::error::Error>> {
const NO_STD_TARGET: &str = "thumbv7m-none-eabi";
if !is_no_std_package(sh, package_dir)? {
rbmt_eprintln!("{} does not appear to be no-std, skipping test", summary.name);
return Ok(());
}
rbmt_eprintln!(
"Detected {} as a no-std package, building for target: {}",
summary.name,
NO_STD_TARGET
);
rbmt_cmd!(sh, "cargo build --target {NO_STD_TARGET} --no-default-features").run_verbose()?;
summary.no_std_checked = true;
Ok(())
}