cargo-treemap 0.1.0

Interactive treemap analyzer for Rust binary size and dependency API usage — like webpack-bundle-analyzer, for cargo.
//! Drive `cargo build --message-format=json`, forwarding the user's build selection, and
//! discover the real artifact paths from the JSON stream (never hand-compute target paths).

use crate::cli::TreemapArgs;
use anyhow::{bail, Context, Result};
use cargo_metadata::{Message, TargetKind};
use std::collections::HashSet;
use std::path::PathBuf;
use std::process::{Command, Stdio};

pub struct BuildOutput {
    /// Executable / cdylib artifacts to analyze.
    pub artifacts: Vec<PathBuf>,
    /// `(crate_name, rlib_path)` for every rlib in the build, feeds the attribution map.
    pub rlibs: Vec<(String, PathBuf)>,
    /// Every crate name seen in the build (underscore-normalized).
    pub crate_names: HashSet<String>,
    pub target: Option<String>,
    pub profile: String,
}

/// The cargo build-selection flags shared by every build we spawn (the analyzed build, the
/// timed build in parity, and the compare-ref self-invocation): target, workspace selection,
/// and target kind. Profile and features vary per caller, so they stay caller-local.
pub(crate) fn push_build_flags(cmd: &mut Command, args: &TreemapArgs) {
    if let Some(t) = &args.target {
        cmd.arg("--target").arg(t);
    }
    if args.workspace.workspace || args.workspace.all {
        cmd.arg("--workspace");
    }
    for p in &args.workspace.package {
        cmd.arg("--package").arg(p);
    }
    for e in &args.workspace.exclude {
        cmd.arg("--exclude").arg(e);
    }
    for b in &args.bin {
        cmd.arg("--bin").arg(b);
    }
    if args.lib {
        cmd.arg("--lib");
    }
}

/// Pick which analyzable artifacts to keep. With specific packages requested (-p), drop those
/// belonging to other crates: dependency crates declaring `crate-type = ["cdylib"]` (common on
/// wasm) build their own standalone cdylib, which would otherwise show as phantom top-level
/// artifacts whose code is already linked into the requested one. Guard: if the filter empties
/// the set (e.g. a selected bin whose target name differs from its package), keep everything.
fn select_artifacts(artifacts: Vec<(String, PathBuf)>, selected: &HashSet<String>) -> Vec<PathBuf> {
    if selected.is_empty() {
        return artifacts.into_iter().map(|(_, p)| p).collect();
    }
    let kept: Vec<PathBuf> = artifacts
        .iter()
        .filter(|(c, _)| selected.contains(c))
        .map(|(_, p)| p.clone())
        .collect();
    if kept.is_empty() {
        artifacts.into_iter().map(|(_, p)| p).collect()
    } else {
        kept
    }
}

pub fn build(
    args: &TreemapArgs,
    usage_mode: bool,
    feat_override: Option<(bool, Vec<String>)>,
) -> Result<BuildOutput> {
    // Invoke through $CARGO so `+toolchain` / a wrapping rustup pick the right toolchain.
    let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
    let mut cmd = Command::new(&cargo);
    cmd.arg("build")
        .arg("--message-format=json-render-diagnostics");

    if usage_mode {
        // opt-level-0 (dev profile) + v0 mangling: don't inline cross-crate calls away, so the
        // functions your code actually uses survive as symbols for the API-usage intersection.
        let mut rf = std::env::var("RUSTFLAGS").unwrap_or_default();
        rf.push_str(" -C symbol-mangling-version=v0");
        cmd.env("RUSTFLAGS", rf.trim());
    } else if let Some(p) = &args.profile {
        cmd.arg("--profile").arg(p);
    } else if args.release {
        cmd.arg("--release");
    }

    match &feat_override {
        Some((no_default, feats)) => {
            if *no_default {
                cmd.arg("--no-default-features");
            }
            if !feats.is_empty() {
                cmd.arg("--features").arg(feats.join(","));
            }
        }
        None => {
            if args.features.all_features {
                cmd.arg("--all-features");
            }
            if args.features.no_default_features {
                cmd.arg("--no-default-features");
            }
            if !args.features.features.is_empty() {
                cmd.arg("--features").arg(args.features.features.join(","));
            }
        }
    }

    push_build_flags(&mut cmd, args);
    if let Some(mp) = &args.manifest.manifest_path {
        cmd.arg("--manifest-path").arg(mp);
    }

    if args.dwarf && !usage_mode {
        // DWARF attribution needs line info; release ships debuginfo=0, so force it on for the
        // active profile via env (no Cargo.toml edit).
        let pname = match (&args.profile, args.release) {
            (Some(p), _) => p.to_uppercase().replace('-', "_"),
            (None, true) => "RELEASE".to_string(),
            (None, false) => "DEV".to_string(),
        };
        cmd.env(format!("CARGO_PROFILE_{pname}_DEBUG"), "2");
    }

    cmd.stdout(Stdio::piped()).stderr(Stdio::inherit());
    let mut child = cmd.spawn().context("failed to spawn cargo build")?;
    let reader = std::io::BufReader::new(child.stdout.take().expect("piped stdout"));

    let mut artifacts: Vec<(String, PathBuf)> = Vec::new();
    let mut rlibs = Vec::new();
    let mut crate_names = HashSet::new();
    let mut finished_ok = false;

    for msg in Message::parse_stream(reader) {
        match msg.context("failed to parse cargo message stream")? {
            Message::CompilerArtifact(art) => {
                let cname = art.target.name.replace('-', "_");
                crate_names.insert(cname.clone());

                // Only real binaries/cdylibs, not build-script-build executables.
                let is_analyzable = art
                    .target
                    .kind
                    .iter()
                    .any(|k| matches!(k, TargetKind::Bin | TargetKind::CDyLib));
                if is_analyzable {
                    if let Some(exe) = &art.executable {
                        artifacts.push((cname.clone(), exe.clone().into_std_path_buf()));
                    }
                    // A cdylib's path is in filenames, not executable.
                    for f in &art.filenames {
                        let ext = f.extension().unwrap_or("");
                        if matches!(ext, "so" | "dylib" | "dll" | "wasm") {
                            artifacts.push((cname.clone(), f.clone().into_std_path_buf()));
                        }
                    }
                }
                for f in &art.filenames {
                    if f.extension() == Some("rlib") {
                        rlibs.push((cname.clone(), f.clone().into_std_path_buf()));
                    }
                }
            }
            Message::BuildFinished(bf) => finished_ok = bf.success,
            _ => {}
        }
    }

    let status = child.wait().context("waiting on cargo build")?;
    if !finished_ok || !status.success() {
        bail!("cargo build failed; see diagnostics above");
    }

    // Deduplicate (a cdylib can appear via both executable and filenames paths).
    artifacts.sort();
    artifacts.dedup();

    let selected: HashSet<String> = args
        .workspace
        .package
        .iter()
        .map(|p| p.replace('-', "_"))
        .collect();
    let artifacts = select_artifacts(artifacts, &selected);

    let profile = match (usage_mode, &args.profile, args.release) {
        (true, _, _) => "dev (usage probe)".to_string(),
        (false, Some(p), _) => p.clone(),
        (false, None, true) => "release".to_string(),
        (false, None, false) => "debug".to_string(),
    };

    Ok(BuildOutput {
        artifacts,
        rlibs,
        crate_names,
        target: args.target.clone(),
        profile,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn arts() -> Vec<(String, PathBuf)> {
        vec![
            ("veilid_wasm".into(), PathBuf::from("veilid_wasm.wasm")),
            ("veilid_core".into(), PathBuf::from("veilid_core.wasm")),
            ("veilid_tools".into(), PathBuf::from("veilid_tools.wasm")),
        ]
    }

    #[test]
    fn no_selection_keeps_all() {
        let kept = select_artifacts(arts(), &HashSet::new());
        assert_eq!(kept.len(), 3);
    }

    #[test]
    fn selection_drops_dependency_cdylibs() {
        let sel = HashSet::from(["veilid_wasm".to_string()]);
        let kept = select_artifacts(arts(), &sel);
        assert_eq!(kept, vec![PathBuf::from("veilid_wasm.wasm")]);
    }

    #[test]
    fn empty_match_falls_back_to_all() {
        // A selected bin whose target name differs from its package: keep everything.
        let sel = HashSet::from(["foo_cli".to_string()]);
        let kept = select_artifacts(arts(), &sel);
        assert_eq!(kept.len(), 3);
    }
}