blob: 51fb9866f34d43a714ff602ee3363f781455c8ea [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
use std::path::Path;
use std::process::Command as StdCommand;
use std::process::ExitStatus;
use std::time::Duration;
use clap::Parser;
use clap::Subcommand;
use semver::Version;
use serde::Deserialize;
const PACKAGE_NAME: &str = "asyncband";
// cargo-semver-checks reserves exit code 100 for completed checks that found deny-level violations.
const SEMVER_VIOLATIONS_EXIT_CODE: i32 = 100;
#[derive(Parser)]
struct Command {
#[clap(subcommand)]
sub: SubCommand,
}
impl Command {
fn run(self) {
match self.sub {
SubCommand::Bench(cmd) => cmd.run(),
SubCommand::Build(cmd) => cmd.run(),
SubCommand::Check(cmd) => cmd.run(),
SubCommand::Lint(cmd) => cmd.run(),
SubCommand::Miri(cmd) => cmd.run(),
SubCommand::Semver(cmd) => cmd.run(),
SubCommand::Test(cmd) => cmd.run(),
}
}
}
#[derive(Subcommand)]
enum SubCommand {
#[clap(about = "Run workspace benchmarks.")]
Bench(CommandBench),
#[clap(about = "Compile workspace packages.")]
Build(CommandBuild),
#[clap(about = "Check asyncband under the feature matrix.")]
Check(CommandCheck),
#[clap(about = "Run workspace quality checks.")]
Lint(CommandLint),
#[clap(about = "Check memory safety with Miri.")]
Miri(CommandMiri),
#[clap(about = "Verify API compatibility for a planned release.")]
Semver(CommandSemver),
#[clap(about = "Run unit tests.")]
Test(CommandTest),
}
#[derive(Parser)]
struct CommandBench {
#[arg(long, help = "Compile benchmarks without running them.")]
no_run: bool,
}
impl CommandBench {
fn run(self) {
run_command(make_bench_cmd(self.no_run));
}
}
#[derive(Parser)]
struct CommandBuild {
#[arg(long, help = "Assert that `Cargo.lock` will remain unchanged.")]
locked: bool,
}
impl CommandBuild {
fn run(self) {
run_command(make_build_cmd(self.locked));
}
}
#[derive(Parser)]
struct CommandCheck;
impl CommandCheck {
fn run(self) {
let features = asyncband_features();
run_command(make_check_cmd(&[]));
for feature in features.chunks(1) {
run_command(make_check_cmd(feature));
}
run_command(make_check_cmd(&features));
}
}
#[derive(Parser)]
struct CommandMiri;
impl CommandMiri {
fn run(self) {
run_command(make_miri_cmd(PACKAGE_NAME, &["--lib", "--all-features"]));
run_command(make_miri_cmd(
"tests-integration",
&["--test", "oneshot_test"],
));
run_command(make_miri_cmd(
"tests-integration",
&["--test", "unsafe_paths_test"],
));
run_command(make_miri_cmd("tests-integration", &["--test", "mpsc_test"]));
run_command(make_miri_cmd(
"tests-integration",
&["--test", "phaser_test"],
));
}
}
#[derive(Parser)]
struct CommandTest {
#[arg(long, help = "Do not capture test output.")]
no_capture: bool,
}
impl CommandTest {
fn run(self) {
run_command(make_test_cmd(self.no_capture, &asyncband_features()));
}
}
fn asyncband_features() -> Vec<String> {
use cargo_metadata::Metadata;
use cargo_metadata::MetadataCommand;
let manifest = Path::new(env!("CARGO_WORKSPACE_DIR")).join("Cargo.toml");
let Metadata { packages, .. } = MetadataCommand::new()
.manifest_path(manifest)
.exec()
.expect("failed to get cargo metadata");
let package = packages
.into_iter()
.find(|package| package.name == PACKAGE_NAME)
.expect("failed to find asyncband package");
let mut features = package
.features
.into_keys()
.filter(|feature| feature != "default")
.collect::<Vec<_>>();
features.sort();
features
}
#[derive(Parser)]
struct CommandSemver {
#[arg(long, value_name = "VERSION", help = "Version that will be released.")]
release_version: Version,
#[arg(
long,
help = "Accept reviewed breaking API changes for a semver-major release."
)]
acknowledge_breaking_changes: bool,
}
impl CommandSemver {
fn run(self) {
let Some(baseline_version) = find_latest_release() else {
println!(
"{PACKAGE_NAME} has not been published; skipping semver checks for the first release."
);
return;
};
let release_type = classify_release_type(&baseline_version, &self.release_version);
assert!(
release_type == SemverReleaseType::Major || !self.acknowledge_breaking_changes,
"--acknowledge-breaking-changes is only valid for a semver-major release"
);
let audit_release_type = release_type.audit_release_type();
println!(
"Checking release {} against {PACKAGE_NAME}@{baseline_version} as a {} release.",
self.release_version,
release_type.as_str()
);
if audit_release_type != release_type {
println!(
"Auditing with minor compatibility rules so breaking API changes remain visible."
);
}
let status = command_status(make_semver_check_cmd(&baseline_version, audit_release_type));
if status.success() {
return;
}
if release_type == SemverReleaseType::Major
&& status.code() == Some(SEMVER_VIOLATIONS_EXIT_CODE)
{
assert!(
self.acknowledge_breaking_changes,
"breaking API changes were reported; document and review them, then rerun with \
--acknowledge-breaking-changes"
);
println!(
"The breaking API changes reported above are acknowledged for release {}.",
self.release_version
);
return;
}
panic!("command failed: {status}");
}
}
#[derive(Parser)]
#[clap(name = "lint")]
struct CommandLint {
#[arg(long, help = "Automatically apply lint suggestions.")]
fix: bool,
}
impl CommandLint {
fn run(self) {
run_command(make_clippy_cmd(self.fix));
run_command(make_format_cmd(self.fix));
run_command(make_taplo_cmd(self.fix));
run_command(make_typos_cmd());
run_command(make_hawkeye_cmd(self.fix));
run_command(make_doc_cmd());
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SemverReleaseType {
Major,
Minor,
Patch,
}
impl SemverReleaseType {
fn as_str(self) -> &'static str {
match self {
Self::Major => "major",
Self::Minor => "minor",
Self::Patch => "patch",
}
}
fn audit_release_type(self) -> Self {
match self {
Self::Major => Self::Minor,
release_type => release_type,
}
}
}
fn find_command(cmd: &str) -> StdCommand {
match which::which(cmd) {
Ok(exe) => {
let mut cmd = StdCommand::new(exe);
cmd.current_dir(env!("CARGO_WORKSPACE_DIR"));
cmd
}
Err(err) => {
panic!("{cmd} not found: {err}");
}
}
}
fn ensure_installed(bin: &str, crate_name: &str) {
if which::which(bin).is_err() {
let mut cmd = find_command("cargo");
cmd.args(["install", crate_name]);
run_command(cmd);
}
}
fn run_command(cmd: StdCommand) {
let status = command_status(cmd);
assert!(status.success(), "command failed: {status}");
}
fn command_status(mut cmd: StdCommand) -> ExitStatus {
println!("{cmd:?}");
cmd.status().expect("failed to execute process")
}
fn find_latest_release() -> Option<Version> {
let agent = ureq::Agent::from(
ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(10)))
.build(),
);
let url = format!("https://crates.io/api/v1/crates/{PACKAGE_NAME}");
let mut response = match agent.get(&url).call() {
Ok(response) => response,
Err(ureq::Error::StatusCode(404)) => return None,
Err(err) => panic!("failed to query crates.io for {PACKAGE_NAME}: {err}"),
};
#[derive(Deserialize)]
struct CratesIoResponse {
#[serde(rename = "crate")]
crate_data: CratesIoCrate,
}
#[derive(Deserialize)]
struct CratesIoCrate {
max_version: String,
max_stable_version: Option<String>,
}
let response: CratesIoResponse = response.body_mut().read_json().unwrap_or_else(|err| {
panic!("failed to decode crates.io response for {PACKAGE_NAME}: {err}")
});
let version = response
.crate_data
.max_stable_version
.unwrap_or(response.crate_data.max_version);
Some(
Version::parse(&version)
.unwrap_or_else(|err| panic!("crates.io returned invalid version {version:?}: {err}")),
)
}
fn classify_release_type(baseline: &Version, release: &Version) -> SemverReleaseType {
assert!(
baseline.cmp_precedence(release).is_lt(),
"release version {release} must be greater than baseline {baseline}"
);
if baseline.major != release.major {
SemverReleaseType::Major
} else if baseline.minor != release.minor {
if release.major == 0 {
SemverReleaseType::Major
} else {
SemverReleaseType::Minor
}
} else if baseline.patch != release.patch {
match (release.major, release.minor) {
(0, 0) => SemverReleaseType::Major,
(0, _) => SemverReleaseType::Minor,
_ => SemverReleaseType::Patch,
}
} else {
SemverReleaseType::Major
}
}
fn make_bench_cmd(no_run: bool) -> StdCommand {
let mut cmd = find_command("cargo");
cmd.args(["bench", "--workspace", "--all-features", "--bench", "*"]);
if no_run {
cmd.arg("--no-run");
}
cmd
}
fn make_build_cmd(locked: bool) -> StdCommand {
let mut cmd = find_command("cargo");
cmd.args([
"build",
"--workspace",
"--all-features",
"--tests",
"--examples",
"--benches",
"--bins",
]);
if locked {
cmd.arg("--locked");
}
cmd
}
fn make_test_cmd(no_capture: bool, features: &[String]) -> StdCommand {
let mut cmd = find_command("cargo");
cmd.args(["test", "--workspace", "--no-default-features"]);
for feature in features {
cmd.args(["--features", feature]);
}
if no_capture {
cmd.args(["--", "--nocapture"]);
}
cmd
}
fn make_check_cmd(features: &[String]) -> StdCommand {
let mut cmd = find_command("cargo");
cmd.env("RUSTFLAGS", "-Dwarnings");
cmd.args([
"+nightly",
"check",
"--package",
PACKAGE_NAME,
"--all-targets",
"--no-default-features",
]);
for feature in features {
cmd.args(["--features", feature]);
}
cmd
}
fn make_miri_cmd(package: &str, target: &[&str]) -> StdCommand {
let mut cmd = find_command("cargo");
cmd.args(["+nightly", "miri", "test", "--package", package]);
cmd.args(target);
cmd
}
fn make_format_cmd(fix: bool) -> StdCommand {
let mut cmd = find_command("cargo");
cmd.args(["+nightly", "fmt", "--all"]);
if !fix {
cmd.arg("--check");
}
cmd
}
fn make_clippy_cmd(fix: bool) -> StdCommand {
let mut cmd = find_command("cargo");
cmd.args([
"+nightly",
"clippy",
"--tests",
"--all-features",
"--all-targets",
"--workspace",
]);
if fix {
cmd.args(["--allow-staged", "--allow-dirty", "--fix"]);
} else {
cmd.args(["--", "-D", "warnings"]);
}
cmd
}
fn make_doc_cmd() -> StdCommand {
let mut cmd = find_command("cargo");
cmd.env("RUSTDOCFLAGS", "-D warnings --cfg docsrs");
cmd.args([
"+nightly",
"doc",
"--workspace",
"--all-features",
"--no-deps",
]);
cmd
}
fn make_semver_check_cmd(
baseline_version: &Version,
release_type: SemverReleaseType,
) -> StdCommand {
ensure_installed("cargo-semver-checks", "cargo-semver-checks");
let mut cmd = find_command("cargo");
cmd.args([
"+stable",
"semver-checks",
"check-release",
"--package",
PACKAGE_NAME,
"--all-features",
"--baseline-version",
])
.arg(baseline_version.to_string())
.args(["--release-type", release_type.as_str()]);
cmd
}
fn make_hawkeye_cmd(fix: bool) -> StdCommand {
ensure_installed("hawkeye", "hawkeye");
let mut cmd = find_command("hawkeye");
if fix {
cmd.args(["format"]);
} else {
cmd.args(["check"]);
}
cmd
}
fn make_typos_cmd() -> StdCommand {
ensure_installed("typos", "typos-cli");
find_command("typos")
}
fn make_taplo_cmd(fix: bool) -> StdCommand {
ensure_installed("taplo", "taplo-cli");
let mut cmd = find_command("taplo");
if fix {
cmd.args(["format"]);
} else {
cmd.args(["format", "--check"]);
}
cmd
}
fn main() {
let cmd = Command::parse();
cmd.run()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_release_types_with_cargo_pre_one_semantics() {
let cases = [
("0.0.1", "0.0.2", SemverReleaseType::Major),
("0.6.5", "0.6.6", SemverReleaseType::Minor),
("0.6.5", "0.7.0", SemverReleaseType::Major),
("1.2.3", "1.2.4", SemverReleaseType::Patch),
("1.2.3", "1.3.0", SemverReleaseType::Minor),
("1.2.3", "2.0.0", SemverReleaseType::Major),
];
for (baseline, release, expected) in cases {
assert_eq!(
classify_release_type(
&Version::parse(baseline).unwrap(),
&Version::parse(release).unwrap()
),
expected
);
}
}
#[test]
fn audit_major_releases_with_minor_compatibility_rules() {
assert_eq!(
SemverReleaseType::Major.audit_release_type(),
SemverReleaseType::Minor
);
assert_eq!(
SemverReleaseType::Minor.audit_release_type(),
SemverReleaseType::Minor
);
assert_eq!(
SemverReleaseType::Patch.audit_release_type(),
SemverReleaseType::Patch
);
}
}