From 983a7dbfc4cb27b515079234ce285754de6476b4 Mon Sep 17 00:00:00 2001 From: Austin Gill Date: Fri, 20 Feb 2026 17:20:50 -0600 Subject: [PATCH 1/6] Upgrade petgraph to 0.8 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 28fe422..8c43aa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ mermaid-rs-renderer = { version = "0.1.2", default-features = false } itertools-num = "0.1.3" kernel-density-estimation = "0.2.0" ordered-float = "5.0.0" -petgraph = "0.7" +petgraph = "0.8" regex = "1" pathdiff = "0.2.3" serde = { version = "1.0.217", features = ["derive"] } From f23a1712b19a7feb3cf33bf764c7f5d4bf5ae432 Mon Sep 17 00:00:00 2001 From: Austin Gill Date: Fri, 20 Feb 2026 17:27:58 -0600 Subject: [PATCH 2/6] Add graphdiff tool --- crates/csvizmo-depgraph/src/algorithm/diff.rs | 846 ++++++++++++++++++ crates/csvizmo-depgraph/src/algorithm/mod.rs | 1 + crates/csvizmo-depgraph/src/bin/graphdiff.rs | 169 ++++ crates/csvizmo-depgraph/tests/graphdiff.rs | 605 +++++++++++++ 4 files changed, 1621 insertions(+) create mode 100644 crates/csvizmo-depgraph/src/algorithm/diff.rs create mode 100644 crates/csvizmo-depgraph/src/bin/graphdiff.rs create mode 100644 crates/csvizmo-depgraph/tests/graphdiff.rs diff --git a/crates/csvizmo-depgraph/src/algorithm/diff.rs b/crates/csvizmo-depgraph/src/algorithm/diff.rs new file mode 100644 index 0000000..4181eca --- /dev/null +++ b/crates/csvizmo-depgraph/src/algorithm/diff.rs @@ -0,0 +1,846 @@ +use std::collections::HashSet; +use std::io::Write; + +use indexmap::IndexMap; + +use crate::{DepGraph, Edge, NodeInfo}; + +/// Status of a node or edge in a graph diff. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiffStatus { + Added, + Removed, + Changed, + Moved, + Unchanged, +} + +/// A node with its diff status. +#[derive(Debug)] +pub struct DiffNode { + pub status: DiffStatus, + pub info: NodeInfo, +} + +/// An edge with its diff status. +#[derive(Debug)] +pub struct DiffEdge { + pub status: DiffStatus, + pub edge: Edge, +} + +/// Result of diffing two dependency graphs. +#[derive(Debug)] +pub struct GraphDiff { + pub nodes: IndexMap, + pub edges: Vec, +} + +impl GraphDiff { + /// Returns true if any node or edge has a status other than Unchanged. + pub fn has_changes(&self) -> bool { + self.nodes + .values() + .any(|n| n.status != DiffStatus::Unchanged) + || self.edges.iter().any(|e| e.status != DiffStatus::Unchanged) + } +} + +fn node_eq(a: &NodeInfo, b: &NodeInfo) -> bool { + a.label == b.label && a.node_type == b.node_type && a.attrs == b.attrs +} + +fn edge_eq(a: &Edge, b: &Edge) -> bool { + a.label == b.label && a.attrs == b.attrs +} + +fn build_incoming(edges: &[Edge]) -> IndexMap> { + let mut incoming: IndexMap> = IndexMap::new(); + for edge in edges { + incoming + .entry(edge.to.clone()) + .or_default() + .push(edge.from.clone()); + } + incoming +} + +/// Compute the difference between two dependency graphs. +/// +/// Nodes are matched by ID. Edges are matched by (from, to) tuple. +/// Content equality for nodes compares label, node_type, and attrs. +/// Content equality for edges compares label and attrs. +/// Nodes that are unchanged in content but have a single parent that +/// changed are marked as Moved. +pub fn diff(before: &DepGraph, after: &DepGraph) -> GraphDiff { + let before_nodes = before.all_nodes(); + let after_nodes = after.all_nodes(); + let before_edges = before.all_edges(); + let after_edges = after.all_edges(); + + let mut nodes = IndexMap::new(); + + // After-graph nodes: Added, Changed, or Unchanged + for (id, after_info) in after_nodes { + let status = match before_nodes.get(id) { + Some(before_info) => { + if node_eq(before_info, after_info) { + DiffStatus::Unchanged + } else { + DiffStatus::Changed + } + } + None => DiffStatus::Added, + }; + nodes.insert( + id.clone(), + DiffNode { + status, + info: after_info.clone(), + }, + ); + } + + // Before-only nodes: Removed + for (id, before_info) in before_nodes { + if !after_nodes.contains_key(id) { + nodes.insert( + id.clone(), + DiffNode { + status: DiffStatus::Removed, + info: before_info.clone(), + }, + ); + } + } + + // Build before-edge lookup grouped by (from, to), consuming matched entries as we go + let mut before_edge_map: IndexMap<(String, String), Vec> = IndexMap::new(); + for edge in before_edges { + let key = (edge.from.clone(), edge.to.clone()); + before_edge_map.entry(key).or_default().push(edge.clone()); + } + + let mut edges = Vec::new(); + + for edge in after_edges { + let key = (edge.from.clone(), edge.to.clone()); + let status = match before_edge_map.get_mut(&key) { + Some(before_edges) => { + if let Some(pos) = before_edges.iter().position(|be| edge_eq(be, edge)) { + before_edges.swap_remove(pos); + DiffStatus::Unchanged + } else if !before_edges.is_empty() { + before_edges.swap_remove(0); + DiffStatus::Changed + } else { + DiffStatus::Added + } + } + None => DiffStatus::Added, + }; + edges.push(DiffEdge { + status, + edge: edge.clone(), + }); + } + + // Remaining before edges are Removed + for (_, remaining) in before_edge_map { + for edge in remaining { + edges.push(DiffEdge { + status: DiffStatus::Removed, + edge, + }); + } + } + + // Move detection: upgrade Unchanged nodes whose single parent changed + let before_incoming = build_incoming(before_edges); + let after_incoming = build_incoming(after_edges); + + for (id, diff_node) in &mut nodes { + if diff_node.status != DiffStatus::Unchanged { + continue; + } + let before_parents = before_incoming.get(id.as_str()); + let after_parents = after_incoming.get(id.as_str()); + match (before_parents, after_parents) { + (Some(bp), Some(ap)) if bp.len() == 1 && ap.len() == 1 && bp[0] != ap[0] => { + diff_node.status = DiffStatus::Moved; + } + _ => {} + } + } + + GraphDiff { nodes, edges } +} + +/// Build an annotated graph combining both inputs with visual diff styling. +/// +/// Added nodes/edges are green, removed are red, changed are orange, +/// moved are blue. Each element gets a `diff` attribute for programmatic +/// filtering. The after-graph's subgraph structure is preserved: nodes +/// appear in their original subgraph positions. Removed nodes (only in +/// the before-graph) are placed at root level, or into a `cluster_removed` +/// subgraph when `cluster` is true. +pub fn annotate_graph(diff: &GraphDiff, after: &DepGraph, cluster: bool) -> DepGraph { + fn annotate_node(diff_node: &DiffNode) -> NodeInfo { + let mut info = diff_node.info.clone(); + match diff_node.status { + DiffStatus::Added => { + info.label = format!("+ {}", info.label); + info.attrs.insert("color".into(), "green".into()); + info.attrs.insert("fontcolor".into(), "green".into()); + info.attrs.insert("diff".into(), "added".into()); + } + DiffStatus::Removed => { + info.label = format!("- {}", info.label); + info.attrs.insert("color".into(), "red".into()); + info.attrs.insert("fontcolor".into(), "red".into()); + info.attrs.insert("diff".into(), "removed".into()); + } + DiffStatus::Changed => { + info.label = format!("~ {}", info.label); + info.attrs.insert("color".into(), "orange".into()); + info.attrs.insert("fontcolor".into(), "orange".into()); + info.attrs.insert("diff".into(), "changed".into()); + } + DiffStatus::Moved => { + info.label = format!("> {}", info.label); + info.attrs.insert("color".into(), "blue".into()); + info.attrs.insert("fontcolor".into(), "blue".into()); + info.attrs.insert("diff".into(), "moved".into()); + } + DiffStatus::Unchanged => { + info.attrs.insert("diff".into(), "unchanged".into()); + } + } + info + } + + fn annotate_subgraph(diff: &GraphDiff, subgraph: &DepGraph) -> DepGraph { + let nodes: IndexMap = subgraph + .nodes + .keys() + .filter_map(|id| { + let diff_node = diff.nodes.get(id)?; + Some((id.clone(), annotate_node(diff_node))) + }) + .collect(); + + let subgraphs: Vec = subgraph + .subgraphs + .iter() + .map(|sg| annotate_subgraph(diff, sg)) + .filter(|sg| !sg.nodes.is_empty() || !sg.subgraphs.is_empty()) + .collect(); + + DepGraph { + id: subgraph.id.clone(), + attrs: subgraph.attrs.clone(), + nodes, + subgraphs, + ..Default::default() + } + } + + // Walk the after-graph tree to place nodes in their original positions. + let mut root = annotate_subgraph(diff, after); + + // Removed nodes are not in the after-graph; collect them separately. + let removed_nodes: IndexMap = diff + .nodes + .iter() + .filter(|(_, n)| n.status == DiffStatus::Removed) + .map(|(id, n)| (id.clone(), annotate_node(n))) + .collect(); + + if !removed_nodes.is_empty() { + if cluster { + root.subgraphs.push(DepGraph { + id: Some("cluster_removed".into()), + nodes: removed_nodes, + ..Default::default() + }); + } else { + root.nodes.extend(removed_nodes); + } + } + + // Edges stay at root level. + for diff_edge in &diff.edges { + let mut edge = diff_edge.edge.clone(); + match diff_edge.status { + DiffStatus::Added => { + edge.attrs.insert("color".into(), "green".into()); + edge.attrs.insert("diff".into(), "added".into()); + } + DiffStatus::Removed => { + edge.attrs.insert("color".into(), "red".into()); + edge.attrs.insert("diff".into(), "removed".into()); + } + DiffStatus::Changed => { + edge.attrs.insert("color".into(), "orange".into()); + edge.attrs.insert("diff".into(), "changed".into()); + } + DiffStatus::Moved => { + edge.attrs.insert("color".into(), "blue".into()); + edge.attrs.insert("diff".into(), "moved".into()); + } + DiffStatus::Unchanged => { + edge.attrs.insert("diff".into(), "unchanged".into()); + } + } + root.edges.push(edge); + } + + root +} + +/// Build a graph containing only nodes exclusive to the "before" graph. +/// +/// The before-graph's subgraph structure is preserved. Edges are included +/// only when both endpoints are removed nodes. Empty subgraphs are dropped. +pub fn subtract_graph(diff: &GraphDiff, before: &DepGraph) -> DepGraph { + let removed_ids: HashSet<&str> = diff + .nodes + .iter() + .filter(|(_, n)| n.status == DiffStatus::Removed) + .map(|(id, _)| id.as_str()) + .collect(); + + fn filter_subgraph(graph: &DepGraph, keep: &HashSet<&str>) -> DepGraph { + DepGraph { + id: graph.id.clone(), + attrs: graph.attrs.clone(), + nodes: graph + .nodes + .iter() + .filter(|(id, _)| keep.contains(id.as_str())) + .map(|(id, info)| (id.clone(), info.clone())) + .collect(), + edges: graph + .edges + .iter() + .filter(|e| keep.contains(e.from.as_str()) && keep.contains(e.to.as_str())) + .cloned() + .collect(), + subgraphs: graph + .subgraphs + .iter() + .map(|sg| filter_subgraph(sg, keep)) + .filter(|sg| !sg.nodes.is_empty() || !sg.subgraphs.is_empty()) + .collect(), + ..Default::default() + } + } + + filter_subgraph(before, &removed_ids) +} + +/// Write a tab-delimited listing of changed nodes and edges. +/// +/// Unchanged items are omitted. Node format: `\t\t