use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use xshell::Shell;
use crate::environment::{
get_target_dir, get_workspace_packages, get_workspace_root, Manifest, Package, PackageManifest,
ProgressGuard,
};
use crate::lock::LockFile;
use crate::{git, toolchain};
const API_DIR: &str = "api";
const RUSTDOCFLAGS_ALLOW_BROKEN_LINKS: &str = "-A rustdoc::broken_intra_doc_links";
type PackageApis = HashMap<FeatureConfig, public_api::PublicApi>;
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default)]
struct ApiConfig {
enabled: bool,
features: Vec<Vec<String>>,
}
impl ApiConfig {
fn load(package_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
#[derive(serde::Deserialize, Default)]
struct RbmtTable {
#[serde(default)]
api: ApiConfig,
}
let path = package_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.api)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum FeatureConfig {
None,
Some(Vec<String>),
All,
}
impl FeatureConfig {
fn filename(&self) -> String { format!("{}.txt", self.name()) }
fn name(&self) -> String {
match self {
Self::None => "no-features".to_string(),
Self::Some(features) => format!("{}-only", features.join("-")),
Self::All => "all-features".to_string(),
}
}
fn cargo_args(&self) -> Vec<String> {
match self {
Self::None => vec!["--no-default-features".to_string()],
Self::Some(features) => {
let mut args = vec!["--no-default-features".to_string()];
args.push(format!("--features={}", features.join(",")));
args
}
Self::All => vec!["--all-features".to_string()],
}
}
}
pub fn run(
sh: &Shell,
lockfile: LockFile,
packages: &[String],
baseline: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let packages = get_workspace_packages(sh, packages)?;
let _lockfile_guard = lockfile.activate(sh)?;
let _progress = ProgressGuard::new();
rbmt_eprintln!("Running API check...");
toolchain::prepare_toolchain(sh, toolchain::Toolchain::Nightly)?;
check_apis(sh, &packages, baseline)?;
Ok(())
}
fn get_package_apis(
sh: &Shell,
package_name: &str,
package_dir: &PathBuf,
) -> Result<PackageApis, Box<dyn std::error::Error>> {
let workspace_root = get_workspace_root(sh)?;
let mut apis = HashMap::new();
let mut feature_configs = vec![FeatureConfig::None, FeatureConfig::All];
let api_config = ApiConfig::load(Path::new(package_dir))?;
for features in &api_config.features {
if !features.is_empty() {
feature_configs.push(FeatureConfig::Some(features.clone()));
}
}
for config in feature_configs {
sh.change_dir(package_dir);
let mut cmd = rbmt_cmd!(sh, "cargo rustdoc --lib");
for arg in config.cargo_args() {
cmd = cmd.arg(arg);
}
cmd = cmd.args(&["--", "-Z", "unstable-options", "--output-format", "json"]);
cmd.env("RUSTDOCFLAGS", RUSTDOCFLAGS_ALLOW_BROKEN_LINKS).run()?;
sh.change_dir(&workspace_root);
let target_dir = get_target_dir(sh)?;
let json_path = Path::new(&target_dir)
.join("doc")
.join(package_name.replace('-', "_"))
.with_extension("json");
let public_api = public_api::Builder::from_rustdoc_json(&json_path).build()?;
apis.insert(config, public_api);
}
Ok(apis)
}
fn check_apis(
sh: &Shell,
package_info: &[Package],
baseline: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut api_dirs: Vec<PathBuf> = Vec::new();
for package in package_info {
let api_config = ApiConfig::load(&package.dir)?;
if !api_config.enabled {
continue;
}
check_api_excluded(&package.dir, &package.name)?;
let mut apis = get_package_apis(sh, &package.name, &package.dir)?;
let package_api_dir = package.dir.join(API_DIR);
fs::create_dir_all(&package_api_dir)?;
api_dirs.push(package_api_dir.clone());
for (config, public_api) in &apis {
let output_file = package_api_dir.join(config.filename());
fs::write(&output_file, public_api.to_string())?;
}
let no_features =
apis.remove(&FeatureConfig::None).ok_or("No-features config not found")?;
let all_features =
apis.remove(&FeatureConfig::All).ok_or("All-features config not found")?;
let diff = public_api::diff::PublicApiDiff::between(no_features, all_features);
if !diff.removed.is_empty() || !diff.changed.is_empty() {
println!("Non-additive features detected in {}:", package.name);
if !diff.removed.is_empty() {
println!(" Items removed when enabling features:");
for item in &diff.removed {
println!(" - {}", item);
}
}
if !diff.changed.is_empty() {
println!(" Items changed when enabling features:");
for item in &diff.changed {
println!(" - old: {}", item.old);
println!(" new: {}", item.new);
}
}
return Err("Non-additive features detected".into());
}
if let Some(baseline) = baseline {
check_semver(sh, &package.name, &package.dir, baseline)?;
}
}
for api_dir in &api_dirs {
let status_output = rbmt_cmd!(sh, "git status --porcelain {api_dir}").read()?;
if !status_output.trim().is_empty() {
rbmt_cmd!(sh, "git diff --color=always {api_dir}").run()?;
return Err(format!(
"You have introduced changes to the public API, commit the changes to {} currently in your working directory",
api_dir.display()
).into());
}
}
Ok(())
}
fn check_api_excluded(
package_dir: &Path,
package_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let manifest = Manifest::read(package_dir)?;
if !manifest.exclude.iter().any(|e| e.starts_with("api")) {
return Err(format!(
"Package '{}' has an api/ directory but does not exclude it from publishing. \
Add \"api\" to the `exclude` list in {}/Cargo.toml.",
package_name,
package_dir.display(),
)
.into());
}
Ok(())
}
fn check_semver(
sh: &Shell,
package_name: &str,
package_dir: &PathBuf,
baseline: &str,
) -> Result<(), Box<dyn std::error::Error>> {
rbmt_eprintln!("Running semver check against baseline: {}", baseline);
let mut current_apis = get_package_apis(sh, package_name, package_dir)?;
let mut baseline_apis = {
let _guard = git::GitSwitchGuard::new(sh, baseline)?;
get_package_apis(sh, package_name, package_dir)?
};
let baseline_api = baseline_apis
.remove(&FeatureConfig::All)
.ok_or("All-features config not found in baseline")?;
let current_api = current_apis
.remove(&FeatureConfig::All)
.ok_or("All-features config not found in current")?;
let diff = public_api::diff::PublicApiDiff::between(baseline_api, current_api);
println!("Semver check vs {}:", baseline);
if !diff.removed.is_empty() {
println!(" Removed (possibly breaking):");
for item in &diff.removed {
println!(" - {}", item);
}
}
if !diff.changed.is_empty() {
println!(" Changed (possibly breaking):");
for item in &diff.changed {
println!(" old: {}", item.old);
println!(" new: {}", item.new);
}
}
if !diff.added.is_empty() {
println!(" Added:");
for item in &diff.added {
println!(" + {}", item);
}
}
println!(
" Summary: {} removed, {} changed, {} added",
diff.removed.len(),
diff.changed.len(),
diff.added.len()
);
Ok(())
}