rust_mir2_core 0.1.6

Shared Rust MIR extraction model and helpers for rust_mir2
Documentation
use std::path::Path;

use anyhow::{Context, bail};
use toml::Value;

use crate::model::{DefaultTarget, InputKind, RustMir2Error, TargetProject};

fn read_manifest(manifest_path: &Path) -> anyhow::Result<Value> {
    let content = std::fs::read_to_string(manifest_path)
        .with_context(|| format!("failed to read manifest `{}`", manifest_path.display()))?;
    toml::from_str(&content)
        .with_context(|| format!("failed to parse manifest `{}`", manifest_path.display()))
}

fn package_name_from_manifest(manifest: &Value) -> Option<String> {
    manifest
        .get("package")
        .and_then(Value::as_table)
        .and_then(|package| package.get("name"))
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
}

fn workspace_members_from_manifest(manifest: &Value) -> Vec<String> {
    manifest
        .get("workspace")
        .and_then(Value::as_table)
        .and_then(|workspace| workspace.get("members"))
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(Value::as_str)
        .map(ToOwned::to_owned)
        .collect()
}

fn default_run_from_manifest(manifest: &Value) -> Option<String> {
    manifest
        .get("package")
        .and_then(Value::as_table)
        .and_then(|package| package.get("default-run"))
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
}

fn lib_name_from_manifest(
    manifest: &Value,
    manifest_dir: &Path,
    package_name: &str,
) -> Option<String> {
    if let Some(lib) = manifest.get("lib").and_then(Value::as_table) {
        return Some(
            lib.get("name")
                .and_then(Value::as_str)
                .unwrap_or(package_name)
                .to_string(),
        );
    }
    manifest_dir
        .join("src")
        .join("lib.rs")
        .is_file()
        .then(|| package_name.to_string())
}

fn bin_names_from_manifest(
    manifest: &Value,
    manifest_dir: &Path,
    package_name: &str,
) -> Vec<String> {
    let mut bins = manifest
        .get("bin")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|bin| {
            bin.as_table()
                .and_then(|table| table.get("name"))
                .and_then(Value::as_str)
                .map(ToOwned::to_owned)
        })
        .collect::<Vec<_>>();
    if manifest_dir.join("src").join("main.rs").is_file()
        && !bins.iter().any(|bin| bin == package_name)
    {
        bins.push(package_name.to_string());
    }
    bins.sort();
    bins.dedup();
    bins
}

fn default_targets_from_manifest(
    manifest: &Value,
    manifest_dir: &Path,
    package_name: &str,
) -> Vec<DefaultTarget> {
    let mut targets = Vec::new();
    if let Some(lib_name) = lib_name_from_manifest(manifest, manifest_dir, package_name) {
        let target_identity = format!("lib:{lib_name}:normal");
        targets.push(DefaultTarget {
            package_name: package_name.to_string(),
            target_name: lib_name,
            target_kind: "lib".to_string(),
            target_identity,
        });
    }

    let bin_names = bin_names_from_manifest(manifest, manifest_dir, package_name);
    let default_bin = default_run_from_manifest(manifest)
        .or_else(|| bin_names.iter().find(|bin| *bin == package_name).cloned())
        .or_else(|| bin_names.first().cloned());
    if let Some(bin_name) = default_bin {
        targets.push(DefaultTarget {
            package_name: package_name.to_string(),
            target_name: bin_name.clone(),
            target_kind: "bin".to_string(),
            target_identity: format!("bin:{bin_name}:normal"),
        });
    }

    targets
}

pub fn resolve_target_project(input_path: &Path) -> Result<TargetProject, RustMir2Error> {
    try_resolve_target_project(input_path).map_err(Into::into)
}

pub(crate) fn try_resolve_target_project(input_path: &Path) -> anyhow::Result<TargetProject> {
    let canonical = input_path.canonicalize().with_context(|| {
        format!(
            "failed to canonicalize input path `{}`",
            input_path.display()
        )
    })?;

    let manifest_path = if canonical.is_file() {
        if canonical
            .file_name()
            .is_some_and(|name| name == "Cargo.toml")
        {
            canonical.clone()
        } else {
            bail!("input file `{}` is not a Cargo.toml", canonical.display());
        }
    } else {
        canonical.join("Cargo.toml")
    };

    if !manifest_path.is_file() {
        bail!("no Cargo.toml found at `{}`", manifest_path.display());
    }

    let manifest = read_manifest(&manifest_path)?;
    let manifest_dir = manifest_path
        .parent()
        .context("manifest path had no parent directory")?
        .to_path_buf();
    let package_name = package_name_from_manifest(&manifest);
    let workspace_members = workspace_members_from_manifest(&manifest);

    if let Some(package_name) = package_name.clone() {
        return Ok(TargetProject {
            input_kind: InputKind::SingleCrate,
            project_root: manifest_dir.clone(),
            manifest_path,
            expected_package_names: vec![package_name.clone()],
            default_targets: default_targets_from_manifest(
                &manifest,
                &manifest_dir,
                package_name.as_str(),
            ),
        });
    }

    if !workspace_members.is_empty() {
        let mut expected_package_names = workspace_members
            .into_iter()
            .map(|member| {
                let member_manifest = manifest_dir.join(member).join("Cargo.toml");
                let member_value = read_manifest(&member_manifest)?;
                package_name_from_manifest(&member_value).with_context(|| {
                    format!(
                        "workspace member manifest `{}` has no package.name",
                        member_manifest.display()
                    )
                })
            })
            .collect::<anyhow::Result<Vec<_>>>()?;
        if let Some(package_name) = package_name {
            expected_package_names.push(package_name);
        }
        expected_package_names.sort();
        expected_package_names.dedup();
        return Ok(TargetProject {
            input_kind: InputKind::Workspace,
            project_root: manifest_dir,
            manifest_path,
            expected_package_names,
            default_targets: Vec::new(),
        });
    }

    bail!("failed to resolve target package name")
}

#[cfg(test)]
mod tests {
    use tempfile::TempDir;

    use super::*;

    fn write(path: &Path, text: &str) {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent).unwrap();
        }
        std::fs::write(path, text).unwrap();
    }

    #[test]
    fn package_manifest_with_workspace_members_resolves_as_workspace() {
        let temp = TempDir::new().unwrap();
        write(
            &temp.path().join("Cargo.toml"),
            r#"
[package]
name = "root_pkg"
version = "0.1.0"
edition = "2024"

[workspace]
members = ["adder", "add-one", "test3"]
"#,
        );
        for (dir, name) in [
            ("adder", "adder"),
            ("add-one", "add-one"),
            ("test3", "test3"),
        ] {
            write(
                &temp.path().join(dir).join("Cargo.toml"),
                &format!(
                    r#"
[package]
name = "{name}"
version = "0.1.0"
edition = "2024"
"#
                ),
            );
        }

        let project = try_resolve_target_project(temp.path()).unwrap();

        assert_eq!(project.input_kind, InputKind::SingleCrate);
        assert_eq!(project.expected_package_names, vec!["root_pkg"]);
    }

    #[test]
    fn root_package_name_wins_before_reading_workspace_members() {
        let temp = TempDir::new().unwrap();
        write(
            &temp.path().join("Cargo.toml"),
            r#"
[package]
name = "rustdesk"
version = "0.1.0"
edition = "2024"

[workspace]
members = ["rustdesk"]
"#,
        );

        let project = try_resolve_target_project(temp.path()).unwrap();

        assert_eq!(project.input_kind, InputKind::SingleCrate);
        assert_eq!(project.expected_package_names, vec!["rustdesk"]);
    }

    #[test]
    fn bin_only_package_uses_package_name_for_implicit_main_bin() {
        let temp = TempDir::new().unwrap();
        write(
            &temp.path().join("Cargo.toml"),
            r#"
[package]
name = "minigrep_by_shyoy"
version = "0.1.0"
edition = "2024"
"#,
        );
        write(&temp.path().join("src/main.rs"), "fn main() {}\n");

        let project = try_resolve_target_project(temp.path()).unwrap();

        assert_eq!(project.default_targets.len(), 1);
        assert_eq!(project.default_targets[0].target_kind, "bin");
        assert_eq!(project.default_targets[0].target_name, "minigrep_by_shyoy");
        assert_eq!(
            project.default_targets[0].target_identity,
            "bin:minigrep_by_shyoy:normal"
        );
    }

    #[test]
    fn package_with_lib_and_default_run_uses_only_lib_and_default_bin() {
        let temp = TempDir::new().unwrap();
        write(
            &temp.path().join("Cargo.toml"),
            r#"
[package]
name = "rustdesk"
version = "0.1.0"
edition = "2024"
default-run = "rustdesk"

[lib]
name = "librustdesk"

[[bin]]
name = "rustdesk"
path = "src/main.rs"

[[bin]]
name = "service"
path = "src/bin/service.rs"
"#,
        );
        write(&temp.path().join("src/lib.rs"), "pub fn x() {}\n");
        write(&temp.path().join("src/main.rs"), "fn main() {}\n");
        write(&temp.path().join("src/bin/service.rs"), "fn main() {}\n");

        let project = try_resolve_target_project(temp.path()).unwrap();
        let identities = project
            .default_targets
            .iter()
            .map(|target| target.target_identity.as_str())
            .collect::<Vec<_>>();

        assert_eq!(
            identities,
            vec!["lib:librustdesk:normal", "bin:rustdesk:normal"]
        );
    }

    #[test]
    fn package_without_lib_file_does_not_add_lib_target() {
        let temp = TempDir::new().unwrap();
        write(
            &temp.path().join("Cargo.toml"),
            r#"
[package]
name = "tool"
version = "0.1.0"
edition = "2024"

[[bin]]
name = "helper"
path = "src/bin/helper.rs"
"#,
        );
        write(&temp.path().join("src/bin/helper.rs"), "fn main() {}\n");

        let project = try_resolve_target_project(temp.path()).unwrap();

        assert_eq!(project.default_targets.len(), 1);
        assert_eq!(
            project.default_targets[0].target_identity,
            "bin:helper:normal"
        );
    }
}