1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::{anyhow, Context, Result};
6use serde::Serialize;
7
8use crate::core::graph_provider::{self, GraphProvider};
9
10#[derive(Debug, Clone, Serialize)]
11#[serde(rename_all = "camelCase")]
12struct ExportNode {
13 id: usize,
14 path: String,
15 label: String,
16 language: String,
17 summary: String,
18 exports: Vec<String>,
19 token_count: usize,
20 line_count: usize,
21 degree: usize,
22}
23
24#[derive(Debug, Clone, Serialize)]
25#[serde(rename_all = "camelCase")]
26struct ExportEdge {
27 source: usize,
28 target: usize,
29 kind: String,
30}
31
32#[derive(Debug, Clone, Serialize)]
33#[serde(rename_all = "camelCase")]
34struct ExportGraph {
35 project_root: String,
36 generated_at_unix_ms: u128,
37 nodes: Vec<ExportNode>,
38 edges: Vec<ExportEdge>,
39 truncated: bool,
40 original_node_count: usize,
41 original_edge_count: usize,
42}
43
44fn now_unix_ms() -> u128 {
45 SystemTime::now()
46 .duration_since(UNIX_EPOCH)
47 .unwrap_or_default()
48 .as_millis()
49}
50
51fn escape_for_script_tag(json: &str) -> String {
52 json.replace("</script", "<\\/script")
54 .replace("<!--", "<\\!--")
55}
56
57fn select_nodes(gp: &GraphProvider, max_nodes: usize) -> Vec<String> {
58 let paths = gp.file_paths();
59 if paths.len() <= max_nodes {
60 return paths;
61 }
62
63 let edges = gp.edges();
64 let mut degree: HashMap<&str, usize> = HashMap::new();
65 for e in &edges {
66 *degree.entry(e.from.as_str()).or_insert(0) += 1;
67 *degree.entry(e.to.as_str()).or_insert(0) += 1;
68 }
69
70 let mut scored: Vec<(String, usize, usize)> = paths
71 .into_iter()
72 .map(|p| {
73 let d = degree.get(p.as_str()).copied().unwrap_or(0);
74 let tok = gp.get_file_entry(&p).map_or(0, |f| f.token_count);
75 (p, d, tok)
76 })
77 .collect();
78
79 scored.sort_by(|(pa, da, ta), (pb, db, tb)| {
80 db.cmp(da).then_with(|| tb.cmp(ta)).then_with(|| pa.cmp(pb))
81 });
82
83 scored
84 .into_iter()
85 .take(max_nodes)
86 .map(|(p, _, _)| p)
87 .collect()
88}
89
90fn file_label(path: &str) -> String {
91 Path::new(path)
92 .file_name()
93 .and_then(|s| s.to_str())
94 .unwrap_or(path)
95 .to_string()
96}
97
98fn ext_language(path: &str) -> String {
101 Path::new(path)
102 .extension()
103 .and_then(|e| e.to_str())
104 .unwrap_or("")
105 .to_string()
106}
107
108fn add_phantom_endpoints(
114 gp: &GraphProvider,
115 all_edges: &[graph_provider::EdgeInfo],
116 max_nodes: usize,
117 node_paths: &mut Vec<String>,
118 node_set: &mut HashSet<String>,
119) {
120 let budget = max_nodes.saturating_sub(node_set.len());
121 if budget == 0 {
122 return;
123 }
124
125 let mut phantoms: Vec<&str> = Vec::new();
126 let mut seen: HashSet<&str> = HashSet::new();
127 for e in all_edges {
128 for (endpoint, counterpart) in [
129 (e.to.as_str(), e.from.as_str()),
130 (e.from.as_str(), e.to.as_str()),
131 ] {
132 if node_set.contains(endpoint)
136 || !node_set.contains(counterpart)
137 || gp.get_file_entry(endpoint).is_some()
138 {
139 continue;
140 }
141 if seen.insert(endpoint) {
142 phantoms.push(endpoint);
143 }
144 }
145 }
146
147 phantoms.sort_unstable();
148 for p in phantoms.into_iter().take(budget) {
149 node_set.insert(p.to_string());
150 node_paths.push(p.to_string());
151 }
152}
153
154fn build_export_graph(gp: &GraphProvider, project_root: &str, max_nodes: usize) -> ExportGraph {
155 let original_node_count = gp.file_count();
156 let all_edges = gp.edges();
157 let original_edge_count = all_edges.len();
158
159 let mut node_paths = select_nodes(gp, max_nodes);
160 let mut node_set: HashSet<String> = node_paths.iter().cloned().collect();
161 add_phantom_endpoints(gp, &all_edges, max_nodes, &mut node_paths, &mut node_set);
162
163 let mut degree: HashMap<&str, usize> = HashMap::new();
164 for e in &all_edges {
165 if node_set.contains(e.from.as_str()) && node_set.contains(e.to.as_str()) {
166 *degree.entry(e.from.as_str()).or_insert(0) += 1;
167 *degree.entry(e.to.as_str()).or_insert(0) += 1;
168 }
169 }
170
171 let mut nodes: Vec<ExportNode> = Vec::with_capacity(node_paths.len());
174 let mut id_by_path: HashMap<&str, usize> = HashMap::new();
175 for path in &node_paths {
176 let id = nodes.len();
177 let degree_val = degree.get(path.as_str()).copied().unwrap_or(0);
178 let node = match gp.get_file_entry(path) {
179 Some(f) => ExportNode {
180 id,
181 path: f.path.clone(),
182 label: file_label(&f.path),
183 language: f.language,
184 summary: f.summary,
185 exports: f.exports,
186 token_count: f.token_count,
187 line_count: f.line_count,
188 degree: degree_val,
189 },
190 None => ExportNode {
193 id,
194 path: path.clone(),
195 label: file_label(path),
196 language: ext_language(path),
197 summary: String::new(),
198 exports: Vec::new(),
199 token_count: 0,
200 line_count: 0,
201 degree: degree_val,
202 },
203 };
204 id_by_path.insert(path.as_str(), id);
205 nodes.push(node);
206 }
207
208 let mut edges: Vec<ExportEdge> = Vec::new();
209 for e in &all_edges {
210 let Some(&s) = id_by_path.get(e.from.as_str()) else {
211 continue;
212 };
213 let Some(&t) = id_by_path.get(e.to.as_str()) else {
214 continue;
215 };
216 edges.push(ExportEdge {
217 source: s,
218 target: t,
219 kind: e.kind.clone(),
220 });
221 }
222
223 ExportGraph {
224 project_root: project_root.to_string(),
225 generated_at_unix_ms: now_unix_ms(),
226 nodes,
227 edges,
228 truncated: original_node_count > max_nodes,
229 original_node_count,
230 original_edge_count,
231 }
232}
233
234fn render_html(graph: &ExportGraph) -> Result<String> {
235 let json = serde_json::to_string(graph).context("serialize graph export")?;
236 let json = escape_for_script_tag(&json);
237
238 Ok(format!(
239 r#"<!doctype html>
240<html lang="en">
241<head>
242 <meta charset="utf-8" />
243 <meta name="viewport" content="width=device-width, initial-scale=1" />
244 <title>lean-ctx graph export</title>
245 <style>
246 :root {{
247 --bg: #0b1220;
248 --panel: #0f172a;
249 --panel2: #111c33;
250 --text: #e5e7eb;
251 --muted: #94a3b8;
252 --accent: #38bdf8;
253 --danger: #fb7185;
254 --edge: rgba(148, 163, 184, 0.28);
255 --edge-hi: rgba(56, 189, 248, 0.65);
256 }}
257 html, body {{ height: 100%; }}
258 body {{
259 margin: 0;
260 background: var(--bg);
261 color: var(--text);
262 font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
263 }}
264 .layout {{
265 display: grid;
266 grid-template-columns: 360px 1fr;
267 height: 100vh;
268 }}
269 .sidebar {{
270 background: linear-gradient(180deg, var(--panel), var(--panel2));
271 border-right: 1px solid rgba(148, 163, 184, 0.15);
272 padding: 16px;
273 overflow: auto;
274 }}
275 .h1 {{ font-size: 14px; font-weight: 700; letter-spacing: 0.02em; margin: 0 0 10px 0; }}
276 .meta {{ font-size: 12px; color: var(--muted); line-height: 1.35; }}
277 .row {{ display: flex; gap: 8px; align-items: center; }}
278 input[type="text"] {{
279 width: 100%;
280 padding: 10px 10px;
281 border-radius: 10px;
282 border: 1px solid rgba(148, 163, 184, 0.18);
283 background: rgba(2, 6, 23, 0.35);
284 color: var(--text);
285 outline: none;
286 }}
287 input[type="text"]:focus {{
288 border-color: rgba(56, 189, 248, 0.65);
289 box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
290 }}
291 .btn {{
292 padding: 10px 10px;
293 border-radius: 10px;
294 border: 1px solid rgba(148, 163, 184, 0.18);
295 background: rgba(2, 6, 23, 0.25);
296 color: var(--text);
297 cursor: pointer;
298 white-space: nowrap;
299 }}
300 .btn:hover {{ border-color: rgba(56, 189, 248, 0.35); }}
301 .divider {{ height: 1px; background: rgba(148, 163, 184, 0.12); margin: 12px 0; }}
302 .kv {{ display: grid; grid-template-columns: 110px 1fr; gap: 6px 10px; font-size: 12px; }}
303 .k {{ color: var(--muted); }}
304 .v {{ overflow-wrap: anywhere; }}
305 .badge {{
306 display: inline-block;
307 font-size: 11px;
308 padding: 2px 8px;
309 border-radius: 999px;
310 background: rgba(56, 189, 248, 0.12);
311 border: 1px solid rgba(56, 189, 248, 0.22);
312 color: var(--text);
313 margin-right: 6px;
314 margin-top: 6px;
315 }}
316 .warn {{
317 margin-top: 10px;
318 font-size: 12px;
319 color: var(--muted);
320 border: 1px solid rgba(251, 113, 133, 0.25);
321 background: rgba(251, 113, 133, 0.08);
322 border-radius: 12px;
323 padding: 10px;
324 }}
325 .canvasWrap {{ position: relative; }}
326 canvas {{ display: block; width: 100%; height: 100%; }}
327 .hint {{
328 position: absolute;
329 left: 12px;
330 bottom: 12px;
331 font-size: 12px;
332 color: var(--muted);
333 background: rgba(2, 6, 23, 0.55);
334 border: 1px solid rgba(148, 163, 184, 0.14);
335 border-radius: 999px;
336 padding: 6px 10px;
337 backdrop-filter: blur(6px);
338 }}
339 </style>
340</head>
341<body>
342 <div class="layout">
343 <aside class="sidebar">
344 <div class="h1">lean-ctx — graph export</div>
345 <div class="meta" id="meta"></div>
346 <div class="divider"></div>
347 <div class="row">
348 <input id="q" type="text" placeholder="Search by path (substring)..." />
349 <button class="btn" id="reset">Reset</button>
350 </div>
351 <div class="row" style="margin-top: 10px;">
352 <button class="btn" id="exportPng">Export PNG</button>
353 <button class="btn" id="clearHighlight">Clear highlight</button>
354 </div>
355 <div class="divider"></div>
356 <div class="h1">Selection</div>
357 <div class="kv">
358 <div class="k">Path</div><div class="v" id="selPath">—</div>
359 <div class="k">Language</div><div class="v" id="selLang">—</div>
360 <div class="k">Tokens</div><div class="v" id="selTokens">—</div>
361 <div class="k">Lines</div><div class="v" id="selLines">—</div>
362 <div class="k">Degree</div><div class="v" id="selDegree">—</div>
363 </div>
364 <div id="exports"></div>
365 <div class="divider"></div>
366 <div class="h1">Imports</div>
367 <div class="meta" id="selImports">—</div>
368 <div class="divider"></div>
369 <div class="h1">Dependents</div>
370 <div class="meta" id="selDependents">—</div>
371 <div class="divider"></div>
372 <div class="h1">Summary</div>
373 <div class="meta" id="selSummary">—</div>
374 <div id="warn" class="warn" style="display:none"></div>
375 </aside>
376 <main class="canvasWrap">
377 <canvas id="c"></canvas>
378 <div class="hint">Drag = pan · Wheel = zoom · Click = select</div>
379 </main>
380 </div>
381
382 <script id="graph-data" type="application/json">{json}</script>
383 <script>
384 const data = JSON.parse(document.getElementById('graph-data').textContent);
385 const meta = document.getElementById('meta');
386 const warn = document.getElementById('warn');
387 meta.textContent = data.projectRoot + " · nodes=" + data.nodes.length + " · edges=" + data.edges.length;
388 if (data.truncated) {{
389 warn.style.display = "block";
390 warn.textContent = "Export truncated: original nodes=" + data.originalNodeCount + ", exported nodes=" + data.nodes.length + ". Use --max-nodes to adjust.";
391 }}
392
393 const canvas = document.getElementById('c');
394 const ctx = canvas.getContext('2d');
395
396 function fitCanvas() {{
397 const dpr = window.devicePixelRatio || 1;
398 canvas.width = Math.floor(canvas.clientWidth * dpr);
399 canvas.height = Math.floor(canvas.clientHeight * dpr);
400 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
401 }}
402 window.addEventListener('resize', () => {{ fitCanvas(); draw(); }});
403 fitCanvas();
404
405 const nodes = data.nodes.map(n => ({{ ...n, x: 0, y: 0 }}));
406 const edges = data.edges;
407
408 // Simple circular layout (fast + deterministic).
409 const R = 420;
410 for (let i = 0; i < nodes.length; i++) {{
411 const a = (i / Math.max(1, nodes.length)) * Math.PI * 2;
412 nodes[i].x = Math.cos(a) * R;
413 nodes[i].y = Math.sin(a) * R;
414 }}
415
416 const adj = new Map();
417 const imports = new Map();
418 const dependents = new Map();
419 for (const e of edges) {{
420 if (!adj.has(e.source)) adj.set(e.source, new Set());
421 if (!adj.has(e.target)) adj.set(e.target, new Set());
422 adj.get(e.source).add(e.target);
423 adj.get(e.target).add(e.source);
424
425 if ((e.kind || '') === 'import') {{
426 if (!imports.has(e.source)) imports.set(e.source, new Set());
427 if (!dependents.has(e.target)) dependents.set(e.target, new Set());
428 imports.get(e.source).add(e.target);
429 dependents.get(e.target).add(e.source);
430 }}
431 }}
432
433 let view = {{ x: canvas.clientWidth / 2, y: canvas.clientHeight / 2, k: 1 }};
434 let dragging = false;
435 let last = null;
436 let selected = null;
437 let filtered = new Set(nodes.map(n => n.id));
438 let revHi = new Set();
439
440 function screenToWorld(px, py) {{
441 return {{
442 x: (px - view.x) / view.k,
443 y: (py - view.y) / view.k
444 }};
445 }}
446
447 function hitTest(px, py) {{
448 const w = screenToWorld(px, py);
449 let best = null;
450 let bestD2 = 1e18;
451 for (const n of nodes) {{
452 if (!filtered.has(n.id)) continue;
453 const dx = n.x - w.x;
454 const dy = n.y - w.y;
455 const d2 = dx*dx + dy*dy;
456 const r = 6 + Math.min(14, Math.floor(Math.sqrt(n.degree || 0)));
457 if (d2 <= r*r && d2 < bestD2) {{
458 best = n;
459 bestD2 = d2;
460 }}
461 }}
462 return best;
463 }}
464
465 function draw() {{
466 ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
467
468 ctx.save();
469 ctx.translate(view.x, view.y);
470 ctx.scale(view.k, view.k);
471
472 // Edges
473 ctx.lineWidth = 1 / view.k;
474 for (const e of edges) {{
475 if (!filtered.has(e.source) || !filtered.has(e.target)) continue;
476 const s = nodes[e.source];
477 const t = nodes[e.target];
478 if (!s || !t) continue;
479 const edgeHiSel = selected !== null && (e.source === selected || e.target === selected);
480 const edgeHiRev = revHi.size && (revHi.has(e.source) && revHi.has(e.target));
481 if (edgeHiRev) {{
482 ctx.strokeStyle = 'rgba(251, 113, 133, 0.65)';
483 }} else {{
484 ctx.strokeStyle = edgeHiSel ? getComputedStyle(document.documentElement).getPropertyValue('--edge-hi') : getComputedStyle(document.documentElement).getPropertyValue('--edge');
485 }}
486 ctx.beginPath();
487 ctx.moveTo(s.x, s.y);
488 ctx.lineTo(t.x, t.y);
489 ctx.stroke();
490 }}
491
492 // Nodes
493 for (const n of nodes) {{
494 if (!filtered.has(n.id)) continue;
495 const isSel = selected === n.id;
496 const isNbr = selected !== null && adj.get(selected)?.has(n.id);
497 const isRev = revHi.size && revHi.has(n.id);
498 const r = 6 + Math.min(14, Math.floor(Math.sqrt(n.degree || 0)));
499 ctx.beginPath();
500 ctx.arc(n.x, n.y, r, 0, Math.PI*2);
501 if (isSel) {{
502 ctx.fillStyle = '#38bdf8';
503 }} else if (isRev) {{
504 ctx.fillStyle = 'rgba(251, 113, 133, 0.80)';
505 }} else if (isNbr) {{
506 ctx.fillStyle = 'rgba(56,189,248,0.65)';
507 }} else {{
508 ctx.fillStyle = 'rgba(229,231,235,0.65)';
509 }}
510 ctx.fill();
511 }}
512
513 ctx.restore();
514 }}
515
516 function renderPathList(containerId, ids) {{
517 const el = document.getElementById(containerId);
518 el.innerHTML = '';
519 if (!ids || !ids.length) {{
520 el.textContent = '—';
521 return;
522 }}
523 for (const id of ids.slice(0, 30)) {{
524 const n = nodes[id];
525 if (!n) continue;
526 const a = document.createElement('a');
527 a.href = '#';
528 a.style.color = 'inherit';
529 a.style.textDecoration = 'none';
530 a.style.display = 'block';
531 a.style.padding = '2px 0';
532 a.textContent = n.path;
533 a.addEventListener('click', (ev) => {{
534 ev.preventDefault();
535 setSelection(n);
536 }});
537 el.appendChild(a);
538 }}
539 if (ids.length > 30) {{
540 const more = document.createElement('div');
541 more.className = 'meta';
542 more.style.marginTop = '6px';
543 more.textContent = '+' + (ids.length - 30) + ' more';
544 el.appendChild(more);
545 }}
546 }}
547
548 function computeReverseTransitive(startId) {{
549 const out = new Set();
550 const q = [startId];
551 out.add(startId);
552 while (q.length) {{
553 const cur = q.pop();
554 const preds = dependents.get(cur);
555 if (!preds) continue;
556 for (const p of preds) {{
557 if (out.has(p)) continue;
558 out.add(p);
559 q.push(p);
560 }}
561 }}
562 return out;
563 }}
564
565 function setSelection(n) {{
566 const p = document.getElementById('selPath');
567 const l = document.getElementById('selLang');
568 const t = document.getElementById('selTokens');
569 const lc = document.getElementById('selLines');
570 const d = document.getElementById('selDegree');
571 const s = document.getElementById('selSummary');
572 const ex = document.getElementById('exports');
573 const impEl = document.getElementById('selImports');
574 const depEl = document.getElementById('selDependents');
575 ex.innerHTML = '';
576 if (!n) {{
577 selected = null;
578 revHi = new Set();
579 p.textContent = '—';
580 l.textContent = '—';
581 t.textContent = '—';
582 lc.textContent = '—';
583 d.textContent = '—';
584 s.textContent = '—';
585 impEl.textContent = '—';
586 depEl.textContent = '—';
587 draw();
588 return;
589 }}
590 selected = n.id;
591 revHi = new Set();
592 p.textContent = n.path;
593 l.textContent = n.language || '—';
594 t.textContent = String(n.tokenCount ?? 0);
595 lc.textContent = String(n.lineCount ?? 0);
596 d.textContent = String(n.degree ?? 0);
597 s.textContent = n.summary || '—';
598 if (Array.isArray(n.exports) && n.exports.length) {{
599 for (const e of n.exports.slice(0, 25)) {{
600 const b = document.createElement('span');
601 b.className = 'badge';
602 b.textContent = e;
603 ex.appendChild(b);
604 }}
605 }}
606
607 const imps = Array.from(imports.get(n.id) || []).sort((a, b) => (nodes[a]?.path || '').localeCompare(nodes[b]?.path || ''));
608 const deps = Array.from(dependents.get(n.id) || []).sort((a, b) => (nodes[a]?.path || '').localeCompare(nodes[b]?.path || ''));
609 renderPathList('selImports', imps);
610 renderPathList('selDependents', deps);
611
612 draw();
613 }}
614
615 canvas.addEventListener('mousedown', (ev) => {{
616 dragging = true;
617 last = {{ x: ev.clientX, y: ev.clientY }};
618 }});
619 window.addEventListener('mouseup', () => {{ dragging = false; last = null; }});
620 window.addEventListener('mousemove', (ev) => {{
621 if (!dragging || !last) return;
622 view.x += (ev.clientX - last.x);
623 view.y += (ev.clientY - last.y);
624 last = {{ x: ev.clientX, y: ev.clientY }};
625 draw();
626 }});
627 canvas.addEventListener('wheel', (ev) => {{
628 ev.preventDefault();
629 const scale = Math.exp(-ev.deltaY * 0.001);
630 const before = screenToWorld(ev.clientX, ev.clientY);
631 view.k = Math.min(6, Math.max(0.2, view.k * scale));
632 const after = screenToWorld(ev.clientX, ev.clientY);
633 view.x += (after.x - before.x) * view.k;
634 view.y += (after.y - before.y) * view.k;
635 draw();
636 }}, {{ passive: false }});
637 canvas.addEventListener('click', (ev) => {{
638 const n = hitTest(ev.clientX, ev.clientY);
639 setSelection(n);
640 }});
641
642 canvas.addEventListener('contextmenu', (ev) => {{
643 ev.preventDefault();
644 const n = hitTest(ev.clientX, ev.clientY);
645 if (!n) return;
646 setSelection(n);
647 revHi = computeReverseTransitive(n.id);
648 draw();
649 }});
650
651 const q = document.getElementById('q');
652 q.addEventListener('input', () => {{
653 const needle = q.value.trim().toLowerCase();
654 filtered = new Set();
655 if (!needle) {{
656 for (const n of nodes) filtered.add(n.id);
657 }} else {{
658 for (const n of nodes) {{
659 if ((n.path || '').toLowerCase().includes(needle)) filtered.add(n.id);
660 }}
661 }}
662 if (selected !== null && !filtered.has(selected)) setSelection(null);
663 draw();
664 }});
665
666 document.getElementById('reset').addEventListener('click', () => {{
667 view = {{ x: canvas.clientWidth / 2, y: canvas.clientHeight / 2, k: 1 }};
668 q.value = '';
669 filtered = new Set(nodes.map(n => n.id));
670 setSelection(null);
671 draw();
672 }});
673
674 document.getElementById('clearHighlight').addEventListener('click', () => {{
675 revHi = new Set();
676 draw();
677 }});
678
679 document.getElementById('exportPng').addEventListener('click', () => {{
680 const url = canvas.toDataURL('image/png');
681 const a = document.createElement('a');
682 a.href = url;
683 a.download = 'lean-ctx-graph.png';
684 document.body.appendChild(a);
685 a.click();
686 a.remove();
687 }});
688
689 draw();
690 </script>
691</body>
692</html>
693"#
694 ))
695}
696
697pub fn export_graph_html_string_from_provider(
698 gp: &GraphProvider,
699 project_root: &str,
700 max_nodes: usize,
701) -> Result<String> {
702 if max_nodes == 0 {
703 return Err(anyhow!("max_nodes must be >= 1"));
704 }
705 let graph = build_export_graph(gp, project_root, max_nodes);
706 render_html(&graph)
707}
708
709pub fn export_graph_html_string(project_root: &str, max_nodes: usize) -> Result<String> {
710 if max_nodes == 0 {
711 return Err(anyhow!("max_nodes must be >= 1"));
712 }
713 let open =
714 graph_provider::open_or_build(project_root).ok_or_else(|| anyhow!("No graph available"))?;
715 export_graph_html_string_from_provider(&open.provider, project_root, max_nodes)
716}
717
718pub fn export_graph_html(project_root: &str, out_path: &Path, max_nodes: usize) -> Result<()> {
719 let html = export_graph_html_string(project_root, max_nodes)?;
720 std::fs::write(out_path, html).with_context(|| format!("write {}", out_path.display()))?;
721 Ok(())
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn escape_prevents_script_breakout() {
730 let s = r#"{"x":"</script><script>alert(1)</script><!--"}"#;
731 let out = escape_for_script_tag(s);
732 assert!(!out.contains("</script"));
733 assert!(!out.contains("<!--"));
734 }
735
736 fn gd_provider_with_scene_edge() -> GraphProvider {
738 use crate::core::graph_index::{FileEntry, IndexEdge, ProjectIndex};
739 let mut idx = ProjectIndex::new("/project");
740 idx.files.insert(
741 "main.gd".to_string(),
742 FileEntry {
743 path: "main.gd".to_string(),
744 hash: "h".to_string(),
745 language: "gd".to_string(),
746 line_count: 3,
747 token_count: 10,
748 exports: Vec::new(),
749 summary: String::new(),
750 },
751 );
752 idx.edges.push(IndexEdge {
753 from: "main.gd".to_string(),
754 to: "scenes/Main.tscn".to_string(),
755 kind: "import".to_string(),
756 weight: 1.0,
757 });
758 GraphProvider::GraphIndex(idx)
759 }
760
761 #[test]
762 fn export_synthesizes_phantom_scene_node_and_keeps_import_edge() {
763 let gp = gd_provider_with_scene_edge();
764 let graph = build_export_graph(&gp, "/project", 100);
765
766 let scene = graph
767 .nodes
768 .iter()
769 .find(|n| n.path == "scenes/Main.tscn")
770 .expect("phantom .tscn node must be synthesized");
771 assert_eq!(scene.language, "tscn");
772
773 let main = graph
774 .nodes
775 .iter()
776 .find(|n| n.path == "main.gd")
777 .expect("real .gd node");
778 let import_edge = graph
779 .edges
780 .iter()
781 .find(|e| e.kind == "import")
782 .expect("import edge must survive into the export");
783 assert_eq!(import_edge.source, main.id);
784 assert_eq!(import_edge.target, scene.id);
785 }
786
787 #[test]
788 fn export_drops_dangling_edge_when_budget_is_full() {
789 let gp = gd_provider_with_scene_edge();
792 let graph = build_export_graph(&gp, "/project", 1);
793 assert_eq!(graph.nodes.len(), 1);
794 assert!(graph.edges.is_empty());
795 }
796}