use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::path::Path;
use serde::Deserialize;
use xshell::{Cmd, Shell};
use crate::environment::{
discover_features, git_commit_id, quiet_println, Package, PackageManifest,
};
use crate::toolchain::{prepare_toolchain, Toolchain};
use crate::{git, quiet_cmd};
#[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) {
quiet_println("Test Summary");
for (sha, packages) in &self.commits {
quiet_println(&format!("Commit: {}", sha));
for pkg in packages {
quiet_println(&pkg.to_string());
}
}
}
}
#[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(" ");
quiet_println(&format!("Testing features: {}", features_str));
with_release(
quiet_cmd!(sh, "cargo --locked build --no-default-features --features={features_str}"),
release,
)
.run()?;
with_release(
quiet_cmd!(sh, "cargo --locked test --no-default-features --features={features_str}"),
release,
)
.run()?;
Ok(())
}
fn with_release(cmd: Cmd<'_>, release: bool) -> Cmd<'_> {
if release {
cmd.arg("--release")
} else {
cmd
}
}
pub fn run(
sh: &Shell,
toolchain: Toolchain,
no_debug_assertions: bool,
release: bool,
baseline: Option<&str>,
packages: &[Package],
) -> Result<(), Box<dyn std::error::Error>> {
let mut summary = TestSummary::default();
if let Some(baseline) = baseline {
let commits = git::list_commits(sh, baseline)?;
if commits.is_empty() {
quiet_println(&format!("No commits found between '{}' and HEAD.", baseline));
return Ok(());
}
quiet_println(&format!(
"Testing {} commit(s) against baseline '{}'",
commits.len(),
baseline
));
for sha in &commits {
quiet_println(&format!("Testing commit {}...", &sha[..12]));
let _guard = git::GitSwitchGuard::new(sh, sha)?;
let pkg_summaries = test_commit(sh, toolchain, no_debug_assertions, release, packages)?;
summary.commits.push((sha.clone(), pkg_summaries));
}
} else {
let sha = git_commit_id(sh).unwrap_or_else(|| "unknown".to_owned());
let pkg_summaries = test_commit(sh, toolchain, no_debug_assertions, release, packages)?;
summary.commits.push((sha, pkg_summaries));
}
summary.print();
Ok(())
}
fn test_commit(
sh: &Shell,
toolchain: Toolchain,
no_debug_assertions: bool,
release: bool,
packages: &[Package],
) -> Result<Vec<PackageSummary>, Box<dyn std::error::Error>> {
quiet_println(&format!("Testing {} crate(s)", packages.len()));
let _env = sh.push_env(
"RUSTFLAGS",
if no_debug_assertions { "-C debug-assertions=off" } else { "-C debug-assertions=on" },
);
let mut pkg_summaries = Vec::new();
for package in packages {
quiet_println(&format!("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>> {
quiet_println("Running basic tests");
with_release(quiet_cmd!(sh, "cargo --locked build"), release).run()?;
with_release(quiet_cmd!(sh, "cargo --locked test"), release).run()?;
for example in &config.examples {
let parts: Vec<&str> = example.split(':').collect();
match parts.len() {
1 => {
let name = parts[0];
with_release(quiet_cmd!(sh, "cargo --locked run --example {name}"), release)
.run()?;
}
2 => {
let name = parts[0];
let features = parts[1];
if features == "-" {
with_release(
quiet_cmd!(sh, "cargo --locked run --no-default-features --example {name}"),
release,
)
.run()?;
} else {
with_release(
quiet_cmd!(sh, "cargo --locked run --example {name} --features={features}"),
release,
)
.run()?;
}
}
_ => {
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>> {
quiet_println("Running feature matrix tests");
quiet_println("Testing all features");
with_release(quiet_cmd!(sh, "cargo --locked build --all-features"), release).run()?;
with_release(quiet_cmd!(sh, "cargo --locked test --all-features"), release).run()?;
quiet_println("Testing no features");
with_release(quiet_cmd!(sh, "cargo --locked build --no-default-features"), release).run()?;
with_release(quiet_cmd!(sh, "cargo --locked test --no-default-features"), release).run()?;
let features: Vec<String> = discover_features(sh, package)?
.into_iter()
.filter(|f| !config.exclude_features.contains(f))
.collect();
if !features.is_empty() {
quiet_println(&format!(
"Discovered {} feature(s) to test: {}",
features.len(),
features.join(", ")
));
sampled_feature_matrix(sh, &features, release, summary)?;
}
for features in &config.exact_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 {
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;
}
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 = quiet_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)? {
return Ok(());
}
quiet_println(&format!("Detected no-std package, building for target: {}", NO_STD_TARGET));
quiet_cmd!(sh, "cargo build --target {NO_STD_TARGET} --no-default-features").run()?;
quiet_println("no-std build passed!");
summary.no_std_checked = true;
Ok(())
}