Skip to main content

lean_ctx/core/
graph_export.rs

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    // Prevent ending the <script> tag accidentally.
53    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
98/// Display language for a node, taken from the file extension to stay consistent
99/// with the scanner (which stores `FileEntry.language` as the raw extension).
100fn 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
108/// Add visualization-only ("phantom") nodes for edge endpoints that aren't
109/// scanned files — e.g. Godot `.tscn`/`.tres` scenes referenced via `res://`
110/// before scene indexing exists (#316). Each phantom must connect to a real
111/// selected node, so GDScript import edges render instead of being dropped to
112/// sibling-only links. Capped by the remaining node budget. #315
113fn 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            // Only synthesize an endpoint that (a) isn't already a node, (b) is
133            // anchored to a real selected node, and (c) is genuinely unscanned
134            // (a real file omitted by the node budget stays truncated, not faked).
135            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    // `id` is the running node index (never `enumerate`) so it always equals the
172    // position in `nodes`, which the export JS uses to index edges directly.
173    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            // Phantom node (an edge target that isn't a scanned file, e.g. a
191            // `.tscn` scene): minimal metadata, language inferred from the ext.
192            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    /// A GDScript file that imports a not-yet-indexed `.tscn` scene (#315).
737    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        // A single real node fills max_nodes=1, so no phantom is added and the
790        // edge to the unscanned scene is dropped (both endpoints must be nodes).
791        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}