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
220
221pub fn export_html_split(
231 graph: &KnowledgeGraph,
232 communities: &HashMap<usize, Vec<String>>,
233 community_labels: &HashMap<usize, String>,
234 output_dir: &Path,
235) -> anyhow::Result<PathBuf> {
236 let html_dir = output_dir.join("html");
237 fs::create_dir_all(&html_dir)?;
238
239 let node_community = graphify_core::build_node_to_community(communities);
240 generate_overview(
241 &html_dir,
242 graph,
243 communities,
244 community_labels,
245 &node_community,
246 )?;
247
248 let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
249 sorted_cids.sort_unstable();
250 for &cid in &sorted_cids {
251 let members = &communities[&cid];
252 let label = community_labels
253 .get(&cid)
254 .cloned()
255 .unwrap_or_else(|| format!("Community {cid}"));
256 generate_community_page(
257 &html_dir,
258 graph,
259 cid,
260 &label,
261 members,
262 community_labels,
263 &node_community,
264 )?;
265 }
266
267 info!(
268 path = %html_dir.display(),
269 communities = communities.len(),
270 "exported split HTML visualization"
271 );
272 Ok(html_dir)
273}
274
275fn generate_overview(
277 html_dir: &Path,
278 graph: &KnowledgeGraph,
279 communities: &HashMap<usize, Vec<String>>,
280 community_labels: &HashMap<usize, String>,
281 node_community: &HashMap<&str, usize>,
282) -> anyhow::Result<()> {
283 let mut vis_nodes = String::from("[");
284 let mut first = true;
285 for (&cid, members) in communities {
286 if !first {
287 vis_nodes.push(',');
288 }
289 first = false;
290 let label = community_labels
291 .get(&cid)
292 .cloned()
293 .unwrap_or_else(|| format!("Community {cid}"));
294 let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
295 let size = 20.0 + (members.len() as f64).sqrt() * 5.0;
296 let title = format!(
297 "{} ({} nodes)\\nClick to view details",
298 label,
299 members.len()
300 );
301 write!(
302 vis_nodes,
303 r#"{{id:{cid},label:"{label} ({count})",title:"{title}",color:"{color}",size:{size:.1},url:"community_{cid}.html"}}"#,
304 cid = cid,
305 label = escape_js(&label),
306 count = members.len(),
307 title = escape_js(&title),
308 color = color,
309 size = size,
310 )?;
311 }
312 vis_nodes.push(']');
313
314 let mut cross_edges: HashMap<(usize, usize), usize> = HashMap::new();
315 for edge in graph.edges() {
316 let src_cid = node_community.get(edge.source.as_str()).copied();
317 let tgt_cid = node_community.get(edge.target.as_str()).copied();
318 if let (Some(sc), Some(tc)) = (src_cid, tgt_cid)
319 && sc != tc
320 {
321 let key = if sc < tc { (sc, tc) } else { (tc, sc) };
322 *cross_edges.entry(key).or_default() += 1;
323 }
324 }
325
326 let mut vis_edges = String::from("[");
327 first = true;
328 for ((from, to), count) in &cross_edges {
329 if !first {
330 vis_edges.push(',');
331 }
332 first = false;
333 let width = 1.0 + (*count as f64).sqrt();
334 write!(
335 vis_edges,
336 r#"{{from:{from},to:{to},label:"{count}",width:{width:.1},title:"{count} cross-community edges"}}"#,
337 )?;
338 }
339 vis_edges.push(']');
340
341 let mut nav_html = String::new();
342 let mut sorted_cids: Vec<usize> = communities.keys().copied().collect();
343 sorted_cids.sort_unstable();
344 for cid in &sorted_cids {
345 let label = community_labels
346 .get(cid)
347 .cloned()
348 .unwrap_or_else(|| format!("Community {cid}"));
349 let color = COMMUNITY_COLORS[*cid % COMMUNITY_COLORS.len()];
350 let count = communities[cid].len();
351 write!(
352 nav_html,
353 r#"<a href="community_{cid}.html" class="nav-link"><span class="legend-dot" style="background:{color}"></span>{label} ({count})</a>"#,
354 cid = cid,
355 color = color,
356 label = escape_html(&label),
357 count = count,
358 )?;
359 }
360
361 let html = format!(
362 r#"<!DOCTYPE html>
363<html lang="en">
364<head>
365<meta charset="utf-8">
366<meta name="viewport" content="width=device-width, initial-scale=1">
367<title>Knowledge Graph β Overview</title>
368<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
369<style>
370* {{ margin: 0; padding: 0; box-sizing: border-box; }}
371body {{ background: #0f0f1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; }}
372#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; }}
373#sidebar h2 {{ font-size: 18px; color: #76B7B2; margin-bottom: 4px; }}
374#sidebar h3 {{ font-size: 14px; color: #9ca3af; margin-bottom: 8px; }}
375.nav-link {{ display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 6px 8px; border-radius: 4px; color: #e0e0e0; text-decoration: none; }}
376.nav-link:hover {{ background: #2a2a4a; }}
377.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
378#graph-container {{ flex: 1; position: relative; }}
379#info {{ background: #0f0f1a; border-radius: 8px; padding: 12px; font-size: 13px; color: #9ca3af; }}
380</style>
381</head>
382<body>
383<div id="sidebar">
384 <div>
385 <h2>π§ Overview</h2>
386 <p style="font-size:12px;color:#666;">Each node is a community. Click to view details.</p>
387 </div>
388 <div id="info">{node_count} nodes, {edge_count} edges, {community_count} communities</div>
389 <div>
390 <h3>Communities</h3>
391 {nav}
392 </div>
393</div>
394<div id="graph-container"></div>
395<script>
396(function() {{
397 var nodesData = {nodes};
398 var edgesData = {edges};
399 var container = document.getElementById('graph-container');
400 var nodes = new vis.DataSet(nodesData);
401 var edges = new vis.DataSet(edgesData);
402 var options = {{
403 physics: {{
404 solver: 'forceAtlas2Based',
405 forceAtlas2Based: {{ gravitationalConstant: -100, centralGravity: 0.01, springLength: 200, springConstant: 0.05, damping: 0.4 }},
406 stabilization: {{ iterations: 100 }}
407 }},
408 nodes: {{ shape: 'dot', font: {{ color: '#e0e0e0', size: 14, multi: true }}, borderWidth: 2 }},
409 edges: {{ color: {{ color: '#4a4a6a' }}, font: {{ color: '#888', size: 12 }}, smooth: {{ type: 'continuous' }} }},
410 interaction: {{ hover: true, zoomView: true, dragView: true }}
411 }};
412 var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
413 network.on('stabilizationIterationsDone', function() {{ network.setOptions({{ physics: {{ enabled: false }} }}); }});
414 network.on('doubleClick', function(params) {{
415 if (params.nodes.length > 0) {{
416 var node = nodes.get(params.nodes[0]);
417 if (node && node.url) {{ window.location.href = node.url; }}
418 }}
419 }});
420}})();
421</script>
422</body>
423</html>"#,
424 nodes = vis_nodes,
425 edges = vis_edges,
426 nav = nav_html,
427 node_count = graph.node_count(),
428 edge_count = graph.edge_count(),
429 community_count = communities.len(),
430 );
431
432 fs::write(html_dir.join("index.html"), &html)?;
433 Ok(())
434}
435
436fn generate_community_page(
438 html_dir: &Path,
439 graph: &KnowledgeGraph,
440 cid: usize,
441 label: &str,
442 members: &[String],
443 community_labels: &HashMap<usize, String>,
444 node_community: &HashMap<&str, usize>,
445) -> anyhow::Result<()> {
446 let member_set: HashSet<&str> = members.iter().map(std::string::String::as_str).collect();
447 let color = COMMUNITY_COLORS[cid % COMMUNITY_COLORS.len()];
448
449 let mut vis_nodes = String::from("[");
450 let mut first = true;
451 for node in graph.nodes() {
452 if !member_set.contains(node.id.as_str()) {
453 continue;
454 }
455 if !first {
456 vis_nodes.push(',');
457 }
458 first = false;
459 let degree = graph.degree(&node.id);
460 let size = 8.0 + (degree as f64).sqrt() * 4.0;
461 write!(
462 vis_nodes,
463 r#"{{id:"{}",label:"{}",title:"{}",color:"{}",size:{:.1}}}"#,
464 escape_js(&node.id),
465 escape_js(&node.label),
466 escape_js(&format!(
467 "{}\nType: {}\nFile: {}\nDegree: {}",
468 node.label, node.node_type, node.source_file, degree
469 )),
470 color,
471 size,
472 )?;
473 }
474 vis_nodes.push(']');
475
476 let mut vis_edges = String::from("[");
477 first = true;
478 for edge in graph.edges() {
479 if !member_set.contains(edge.source.as_str()) || !member_set.contains(edge.target.as_str())
480 {
481 continue;
482 }
483 if !first {
484 vis_edges.push(',');
485 }
486 first = false;
487 let dashes = match edge.confidence {
488 Confidence::Extracted => "false",
489 _ => "true",
490 };
491 write!(
492 vis_edges,
493 r#"{{from:"{}",to:"{}",label:"{}",dashes:{},title:"{}"}}"#,
494 escape_js(&edge.source),
495 escape_js(&edge.target),
496 escape_js(&edge.relation),
497 dashes,
498 escape_js(&format!(
499 "{}: {} β {}\nConfidence: {}",
500 edge.relation, edge.source, edge.target, edge.confidence
501 )),
502 )?;
503 }
504 vis_edges.push(']');
505
506 let mut external_links: HashMap<usize, usize> = HashMap::new();
507 for node_id in members {
508 for edge in graph.edges() {
509 let other = if edge.source == *node_id {
510 &edge.target
511 } else if edge.target == *node_id {
512 &edge.source
513 } else {
514 continue;
515 };
516 if let Some(&other_cid) = node_community.get(other.as_str())
517 && other_cid != cid
518 {
519 *external_links.entry(other_cid).or_default() += 1;
520 }
521 }
522 }
523
524 let mut nav_html = String::from(
525 r#"<a href="index.html" class="nav-link" style="font-weight:bold;">β Overview</a>"#,
526 );
527 let mut sorted_ext: Vec<(usize, usize)> = external_links.into_iter().collect();
528 sorted_ext.sort_by_key(|b| std::cmp::Reverse(b.1));
529 for (ext_cid, count) in &sorted_ext {
530 let ext_label = community_labels
531 .get(ext_cid)
532 .cloned()
533 .unwrap_or_else(|| format!("Community {ext_cid}"));
534 let ext_color = COMMUNITY_COLORS[*ext_cid % COMMUNITY_COLORS.len()];
535 write!(
536 nav_html,
537 r#"<a href="community_{cid}.html" class="nav-link"><span class="legend-dot" style="background:{color}"></span>{label} ({count} links)</a>"#,
538 cid = ext_cid,
539 color = ext_color,
540 label = escape_html(&ext_label),
541 count = count,
542 )?;
543 }
544
545 let is_large = members.len() > 500;
546 let physics = if is_large {
547 "solver:'barnesHut',barnesHut:{gravitationalConstant:-3000,springLength:95,damping:0.09},stabilization:{iterations:150}"
548 } else {
549 "solver:'forceAtlas2Based',forceAtlas2Based:{gravitationalConstant:-50,centralGravity:0.01,springLength:120,springConstant:0.08,damping:0.4,avoidOverlap:0.5},stabilization:{iterations:200}"
550 };
551 let edge_font = if is_large { 0 } else { 10 };
552
553 let html = format!(
554 r#"<!DOCTYPE html>
555<html lang="en">
556<head>
557<meta charset="utf-8">
558<meta name="viewport" content="width=device-width, initial-scale=1">
559<title>{title}</title>
560<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
561<style>
562* {{ margin: 0; padding: 0; box-sizing: border-box; }}
563body {{ background: #0f0f1a; color: #e0e0e0; font-family: 'Segoe UI', system-ui, sans-serif; display: flex; height: 100vh; overflow: hidden; }}
564#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; }}
565#sidebar h2 {{ font-size: 18px; color: {color}; margin-bottom: 4px; }}
566#sidebar h3 {{ font-size: 14px; color: #9ca3af; margin-bottom: 8px; }}
567.nav-link {{ display: flex; align-items: center; gap: 8px; font-size: 13px; padding: 6px 8px; border-radius: 4px; color: #e0e0e0; text-decoration: none; }}
568.nav-link:hover {{ background: #2a2a4a; }}
569.legend-dot {{ width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }}
570#graph-container {{ flex: 1; position: relative; }}
571#search {{ width: 100%; padding: 8px 12px; border-radius: 6px; border: 1px solid #3a3a5a; background: #0f0f1a; color: #e0e0e0; font-size: 14px; }}
572#search:focus {{ outline: none; border-color: {color}; }}
573#info-panel {{ background: #0f0f1a; border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.6; min-height: 100px; }}
574#info-panel .prop {{ color: #9ca3af; }}
575#info-panel .val {{ color: #e0e0e0; }}
576</style>
577</head>
578<body>
579<div id="sidebar">
580 <div>
581 <h2>{label}</h2>
582 <p style="font-size:12px;color:#666;">{node_count} nodes Β· Community {cid}</p>
583 </div>
584 <input id="search" type="text" placeholder="Search nodesβ¦" />
585 <div>
586 <h3>Node Info</h3>
587 <div id="info-panel"><i style="color:#666">Click a node to see details</i></div>
588 </div>
589 <div>
590 <h3>Navigation</h3>
591 {nav}
592 </div>
593</div>
594<div id="graph-container"></div>
595<script>
596(function() {{
597 var nodesData = {nodes};
598 var edgesData = {edges};
599 var container = document.getElementById('graph-container');
600 var nodes = new vis.DataSet(nodesData);
601 var edges = new vis.DataSet(edgesData);
602 var options = {{
603 physics: {{{physics}}},
604 nodes: {{ shape: 'dot', font: {{ color: '#e0e0e0', size: 12 }}, borderWidth: 2 }},
605 edges: {{ color: {{ color: '#4a4a6a', highlight: '{color}', hover: '{color}' }}, font: {{ color: '#888', size: {edge_font} }}, arrows: {{ to: {{ enabled: false }} }}, smooth: {{ type: 'continuous' }} }},
606 interaction: {{ hover: true, tooltipDelay: 200, zoomView: true, dragView: true }}
607 }};
608 var network = new vis.Network(container, {{ nodes: nodes, edges: edges }}, options);
609 network.on('stabilizationIterationsDone', function() {{ network.setOptions({{ physics: {{ enabled: false }} }}); }});
610 network.on('click', function(params) {{
611 var panel = document.getElementById('info-panel');
612 if (params.nodes.length > 0) {{
613 var node = nodes.get(params.nodes[0]);
614 if (node) {{
615 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>';
616 network.focus(params.nodes[0], {{ scale: 1.2, animation: true }});
617 }}
618 }}
619 }});
620 var searchEl = document.getElementById('search');
621 var sTimer = null;
622 searchEl.addEventListener('input', function() {{
623 clearTimeout(sTimer);
624 sTimer = setTimeout(function() {{
625 var term = searchEl.value.toLowerCase();
626 var updates = [];
627 nodes.forEach(function(n) {{
628 var h = term && !n.label.toLowerCase().includes(term);
629 if (n.hidden !== h) {{ updates.push({{ id: n.id, hidden: h }}); }}
630 }});
631 if (updates.length > 0) {{ nodes.update(updates); }}
632 }}, 200);
633 }});
634}})();
635</script>
636</body>
637</html>"#,
638 title = escape_html(&format!("{label} β Community {cid}")),
639 color = color,
640 label = escape_html(label),
641 cid = cid,
642 node_count = members.len(),
643 nodes = vis_nodes,
644 edges = vis_edges,
645 nav = nav_html,
646 physics = physics,
647 edge_font = edge_font,
648 );
649
650 fs::write(html_dir.join(format!("community_{cid}.html")), &html)?;
651 Ok(())
652}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657 use graphify_core::confidence::Confidence;
658 use graphify_core::graph::KnowledgeGraph;
659 use graphify_core::model::{GraphEdge, GraphNode, NodeType};
660
661 fn sample_graph() -> KnowledgeGraph {
662 let mut kg = KnowledgeGraph::new();
663 kg.add_node(GraphNode {
664 id: "a".into(),
665 label: "NodeA".into(),
666 source_file: "test.rs".into(),
667 source_location: None,
668 node_type: NodeType::Class,
669 community: Some(0),
670 extra: HashMap::new(),
671 })
672 .unwrap();
673 kg.add_node(GraphNode {
674 id: "b".into(),
675 label: "NodeB".into(),
676 source_file: "test.rs".into(),
677 source_location: None,
678 node_type: NodeType::Function,
679 community: Some(1),
680 extra: HashMap::new(),
681 })
682 .unwrap();
683 kg.add_edge(GraphEdge {
684 source: "a".into(),
685 target: "b".into(),
686 relation: "calls".into(),
687 confidence: Confidence::Inferred,
688 confidence_score: 0.7,
689 source_file: "test.rs".into(),
690 source_location: None,
691 weight: 1.0,
692 extra: HashMap::new(),
693 })
694 .unwrap();
695 kg
696 }
697
698 #[test]
699 fn export_html_creates_file() {
700 let dir = tempfile::tempdir().unwrap();
701 let kg = sample_graph();
702 let communities: HashMap<usize, Vec<String>> =
703 [(0, vec!["a".into()]), (1, vec!["b".into()])].into();
704 let labels: HashMap<usize, String> =
705 [(0, "Cluster A".into()), (1, "Cluster B".into())].into();
706
707 let path = export_html(&kg, &communities, &labels, dir.path(), None).unwrap();
708 assert!(path.exists());
709
710 let content = std::fs::read_to_string(&path).unwrap();
711 assert!(content.contains("vis-network"));
712 assert!(content.contains("NodeA"));
713 assert!(content.contains("forceAtlas2Based"));
714 }
715
716 #[test]
717 fn escape_js_special_chars() {
718 assert_eq!(escape_js("a\"b"), r#"a\"b"#);
719 assert_eq!(escape_js("a\nb"), r"a\nb");
720 }
721
722 #[test]
723 fn escape_html_special_chars() {
724 assert_eq!(escape_html("<b>hi</b>"), "<b>hi</b>");
725 }
726
727 #[test]
728 fn prune_nodes_caps_at_max() {
729 let mut kg = KnowledgeGraph::new();
730 for i in 0..100 {
731 kg.add_node(GraphNode {
732 id: format!("n{}", i),
733 label: format!("Node{}", i),
734 source_file: "test.rs".into(),
735 source_location: None,
736 node_type: NodeType::Function,
737 community: Some(i % 3),
738 extra: HashMap::new(),
739 })
740 .unwrap();
741 }
742 for i in 0..50 {
743 let _ = kg.add_edge(GraphEdge {
744 source: "n0".into(),
745 target: format!("n{}", i + 1),
746 relation: "calls".into(),
747 confidence: Confidence::Extracted,
748 confidence_score: 1.0,
749 source_file: "test.rs".into(),
750 source_location: None,
751 weight: 1.0,
752 extra: HashMap::new(),
753 });
754 }
755
756 let communities: HashMap<usize, Vec<String>> = HashMap::from([
757 (0, (0..34).map(|i| format!("n{}", i)).collect()),
758 (1, (34..67).map(|i| format!("n{}", i)).collect()),
759 (2, (67..100).map(|i| format!("n{}", i)).collect()),
760 ]);
761
762 let pruned = prune_nodes(&kg, &communities, 20);
763 assert!(pruned.len() <= 20, "should cap at 20, got {}", pruned.len());
764 assert!(
765 pruned.contains("n0"),
766 "highest-degree node should be included"
767 );
768 }
769
770 #[test]
771 fn export_html_split_creates_files() {
772 let dir = tempfile::tempdir().unwrap();
773 let kg = sample_graph();
774 let communities: HashMap<usize, Vec<String>> =
775 [(0, vec!["a".into()]), (1, vec!["b".into()])].into();
776 let labels: HashMap<usize, String> =
777 [(0, "Cluster A".into()), (1, "Cluster B".into())].into();
778
779 let path = export_html_split(&kg, &communities, &labels, dir.path()).unwrap();
780 assert!(path.exists());
781 assert!(path.join("index.html").exists(), "index.html should exist");
782 assert!(
783 path.join("community_0.html").exists(),
784 "community_0.html should exist"
785 );
786 assert!(
787 path.join("community_1.html").exists(),
788 "community_1.html should exist"
789 );
790
791 let index = std::fs::read_to_string(path.join("index.html")).unwrap();
792 assert!(index.contains("Overview"));
793 assert!(index.contains("Cluster A"));
794 assert!(index.contains("community_0.html"));
795
796 let c0 = std::fs::read_to_string(path.join("community_0.html")).unwrap();
797 assert!(c0.contains("Cluster A"));
798 assert!(c0.contains("index.html"));
799 }
800
801 #[test]
802 fn export_html_respects_max_nodes() -> anyhow::Result<()> {
803 let mut kg = KnowledgeGraph::new();
804 for i in 0..10 {
805 kg.add_node(GraphNode {
806 id: format!("n{i}"),
807 label: format!("Node{i}"),
808 source_file: "test.rs".into(),
809 source_location: None,
810 node_type: NodeType::Function,
811 community: Some(0),
812 extra: HashMap::new(),
813 })
814 .unwrap();
815 }
816 for i in 1..10 {
817 let _ = kg.add_edge(GraphEdge {
818 source: "n0".into(),
819 target: format!("n{i}"),
820 relation: "calls".into(),
821 confidence: Confidence::Extracted,
822 confidence_score: 1.0,
823 source_file: "test.rs".into(),
824 source_location: None,
825 weight: 1.0,
826 extra: HashMap::new(),
827 });
828 }
829
830 let communities: HashMap<usize, Vec<String>> =
831 [(0, (0..10).map(|i| format!("n{i}")).collect())].into();
832 let labels: HashMap<usize, String> = [(0, "All".into())].into();
833 let dir = tempfile::tempdir().unwrap();
834
835 let path = export_html(&kg, &communities, &labels, dir.path(), Some(5)).unwrap();
836 assert!(path.exists());
837 let html = std::fs::read_to_string(&path).unwrap();
838 assert!(html.contains("Node0"));
839 assert!(
840 html.contains("pruned") || html.contains("Showing"),
841 "should indicate pruning occurred"
842 );
843 Ok(())
844 }
845}