pugio 0.3.0

A command-line dependency binary size graph visualisation tool for Rust
use std::{
    io::Write,
    process::{Command, Stdio},
};

use anyhow::Context;
use pugio_lib::graph::Graph;

#[derive(Debug, Default)]
pub struct CargoOptions {
    pub package: Option<String>,
    pub bin: Option<String>,
    pub features: Option<String>,
    pub all_features: bool,
    pub no_default_features: bool,
    pub release: bool,
}

pub fn cargo_tree_output(options: &CargoOptions) -> anyhow::Result<String> {
    let mut command = Command::new("cargo");
    command
        .stdout(Stdio::piped())
        .arg("tree")
        .arg("--edges=no-build,no-proc-macro,no-dev,features")
        .arg("--prefix=depth")
        .arg("--color=never");

    if let Some(package) = &options.package {
        command.arg(format!("--package={package}"));
    }

    if let Some(features) = &options.features {
        command.arg(format!("--features={features}"));
    }

    if options.all_features {
        command.arg("--all-features");
    }

    if options.no_default_features {
        command.arg("--no-default-features");
    }

    command
        .spawn()
        .context("failed to execute cargo-tree")?
        .wait_with_output()
        .map(|o| String::from_utf8(o.stdout).unwrap())
        .context("failed to wait on cargo-tree")
}

pub fn cargo_bloat_output(options: &CargoOptions) -> anyhow::Result<String> {
    let mut command = Command::new("cargo");
    command
        .stdout(Stdio::piped())
        .arg("bloat")
        .arg("-n0")
        .arg("--message-format=json")
        .arg("--crates");

    if let Some(package) = &options.package {
        command.arg(format!("--package={package}"));
    }

    if let Some(binary) = &options.bin {
        command.arg(format!("--bin={binary}"));
    }

    if let Some(features) = &options.features {
        command.arg(format!("--features={features}"));
    }

    if options.all_features {
        command.arg("--all-features");
    }

    if options.no_default_features {
        command.arg("--no-default-features");
    }

    if options.release {
        command.arg("--release");
    }

    command
        .spawn()
        .context("failed to execute cargo-bloat")?
        .wait_with_output()
        .map(|o| String::from_utf8(o.stdout).unwrap())
        .context("failed to wait on cargo-bloat")
}

#[derive(Debug, Default)]
pub struct SvgOptions {
    pub scale_factor: Option<f32>,
    pub separation_factor: Option<f32>,
    pub padding: Option<f32>,
    pub dark_mode: bool,
    pub highlight: Option<bool>,
    pub highlight_amount: Option<f32>,
    pub no_open: bool,
}

pub fn output_svg(
    dot_output: &str,
    graph: &Graph,
    output_filename: &str,
    svg_options: &SvgOptions,
) -> anyhow::Result<()> {
    let node_count_factor = (graph.node_count() as f32 / 32.0).floor();
    let scale_factor = svg_options.scale_factor.unwrap_or(1.0);
    let node_font_size = (node_count_factor * 3.0 + 15.0) * scale_factor;
    let edge_font_size = node_font_size * 0.75;
    let arrow_size = (node_count_factor * 0.2 + 0.6) * scale_factor;
    let edge_width = arrow_size * 2.0;
    let node_border_width = edge_width * 0.75;

    let sep_factor = svg_options.separation_factor.unwrap_or(1.0);
    let node_sep = 0.35 * sep_factor;
    let rank_sep = node_sep * 2.0;
    let padding = svg_options.padding.unwrap_or(1.0);

    let mut command = Command::new("dot");
    command
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .arg("-Tsvg")
        .arg(format!("-Gpad={padding}"))
        .arg("-Nshape=circle")
        .arg(format!("-Npenwidth={node_border_width}"))
        .arg("-Nstyle=filled")
        .arg("-Nfixedsize=shape")
        .arg("-Nfontname=monospace")
        .arg(format!("-Nfontsize={node_font_size}"))
        .arg("-Efontname=monospace")
        .arg(format!("-Efontsize={edge_font_size}"))
        .arg(format!("-Earrowsize={arrow_size}"))
        .arg("-Earrowhead=onormal")
        .arg(format!("-Epenwidth={edge_width}"))
        .arg(format!("-Gnodesep={node_sep}"))
        .arg(format!("-Granksep={rank_sep}"));

    if svg_options.dark_mode {
        command
            .arg("-Gbgcolor=#000000")
            .arg("-Ncolor=#FFFFFF")
            .arg("-Ecolor=#FFFFFF9F")
            .arg("-Efontcolor=#FFFFFFFF")
            .arg("-Nfontcolor=#FFFFFF");
    } else {
        command
            .arg("-Ncolor=#000000")
            .arg("-Nfontcolor=#000000")
            .arg("-Ecolor=#0000009F")
            .arg("-Efontcolor=#000000");
    }

    let mut child = command.spawn().context("failed to execute dot")?;

    let stdin = child.stdin.as_mut().context("failed to get stdin")?;
    stdin
        .write_all(dot_output.as_bytes())
        .context("failed to write into stdin")?;

    let output = child.wait_with_output().context("failed to wait on dot")?;
    let mut svg =
        String::from_utf8(output.stdout).context("failed to convert dot output to string")?;

    if svg_options.highlight.is_some() {
        let index = svg
            .find("<g id=\"graph0\"")
            .context("failed to find graph start")?;

        let highlight_amount = 1.0 - svg_options.highlight_amount.unwrap_or(0.5).clamp(0.0, 1.0);
        let rules = graph
            .node_indices()
            .map(|i| {
                format!(
                    ".graph:has(.node{i}:hover) > g:not(.node{i}) {{ opacity: {highlight_amount} }}"
                )
            })
            .collect::<Vec<_>>()
            .join("\n");

        let style = format!("<style>\n{rules}\n</style>\n");
        svg.insert_str(index, &style);
    }

    std::fs::write(output_filename, svg).context("failed to write output svg file")?;
    if !svg_options.no_open {
        open::that_detached(output_filename).context("failed to open output svg")?;
    }
    Ok(())
}