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
14pub 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}