use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use public_api::rustdoc_types::Id;
use xshell::Shell;
use crate::environment::{
get_target_dir, get_workspace_packages, get_workspace_root, CmdExt, 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,
snapshot: bool,
features: Vec<Vec<String>>,
private: 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()],
}
}
}
struct ItemContext {
map: HashMap<Id, String>,
}
impl ItemContext {
fn new(api: &public_api::PublicApi) -> Self {
let parent_items: Vec<_> = api.items().collect();
let id_to_item: HashMap<_, _> = parent_items.iter().map(|i| (i.id(), i)).collect();
let map = api.items()
.filter_map(|item| {
if let Some(parent_id) = item.parent_id() {
if let Some(parent_item) = id_to_item.get(&parent_id) {
if parent_item.tokens().any(
|token| matches!(token, public_api::tokens::Token::Keyword(kw) if kw == "for"),
) {
let context = format!("[impl: {}]", parent_item);
return Some((parent_id, context));
}
}
}
None
})
.collect();
Self { map }
}
fn format(&self, item: &public_api::PublicItem) -> String {
match item.parent_id().and_then(|pid| self.map.get(&pid)) {
Some(ctx) => format!("{item} {ctx}"),
None => item.to_string(),
}
}
}
struct FeatureDiff {
feature_config: FeatureConfig,
diff: public_api::diff::PublicApiDiff,
baseline_context: ItemContext,
current_context: ItemContext,
}
struct PackageDiff {
package_name: String,
feature_diffs: Vec<FeatureDiff>,
}
struct ApiDiffError {
package_diffs: Vec<PackageDiff>,
}
impl std::fmt::Display for ApiDiffError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "API diffs found in {} package(s)", self.package_diffs.len())?;
for package in &self.package_diffs {
for feature in &package.feature_diffs {
writeln!(
f,
"--- {} API Diff ({})",
package.package_name,
feature.feature_config.name()
)?;
for item in &feature.diff.removed {
writeln!(f, "- {}", feature.baseline_context.format(item))?;
}
for item in &feature.diff.changed {
writeln!(
f,
"~ {} > {}",
feature.baseline_context.format(&item.old),
feature.current_context.format(&item.new)
)?;
}
for item in &feature.diff.added {
writeln!(f, "+ {}", feature.current_context.format(item))?;
}
}
}
Ok(())
}
}
impl std::fmt::Debug for ApiDiffError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ApiDiffError") }
}
impl std::error::Error for ApiDiffError {}
fn check_private_deps(
package_name: &str,
apis: &PackageApis,
private_deps: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
if private_deps.is_empty() {
return Ok(());
}
let mut leaked_items = Vec::new();
for (feature_config, api) in apis {
for item in api.items() {
for token in item.tokens() {
if let public_api::tokens::Token::Identifier(ident) = token {
for private_dep in private_deps {
if ident == private_dep || ident.starts_with(&format!("{}::", private_dep))
{
leaked_items.push((feature_config.name(), item.to_string()));
break;
}
}
}
}
}
}
if !leaked_items.is_empty() {
let mut message =
format!("Private dependency exposed in public API of {}:\n", package_name);
for (feature, item) in leaked_items {
message.push_str(&format!(" [{}] {}\n", feature, item));
}
return Err(message.into());
}
Ok(())
}
pub fn run(
sh: &Shell,
lockfile: LockFile,
packages: &[String],
baseline: Option<&str>,
snapshot: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let packages = get_workspace_packages(sh, packages)?;
let _lockfile_guard = lockfile.activate(sh)?;
let mut progress = ProgressGuard::new();
rbmt_eprintln!("Running API check...");
toolchain::prepare_toolchain(sh, toolchain::Toolchain::Nightly)?;
let mut package_diffs = Vec::new();
let mut package_apis: Vec<(String, PackageApis)> = Vec::new();
for package in packages {
let api_config = ApiConfig::load(&package.dir)?;
if !api_config.enabled {
continue;
}
rbmt_eprintln!("API check enabled in {}", package.name);
let current_apis = get_package_apis(sh, &package.name, &package.dir)?;
check_private_deps(&package.name, ¤t_apis, &api_config.private)?;
if snapshot || api_config.snapshot {
write_api_files(&package, ¤t_apis)?;
}
if let Some(baseline) = baseline {
if let Some(package_diff) = check_baseline(sh, &package, baseline, current_apis)? {
package_diffs.push(package_diff);
}
} else {
package_apis.push((package.name.clone(), current_apis));
}
}
if !package_diffs.is_empty() {
return Err(Box::new(ApiDiffError { package_diffs }));
}
if baseline.is_none() {
progress.disable();
for (package_name, feature_apis) in package_apis {
for (feature_config, api) in feature_apis {
println!("--- {} API ({})", package_name, feature_config.name());
let context = ItemContext::new(&api);
for item in api.items() {
println!("{}", context.format(item));
}
}
}
}
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_with_capture()?;
sh.change_dir(&workspace_root);
let json_path = get_target_dir(sh)?
.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 write_api_files(
package: &Package,
apis: &PackageApis,
) -> 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());
}
let package_api_dir = package.dir.join(API_DIR);
fs::create_dir_all(&package_api_dir)?;
for (config, public_api) in apis {
let output_file = package_api_dir.join(config.filename());
let context = ItemContext::new(public_api);
let api_display =
public_api.items().map(|item| context.format(item)).collect::<Vec<_>>().join("\n");
fs::write(&output_file, api_display)?;
}
Ok(())
}
fn check_baseline(
sh: &Shell,
package: &Package,
baseline: &str,
current_apis: PackageApis,
) -> Result<Option<PackageDiff>, Box<dyn std::error::Error>> {
rbmt_eprintln!("Comparing against baseline: {}", baseline);
let mut baseline_apis = {
let _guard = git::GitSwitchGuard::new(sh, baseline)?;
get_package_apis(sh, &package.name, &package.dir)?
};
let mut feature_diffs = Vec::new();
for (feature_config, current_api) in current_apis {
let baseline_api = baseline_apis.remove(&feature_config).ok_or(format!(
"Feature {:?} not found in baseline for {}",
feature_config, package.name
))?;
let baseline_context = ItemContext::new(&baseline_api);
let current_context = ItemContext::new(¤t_api);
let diff = public_api::diff::PublicApiDiff::between(baseline_api, current_api);
if !diff.is_empty() {
feature_diffs.push(FeatureDiff {
feature_config,
diff,
baseline_context,
current_context,
});
}
}
if feature_diffs.is_empty() {
Ok(None)
} else {
Ok(Some(PackageDiff { package_name: package.name.clone(), feature_diffs }))
}
}