1use std::collections::{HashMap, HashSet};
4use std::fmt::Write as FmtWrite;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use graphify_core::confidence::Confidence;
9use graphify_core::graph::KnowledgeGraph;
10use tracing::{info, warn};
11
12#[path = "html_templates.rs"]
13mod html_templates;
14use html_templates::{build_html_template, escape_html, escape_js};
15
16const COMMUNITY_COLORS: &[&str] = &[
17 "#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F", "#EDC948", "#B07AA1", "#FF9DA7",
18 "#9C755F", "#BAB0AC",
19];
20
21const DEFAULT_MAX_VIS_NODES: usize = 2000;
23
24pub fn export_html(
30 graph: &KnowledgeGraph,
31 communities: &HashMap<usize, Vec<String>>,
32 community_labels: &HashMap<usize, String>,
33 output_dir: &Path,
34 max_nodes: Option<usize>,
35) -> anyhow::Result<PathBuf> {
36 let max_vis = max_nodes.unwrap_or(DEFAULT_MAX_VIS_NODES);
37 let total_nodes = graph.node_count();
38 let total_edges = graph.edge_count();
39
40 let (included_nodes, pruned) = if total_nodes > max_vis {
41 warn!(
42 total_nodes,
43 threshold = max_vis,
44 "graph too large for interactive viz, pruning to top {} nodes",
45 max_vis
46 );
47 (prune_nodes(graph, communities, max_vis), true)
48 } else {
49 (
50 graph.node_ids().into_iter().collect::<HashSet<String>>(),
51 false,
52 )
53 };
54
55 let node_community = graphify_core::build_node_to_community(communities);
56 let mut vis_nodes = String::from("[");
57 let mut first = true;
58 for node in graph.nodes() {
59 if !included_nodes.contains(&node.id) {
60 continue;
61 }
62 if !first {
63 vis_nodes.push(',');
64 }
65 first = false;
66 let cid = node
67 .community
68 .or_else(|| node_community.get(node.id.as_str()).copied());
69 let color = cid.map_or("#888888", |c| COMMUNITY_COLORS[c % COMMUNITY_COLORS.len()]);
70 let degree = graph.degree(&node.id);
71 let size = 8.0 + (degree as f64).sqrt() * 4.0;
72 let label_escaped = escape_js(&node.label);
73 let title_escaped = escape_js(&format!(
74 "{} ({})\nFile: {}\nType: {}\nDegree: {}",
75 node.label, node.id, node.source_file, node.node_type, degree
76 ));
77 write!(
78 vis_nodes,
79 r#"{{id:"{}",label:"{}",title:"{}",color:"{}",community:{},size:{:.1}}}"#,
80 escape_js(&node.id),
81 label_escaped,
82 title_escaped,
83 color,
84 cid.unwrap_or(0),
85 size,
86 )?;
87 }
88 vis_nodes.push(']');
89
90 let mut vis_edges = String::from("[");
91 first = true;
92 for edge in graph.edges() {
93 if !included_nodes.contains(&edge.source) || !included_nodes.contains(&edge.target) {
94 continue;
95 }
96 if !first {
97 vis_edges.push(',');
98 }
99 first = false;
100 let dashes = match edge.confidence {
101 Confidence::Extracted => "false",
102 Confidence::Inferred | Confidence::Ambiguous => "true",
103 };
104 let width = 1.0 + edge.confidence_score * 2.0;
105 let title_escaped = escape_js(&format!(
106 "{}: {} β {}\nConfidence: {} ({:.2})\nFile: {}",
107 edge.relation,
108 edge.source,
109 edge.target,
110 edge.confidence,
111 edge.confidence_score,
112 edge.source_file
113 ));
114 write!(
115 vis_edges,
116 r#"{{from:"{}",to:"{}",label:"{}",title:"{}",dashes:{},width:{:.1}}}"#,
117 escape_js(&edge.source),
118 escape_js(&edge.target),
119 escape_js(&edge.relation),
120 title_escaped,
121 dashes,
122 width,
123 )?;
124 }
125 vis_edges.push(']');
126
127 let mut legend_html = String::new();
128 for (&cid, label) in community_labels {
129 let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
130 write!(
131 legend_html,
132 r#"<div class="legend-item"><span class="legend-dot" style="background:{}"></span>{}</div>"#,
133 color,
134 escape_html(label),
135 )?;
136 }
137
138 let mut hyperedge_html = String::new();
139 for he in &graph.hyperedges {
140 write!(
141 hyperedge_html,
142 "<li><b>{}</b>: {} ({})</li>",
143 escape_html(&he.relation),
144 escape_html(&he.label),
145 he.nodes.join(", "),
146 )?;
147 }
148
149 let prune_banner = if pruned {
150 format!(
151 r#"<div id="prune-banner">Showing top {} of {} nodes ({} edges total). Only highest-degree nodes and community representatives are displayed.</div>"#,
152 included_nodes.len(),
153 total_nodes,
154 total_edges,
155 )
156 } else {
157 String::new()
158 };
159
160 let is_large = included_nodes.len() > 500;
161 let html = build_html_template(
162 &vis_nodes,
163 &vis_edges,
164 &legend_html,
165 &hyperedge_html,
166 &prune_banner,
167 is_large,
168 );
169
170 fs::create_dir_all(output_dir)?;
171 let path = output_dir.join("graph.html");
172 fs::write(&path, &html)?;
173 info!(path = %path.display(), nodes = included_nodes.len(), "exported interactive HTML visualization");
174 Ok(path)
175}
176
177fn prune_nodes(
184 graph: &KnowledgeGraph,
185 communities: &HashMap<usize, Vec<String>>,
186 max_nodes: usize,
187) -> HashSet<String> {
188 let mut included: HashSet<String> = HashSet::new();
189
190 let mut by_degree: Vec<(String, usize)> = graph
191 .node_ids()
192 .into_iter()
193 .map(|id| {
194 let deg = graph.degree(&id);
195 (id, deg)
196 })
197 .collect();
198 by_degree.sort_by_key(|b| std::cmp::Reverse(b.1));
199
200 let community_slots = communities.len().min(max_nodes / 4);
201 let degree_slots = max_nodes.saturating_sub(community_slots);
202
203 for (id, _) in by_degree.iter().take(degree_slots) {
204 included.insert(id.clone());
205 }
206
207 for members in communities.values() {
208 if included.len() >= max_nodes {
209 break;
210 }
211 let best = members.iter().max_by_key(|id| graph.degree(id)).cloned();
212 if let Some(id) = best {
213 included.insert(id);
214 }
215 }
216
217 included
218}
219
220pub fn export_html_split(
230 graph: &KnowledgeGraph,
231 communities: &HashMap<usize, Vec<String>>,
232 community_labels: &HashMap<usize, String>,
233 output_dir: &Path,
234) -> anyhow::Result<PathBuf> {
235 let html_dir = output_dir.join("html");
236 fs::create_dir_all(&html_dir)?;
237
238 let node_community = graphify_core::build_node_to_community(communities);
239 generate_overview(
240 &html_dir,
241 graph,
242 communities,
243 community_labels,
244 &node_community,
245 )?;
246
247 let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
248 sorted_cids.sort_unstable();
249 for &cid in &sorted_cids {
250 let members = &communities[&cid];
251 let label = community_labels
252 .get(&cid)
253 .cloned()
254 .unwrap_or_else(|| format!("Community {cid}"));
255 generate_community_page(
256 &html_dir,
257 graph,
258 cid,
259 &label,
260 members,
261 community_labels,
262 &node_community,
263 )?;
264 }
265
266 info!(
267 path = %html_dir.display(),
268 communities = communities.len(),
269 "exported split HTML visualization"
270 );
271 Ok(html_dir)
272}
273
274fn generate_overview(
276 html_dir: &Path,
277 graph: &KnowledgeGraph,
278 communities: &HashMap<usize, Vec<String>>,
279 community_labels: &HashMap<usize, String>,
280 node_community: &HashMap<&str, usize>,
281) -> anyhow::Result<()> {
282 let mut vis_nodes = String::from("[");
283 let mut first = true;
284 for (&cid, members) in communities {
285 if !first {
286 vis_nodes.push(',');
287 }
288 first = false;
289 let label = community_labels
290 .get(&cid)
291 .cloned()
292 .unwrap_or_else(|| format!("Community {cid}"));
293 let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
294 let size = 20.0 + (members.len() as f64).sqrt() * 5.0;
295 let title = format!(
296 "{} ({} nodes)\\nClick to view details",
297 label,
298 members.len()
299 );
300 write!(
301 vis_nodes,
302 r#"{{id:{cid},label:"{label} ({count})",title:"{title}",color:"{color}",size:{size:.1},url:"community_{cid}.html"}}"#,
303 cid = cid,
304 label = escape_js(&label),
305 count = members.len(),
306 title = escape_js(&title),
307 color = color,
308 size = size,
309 )?;
310 }
311 vis_nodes.push(']');
312
313 let mut cross_edges: HashMap<(usize, usize), usize> = HashMap::new();
314 for edge in graph.edges() {
315 let src_cid = node_community.get(edge.source.as_str()).copied();
316 let tgt_cid = node_community.get(edge.target.as_str()).copied();
317 if let (Some(sc), Some(tc)) = (src_cid, tgt_cid)
318 && sc != tc
319 {
320 let key = if sc < tc { (sc, tc) } else { (tc, sc) };
321 *cross_edges.entry(key).or_default() += 1;
322 }
323 }
324
325 let mut vis_edges = String::from("[");
326 first = true;
327 for ((from, to), count) in &cross_edges {
328 if !first {
329 vis_edges.push(',');
330 }
331 first = false;
332 let width = 1.0 + (*count as f64).sqrt();
333 write!(
334 vis_edges,
335 r#"{{from:{from},to:{to},label:"{count}",width:{width:.1},title:"{count} cross-community edges"}}"#,
336 )?;
337 }
338 vis_edges.push(']');
339
340 let mut nav_html = String::new();
341 let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
342 sorted_cids.sort_unstable();
343 for cid in &sorted_cids {
344 let label = community_labels
345 .get(cid)
346 .cloned()
347 .unwrap_or_else(|| format!("Community {cid}"));
348 let color = COMMUNITY_COLORS[*cid % COMMUNITY_COLORS.len()];
349 let count = communities[cid].len();
350 write!(
351 nav_html,
352 r#"<a href="community_{cid}.html" class="nav-link"><span class="legend-dot" style="background:{color}"></span>{label} ({count})</a>"#,
353 cid = cid,
354 color = color,
355 label = escape_html(&label),
356 count = count,
357 )?;
358 }
359
360 let html = format!(
361 r#"<!DOCTYPE html>
362<html lang="en">
363<head>
364<meta charset="utf-8">
365<meta name="viewport" content="width=device-width, initial-scale=1">
366<title>Knowledge Graph β Overview</title>
367<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
368<style>
369* {{ margin: 0; padding: 0; box-sizing: border-box; }}
370body {{ background: #0f0f1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; }}
371#sidebar {{ width: 320px; min-width: 320px; background: #1a1a2e; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; border-right: 1px solid #2a2a4a; }}
372#sidebar h2 {{ font-size: 18px; color: #76B7B2; margin-bottom: 4px; }}
373#sidebar h3 {{ font-size: 14px; color: #9ca3af; margin-bottom: 8px; }}
374.nav-link {{ display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 6px 8px; border-radius: 4px; color: #e0e0e0; text-decoration: none; }}
375.nav-link:hover {{ background: #2a2a4a; }}
376.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
377#graph-container {{ flex: 1; position: relative; }}
378#info {{ background: #0f0f1a; border-radius: 8px; padding: 12px; font-size: 13px; color: #9ca3af; }}
379</style>
380</head>
381<body>
382<div id="sidebar">
383 <div>
384 <h2>π§ Overview</h2>
385 <p style="font-size:12px;color:#666;">Each node is a community. Click to view details.</p>
386 </div>
387 <div id="info">{node_count} nodes, {edge_count} edges, {community_count} communities</div>
388 <div>
389 <h3>Communities</h3>
390 {nav}
391 </div>
392</div>
393<div id="graph-container"></div>
394<script>
395(function() {{
396 var nodesData = {nodes};
397 var edgesData = {edges};
398 var container = document.getElementById('graph-container');
399 var nodes = new vis.DataSet(nodesData);
400 var edges = new vis.DataSet(edgesData);
401 var options = {{
402 physics: {{
403 solver: 'forceAtlas2Based',
404 forceAtlas2Based: {{ gravitationalConstant: -100, centralGravity: 0.01, springLength: 200, springConstant: 0.05, damping: 0.4 }},
405 stabilization: {{ iterations: 100 }}
406 }},
407 nodes: {{ shape: 'dot', font: {{ color: '#e0e0e0', size: 14, multi: true }}, borderWidth: 2 }},
408 edges: {{ color: {{ color: '#4a4a6a' }}, font: {{ color: '#888', size: 12 }}, smooth: {{ type: 'continuous' }} }},
409 interaction: {{ hover: true, zoomView: true, dragView: true }}
410 }};
411 var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
412 network.on('stabilizationIterationsDone', function() {{ network.setOptions({{ physics: {{ enabled: false }} }}); }});
413 network.on('doubleClick', function(params) {{
414 if (params.nodes.length > 0) {{
415 var node = nodes.get(params.nodes[0]);
416 if (node && node.url) {{ window.location.href = node.url; }}
417 }}
418 }});
419}})();
420</script>
421</body>
422</html>"#,
423 nodes = vis_nodes,
424 edges = vis_edges,
425 nav = nav_html,
426 node_count = graph.node_count(),
427 edge_count = graph.edge_count(),
428 community_count = communities.len(),
429 );
430
431 fs::write(html_dir.join("index.html"), &html)?;
432 Ok(())
433}
434
435fn generate_community_page(
437 html_dir: &Path,
438 graph: &KnowledgeGraph,
439 cid: usize,
440 label: &str,
441 members: &[String],
442 community_labels: &HashMap<usize, String>,
443 node_community: &HashMap<&str, usize>,
444) -> anyhow::Result<()> {
445 let member_set: HashSet<&str> = members.iter().map(std::string::String::as_str).collect();
446 let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
447
448 let mut vis_nodes = String::from("[");
449 let mut first = true;
450 for node in graph.nodes() {
451 if !member_set.contains(node.id.as_str()) {
452 continue;
453 }
454 if !first {
455 vis_nodes.push(',');
456 }
457 first = false;
458 let degree = graph.degree(&node.id);
459 let size = 8.0 + (degree as f64).sqrt() * 4.0;
460 write!(
461 vis_nodes,
462 r#"{{id:"{}",label:"{}",title:"{}",color:"{}",size:{:.1}}}"#,
463 escape_js(&node.id),
464 escape_js(&node.label),
465 escape_js(&format!(
466 "{}\nType: {}\nFile: {}\nDegree: {}",
467 node.label, node.node_type, node.source_file, degree
468 )),
469 color,
470 size,
471 )?;
472 }
473 vis_nodes.push(']');
474
475 let mut vis_edges = String::from("[");
476 first = true;
477 for edge in graph.edges() {
478 if !member_set.contains(edge.source.as_str()) || !member_set.contains(edge.target.as_str())
479 {
480 continue;
481 }
482 if !first {
483 vis_edges.push(',');
484 }
485 first = false;
486 let dashes = match edge.confidence {
487 Confidence::Extracted => "false",
488 _ => "true",
489 };
490 write!(
491 vis_edges,
492 r#"{{from:"{}",to:"{}",label:"{}",dashes:{},title:"{}"}}"#,
493 escape_js(&edge.source),
494 escape_js(&edge.target),
495 escape_js(&edge.relation),
496 dashes,
497 escape_js(&format!(
498 "{}: {} β {}\nConfidence: {}",
499 edge.relation, edge.source, edge.target, edge.confidence
500 )),
501 )?;
502 }
503 vis_edges.push(']');
504
505 let mut external_links: HashMap<usize, usize> = HashMap::new();
506 for node_id in members {
507 for edge in graph.edges() {
508 let other = if edge.source == *node_id {
509 &edge.target
510 } else if edge.target == *node_id {
511 &edge.source
512 } else {
513 continue;
514 };
515 if let Some(&other_cid) = node_community.get(other.as_str())
516 && other_cid != cid
517 {
518 *external_links.entry(other_cid).or_default() += 1;
519 }
520 }
521 }
522
523 let mut nav_html = String::from(
524 r#"<a href="index.html" class="nav-link" style="font-weight:bold;">β Overview</a>"#,
525 );
526 let mut sorted_ext: Vec<(usize, usize)> = external_links.into_iter().collect();
527 sorted_ext.sort_by_key(|b| std::cmp::Reverse(b.1));
528 for (ext_cid, count) in &sorted_ext {
529 let ext_label = community_labels
530 .get(ext_cid)
531 .cloned()
532 .unwrap_or_else(|| format!("Community {ext_cid}"));
533 let ext_color = COMMUNITY_COLORS[*ext_cid % COMMUNITY_COLORS.len()];
534 write!(
535 nav_html,
536 r#"<a href="community_{cid}.html" class="nav-link"><span class="legend-dot" style="background:{color}"></span>{label} ({count} links)</a>"#,
537 cid = ext_cid,
538 color = ext_color,
539 label = escape_html(&ext_label),
540 count = count,
541 )?;
542 }
543
544 let is_large = members.len() > 500;
545 let physics = if is_large {
546 "solver:'barnesHut',barnesHut:{gravitationalConstant:-3000,springLength:95,damping:0.09},stabilization:{iterations:150}"
547 } else {
548 "solver:'forceAtlas2Based',forceAtlas2Based:{gravitationalConstant:-50,centralGravity:0.01,springLength:120,springConstant:0.08,damping:0.4,avoidOverlap:0.5},stabilization:{iterations:200}"
549 };
550 let edge_font = if is_large { 0 } else { 10 };
551
552 let html = format!(
553 r#"<!DOCTYPE html>
554<html lang="en">
555<head>
556<meta charset="utf-8">
557<meta name="viewport" content="width=device-width, initial-scale=1">
558<title>{title}</title>
559<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
560<style>
561* {{ margin: 0; padding: 0; box-sizing: border-box; }}
562body {{ background: #0f0f1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; }}
563#sidebar {{ width: 320px; min-width: 320px; background: #1a1a2e; padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; border-right: 1px solid #2a2a4a; }}
564#sidebar h2 {{ font-size: 18px; color: {color}; margin-bottom: 4px; }}
565#sidebar h3 {{ font-size: 14px; color: #9ca3af; margin-bottom: 8px; }}
566.nav-link {{ display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 6px 8px; border-radius: 4px; color: #e0e0e0; text-decoration: none; }}
567.nav-link:hover {{ background: #2a2a4a; }}
568.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
569#graph-container {{ flex: 1; position: relative; }}
570#search {{ width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid #3a3a5a; background: #0f0f1a; color: #e0e0e0; font-size: 14px; }}
571#search:focus {{ outline: none; border-color: {color}; }}
572#info-panel {{ background: #0f0f1a; border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; min-height: 100px; }}
573#info-panel .prop {{ color: #9ca3af; }}
574#info-panel .val {{ color: #e0e0e0; }}
575</style>
576</head>
577<body>
578<div id="sidebar">
579 <div>
580 <h2>{label}</h2>
581 <p style="font-size:12px;color:#666;">{node_count} nodes Β· Community {cid}</p>
582 </div>
583 <input id="search" type="text" placeholder="Search nodesβ¦" />
584 <div>
585 <h3>Node Info</h3>
586 <div id="info-panel"><i style="color:#666">Click a node to see details</i></div>
587 </div>
588 <div>
589 <h3>Navigation</h3>
590 {nav}
591 </div>
592</div>
593<div id="graph-container"></div>
594<script>
595(function() {{
596 var nodesData = {nodes};
597 var edgesData = {edges};
598 var container = document.getElementById('graph-container');
599 var nodes = new vis.DataSet(nodesData);
600 var edges = new vis.DataSet(edgesData);
601 var options = {{
602 physics: {{{physics}}},
603 nodes: {{ shape: 'dot', font: {{ color: '#e0e0e0', size: 12 }}, borderWidth: 2 }},
604 edges: {{ color: {{ color: '#4a4a6a', highlight: '{color}', hover: '{color}' }}, font: {{ color: '#888', size: {edge_font} }}, arrows: {{ to: {{ enabled: false }} }}, smooth: {{ type: 'continuous' }} }},
605 interaction: {{ hover: true, tooltipDelay: 200, zoomView: true, dragView: true }}
606 }};
607 var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
608 network.on('stabilizationIterationsDone', function() {{ network.setOptions({{ physics: {{ enabled: false }} }}); }});
609 network.on('click', function(params) {{
610 var panel = document.getElementById('info-panel');
611 if (params.nodes.length > 0) {{
612 var node = nodes.get(params.nodes[0]);
613 if (node) {{
614 panel.innerHTML = '<div><span class="prop">Label:</span> <span class="val">' + node.label + '</span></div><div><span class="prop">ID:</span> <span class="val">' + node.id + '</span></div>';
615 network.focus(params.nodes[0], {{ scale: 1.2, animation: true }});
616 }}
617 }}
618 }});
619 var searchEl = document.getElementById('search');
620 var sTimer = null;
621 searchEl.addEventListener('input', function() {{
622 clearTimeout(sTimer);
623 sTimer = setTimeout(function() {{
624 var term = searchEl.value.toLowerCase();
625 var updates = [];
626 nodes.forEach(function(n) {{
627 var h = term && !n.label.toLowerCase().includes(term);
628 if (n.hidden !== h) {{ updates.push({{ id: n.id, hidden: h }}); }}
629 }});
630 if (updates.length > 0) {{ nodes.update(updates); }}
631 }}, 200);
632 }});
633}})();
634</script>
635</body>
636</html>"#,
637 title = escape_html(&format!("{label} β Community {cid}")),
638 color = color,
639 label = escape_html(label),
640 cid = cid,
641 node_count = members.len(),
642 nodes = vis_nodes,
643 edges = vis_edges,
644 nav = nav_html,
645 physics = physics,
646 edge_font = edge_font,
647 );
648
649 fs::write(html_dir.join(format!("community_{cid}.html")), &html)?;
650 Ok(())
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use graphify_core::confidence::Confidence;
657 use graphify_core::graph::KnowledgeGraph;
658 use graphify_core::model::{GraphEdge, GraphNode, NodeType};
659
660 fn sample_graph() -> KnowledgeGraph {
661 let mut kg = KnowledgeGraph::new();
662 kg.add_node(GraphNode {
663 id: "a".into(),
664 label: "NodeA".into(),
665 source_file: "test.rs".into(),
666 source_location: None,
667 node_type: NodeType::Class,
668 community: Some(0),
669 extra: HashMap::new(),
670 })
671 .unwrap();
672 kg.add_node(GraphNode {
673 id: "b".into(),
674 label: "NodeB".into(),
675 source_file: "test.rs".into(),
676 source_location: None,
677 node_type: NodeType::Function,
678 community: Some(1),
679 extra: HashMap::new(),
680 })
681 .unwrap();
682 kg.add_edge(GraphEdge {
683 source: "a".into(),
684 target: "b".into(),
685 relation: "calls".into(),
686 confidence: Confidence::Inferred,
687 confidence_score: 0.7,
688 source_file: "test.rs".into(),
689 source_location: None,
690 weight: 1.0,
691 extra: HashMap::new(),
692 })
693 .unwrap();
694 kg
695 }
696
697 #[test]
698 fn export_html_creates_file() {
699 let dir = tempfile::tempdir().unwrap();
700 let kg = sample_graph();
701 let communities: HashMap<usize, Vec<String>> =
702 [(0, vec!["a".into()]), (1, vec!["b".into()])].into();
703 let labels: HashMap<usize, String> =
704 [(0, "Cluster A".into()), (1, "Cluster B".into())].into();
705
706 let path = export_html(&kg, &communities, &labels, dir.path(), None).unwrap();
707 assert!(path.exists());
708
709 let content = std::fs::read_to_string(&path).unwrap();
710 assert!(content.contains("vis-network"));
711 assert!(content.contains("NodeA"));
712 assert!(content.contains("forceAtlas2Based"));
713 }
714
715 #[test]
716 fn escape_js_special_chars() {
717 assert_eq!(escape_js("a\"b"), r#"a\"b"#);
718 assert_eq!(escape_js("a\nb"), r"a\nb");
719 }
720
721 #[test]
722 fn escape_html_special_chars() {
723 assert_eq!(escape_html("<b>hi</b>"), "<b>hi</b>");
724 }
725
726 #[test]
727 fn prune_nodes_caps_at_max() {
728 let mut kg = KnowledgeGraph::new();
729 for i in 0..100 {
730 kg.add_node(GraphNode {
731 id: format!("n{}", i),
732 label: format!("Node{}", i),
733 source_file: "test.rs".into(),
734 source_location: None,
735 node_type: NodeType::Function,
736 community: Some(i % 3),
737 extra: HashMap::new(),
738 })
739 .unwrap();
740 }
741 for i in 0..50 {
742 let _ = kg.add_edge(GraphEdge {
743 source: "n0".into(),
744 target: format!("n{}", i + 1),
745 relation: "calls".into(),
746 confidence: Confidence::Extracted,
747 confidence_score: 1.0,
748 source_file: "test.rs".into(),
749 source_location: None,
750 weight: 1.0,
751 extra: HashMap::new(),
752 });
753 }
754
755 let communities: HashMap<usize, Vec<String>> = HashMap::from([
756 (0, (0..34).map(|i| format!("n{}", i)).collect()),
757 (1, (34..67).map(|i| format!("n{}", i)).collect()),
758 (2, (67..100).map(|i| format!("n{}", i)).collect()),
759 ]);
760
761 let pruned = prune_nodes(&kg, &communities, 20);
762 assert!(pruned.len() <= 20, "should cap at 20, got {}", pruned.len());
763 assert!(
764 pruned.contains("n0"),
765 "highest-degree node should be included"
766 );
767 }
768
769 #[test]
770 fn export_html_split_creates_files() {
771 let dir = tempfile::tempdir().unwrap();
772 let kg = sample_graph();
773 let communities: HashMap<usize, Vec<String>> =
774 [(0, vec!["a".into()]), (1, vec!["b".into()])].into();
775 let labels: HashMap<usize, String> =
776 [(0, "Cluster A".into()), (1, "Cluster B".into())].into();
777
778 let path = export_html_split(&kg, &communities, &labels, dir.path()).unwrap();
779 assert!(path.exists());
780 assert!(path.join("index.html").exists(), "index.html should exist");
781 assert!(
782 path.join("community_0.html").exists(),
783 "community_0.html should exist"
784 );
785 assert!(
786 path.join("community_1.html").exists(),
787 "community_1.html should exist"
788 );
789
790 let index = std::fs::read_to_string(path.join("index.html")).unwrap();
791 assert!(index.contains("Overview"));
792 assert!(index.contains("Cluster A"));
793 assert!(index.contains("community_0.html"));
794
795 let c0 = std::fs::read_to_string(path.join("community_0.html")).unwrap();
796 assert!(c0.contains("Cluster A"));
797 assert!(c0.contains("index.html"));
798 }
799
800 #[test]
801 fn export_html_respects_max_nodes() -> anyhow::Result<()> {
802 let mut kg = KnowledgeGraph::new();
803 for i in 0..10 {
804 kg.add_node(GraphNode {
805 id: format!("n{i}"),
806 label: format!("Node{i}"),
807 source_file: "test.rs".into(),
808 source_location: None,
809 node_type: NodeType::Function,
810 community: Some(0),
811 extra: HashMap::new(),
812 })
813 .unwrap();
814 }
815 for i in 1..10 {
816 let _ = kg.add_edge(GraphEdge {
817 source: "n0".into(),
818 target: format!("n{i}"),
819 relation: "calls".into(),
820 confidence: Confidence::Extracted,
821 confidence_score: 1.0,
822 source_file: "test.rs".into(),
823 source_location: None,
824 weight: 1.0,
825 extra: HashMap::new(),
826 });
827 }
828
829 let communities: HashMap<usize, Vec<String>> =
830 [(0, (0..10).map(|i| format!("n{i}")).collect())].into();
831 let labels: HashMap<usize, String> = [(0, "All".into())].into();
832 let dir = tempfile::tempdir().unwrap();
833
834 let path = export_html(&kg, &communities, &labels, dir.path(), Some(5)).unwrap();
835 assert!(path.exists());
836 let html = std::fs::read_to_string(&path).unwrap();
837 assert!(html.contains("Node0"));
838 assert!(
839 html.contains("pruned") || html.contains("Showing"),
840 "should indicate pruning occurred"
841 );
842 Ok(())
843 }
844}