Skip to main content

graphify_export/
html.rs

1//! Interactive vis.js HTML export.
2
3use 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
21/// Soft limit: above this we prune to top nodes (default, overridable via `max_nodes`).
22const DEFAULT_MAX_VIS_NODES: usize = 2000;
23
24/// Export an interactive HTML visualization of the knowledge graph.
25///
26/// For large graphs (> `max_nodes` nodes), automatically prunes to the most
27/// important nodes: highest-degree nodes plus community representatives.
28/// Pass `None` for `max_nodes` to use the default of 2000.
29pub 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
177/// Select the most important nodes for visualization when the graph is too large.
178///
179/// Strategy:
180/// 1. Include top N nodes by degree (hub nodes)
181/// 2. Include at least 1 representative from each community
182/// 3. Cap at `max_nodes`
183fn 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/// Export a split HTML visualization into `output_dir/html/`.
221///
222/// Generates:
223/// - `html/index.html` β€” overview page where each community is a single super-node,
224///   edges represent cross-community connections. Click a community to navigate.
225/// - `html/community_N.html` β€” detail page for community N with all its internal
226///   nodes and edges. Links back to index and to other communities.
227///
228/// Returns the path to the `html/` directory.
229pub 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
274/// Generate the overview index.html with communities as super-nodes.
275fn 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
435/// Generate a detail page for a single community.
436fn 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>"), "&lt;b&gt;hi&lt;/b&gt;");
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}