Skip to main content

icb_report/
diff.rs

1use icb_graph::graph::{CodePropertyGraph, Edge, GraphData};
2use petgraph::visit::{EdgeRef, IntoEdgeReferences};
3use serde_json::json;
4use std::collections::{HashMap, HashSet};
5
6#[derive(serde::Serialize)]
7struct DiffNode {
8    name: String,
9    kind: String,
10    start_line: usize,
11    status: String,
12}
13
14/// Generates an HTML diff page comparing two project graphs.
15///
16/// # Arguments
17///
18/// * `old_cpg` - The graph of the old project version.
19/// * `new_cpg` - The graph of the new project version.
20/// * `project_name` - A human‑readable name for the project.
21///
22/// # Returns
23///
24/// A `Result` containing the HTML string, or an error if serialization fails.
25pub fn generate_diff(
26    old_cpg: &CodePropertyGraph,
27    new_cpg: &CodePropertyGraph,
28    project_name: &str,
29) -> Result<String, anyhow::Error> {
30    let old_names: HashSet<&str> = old_cpg
31        .graph
32        .node_weights()
33        .filter_map(|n| n.name.as_deref())
34        .collect();
35    let new_names: HashSet<&str> = new_cpg
36        .graph
37        .node_weights()
38        .filter_map(|n| n.name.as_deref())
39        .collect();
40
41    let added: Vec<DiffNode> = new_names
42        .difference(&old_names)
43        .map(|name| {
44            let node = new_cpg
45                .graph
46                .node_weights()
47                .find(|n| n.name.as_deref() == Some(name))
48                .unwrap();
49            DiffNode {
50                name: name.to_string(),
51                kind: format!("{:?}", node.kind),
52                start_line: node.start_line,
53                status: "added".into(),
54            }
55        })
56        .collect();
57
58    let removed: Vec<DiffNode> = old_names
59        .difference(&new_names)
60        .map(|name| {
61            let node = old_cpg
62                .graph
63                .node_weights()
64                .find(|n| n.name.as_deref() == Some(name))
65                .unwrap();
66            DiffNode {
67                name: name.to_string(),
68                kind: format!("{:?}", node.kind),
69                start_line: node.start_line,
70                status: "removed".into(),
71            }
72        })
73        .collect();
74
75    let mut modified = Vec::new();
76    let common = old_names.intersection(&new_names);
77    for &name in common {
78        let new_node = new_cpg
79            .graph
80            .node_weights()
81            .find(|n| n.name.as_deref() == Some(name))
82            .unwrap();
83        let old_degree = old_cpg
84            .graph
85            .edges(
86                old_cpg
87                    .graph
88                    .node_indices()
89                    .find(|&i| old_cpg.graph[i].name.as_deref() == Some(name))
90                    .unwrap(),
91            )
92            .filter(|e| matches!(e.weight(), Edge::Call))
93            .count();
94        let new_degree = new_cpg
95            .graph
96            .edges(
97                new_cpg
98                    .graph
99                    .node_indices()
100                    .find(|&i| new_cpg.graph[i].name.as_deref() == Some(name))
101                    .unwrap(),
102            )
103            .filter(|e| matches!(e.weight(), Edge::Call))
104            .count();
105        if old_degree != new_degree {
106            modified.push(DiffNode {
107                name: name.to_string(),
108                kind: format!("{:?}", new_node.kind),
109                start_line: new_node.start_line,
110                status: "modified".to_string(),
111            });
112        }
113    }
114
115    let mut graph = GraphData {
116        nodes: vec![],
117        edges: vec![],
118    };
119    let mut node_map = HashMap::new();
120
121    for (idx, node) in new_cpg.graph.node_weights().enumerate() {
122        let name = node.name.as_deref().unwrap_or("");
123        let status = if removed.iter().any(|r| r.name == name) {
124            "removed"
125        } else if added.iter().any(|a| a.name == name) {
126            "added"
127        } else if modified.iter().any(|m| m.name == name) {
128            "modified"
129        } else {
130            "unchanged"
131        };
132        let color = match status {
133            "added" => "#4ade80",
134            "removed" => "#f87171",
135            "modified" => "#facc15",
136            _ => "#60a5fa",
137        };
138        let extra = json!({ "status": status, "color": color });
139        graph.nodes.push(icb_graph::graph::Node {
140            kind: node.kind,
141            name: node.name.clone(),
142            usr: Some(extra.to_string()),
143            start_line: node.start_line,
144            end_line: node.end_line,
145        });
146        node_map.insert(name.to_string(), idx);
147    }
148
149    for edge_ref in new_cpg.graph.edge_references() {
150        let src_name = new_cpg.graph[edge_ref.source()].name.clone();
151        let tgt_name = new_cpg.graph[edge_ref.target()].name.clone();
152        if let (Some(&src), Some(&tgt)) = (
153            src_name.as_ref().and_then(|n| node_map.get(n)),
154            tgt_name.as_ref().and_then(|n| node_map.get(n)),
155        ) {
156            graph.edges.push((src, tgt, edge_ref.weight().clone()));
157        }
158    }
159
160    for node in old_cpg.graph.node_weights() {
161        if let Some(name) = &node.name {
162            if !node_map.contains_key(name) {
163                let idx = graph.nodes.len();
164                graph.nodes.push(icb_graph::graph::Node {
165                    kind: node.kind,
166                    name: node.name.clone(),
167                    usr: Some(json!({ "status": "removed", "color": "#f87171" }).to_string()),
168                    start_line: node.start_line,
169                    end_line: node.end_line,
170                });
171                node_map.insert(name.clone(), idx);
172            }
173        }
174    }
175
176    let json_data = serde_json::to_string(&graph)?;
177    let stats_json = json!({
178        "added": added.len(),
179        "removed": removed.len(),
180        "modified": modified.len(),
181    })
182    .to_string();
183
184    let html = format!(
185        r##"<!DOCTYPE html>
186<html lang="en">
187<head>
188  <meta charset="UTF-8">
189  <title>ICB Diff - {project_name}</title>
190  <style>
191    :root {{ --bg: #0b0f17; --surface: #111827; --text: #e0e0e0; --accent: #60a5fa; }}
192    body {{ font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; }}
193    .header {{ padding: 24px; background: var(--surface); border-bottom: 1px solid #1f2937; }}
194    .header h1 {{ color: var(--accent); }}
195    .stats {{ display: flex; gap: 24px; margin-top: 16px; flex-wrap: wrap; }}
196    .stat {{ background: #1f2937; padding: 16px; border-radius: 12px; min-width: 120px; }}
197    .stat .value {{ font-size: 2rem; font-weight: 700; }}
198    .stat .label {{ color: #9ca3af; }}
199    .legend {{ display: flex; gap: 16px; padding: 8px 16px; font-size: 0.875rem; }}
200    .legend .dot {{ width: 10px; height: 10px; border-radius: 50%; display: inline-block; margin-right: 4px; }}
201    #graph-container {{ height: 500px; margin: 16px; border-radius: 12px; overflow: hidden; }}
202  </style>
203</head>
204<body>
205  <div class="header">
206    <h1>{project_name} – Diff</h1>
207    <div class="stats" id="stats"></div>
208  </div>
209  <div class="legend">
210    <span><span class="dot" style="background:#4ade80"></span> Added</span>
211    <span><span class="dot" style="background:#f87171"></span> Removed</span>
212    <span><span class="dot" style="background:#facc15"></span> Modified</span>
213    <span><span class="dot" style="background:#60a5fa"></span> Unchanged</span>
214  </div>
215  <div id="graph-container"></div>
216
217  <script src="https://cdn.jsdelivr.net/npm/graphology@0.25.4/dist/graphology.umd.min.js"></script>
218  <script src="https://cdn.jsdelivr.net/npm/graphology-layout-forceatlas2@0.9.1/dist/graphology-layout-forceatlas2.min.js"></script>
219  <script src="https://cdn.jsdelivr.net/npm/sigma@2.4.0/build/sigma.min.js"></script>
220  <script>
221    const STATS_DATA = {stats_json};
222    const GRAPH_DATA = {json_data};
223  </script>
224  <script>
225    (function() {{
226      document.getElementById('stats').innerHTML = [
227        {{ value: STATS_DATA.added, label: 'Added' }},
228        {{ value: STATS_DATA.removed, label: 'Removed' }},
229        {{ value: STATS_DATA.modified, label: 'Modified' }}
230      ].map(s => `<div class="stat"><div class="value">${{s.value}}</div><div class="label">${{s.label}}</div></div>`).join('');
231
232      const g = new graphology.Graph();
233      GRAPH_DATA.nodes.forEach((n, idx) => {{
234        let extra = n.usr ? JSON.parse(n.usr) : {{}};
235        g.addNode(idx, {{
236          label: n.name || '?', kind: n.kind, line: n.start_line,
237          x: Math.random() * 100, y: Math.random() * 100, size: 8,
238          color: extra.color || '#60a5fa'
239        }});
240      }});
241      GRAPH_DATA.edges.forEach(([src, tgt]) => {{
242        if (src < GRAPH_DATA.nodes.length && tgt < GRAPH_DATA.nodes.length)
243          g.addEdge(src, tgt, {{ size: 0.5, color: '#374151' }});
244      }});
245
246      const layout = new layoutForceAtlas2(g, {{ iterations: 100, settings: {{ gravity: 0.1, scalingRatio: 20 }} }});
247      layout.start();
248      setTimeout(() => layout.stop(), 2000);
249
250      new Sigma(g, document.getElementById('graph-container'), {{
251        renderLabels: false, minCameraRatio: 0.05, maxCameraRatio: 10
252      }});
253    }})();
254  </script>
255</body>
256</html>"##,
257        project_name = project_name,
258        stats_json = stats_json,
259        json_data = json_data,
260    );
261
262    Ok(html)
263}