cargo-treemap 0.1.0

Interactive treemap analyzer for Rust binary size and dependency API usage — like webpack-bundle-analyzer, for cargo.
//! Diff two analyses. This is the `config -> SizeModel -> diff` spine: it aligns two node
//! trees by path (crate -> module -> symbol) and records the signed size change per node.
//! Feeds the `--compare` view (a saved baseline vs the current build) and, via repeated
//! builds, `--attribute-features`.

use crate::model::Node;
use anyhow::{Context, Result};
use serde::Serialize;
use std::cmp::Reverse;
use std::collections::HashMap;
use std::path::Path;

#[derive(Serialize)]
pub struct DiffNode {
    pub label: String,
    pub path: String,
    pub base_text: u64,
    pub head_text: u64,
    pub base_data: u64,
    pub head_data: u64,
    pub base_gzip: u64,
    pub head_gzip: u64,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub groups: Vec<DiffNode>,
}

impl DiffNode {
    pub fn delta_text(&self) -> i64 {
        self.head_text as i64 - self.base_text as i64
    }
    pub fn delta_data(&self) -> i64 {
        self.head_data as i64 - self.base_data as i64
    }
    pub fn delta_gzip(&self) -> i64 {
        self.head_gzip as i64 - self.base_gzip as i64
    }
}

/// Load the node tree from a saved `--format json` report.
pub fn load_base_tree(path: &Path) -> Result<Node> {
    let text = std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
    let value: serde_json::Value = serde_json::from_str(&text).context("parsing baseline JSON")?;
    let tree = value
        .get("tree")
        .context("baseline JSON has no `tree` field (save it with `--format json`)")?;
    serde_json::from_value(tree.clone()).context("reading baseline tree")
}

/// Diff `head` against `base`, aligning children by label at every level. Nodes only in head
/// are additions (base = 0); nodes only in base are removals (head = 0).
pub fn diff_trees(base: &Node, head: &Node) -> DiffNode {
    let mut base_children: HashMap<&str, &Node> =
        base.groups.iter().map(|c| (c.label.as_str(), c)).collect();
    let mut groups = Vec::new();
    for hc in &head.groups {
        match base_children.remove(hc.label.as_str()) {
            Some(bc) => groups.push(diff_trees(bc, hc)),
            None => groups.push(one_sided(hc, false)),
        }
    }
    for bc in base_children.into_values() {
        groups.push(one_sided(bc, true));
    }
    groups.sort_by_key(|n| Reverse(n.delta_text().abs()));

    DiffNode {
        label: head.label.clone(),
        path: head.path.clone(),
        base_text: base.text,
        head_text: head.text,
        base_data: base.data,
        head_data: head.data,
        base_gzip: base.gzip,
        head_gzip: head.gzip,
        groups,
    }
}

/// A node present on only one side: `removed` puts its values in base (head = 0), otherwise
/// in head (base = 0).
fn one_sided(node: &Node, removed: bool) -> DiffNode {
    let pick = |v: u64| if removed { (v, 0) } else { (0, v) };
    let (base_text, head_text) = pick(node.text);
    let (base_data, head_data) = pick(node.data);
    let (base_gzip, head_gzip) = pick(node.gzip);
    DiffNode {
        label: node.label.clone(),
        path: node.path.clone(),
        base_text,
        head_text,
        base_data,
        head_data,
        base_gzip,
        head_gzip,
        groups: node.groups.iter().map(|c| one_sided(c, removed)).collect(),
    }
}

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

    fn node(label: &str, text: u64, groups: Vec<Node>) -> Node {
        Node {
            label: label.to_string(),
            path: label.to_string(),
            text: text + groups.iter().map(|g| g.text).sum::<u64>(),
            data: 0,
            gzip: 0,
            count: 1,
            conf: String::new(),
            stat: None,
            loc: None,
            unsafe_n: None,
            dep_kind: None,
            groups,
        }
    }

    #[test]
    fn added_removed_changed() {
        // a grew, b removed, c added
        let base = node("bin", 0, vec![node("a", 100, vec![]), node("b", 50, vec![])]);
        let head = node("bin", 0, vec![node("a", 120, vec![]), node("c", 30, vec![])]);
        let d = diff_trees(&base, &head);
        let by: HashMap<_, _> = d.groups.iter().map(|g| (g.label.as_str(), g)).collect();

        assert_eq!((by["a"].base_text, by["a"].head_text), (100, 120));
        assert_eq!(by["a"].delta_text(), 20);
        assert_eq!((by["b"].base_text, by["b"].head_text), (50, 0)); // removed
        assert_eq!((by["c"].base_text, by["c"].head_text), (0, 30)); // added
        assert_eq!((d.base_text, d.head_text), (150, 150));
        assert_eq!(d.delta_text(), 0);
    }

    #[test]
    fn identical_trees_have_zero_delta() {
        let t = node("bin", 0, vec![node("a", 100, vec![node("m", 40, vec![])])]);
        let same = node("bin", 0, vec![node("a", 100, vec![node("m", 40, vec![])])]);
        let d = diff_trees(&t, &same);
        assert_eq!(d.delta_text(), 0);
        assert!(d.groups.iter().all(|g| g.delta_text() == 0));
    }
}