cargo-treemap 0.1.0

Interactive treemap analyzer for Rust binary size and dependency API usage — like webpack-bundle-analyzer, for cargo.
//! Per-crate parity metrics beyond binary size: source LOC/bytes (the webpack "stat" analog),
//! `unsafe` usage, and dependency kind. Monomorphization-copy counts come from the symbol
//! tree itself (a leaf's aggregated symbol count), so they aren't computed here.

use crate::cli::TreemapArgs;
use crate::model::{crate_bin_totals, Node};
use anyhow::{anyhow, Context, Result};
use cargo_metadata::camino::Utf8Path;
use cargo_metadata::{DependencyKind, Metadata, Package, PackageId, TargetKind};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Stdio;

/// One row of the per-crate metrics table: binary contribution + source metrics + dep kind.
#[derive(Serialize)]
pub struct CrateRow {
    pub name: String,
    pub text: u64,
    pub data: u64,
    pub gzip: u64,
    pub mono: u32,
    pub loc: u64,
    pub src_bytes: u64,
    pub unsafe_n: u32,
    pub compile_ms: f64,
    pub dep_kind: String,
}

pub fn crate_rows(root: &Node, metrics: &HashMap<String, CrateMetrics>) -> Vec<CrateRow> {
    let bin = crate_bin_totals(root);
    let mut names: HashSet<&String> = bin.keys().collect();
    names.extend(metrics.keys());

    let mut rows: Vec<CrateRow> = names
        .into_iter()
        .filter(|n| !n.starts_with('[')) // drop synthetic [runtime]/[unattributed] buckets
        .map(|name| {
            let (text, data, gzip, mono) = bin.get(name).copied().unwrap_or_default();
            let m = metrics.get(name).cloned().unwrap_or_default();
            CrateRow {
                name: name.clone(),
                text,
                data,
                gzip,
                mono,
                loc: m.loc,
                src_bytes: m.src_bytes,
                unsafe_n: m.unsafe_n,
                compile_ms: m.compile_ms,
                dep_kind: if m.dep_kind.is_empty() {
                    "normal".to_string()
                } else {
                    m.dep_kind
                },
            }
        })
        .collect();
    rows.sort_by(|a, b| b.text.cmp(&a.text).then(b.src_bytes.cmp(&a.src_bytes)));
    rows
}

#[derive(Serialize, Clone, Default)]
pub struct CrateMetrics {
    pub loc: u64,
    pub src_bytes: u64,
    pub unsafe_n: u32,
    pub dep_kind: String,
    pub compile_ms: f64,
}

/// Per-crate metrics keyed by the crate's lib name (underscored, matching symbol attribution).
/// `linked` is the set of crates actually in the binary; the rest are labeled "compile-only".
pub fn compute(
    meta: &Metadata,
    wanted: &HashSet<String>,
    linked: &HashSet<String>,
) -> HashMap<String, CrateMetrics> {
    let kinds = classify_kinds(meta);
    let mut out = HashMap::new();
    for pkg in &meta.packages {
        let name = lib_name(pkg);
        let base = kinds.get(&pkg.id).map(String::as_str).unwrap_or("normal");
        let is_linked = linked.contains(&name);
        // Show binary crates, plus compile-only crates (proc-macro/build closure) so their
        // compile cost is visible; skip test-only dev deps.
        let interesting = wanted.contains(&name) || (!is_linked && base != "dev");
        if !interesting {
            continue;
        }
        let dep_kind = if !is_linked {
            "compile-only"
        } else if base == "workspace" {
            "workspace"
        } else {
            "normal"
        };
        let (loc, src_bytes, unsafe_n) = scan_source(pkg);
        out.insert(
            name,
            CrateMetrics {
                loc,
                src_bytes,
                unsafe_n,
                dep_kind: dep_kind.to_string(),
                compile_ms: 0.0,
            },
        );
    }
    out
}

/// Lib names of crates actually linked into the target: reachable from workspace members via
/// normal edges WITHOUT passing through a proc-macro crate (whose closure runs at compile time
/// and is never linked in). Keeps host/proc-macro rlibs out of the attribution map.
pub fn linked_crate_names(meta: &Metadata) -> HashSet<String> {
    let libs: HashMap<PackageId, String> =
        meta.packages.iter().map(|p| (p.id.clone(), lib_name(p))).collect();
    let Some(resolve) = &meta.resolve else {
        return libs.into_values().collect();
    };
    let proc: HashSet<PackageId> = meta
        .packages
        .iter()
        .filter(|p| p.targets.iter().any(|t| t.kind.contains(&TargetKind::ProcMacro)))
        .map(|p| p.id.clone())
        .collect();
    let deps: HashMap<PackageId, &Vec<cargo_metadata::NodeDep>> =
        resolve.nodes.iter().map(|n| (n.id.clone(), &n.deps)).collect();

    let mut seen: HashSet<PackageId> = HashSet::new();
    let mut stack: Vec<PackageId> = Vec::new();
    for m in &meta.workspace_members {
        if !proc.contains(m) && seen.insert(m.clone()) {
            stack.push(m.clone());
        }
    }
    while let Some(id) = stack.pop() {
        if let Some(ds) = deps.get(&id) {
            for d in *ds {
                if d.dep_kinds.iter().any(|k| k.kind == DependencyKind::Normal)
                    && !proc.contains(&d.pkg)
                    && seen.insert(d.pkg.clone())
                {
                    stack.push(d.pkg.clone());
                }
            }
        }
    }
    seen.iter().filter_map(|id| libs.get(id).cloned()).collect()
}

/// The crate's lib target name (underscored), matching how symbols name their crate. None if
/// the package has no library target (a bin-only dep).
pub fn lib_target_name(pkg: &Package) -> Option<String> {
    pkg.targets
        .iter()
        .find(|t| {
            t.kind.iter().any(|k| {
                matches!(
                    k,
                    TargetKind::Lib
                        | TargetKind::RLib
                        | TargetKind::ProcMacro
                        | TargetKind::CDyLib
                        | TargetKind::DyLib
                )
            })
        })
        .map(|t| t.name.replace('-', "_"))
}

fn lib_name(pkg: &Package) -> String {
    lib_target_name(pkg).unwrap_or_else(|| pkg.name.to_string().replace('-', "_"))
}

/// Count lines, bytes, and `unsafe` keyword occurrences across the crate's `src/` tree.
fn scan_source(pkg: &Package) -> (u64, u64, u32) {
    let Some(root) = pkg.manifest_path.parent() else {
        return (0, 0, 0);
    };
    let src = root.join("src");
    let dir = if src.exists() { src } else { root.to_path_buf() };
    let (mut loc, mut bytes, mut unsafe_n) = (0u64, 0u64, 0u32);
    let mut stack = vec![dir.into_std_path_buf()];
    while let Some(d) = stack.pop() {
        let Ok(rd) = std::fs::read_dir(&d) else {
            continue;
        };
        for ent in rd.flatten() {
            let p = ent.path();
            if p.is_dir() {
                if p.file_name().and_then(|s| s.to_str()) != Some("target") {
                    stack.push(p);
                }
            } else if p.extension().and_then(|s| s.to_str()) == Some("rs") {
                if let Ok(text) = std::fs::read_to_string(&p) {
                    bytes += text.len() as u64;
                    loc += text.lines().filter(|l| !l.trim().is_empty()).count() as u64;
                    unsafe_n += count_word(&text, "unsafe");
                }
            }
        }
    }
    (loc, bytes, unsafe_n)
}

fn count_word(src: &str, word: &str) -> u32 {
    let mut n = 0;
    let mut from = 0;
    while let Some(rel) = src[from..].find(word) {
        let i = from + rel;
        let before = src[..i].chars().next_back();
        let after = src[i + word.len()..].chars().next();
        let ok = before.is_none_or(|c| !(c.is_alphanumeric() || c == '_'))
            && after.is_none_or(|c| !(c.is_alphanumeric() || c == '_'));
        if ok {
            n += 1;
        }
        from = i + word.len();
    }
    n
}

/// Per-crate compile time (ms), from `cargo build --timings`. Builds into a separate target
/// dir so it doesn't disturb the analyzed build; the first run recompiles everything.
pub fn compile_times(args: &TreemapArgs, target_dir: &Utf8Path) -> Result<HashMap<String, f64>> {
    let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
    let timings_root = target_dir.join("treemap-timings");
    // `--timings` only records units that actually compile, so wipe the dir first for a full,
    // complete measurement every run (a cached build would report ~0 for everything).
    let _ = std::fs::remove_dir_all(timings_root.as_std_path());
    let mut cmd = std::process::Command::new(&cargo);
    cmd.arg("build").arg("--timings");
    cmd.env("CARGO_TARGET_DIR", timings_root.as_str());
    if let Some(p) = &args.profile {
        cmd.arg("--profile").arg(p);
    } else if args.release {
        cmd.arg("--release");
    }
    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(","));
    }
    // Shared selection flags — including --exclude/--bin/--lib, which this build must match so
    // per-crate compile_ms lines up with the analyzed unit set.
    crate::builder::push_build_flags(&mut cmd, args);
    if let Some(mp) = &args.manifest.manifest_path {
        cmd.arg("--manifest-path").arg(mp);
    }
    cmd.stdout(Stdio::null()).stderr(Stdio::inherit());
    let status = cmd.status().context("spawning timed build")?;
    if !status.success() {
        anyhow::bail!("timed build failed");
    }

    let html = newest_timing_html(timings_root.join("cargo-timings").as_std_path())?;
    let text = std::fs::read_to_string(&html).context("reading timings html")?;
    parse_unit_data(&text)
}

fn newest_timing_html(dir: &Path) -> Result<PathBuf> {
    let mut best: Option<(String, PathBuf)> = None;
    for e in std::fs::read_dir(dir).context("reading cargo-timings dir")?.flatten() {
        let p = e.path();
        let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("").to_string();
        // Timestamped names sort lexically by time; skip the `cargo-timing.html` symlink.
        if name.starts_with("cargo-timing-")
            && name.ends_with(".html")
            && best.as_ref().is_none_or(|(b, _)| name > *b)
        {
            best = Some((name, p));
        }
    }
    best.map(|(_, p)| p).ok_or_else(|| anyhow!("no timing report in {}", dir.display()))
}

/// Sum the per-unit `duration` seconds (build script + lib + bin) into ms per crate.
fn parse_unit_data(html: &str) -> Result<HashMap<String, f64>> {
    let key = "const UNIT_DATA = ";
    let start = html.find(key).ok_or_else(|| anyhow!("no UNIT_DATA in timings html"))? + key.len();
    let rest = &html[start..];
    let end = rest.find("];").ok_or_else(|| anyhow!("malformed UNIT_DATA"))? + 1;
    let units: serde_json::Value = serde_json::from_str(&rest[..end])?;
    let mut out: HashMap<String, f64> = HashMap::new();
    if let Some(arr) = units.as_array() {
        for u in arr {
            let name = u.get("name").and_then(|v| v.as_str()).unwrap_or("").replace('-', "_");
            let dur = u.get("duration").and_then(|v| v.as_f64()).unwrap_or(0.0);
            if !name.is_empty() {
                *out.entry(name).or_insert(0.0) += dur * 1000.0;
            }
        }
    }
    Ok(out)
}

/// Classify each package as proc-macro / normal / build / dev, from the resolve graph.
fn classify_kinds(meta: &Metadata) -> HashMap<PackageId, String> {
    let mut out = HashMap::new();
    let Some(resolve) = &meta.resolve else {
        return out;
    };
    let nodes: HashMap<&PackageId, &cargo_metadata::Node> =
        resolve.nodes.iter().map(|n| (&n.id, n)).collect();
    let members: Vec<&PackageId> = meta.workspace_members.iter().collect();

    let proc: HashSet<&PackageId> = meta
        .packages
        .iter()
        .filter(|p| p.targets.iter().any(|t| t.kind.contains(&TargetKind::ProcMacro)))
        .map(|p| &p.id)
        .collect();

    let normal = reach(&nodes, &members, DependencyKind::Normal);
    let build = reach(&nodes, &members, DependencyKind::Build);
    let dev = reach(&nodes, &members, DependencyKind::Development);

    for p in &meta.packages {
        let kind = if members.contains(&&p.id) {
            "workspace"
        } else if proc.contains(&p.id) {
            "proc-macro"
        } else if normal.contains(&p.id) {
            "normal"
        } else if build.contains(&p.id) {
            "build"
        } else if dev.contains(&p.id) {
            "dev"
        } else {
            "normal"
        };
        out.insert(p.id.clone(), kind.to_string());
    }
    out
}

/// Packages reachable from `starts` where the first edge is `first_kind`, then following
/// normal edges transitively (a build-dep's normal deps are still build-time, etc.).
fn reach(
    nodes: &HashMap<&PackageId, &cargo_metadata::Node>,
    starts: &[&PackageId],
    first_kind: DependencyKind,
) -> HashSet<PackageId> {
    let mut seen = HashSet::new();
    let mut stack = Vec::new();
    for s in starts {
        if let Some(n) = nodes.get(s) {
            for d in &n.deps {
                if d.dep_kinds.iter().any(|k| k.kind == first_kind) && seen.insert(d.pkg.clone()) {
                    stack.push(d.pkg.clone());
                }
            }
        }
    }
    while let Some(id) = stack.pop() {
        if let Some(n) = nodes.get(&id) {
            for d in &n.deps {
                if d.dep_kinds.iter().any(|k| k.kind == DependencyKind::Normal)
                    && seen.insert(d.pkg.clone())
                {
                    stack.push(d.pkg.clone());
                }
            }
        }
    }
    seen
}