rust_mir2_core 0.1.1

Shared Rust MIR extraction model and helpers for rust_mir2
Documentation
use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, bail};

use crate::fragments::load_fragments_from_dir;
use crate::manifest::try_resolve_target_project;
use crate::model::{
    EXPECTED_PACKAGES_ENV, InputKind, InvocationMode, OUTPUT_DIR_ENV, RustMir2Error, TargetProject,
    WRAPPER_MODE_ENV, WorkspaceMirDump,
};
use crate::render::build_packages_from_targets;

pub fn detect_invocation_mode(args: &[String], forced_mode: Option<&str>) -> InvocationMode {
    if matches!(forced_mode, Some("workspace-wrapper")) {
        return InvocationMode::WorkspaceWrapper;
    }

    if args.get(1).is_some_and(|arg| {
        let path = Path::new(arg);
        path.file_stem()
            .and_then(OsStr::to_str)
            .is_some_and(|stem| stem == "rustc")
    }) {
        return InvocationMode::WorkspaceWrapper;
    }

    InvocationMode::Library
}

fn wrapper_binary_path() -> anyhow::Result<PathBuf> {
    let Some(exe) = std::env::var_os("RUST_MIR2_WRAPPER_BIN").map(PathBuf::from) else {
        bail!(
            "RUST_MIR2_WRAPPER_BIN is required; install the published rust_mir2_wrapper crate and set RUST_MIR2_WRAPPER_BIN to its rust_mir2-wrapper binary"
        );
    };

    if exe.is_file() {
        return Ok(exe);
    }

    bail!(
        "RUST_MIR2_WRAPPER_BIN points to `{}`, but it is not a file",
        exe.display()
    );
}

fn run_cargo_check(target_project: &TargetProject, output_dir: &Path) -> anyhow::Result<()> {
    let wrapper = wrapper_binary_path()?;
    let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
    let target_dir = output_dir.join("cargo-target");
    let mut cmd = Command::new(cargo);
    cmd.current_dir(&target_project.project_root)
        .arg("check")
        .arg("--all-targets")
        .arg("--target-dir")
        .arg(&target_dir);

    match target_project.input_kind {
        InputKind::Workspace => {
            cmd.arg("--workspace");
        }
        InputKind::SingleCrate => {
            cmd.arg("--package").arg(
                target_project
                    .expected_package_names
                    .first()
                    .context("missing package name")?,
            );
        }
    }

    cmd.env("RUSTC_WORKSPACE_WRAPPER", &wrapper)
        .env(WRAPPER_MODE_ENV, "workspace-wrapper")
        .env(OUTPUT_DIR_ENV, output_dir)
        .env(
            EXPECTED_PACKAGES_ENV,
            target_project.expected_package_names.join(","),
        );

    let status = cmd.status().context("failed to run cargo check")?;
    if !status.success() {
        bail!("cargo check failed with status {status}");
    }

    Ok(())
}

pub fn analyze_workspace(path: &Path) -> Result<WorkspaceMirDump, RustMir2Error> {
    try_analyze_workspace(path).map_err(Into::into)
}

fn try_analyze_workspace(path: &Path) -> anyhow::Result<WorkspaceMirDump> {
    let target_project = try_resolve_target_project(path)?;
    let temp_dir = tempfile::tempdir().context("failed to create temporary output directory")?;

    run_cargo_check(&target_project, temp_dir.path())?;
    let fragments =
        load_fragments_from_dir(temp_dir.path()).map_err(|error| anyhow::anyhow!(error.message))?;

    if fragments.is_empty() {
        bail!("rust_mir2 produced no target fragments");
    }

    let expected = target_project
        .expected_package_names
        .iter()
        .cloned()
        .collect::<BTreeSet<_>>();
    let actual = fragments
        .iter()
        .map(|fragment| fragment.package_name.clone())
        .collect::<BTreeSet<_>>();

    for expected_name in &expected {
        if !actual.contains(expected_name) {
            bail!("missing fragment for local package `{expected_name}`");
        }
    }

    for actual_name in &actual {
        if !expected.contains(actual_name) {
            bail!("unexpected fragment for non-target package `{actual_name}`");
        }
    }

    Ok(WorkspaceMirDump {
        project_path: target_project.project_root,
        packages: build_packages_from_targets(fragments),
    })
}

pub fn analyze_path(path: &Path) -> Result<WorkspaceMirDump, RustMir2Error> {
    analyze_workspace(path)
}

#[cfg(test)]
mod tests {
    use super::wrapper_binary_path;
    use std::sync::Mutex;

    static ENV_LOCK: Mutex<()> = Mutex::new(());

    #[test]
    fn wrapper_binary_path_requires_explicit_env() {
        let _guard = ENV_LOCK.lock().unwrap();
        unsafe {
            std::env::remove_var("RUST_MIR2_WRAPPER_BIN");
        }

        let error = wrapper_binary_path().expect_err("missing env should fail");
        assert!(
            error
                .to_string()
                .contains("RUST_MIR2_WRAPPER_BIN is required")
        );
    }

    #[test]
    fn wrapper_binary_path_rejects_missing_file() {
        let _guard = ENV_LOCK.lock().unwrap();
        unsafe {
            std::env::set_var(
                "RUST_MIR2_WRAPPER_BIN",
                "/definitely/not/a/rust_mir2-wrapper",
            );
        }

        let error = wrapper_binary_path().expect_err("missing wrapper file should fail");
        assert!(error.to_string().contains("but it is not a file"));
    }
}