cargo-rbmt 0.1.0

Maintainer tools for rust-bitcoin projects
//! Pre-release readiness checks.

use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;

use serde::Deserialize;
use xshell::Shell;

use crate::environment::{get_target_dir, quiet_println, Package, PackageManifest};
use crate::lock::LockFile;
use crate::quiet_cmd;
use crate::toolchain::{prepare_toolchain, Toolchain};

/// Pre-release-specific configuration, read from `[package.metadata.rbmt.prerelease]` in `Cargo.toml`.
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
struct PrereleaseConfig {
    /// Whether to run pre-release checks for this package. Defaults to `false`.
    enabled: bool,
}

impl PrereleaseConfig {
    /// Load pre-release configuration from `[package.metadata.rbmt.prerelease]` 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)]
            prerelease: PrereleaseConfig,
        }

        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
            .prerelease)
    }
}

/// Run pre-release readiness checks for all packages.
pub fn run(
    sh: &Shell,
    packages: &[Package],
    force: bool,
    baseline: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    quiet_println(&format!("Running pre-release checks on {} packages", packages.len()));

    for package in packages {
        let config = PrereleaseConfig::load(Path::new(&package.dir))?;

        if !config.enabled {
            quiet_println(&format!("Skipping {} (pre-release not enabled)", package.name));
            continue;
        }

        if !force && !has_version_bump(sh, &package.dir, baseline)? {
            quiet_println(&format!(
                "Skipping {} (no version bump detected since {})",
                package.name, baseline
            ));
            continue;
        }

        quiet_println(&format!("Checking package: {}", package.name));

        let _dir = sh.push_dir(&package.dir);

        // Run all pre-release checks. Return immediately on first failure.
        if let Err(e) = check_todos(sh) {
            eprintln!("Pre-release check failed for {}: {e}", package.name);
            return Err(e);
        }

        if let Err(e) = check_publish(sh) {
            eprintln!("Pre-release check failed for {}: {e}", package.name);
            return Err(e);
        }
    }

    quiet_println("All pre-release checks passed");
    Ok(())
}

/// Returns `true` if there is a version bump in the package's `Cargo.toml` since the baseline ref.
fn has_version_bump(
    sh: &Shell,
    package_dir: &Path,
    baseline: &str,
) -> Result<bool, Box<dyn std::error::Error>> {
    let cargo_toml = package_dir.join("Cargo.toml");
    let range = format!("{baseline}..");
    let output = quiet_cmd!(sh, "git log --patch --reverse {range} -- {cargo_toml}").read()?;
    Ok(output.lines().any(|line| line.starts_with("+version")))
}

// Things which should be patched up before release.
const TODOS: &[&str] = &["// TODO", "/* TODO", "// FIXME", "/* FIXME", "\"TBD\""];
// Things which are banned and can't be released.
const NONOS: &[&str] = &["doc_auto_cfg"];

/// Grep source code for TODO, FIXME, TBD, and `doc_auto_cfg`.
fn check_todos(sh: &Shell) -> Result<(), Box<dyn std::error::Error>> {
    quiet_println("Greping source for todos and nonos...");

    // Recursively walk the src/ directory.
    let mut issues = Vec::new();
    let mut dirs_to_visit = vec![sh.current_dir().join("src")];
    while let Some(dir) = dirs_to_visit.pop() {
        for entry in fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();

            if path.is_dir() {
                dirs_to_visit.push(path);
            } else if path.extension().and_then(|s| s.to_str()) == Some("rs") {
                let file = fs::File::open(&path)?;
                let reader = BufReader::new(file);

                for (line_num, line) in reader.lines().enumerate() {
                    let line = line?;
                    if TODOS.iter().any(|pattern| line.contains(pattern))
                        || NONOS.iter().any(|pattern| line.contains(pattern))
                    {
                        issues.push((path.clone(), line_num, line));
                    }
                }
            }
        }
    }

    if !issues.is_empty() {
        eprintln!("Found {} pre-release issue(s):", issues.len());
        for (file, line_num, line) in &issues {
            eprintln!("{}:{}:{}", file.display(), line_num, line.trim());
        }
        return Err(format!("Found {} pre-release issues", issues.len()).into());
    }

    quiet_println("No pre-release issues found");
    Ok(())
}

/// Check that the package can be published.
///
/// A package may work with local path dependencies, but fail when published
/// because the version specifications don't match the published versions
/// or don't resolve correctly.
fn check_publish(sh: &Shell) -> Result<(), Box<dyn std::error::Error>> {
    prepare_toolchain(sh, Toolchain::Nightly)?;
    quiet_cmd!(sh, "cargo publish --dry-run").run()?;
    let package_dir = get_publish_dir(sh)?;

    let _dir = sh.push_dir(&package_dir);
    quiet_println(&format!("Testing publish package: {}", package_dir));
    // Re-derive dependencies since it is what an end user will see.
    LockFile::Minimal.derive(sh)?;
    quiet_cmd!(sh, "cargo test --all-features --all-targets --locked").run()?;

    quiet_println("Publish tests passed");
    Ok(())
}

/// Get the path to the publish directory for the current package from cargo metadata.
fn get_publish_dir(sh: &Shell) -> Result<String, Box<dyn std::error::Error>> {
    let target_dir = get_target_dir(sh)?;

    // Find the package that matches the current directory.
    let metadata = quiet_cmd!(sh, "cargo metadata --no-deps --format-version 1").read()?;
    let json: serde_json::Value = serde_json::from_str(&metadata)?;
    let current_dir = sh.current_dir();
    let current_manifest = current_dir.join("Cargo.toml");

    let packages =
        json["packages"].as_array().ok_or("Missing 'packages' field in cargo metadata")?;

    for package in packages {
        let manifest_path =
            package["manifest_path"].as_str().ok_or("Missing manifest_path in package")?;

        if manifest_path == current_manifest.to_str().ok_or("Invalid path")? {
            let name = package["name"].as_str().ok_or("Missing name in package")?;

            let version = package["version"].as_str().ok_or("Missing version in package")?;

            return Ok(format!("{}/package/{}-{}", target_dir, name, version));
        }
    }

    Err("Could not find current package in cargo metadata".into())
}