#![deny(unsafe_code)]
use std::{collections::HashSet, env, ffi::OsStr, io::Write as _, path::PathBuf};
use anstream::ColorChoice as AnstreamChoice;
use anstyle::{AnsiColor, Color, Reset, Style};
use cargo_config2::Config;
use cargo_semver_checks::{
FeatureFlag, GlobalConfig, PackageSelection, ReleaseType, Rustdoc, ScopeSelection, SemverQuery,
WitnessGeneration,
};
use clap::{Args, CommandFactory, Parser, Subcommand};
#[cfg(test)]
#[allow(unsafe_code)]
mod snapshot_tests;
fn main() {
human_panic::setup_panic!();
match env::var("CARGO_TERM_COLOR").as_deref() {
Ok("always") => AnstreamChoice::Always,
Ok("never") => AnstreamChoice::Never,
_ => AnstreamChoice::Auto,
}
.write_global();
let Cargo::SemverChecks(args) = Cargo::parse();
let feature_flags = HashSet::from_iter(args.unstable_features.clone());
if let Some(cli_choice) = args.color_choice {
use clap::ColorChoice as ClapChoice;
let choice = match cli_choice {
ClapChoice::Always => AnstreamChoice::Always,
ClapChoice::Auto => AnstreamChoice::Auto,
ClapChoice::Never => AnstreamChoice::Never,
};
choice.write_global();
}
let mut config = GlobalConfig::new();
config.set_log_level(args.verbosity.log_level());
config.set_feature_flags(feature_flags);
exit_on_error(true, || validate_feature_flags(&mut config, &args));
if args.bugreport {
print_issue_url(&mut config);
std::process::exit(0);
}
else if args.list {
exit_on_error(true, || {
let queries = SemverQuery::all_queries();
let mut rows = vec![["id", "type", "description"], ["==", "====", "==========="]];
for query in queries.values() {
rows.push([
query.id.as_str(),
query.required_update.as_str(),
query.description.as_str(),
]);
}
let mut widths = [0; 3];
for row in &rows {
widths[0] = widths[0].max(row[0].len());
widths[1] = widths[1].max(row[1].len());
widths[2] = widths[2].max(row[2].len());
}
let stdout = std::io::stdout();
let mut stdout = stdout.lock();
for row in rows {
writeln!(
stdout,
"{0:<1$} {2:<3$} {4:<5$}",
row[0], widths[0], row[1], widths[1], row[2], widths[2]
)?;
}
config.shell_note("Use `--explain <id>` to see more details")
});
std::process::exit(0);
}
else if let Some(id) = args.explain.as_deref() {
exit_on_error(true, || {
let queries = SemverQuery::all_queries();
let query = queries.get(id).ok_or_else(|| {
let ids = queries.keys().cloned().collect::<Vec<_>>();
anyhow::format_err!(
"Unknown id `{}`, available id's:\n {}",
id,
ids.join("\n ")
)
})?;
println!(
"{}",
query
.reference
.as_deref()
.unwrap_or(query.description.as_str())
);
if let Some(link) = &query.reference_link {
println!();
println!("See also {link}");
}
Ok(())
});
std::process::exit(0);
}
else if config.feature_flag_enabled(FeatureFlag::HELP) {
config
.log_info(|config| {
let header = Style::new()
.bold()
.fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
let option = Style::new().bold();
let mut stdout = config.stdout();
writeln!(stdout, "{header}Unstable feature flags:{header:#}")?;
writeln!(stdout, "{header}{:<20}{header:#}Description", "-Z name",)?;
for flag in FeatureFlag::ALL_FLAGS.iter().filter(|x| !x.stable) {
write!(stdout, "{option}{:<20}{option:#}", flag.id)?;
if let Some(help) = flag.help {
let mut lines = help.lines();
if let Some(first) = lines.next() {
writeln!(stdout, "{first}")?;
for line in lines {
writeln!(stdout, "{:<20}{line}", "")?;
}
}
} else {
writeln!(stdout)?;
}
}
#[derive(Parser)]
#[command(
disable_help_flag = true,
help_template = "{options}",
mut_args = |arg| arg.hide(false),
)]
struct HelpPrinter {
#[command(flatten)]
args: UnstableOptions,
}
write!(
stdout,
"{header}Unstable options:{header:#}\n\
{}",
HelpPrinter::command().render_long_help()
)
.expect("print failed");
Ok(())
})
.expect("write failed");
std::process::exit(0);
}
let check_release = match args.command {
Some(SemverChecksCommands::CheckRelease(c)) => c,
None => args.check_release,
};
let check: cargo_semver_checks::Check = check_release.into();
let report = exit_on_error(config.is_error(), || check.check_release(&mut config));
if report.is_cli_success() {
std::process::exit(0);
} else {
std::process::exit(1);
}
}
fn exit_on_error<T>(log_errors: bool, mut inner: impl FnMut() -> anyhow::Result<T>) -> T {
match inner() {
Ok(x) => x,
Err(err) => {
if log_errors {
eprintln!("error: {err:?}");
}
std::process::exit(1)
}
}
}
fn sanitize_bugreport_output(output: &str) -> String {
output
.split_inclusive('\n')
.map(|line| {
let Some(content) = line.strip_suffix('\n') else {
return line.trim_end_matches([' ', '\t', '\r']).to_owned();
};
format!("{}\n", content.trim_end_matches([' ', '\t', '\r']))
})
.collect()
}
fn print_issue_url(config: &mut GlobalConfig) {
use bugreport::{bugreport, collector::*, format::Markdown};
let other_bug_url: &str = "https://github.com/obi1kenobi/cargo-semver-checks/issues/new?labels=C-bug&template=3-bug-report.yml";
let mut bug_report = bugreport!()
.info(SoftwareVersion::default())
.info(OperatingSystem::default())
.info(CommandLine::default())
.info(CommandOutput::new("cargo version", "cargo", &["-V"]))
.info(CompileTimeInformation::default());
let bold_cyan = Style::new()
.bold()
.fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
writeln!(
config.stdout(),
"{bold_cyan}\
System information:{Reset}\n\
-------------------"
)
.expect("Failed to print bug report system information to stdout");
let bug_report = sanitize_bugreport_output(&bug_report.format::<Markdown>());
write!(config.stdout(), "{bug_report}").expect("Failed to print bug report to stdout");
writeln!(config.stdout()).expect("Failed to print bug report separator");
let bug_report_url = urlencoding::encode(bug_report.trim_end_matches('\n'));
let cargo_config = match Config::load() {
Ok(c) => toml::to_string(&c).unwrap_or_else(|s| {
writeln!(
config.stderr(),
"Error serializing cargo build configuration: {s}"
)
.expect("Failed to print error");
String::default()
}),
Err(e) => {
writeln!(
config.stderr(),
"Error loading cargo build configuration: {e}"
)
.expect("Failed to print error");
String::default()
}
};
writeln!(
config.stdout(),
"{bold_cyan}\
Cargo build configuration:{Reset}\n\
--------------------------\n\
{cargo_config}"
)
.expect("Failed to print bug report Cargo configuration to stdout");
let cargo_config_url: String = urlencoding::encode(&cargo_config).into_owned();
let bold = Style::new().bold();
writeln!(
config.stdout(),
"{bold}Please file an issue on GitHub reporting your bug.\n\
Consider adding the diagnostic information above, either manually or automatically through the link below:{Reset}\n\n\
{other_bug_url}&sys-info={bug_report_url}&build-config={cargo_config_url}",
)
.expect("Failed to print bug report generated github issue link");
}
#[derive(Debug, Parser)]
#[command(name = "cargo")]
#[command(bin_name = "cargo")]
#[command(version, propagate_version = true)]
#[command(styles = clap_cargo::style::CLAP_STYLING)]
enum Cargo {
SemverChecks(SemverChecks),
}
#[derive(Debug, Args)]
#[command(args_conflicts_with_subcommands = true)]
struct SemverChecks {
#[arg(long, global = true, exclusive = true)]
bugreport: bool,
#[arg(long, global = true, exclusive = true)]
explain: Option<String>,
#[arg(long, global = true, exclusive = true)]
list: bool,
#[clap(flatten)]
check_release: CheckRelease,
#[command(subcommand)]
command: Option<SemverChecksCommands>,
#[arg(long = "color", global = true, value_name = "WHEN", value_enum)]
color_choice: Option<clap::ColorChoice>,
#[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity<clap_verbosity_flag::InfoLevel>,
#[arg(
short = 'Z',
value_name = "FLAG",
global = true,
hide_possible_values = true // show explicitly with -Z help
)]
unstable_features: Vec<FeatureFlag>,
}
#[derive(Debug, Clone, Args, Default, PartialEq, Eq)]
#[command(hide = true)]
#[non_exhaustive]
struct UnstableOptions {
#[arg(long, hide = true)]
witness_hints: bool,
#[arg(long, hide = true)]
consistency_check: bool,
}
impl UnstableOptions {
#[must_use]
fn non_default(&self) -> Vec<String> {
let mut list = Vec::new();
let Self {
witness_hints,
consistency_check,
} = self;
if *witness_hints {
list.push("--witness-hints".into());
}
if *consistency_check {
list.push("--consistency-check".into())
}
list
}
}
#[derive(Debug, Subcommand)]
enum SemverChecksCommands {
#[command(alias = "diff-files")]
CheckRelease(CheckRelease),
}
#[derive(Debug, Args, Clone)]
struct CheckRelease {
#[command(flatten, next_help_heading = "Current")]
pub manifest: clap_cargo::Manifest,
#[command(flatten, next_help_heading = "Current")]
pub workspace: clap_cargo::Workspace,
#[arg(
long,
short_alias = 'c',
alias = "current",
value_name = "JSON_PATH",
help_heading = "Current",
requires = "baseline_rustdoc",
conflicts_with_all = [
"default_features",
"only_explicit_features",
"features",
"baseline_features",
"current_features",
"all_features",
"baseline_version",
"baseline_rev",
"baseline_root",
]
)]
current_rustdoc: Option<PathBuf>,
#[arg(
long,
value_name = "X.Y.Z",
help_heading = "Baseline",
group = "baseline"
)]
baseline_version: Option<String>,
#[arg(
long,
value_name = "REV",
help_heading = "Baseline",
group = "baseline"
)]
baseline_rev: Option<String>,
#[arg(
long,
value_name = "MANIFEST_ROOT",
help_heading = "Baseline",
group = "baseline"
)]
baseline_root: Option<PathBuf>,
#[arg(
long,
short_alias = 'b',
alias = "baseline",
value_name = "JSON_PATH",
help_heading = "Baseline",
group = "baseline",
conflicts_with_all = [
"default_features",
"only_explicit_features",
"features",
"baseline_features",
"current_features",
"all_features",
]
)]
baseline_rustdoc: Option<PathBuf>,
#[arg(
value_enum,
long,
value_name = "TYPE",
help_heading = "Overrides",
group = "overrides"
)]
release_type: Option<ReleaseType>,
#[arg(
long,
help_heading = "Features",
conflicts_with = "only_explicit_features"
)]
default_features: bool,
#[arg(long, help_heading = "Features")]
only_explicit_features: bool,
#[arg(
long,
value_delimiter = ',',
value_name = "NAME",
help_heading = "Features"
)]
features: Vec<String>,
#[arg(
long,
value_delimiter = ',',
value_name = "NAME",
help_heading = "Features"
)]
baseline_features: Vec<String>,
#[arg(
long,
value_delimiter = ',',
value_name = "NAME",
help_heading = "Features"
)]
current_features: Vec<String>,
#[arg(
long,
help_heading = "Features",
conflicts_with_all = [
"default_features",
"only_explicit_features",
"features",
"baseline_features",
"current_features",
]
)]
all_features: bool,
#[arg(long = "target")]
build_target: Option<String>,
#[clap(flatten)]
unstable_options: UnstableOptions,
}
impl From<CheckRelease> for cargo_semver_checks::Check {
fn from(value: CheckRelease) -> Self {
let (current, current_project_root) = if let Some(current_rustdoc) = value.current_rustdoc {
(Rustdoc::from_path(current_rustdoc), None)
} else if let Some(manifest) = value.manifest.manifest_path {
let project_root = if manifest.is_dir() {
manifest
} else {
let parent = manifest
.parent()
.expect("manifest path doesn't have a parent");
if parent.to_string_lossy().is_empty() {
std::env::current_dir().expect("can't determine current directory")
} else {
parent.to_path_buf()
}
};
(Rustdoc::from_root(&project_root), Some(project_root))
} else {
let project_root = std::env::current_dir().expect("can't determine current directory");
(Rustdoc::from_root(&project_root), Some(project_root))
};
let mut check = Self::new(current);
if value.workspace.all || value.workspace.workspace {
let mut selection = PackageSelection::new(ScopeSelection::Workspace);
selection.set_excluded_packages(value.workspace.exclude);
check.set_package_selection(selection);
} else if !value.workspace.package.is_empty() {
check.set_packages(value.workspace.package);
} else if !value.workspace.exclude.is_empty() {
let mut selection = PackageSelection::new(ScopeSelection::DefaultMembers);
selection.set_excluded_packages(value.workspace.exclude);
check.set_package_selection(selection);
}
let custom_baseline = {
if let Some(baseline_version) = value.baseline_version {
Some(Rustdoc::from_registry(baseline_version))
} else if let Some(baseline_rev) = value.baseline_rev {
let root = if let Some(baseline_root) = value.baseline_root {
lenient_baseline_root(baseline_root)
} else if let Some(current_root) = current_project_root {
current_root
} else {
std::env::current_dir().expect("can't determine current directory")
};
Some(Rustdoc::from_git_revision(root, baseline_rev))
} else if let Some(baseline_rustdoc) = value.baseline_rustdoc {
Some(Rustdoc::from_path(baseline_rustdoc))
} else {
value
.baseline_root
.map(lenient_baseline_root)
.map(Rustdoc::from_root)
}
};
if let Some(baseline) = custom_baseline {
check.set_baseline(baseline);
}
if let Some(release_type) = value.release_type {
check.set_release_type(release_type);
}
if value.all_features {
check.with_all_features();
} else if value.default_features {
check.with_default_features();
} else if value.only_explicit_features {
check.with_only_explicit_features();
} else {
check.with_heuristically_included_features();
}
let mut mutual_features = value.features;
let mut current_features = value.current_features;
let mut baseline_features = value.baseline_features;
current_features.append(&mut mutual_features.clone());
baseline_features.append(&mut mutual_features);
let trim_features = |features: &mut Vec<String>| {
features.retain(|feature| !(feature.is_empty() || feature == "\"\""));
};
trim_features(&mut current_features);
trim_features(&mut baseline_features);
check.set_extra_features(current_features, baseline_features);
if let Some(build_target) = value.build_target {
check.set_build_target(build_target);
}
let mut witness_generation = WitnessGeneration::new();
witness_generation.show_hints = value.unstable_options.witness_hints;
witness_generation.run_consistency_checks = value.unstable_options.consistency_check;
check.set_witness_generation(witness_generation);
check
}
}
fn lenient_baseline_root(baseline_root: PathBuf) -> PathBuf {
if baseline_root.is_file()
&& baseline_root.file_name().and_then(OsStr::to_str) == Some("Cargo.toml")
{
let parent = baseline_root.parent().expect("file doesn't have a parent");
if parent.to_string_lossy().is_empty() {
std::env::current_dir().expect("can't determine current directory")
} else {
parent.to_path_buf()
}
} else {
baseline_root
}
}
fn validate_feature_flags(config: &mut GlobalConfig, args: &SemverChecks) -> anyhow::Result<()> {
let stable_flags: Vec<_> = config
.feature_flags()
.iter()
.filter(|x| x.stable)
.copied()
.collect();
for stable_flag in stable_flags {
config
.shell_warn(format_args!(
"the feature flag {} has been stabilized and may be removed
from the list of feature flags in a future release.",
stable_flag.id
))
.expect("printing failed");
}
if !config.feature_flag_enabled(FeatureFlag::UNSTABLE_OPTIONS) {
let unstable_options = match &args.command {
Some(SemverChecksCommands::CheckRelease(cr)) => &cr.unstable_options,
None => &args.check_release.unstable_options,
};
let non_default_options = unstable_options.non_default();
if !non_default_options.is_empty() {
let mut message = String::from(
"the following options are not supported without `-Z unstable-options`:\n",
);
for option in non_default_options {
use std::fmt::Write as _;
writeln!(&mut message, " - `{option}`").expect("writes to strings are infallible");
}
anyhow::bail!(message);
}
}
Ok(())
}
#[test]
fn verify_cli() {
use clap::CommandFactory;
Cargo::command().debug_assert()
}
#[test]
fn features_empty_string_is_no_op() {
use cargo_semver_checks::Check;
let Cargo::SemverChecks(SemverChecks {
check_release: no_features,
..
}) = Cargo::parse_from(["cargo", "semver-checks"]);
let empty_features = CheckRelease {
features: vec![String::new()],
current_features: vec![String::new(), "\"\"".to_string()],
baseline_features: vec!["\"\"".to_string()],
..no_features.clone()
};
assert_eq!(Check::from(no_features), Check::from(empty_features));
}
#[test]
fn all_unstable_features_are_hidden() {
#[derive(Debug, Parser)]
struct Wrapper {
#[clap(flatten)]
inner: UnstableOptions,
}
let unstable_options = Wrapper::command();
let cargo_command = Cargo::command();
let semver_checks = cargo_command
.find_subcommand("semver-checks")
.expect("expected semver-checks command");
for option in unstable_options.get_arguments() {
let argument = semver_checks
.get_arguments()
.find(|x| x.get_id() == option.get_id())
.expect("expected unstable argument");
assert!(
argument.is_hide_set(),
"unstable argument {} should be hidden by default",
argument.get_id()
);
}
}
#[test]
fn current_rustdoc_conflict_errors() {
use clap::CommandFactory as _;
assert!(
Cargo::command()
.try_get_matches_from([
"cargo",
"semver-checks",
"check-release",
"--current-rustdoc",
"foo.json",
])
.is_err()
);
assert!(
Cargo::command()
.try_get_matches_from([
"cargo",
"semver-checks",
"check-release",
"--current-rustdoc",
"foo.json",
"--baseline-version",
"1.0.0",
])
.is_err()
);
assert!(
Cargo::command()
.try_get_matches_from([
"cargo",
"semver-checks",
"check-release",
"--current-rustdoc",
"foo.json",
"--baseline-root",
".",
])
.is_err()
);
assert!(
Cargo::command()
.try_get_matches_from([
"cargo",
"semver-checks",
"check-release",
"--current-rustdoc",
"foo.json",
"--baseline-rev",
"main",
])
.is_err()
);
}