cargo-rbmt 0.2.0

Maintainer tools for rust-bitcoin projects
// SPDX-License-Identifier: MIT AND Apache-2.0

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};

/// Directory where API files are stored, relative to each package directory.
const API_DIR: &str = "api";

/// RUSTDOCFLAGS to allow broken intra-doc links during API checking.
///
/// When generating API documentation with limited features (e.g., --no-default-features),
/// some doc links may reference items that don't exist without those features.
/// This flag suppresses those warnings so we can focus on actual API changes.
const RUSTDOCFLAGS_ALLOW_BROKEN_LINKS: &str = "-A rustdoc::broken_intra_doc_links";

/// A collection of public APIs for a single package across different feature configurations.
type PackageApis = HashMap<FeatureConfig, public_api::PublicApi>;

/// API-specific configuration, read from `[package.metadata.rbmt.api]` in `Cargo.toml`.
#[derive(Debug, Default, serde::Deserialize)]
#[serde(default)]
struct ApiConfig {
    /// Whether to run API checks for this package. Defaults to `false`.
    enabled: bool,
    /// Feature combinations to test (in addition to no-features and all-features).
    features: Vec<Vec<String>>,
}

impl ApiConfig {
    /// Load API configuration from `[package.metadata.rbmt.api]` in the package's `Cargo.toml`.
    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)
    }
}

/// Feature configurations to test for API generation.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum FeatureConfig {
    /// No features enabled (--no-default-features).
    None,
    /// Specific features enabled (--no-default-features --features=X,Y).
    Some(Vec<String>),
    /// All features enabled (--all-features).
    All,
}

impl FeatureConfig {
    /// Get the filename for this configuration.
    fn filename(&self) -> String { format!("{}.txt", self.name()) }

    /// Get the display name for this configuration.
    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(),
        }
    }

    /// Get the cargo arguments for this configuration.
    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()],
        }
    }
}

/// Run the API check task.
///
/// This command checks for changes to the public API of workspace packages by generating
/// API files using the `public-api` library and comparing them with committed versions in each
/// package's own `api/` directory.
///
/// Always checks that features are additive and API files match git state.
/// When a baseline ref is given or configured, also performs semver
/// compatibility checking by comparing the current API against the baseline.
///
/// # Arguments
///
/// * `packages` - Optional list of packages to check. If empty, checks all packages in the workspace.
/// * `baseline` - Git ref for optional semver comparison.
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(())
}

/// Get the public APIs for a single package across all feature configurations.
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 {
        // Change to package directory to run rustdoc.
        // This is necessary because cargo doesn't allow feature flags with -p option.
        sh.change_dir(package_dir);

        // Generate rustdoc JSON.
        // Use --lib to avoid ambiguity errors in packages with multiple targets (e.g. lib + bin).
        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()?;

        // Change back to workspace root and parse JSON.
        sh.change_dir(&workspace_root);
        let target_dir = get_target_dir(sh)?;
        let json_path = Path::new(&target_dir)
            .join("doc")
            // Rustdoc replaces hyphens with underscores in the filename.
            .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)
}

/// Check API files for all packages.
///
/// For each package, generates public API files for different feature configurations,
/// validates that features are additive, and checks for git changes.
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)?;

        // Write API files into the package's own api/ directory.
        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())?;
        }

        // Check that features are additive (all-features contains everything from no-features).
        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() {
            // Show the diff for context.
            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(())
}

/// Check that the package's manifest excludes the `api/` directory from publishing.
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(())
}

/// Run semver compatibility check against a baseline ref.
///
/// Compares the current all-features API against the baseline to report removed, changed, and
/// added items. This check is informational and never fails, it just prints a summary of API
/// differences to help maintainers assess semver impact.
///
/// Only checks the all-features configuration. This means items that were moved behind a
/// feature gate (from unconditional to `#[cfg(feature = "...")]`) will not be detected as
/// removed, since they still appear in the all-features API. Detecting such changes would
/// require checking every feature combination from the baseline.
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(())
}