#![forbid(unsafe_code)]
mod check_release;
mod config;
mod manifest;
mod query;
mod rustdoc_cmd;
mod rustdoc_gen;
mod templating;
mod util;
use anyhow::Context;
use cargo_metadata::PackageId;
use clap::ValueEnum;
use directories::ProjectDirs;
use check_release::run_check_release;
use rustdoc_gen::CrateDataForRustdoc;
use trustfall_rustdoc::{load_rustdoc, VersionedCrate};
use rustdoc_cmd::RustdocCommand;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
pub use config::GlobalConfig;
pub use query::{ActualSemverUpdate, RequiredSemverUpdate, SemverQuery};
#[non_exhaustive]
#[derive(Debug, PartialEq, Eq)]
pub struct Check {
scope: Scope,
current: Rustdoc,
baseline: Rustdoc,
log_level: Option<log::Level>,
release_type: Option<ReleaseType>,
current_feature_config: rustdoc_gen::FeatureConfig,
baseline_feature_config: rustdoc_gen::FeatureConfig,
}
#[non_exhaustive]
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReleaseType {
Major,
Minor,
Patch,
}
#[non_exhaustive]
#[derive(Debug, PartialEq, Eq)]
pub struct Rustdoc {
source: RustdocSource,
}
impl Rustdoc {
pub fn from_path(rustdoc_path: impl Into<PathBuf>) -> Self {
Self {
source: RustdocSource::Rustdoc(rustdoc_path.into()),
}
}
pub fn from_root(project_root: impl Into<PathBuf>) -> Self {
Self {
source: RustdocSource::Root(project_root.into()),
}
}
pub fn from_git_revision(
project_root: impl Into<PathBuf>,
revision: impl Into<String>,
) -> Self {
Self {
source: RustdocSource::Revision(project_root.into(), revision.into()),
}
}
pub fn from_registry_latest_crate_version() -> Self {
Self {
source: RustdocSource::VersionFromRegistry(None),
}
}
pub fn from_registry(crate_version: impl Into<String>) -> Self {
Self {
source: RustdocSource::VersionFromRegistry(Some(crate_version.into())),
}
}
}
#[derive(Debug, PartialEq, Eq)]
enum RustdocSource {
Rustdoc(PathBuf),
Root(PathBuf),
Revision(PathBuf, String),
VersionFromRegistry(Option<String>),
}
#[derive(Default, Debug, PartialEq, Eq)]
struct Scope {
mode: ScopeMode,
}
#[derive(Debug, PartialEq, Eq)]
enum ScopeMode {
DenyList(PackageSelection),
AllowList(Vec<String>),
}
impl Default for ScopeMode {
fn default() -> Self {
Self::DenyList(PackageSelection::default())
}
}
#[non_exhaustive]
#[derive(Default, Clone, Debug, PartialEq, Eq)]
pub struct PackageSelection {
selection: ScopeSelection,
excluded_packages: Vec<String>,
}
impl PackageSelection {
pub fn new(selection: ScopeSelection) -> Self {
Self {
selection,
excluded_packages: vec![],
}
}
pub fn with_excluded_packages(&mut self, packages: Vec<String>) -> &mut Self {
self.excluded_packages = packages;
self
}
}
#[non_exhaustive]
#[derive(Default, Debug, PartialEq, Eq, Clone)]
pub enum ScopeSelection {
Workspace,
#[default]
DefaultMembers,
}
impl Scope {
fn selected_packages<'m>(
&self,
meta: &'m cargo_metadata::Metadata,
) -> Vec<&'m cargo_metadata::Package> {
let workspace_members: HashSet<&PackageId> = meta.workspace_members.iter().collect();
let base_ids: HashSet<&PackageId> = match &self.mode {
ScopeMode::DenyList(PackageSelection {
selection,
excluded_packages,
}) => {
let packages = match selection {
ScopeSelection::Workspace => workspace_members,
ScopeSelection::DefaultMembers => {
let resolve = meta.resolve.as_ref().expect("no-deps is unsupported");
match &resolve.root {
Some(root) => {
let mut base_ids = HashSet::new();
base_ids.insert(root);
base_ids
}
None => workspace_members,
}
}
};
packages
.iter()
.filter(|p| !excluded_packages.contains(&meta[p].name))
.copied()
.collect()
}
ScopeMode::AllowList(patterns) => {
meta.packages
.iter()
.filter(|p| workspace_members.contains(&p.id) && patterns.contains(&p.name))
.map(|p| &p.id)
.collect()
}
};
meta.packages
.iter()
.filter(|&p| {
base_ids.contains(&p.id) && p.targets.iter().any(|target| target.is_lib())
})
.collect()
}
}
impl Check {
pub fn new(current: Rustdoc) -> Self {
Self {
scope: Scope::default(),
current,
baseline: Rustdoc::from_registry_latest_crate_version(),
log_level: Default::default(),
release_type: None,
current_feature_config: rustdoc_gen::FeatureConfig::default_for_current(),
baseline_feature_config: rustdoc_gen::FeatureConfig::default_for_baseline(),
}
}
pub fn with_package_selection(&mut self, selection: PackageSelection) -> &mut Self {
self.scope.mode = ScopeMode::DenyList(selection);
self
}
pub fn with_packages(&mut self, packages: Vec<String>) -> &mut Self {
self.scope.mode = ScopeMode::AllowList(packages);
self
}
pub fn with_baseline(&mut self, baseline: Rustdoc) -> &mut Self {
self.baseline = baseline;
self
}
pub fn with_log_level(&mut self, log_level: Option<log::Level>) -> &mut Self {
self.log_level = log_level;
self
}
pub fn with_release_type(&mut self, release_type: ReleaseType) -> &mut Self {
self.release_type = Some(release_type);
self
}
pub fn with_only_explicit_features(&mut self) -> &mut Self {
self.current_feature_config.features_group = rustdoc_gen::FeaturesGroup::None;
self.baseline_feature_config.features_group = rustdoc_gen::FeaturesGroup::None;
self
}
pub fn with_default_features(&mut self) -> &mut Self {
self.current_feature_config.features_group = rustdoc_gen::FeaturesGroup::Default;
self.baseline_feature_config.features_group = rustdoc_gen::FeaturesGroup::Default;
self
}
pub fn with_heuristically_included_features(&mut self) -> &mut Self {
self.current_feature_config.features_group = rustdoc_gen::FeaturesGroup::Heuristic;
self.baseline_feature_config.features_group = rustdoc_gen::FeaturesGroup::Heuristic;
self
}
pub fn with_all_features(&mut self) -> &mut Self {
self.current_feature_config.features_group = rustdoc_gen::FeaturesGroup::All;
self.baseline_feature_config.features_group = rustdoc_gen::FeaturesGroup::All;
self
}
pub fn with_extra_features(
&mut self,
extra_current_features: Vec<String>,
extra_baseline_features: Vec<String>,
) -> &mut Self {
self.current_feature_config.extra_features = extra_current_features;
self.baseline_feature_config.extra_features = extra_baseline_features;
self
}
fn get_target_dir(&self, source: &RustdocSource) -> anyhow::Result<PathBuf> {
Ok(
if let Some(path) = get_target_dir_from_project_root(source)? {
path
} else if let Some(path) = get_target_dir_from_project_root(&self.current.source)? {
path
} else if let Some(path) = get_target_dir_from_project_root(&self.baseline.source)? {
path
} else {
get_cache_dir()?
},
)
}
fn get_rustdoc_generator(
&self,
config: &mut GlobalConfig,
source: &RustdocSource,
) -> anyhow::Result<Box<dyn rustdoc_gen::RustdocGenerator>> {
let target_dir = self.get_target_dir(source)?;
Ok(match source {
RustdocSource::Rustdoc(path) => {
Box::new(rustdoc_gen::RustdocFromFile::new(path.to_owned()))
}
RustdocSource::Root(root) => {
Box::new(rustdoc_gen::RustdocFromProjectRoot::new(root, &target_dir)?)
}
RustdocSource::Revision(root, rev) => {
let metadata = manifest_metadata_no_deps(root)?;
let source = metadata.workspace_root.as_std_path();
Box::new(rustdoc_gen::RustdocFromGitRevision::with_rev(
source,
&target_dir,
rev,
config,
)?)
}
RustdocSource::VersionFromRegistry(version) => {
let mut registry = rustdoc_gen::RustdocFromRegistry::new(&target_dir, config)?;
if let Some(ver) = version {
let semver = semver::Version::parse(ver)?;
registry.set_version(semver);
}
Box::new(registry)
}
})
}
pub fn check_release(&self) -> anyhow::Result<Report> {
let mut config = GlobalConfig::new().set_level(self.log_level);
let rustdoc_cmd = RustdocCommand::new().deps(false).silence(config.is_info());
if !(matches!(self.current.source, RustdocSource::Rustdoc(_))
&& matches!(self.baseline.source, RustdocSource::Rustdoc(_)))
{
let rustc_version_needed = config.minimum_rustc_version();
match rustc_version::version() {
Ok(rustc_version) => {
if rustc_version < *rustc_version_needed {
let help = "HELP: to use the latest rustc, run `rustup update stable && cargo +stable semver-checks <args>`";
anyhow::bail!("rustc version is not high enough: >={rustc_version_needed} needed, got {rustc_version}\n\n{help}");
}
}
Err(error) => {
let help = format!("HELP: to avoid errors please ensure rustc >={rustc_version_needed} is used");
config.shell_warn(format_args!(
"failed to determine the current rustc version: {error}\n\n{help}"
))?;
}
};
}
let current_loader = self.get_rustdoc_generator(&mut config, &self.current.source)?;
let baseline_loader = self.get_rustdoc_generator(&mut config, &self.baseline.source)?;
let all_outcomes: Vec<anyhow::Result<(String, Option<CrateReport>)>> = match &self
.current
.source
{
RustdocSource::Rustdoc(_)
| RustdocSource::Revision(_, _)
| RustdocSource::VersionFromRegistry(_) => {
let names = match &self.scope.mode {
ScopeMode::DenyList(_) =>
match &self.current.source {
RustdocSource::Rustdoc(_) =>
vec!["<unknown>".to_string()],
_ => panic!("couldn't deduce crate name, specify one through the package allow list")
}
ScopeMode::AllowList(lst) => lst.clone(),
};
names
.into_iter()
.map(|name| {
let version = None;
let (current_crate, baseline_crate) = generate_versioned_crates(
&mut config,
&rustdoc_cmd,
&*current_loader,
&*baseline_loader,
CrateDataForRustdoc {
crate_type: rustdoc_gen::CrateType::Current,
name: &name,
feature_config: &self.current_feature_config,
},
CrateDataForRustdoc {
crate_type: rustdoc_gen::CrateType::Baseline {
highest_allowed_version: version,
},
name: &name,
feature_config: &self.baseline_feature_config,
},
)?;
let report = run_check_release(
&mut config,
&name,
current_crate,
baseline_crate,
self.release_type,
)?;
Ok((name, Some(report)))
})
.collect()
}
RustdocSource::Root(project_root) => {
let metadata = manifest_metadata(project_root)?;
let selected = self.scope.selected_packages(&metadata);
if selected.is_empty() {
anyhow::bail!(
"no crates with library targets selected, nothing to semver-check"
);
}
selected
.iter()
.map(|selected| {
let crate_name = &selected.name;
let version = &selected.version;
let is_implied = matches!(self.scope.mode, ScopeMode::DenyList(..))
&& metadata.workspace_members.len() > 1
&& selected.publish == Some(vec![]);
if is_implied {
config.log_verbose(|config| {
config.shell_status(
"Skipping",
format_args!("{crate_name} v{version} (current)"),
)
})?;
Ok((crate_name.clone(), None))
} else {
let (current_crate, baseline_crate) = generate_versioned_crates(
&mut config,
&rustdoc_cmd,
&*current_loader,
&*baseline_loader,
CrateDataForRustdoc {
crate_type: rustdoc_gen::CrateType::Current,
name: crate_name,
feature_config: &self.current_feature_config,
},
CrateDataForRustdoc {
crate_type: rustdoc_gen::CrateType::Baseline {
highest_allowed_version: Some(version),
},
name: crate_name,
feature_config: &self.baseline_feature_config,
},
)?;
Ok((
crate_name.clone(),
Some(run_check_release(
&mut config,
crate_name,
current_crate,
baseline_crate,
self.release_type,
)?),
))
}
})
.collect()
}
};
let crate_reports: BTreeMap<String, CrateReport> = {
let mut reports = BTreeMap::new();
for outcome in all_outcomes {
let (name, outcome) = outcome?;
if let Some(outcome) = outcome {
reports.insert(name, outcome);
}
}
reports
};
Ok(Report { crate_reports })
}
}
#[non_exhaustive]
#[derive(Debug)]
pub struct CrateReport {
detected_bump: ActualSemverUpdate,
required_bump: Option<ReleaseType>,
}
impl CrateReport {
pub fn success(&self) -> bool {
match self.required_bump {
None => true,
Some(required_bump) => {
match self.detected_bump {
ActualSemverUpdate::Major => panic!(
"detected_bump is major, while required_bump is {:?}",
required_bump
),
ActualSemverUpdate::Minor => {
assert_eq!(required_bump, ReleaseType::Major);
}
ActualSemverUpdate::Patch | ActualSemverUpdate::NotChanged => {
assert!(matches!(
required_bump,
ReleaseType::Major | ReleaseType::Minor
));
}
}
false
}
}
}
pub fn required_bump(&self) -> Option<ReleaseType> {
self.required_bump
}
pub fn detected_bump(&self) -> ActualSemverUpdate {
self.detected_bump
}
}
#[non_exhaustive]
#[derive(Debug)]
pub struct Report {
crate_reports: BTreeMap<String, CrateReport>,
}
impl Report {
pub fn success(&self) -> bool {
self.crate_reports.values().all(|report| report.success())
}
pub fn crate_reports(&self) -> &BTreeMap<String, CrateReport> {
&self.crate_reports
}
}
fn generate_versioned_crates(
config: &mut GlobalConfig,
rustdoc_cmd: &RustdocCommand,
current_loader: &dyn rustdoc_gen::RustdocGenerator,
baseline_loader: &dyn rustdoc_gen::RustdocGenerator,
current_crate_data: rustdoc_gen::CrateDataForRustdoc,
baseline_crate_data: rustdoc_gen::CrateDataForRustdoc,
) -> anyhow::Result<(VersionedCrate, VersionedCrate)> {
let current_path = current_loader.load_rustdoc(config, rustdoc_cmd, current_crate_data)?;
let current_crate = load_rustdoc(¤t_path)?;
let current_rustdoc_version = current_crate.version();
let baseline_path = get_baseline_rustdoc_path(
config,
rustdoc_cmd,
baseline_loader,
baseline_crate_data.clone(),
)?;
let baseline_crate = {
let mut baseline_crate = load_rustdoc(&baseline_path)?;
if baseline_crate.version() != current_rustdoc_version {
let crate_name = baseline_crate_data.name;
config.shell_status(
"Removing",
format_args!("stale cached baseline rustdoc for {crate_name}"),
)?;
std::fs::remove_file(baseline_path)?;
let baseline_path = get_baseline_rustdoc_path(
config,
rustdoc_cmd,
baseline_loader,
baseline_crate_data,
)?;
baseline_crate = load_rustdoc(&baseline_path)?;
assert_eq!(
baseline_crate.version(),
current_rustdoc_version,
"Deleting and regenerating the baseline JSON file did not resolve the rustdoc \
version mismatch."
);
}
baseline_crate
};
Ok((current_crate, baseline_crate))
}
fn get_baseline_rustdoc_path(
config: &mut GlobalConfig,
rustdoc_cmd: &RustdocCommand,
baseline_loader: &dyn rustdoc_gen::RustdocGenerator,
baseline_crate_data: rustdoc_gen::CrateDataForRustdoc,
) -> anyhow::Result<PathBuf> {
let baseline_path = baseline_loader.load_rustdoc(config, rustdoc_cmd, baseline_crate_data)?;
Ok(baseline_path)
}
fn manifest_path(project_root: &Path) -> anyhow::Result<PathBuf> {
if project_root.is_dir() {
let manifest_path = project_root.join("Cargo.toml");
if manifest_path.exists() {
Ok(manifest_path)
} else {
anyhow::bail!(
"couldn't find Cargo.toml in directory {}",
project_root.display()
)
}
} else if project_root.ends_with("Cargo.toml") {
Ok(project_root.to_path_buf())
} else {
anyhow::bail!(
"path {} is not a directory or a manifest",
project_root.display()
)
}
}
fn manifest_metadata(project_root: &Path) -> anyhow::Result<cargo_metadata::Metadata> {
let manifest_path = manifest_path(project_root)?;
let mut command = cargo_metadata::MetadataCommand::new();
let metadata = command.manifest_path(manifest_path).exec()?;
Ok(metadata)
}
fn manifest_metadata_no_deps(project_root: &Path) -> anyhow::Result<cargo_metadata::Metadata> {
let manifest_path = manifest_path(project_root)?;
let mut command = cargo_metadata::MetadataCommand::new();
let metadata = command.manifest_path(manifest_path).no_deps().exec()?;
Ok(metadata)
}
fn get_cache_dir() -> anyhow::Result<PathBuf> {
let project_dirs =
ProjectDirs::from("", "", "cargo-semver-checks").context("can't determine project dirs")?;
let cache_dir = project_dirs.cache_dir();
std::fs::create_dir_all(cache_dir).context("can't create cache dir")?;
Ok(cache_dir.to_path_buf())
}
fn get_target_dir_from_project_root(source: &RustdocSource) -> anyhow::Result<Option<PathBuf>> {
Ok(match source {
RustdocSource::Root(root) => {
let metadata = manifest_metadata_no_deps(root)?;
let target = metadata.target_directory.as_std_path().join(util::SCOPE);
Some(target)
}
RustdocSource::Revision(root, rev) => {
let metadata = manifest_metadata_no_deps(root)?;
let target = metadata.target_directory.as_std_path().join(util::SCOPE);
let target = target.join(format!("git-{}", util::slugify(rev)));
Some(target)
}
RustdocSource::Rustdoc(_path) => None,
RustdocSource::VersionFromRegistry(_version) => None,
})
}