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_index::{self, ProjectIndex};
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(index: &ProjectIndex, max_nodes: usize) -> Vec<String> {
58 if index.files.len() <= max_nodes {
59 let mut all: Vec<String> = index.files.keys().cloned().collect();
60 all.sort();
61 return all;
62 }
63
64 let mut degree: HashMap<&str, usize> = HashMap::new();
65 for e in &index.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)> = index
71 .files
72 .iter()
73 .map(|(p, f)| {
74 let d = degree.get(p.as_str()).copied().unwrap_or(0);
75 (p, d, f.token_count)
76 })
77 .collect();
78
79 scored.sort_by(|(pa, da, ta), (pb, db, tb)| {
81 db.cmp(da).then_with(|| tb.cmp(ta)).then_with(|| pa.cmp(pb))
82 });
83
84 scored
85 .into_iter()
86 .take(max_nodes)
87 .map(|(p, _, _)| p.clone())
88 .collect()
89}
90
91fn build_export_graph(index: &ProjectIndex, max_nodes: usize) -> ExportGraph {
92 let original_node_count = index.files.len();
93 let original_edge_count = index.edges.len();
94
95 let selected_paths = select_nodes(index, max_nodes);
96 let selected_set: HashSet<&str> = selected_paths.iter().map(String::as_str).collect();
97
98 let mut degree: HashMap<&str, usize> = HashMap::new();
99 for e in &index.edges {
100 if selected_set.contains(e.from.as_str()) && selected_set.contains(e.to.as_str()) {
101 *degree.entry(e.from.as_str()).or_insert(0) += 1;
102 *degree.entry(e.to.as_str()).or_insert(0) += 1;
103 }
104 }
105
106 let mut nodes: Vec<ExportNode> = Vec::with_capacity(selected_paths.len());
107 let mut id_by_path: HashMap<&str, usize> = HashMap::new();
108 for (id, path) in selected_paths.iter().enumerate() {
109 if let Some(f) = index.files.get(path) {
110 id_by_path.insert(path.as_str(), id);
111 nodes.push(ExportNode {
112 id,
113 path: f.path.clone(),
114 label: Path::new(&f.path)
115 .file_name()
116 .and_then(|s| s.to_str())
117 .unwrap_or(&f.path)
118 .to_string(),
119 language: f.language.clone(),
120 summary: f.summary.clone(),
121 exports: f.exports.clone(),
122 token_count: f.token_count,
123 line_count: f.line_count,
124 degree: degree.get(path.as_str()).copied().unwrap_or(0),
125 });
126 }
127 }
128
129 let mut edges: Vec<ExportEdge> = Vec::new();
130 for e in &index.edges {
131 let Some(&s) = id_by_path.get(e.from.as_str()) else {
132 continue;
133 };
134 let Some(&t) = id_by_path.get(e.to.as_str()) else {
135 continue;
136 };
137 edges.push(ExportEdge {
138 source: s,
139 target: t,
140 kind: e.kind.clone(),
141 });
142 }
143
144 ExportGraph {
145 project_root: index.project_root.clone(),
146 generated_at_unix_ms: now_unix_ms(),
147 nodes,
148 edges,
149 truncated: original_node_count > max_nodes,
150 original_node_count,
151 original_edge_count,
152 }
153}
154
155fn render_html(graph: &ExportGraph) -> Result<String> {
156 let json = serde_json::to_string(graph).context("serialize graph export")?;
157 let json = escape_for_script_tag(&json);
158
159 Ok(format!(
160 r#"<!doctype html>
161<html lang="en">
162<head>
163 <meta charset="utf-8" />
164 <meta name="viewport" content="width=device-width, initial-scale=1" />
165 <title>lean-ctx graph export</title>
166 <style>
167 :root {{
168 --bg: #0b1220;
169 --panel: #0f172a;
170 --panel2: #111c33;
171 --text: #e5e7eb;
172 --muted: #94a3b8;
173 --accent: #38bdf8;
174 --danger: #fb7185;
175 --edge: rgba(148, 163, 184, 0.28);
176 --edge-hi: rgba(56, 189, 248, 0.65);
177 }}
178 html, body {{ height: 100%; }}
179 body {{
180 margin: 0;
181 background: var(--bg);
182 color: var(--text);
183 font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
184 }}
185 .layout {{
186 display: grid;
187 grid-template-columns: 360px 1fr;
188 height: 100vh;
189 }}
190 .sidebar {{
191 background: linear-gradient(180deg, var(--panel), var(--panel2));
192 border-right: 1px solid rgba(148, 163, 184, 0.15);
193 padding: 16px;
194 overflow: auto;
195 }}
196 .h1 {{ font-size: 14px; font-weight: 700; letter-spacing: 0.02em; margin: 0 0 10px 0; }}
197 .meta {{ font-size: 12px; color: var(--muted); line-height: 1.35; }}
198 .row {{ display: flex; gap: 8px; align-items: center; }}
199 input[type="text"] {{
200 width: 100%;
201 padding: 10px 10px;
202 border-radius: 10px;
203 border: 1px solid rgba(148, 163, 184, 0.18);
204 background: rgba(2, 6, 23, 0.35);
205 color: var(--text);
206 outline: none;
207 }}
208 input[type="text"]:focus {{
209 border-color: rgba(56, 189, 248, 0.65);
210 box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
211 }}
212 .btn {{
213 padding: 10px 10px;
214 border-radius: 10px;
215 border: 1px solid rgba(148, 163, 184, 0.18);
216 background: rgba(2, 6, 23, 0.25);
217 color: var(--text);
218 cursor: pointer;
219 white-space: nowrap;
220 }}
221 .btn:hover {{ border-color: rgba(56, 189, 248, 0.35); }}
222 .divider {{ height: 1px; background: rgba(148, 163, 184, 0.12); margin: 12px 0; }}
223 .kv {{ display: grid; grid-template-columns: 110px 1fr; gap: 6px 10px; font-size: 12px; }}
224 .k {{ color: var(--muted); }}
225 .v {{ overflow-wrap: anywhere; }}
226 .badge {{
227 display: inline-block;
228 font-size: 11px;
229 padding: 2px 8px;
230 border-radius: 999px;
231 background: rgba(56, 189, 248, 0.12);
232 border: 1px solid rgba(56, 189, 248, 0.22);
233 color: var(--text);
234 margin-right: 6px;
235 margin-top: 6px;
236 }}
237 .warn {{
238 margin-top: 10px;
239 font-size: 12px;
240 color: var(--muted);
241 border: 1px solid rgba(251, 113, 133, 0.25);
242 background: rgba(251, 113, 133, 0.08);
243 border-radius: 12px;
244 padding: 10px;
245 }}
246 .canvasWrap {{ position: relative; }}
247 canvas {{ display: block; width: 100%; height: 100%; }}
248 .hint {{
249 position: absolute;
250 left: 12px;
251 bottom: 12px;
252 font-size: 12px;
253 color: var(--muted);
254 background: rgba(2, 6, 23, 0.55);
255 border: 1px solid rgba(148, 163, 184, 0.14);
256 border-radius: 999px;
257 padding: 6px 10px;
258 backdrop-filter: blur(6px);
259 }}
260 </style>
261</head>
262<body>
263 <div class="layout">
264 <aside class="sidebar">
265 <div class="h1">lean-ctx — graph export</div>
266 <div class="meta" id="meta"></div>
267 <div class="divider"></div>
268 <div class="row">
269 <input id="q" type="text" placeholder="Search by path (substring)..." />
270 <button class="btn" id="reset">Reset</button>
271 </div>
272 <div class="row" style="margin-top: 10px;">
273 <button class="btn" id="exportPng">Export PNG</button>
274 <button class="btn" id="clearHighlight">Clear highlight</button>
275 </div>
276 <div class="divider"></div>
277 <div class="h1">Selection</div>
278 <div class="kv">
279 <div class="k">Path</div><div class="v" id="selPath">—</div>
280 <div class="k">Language</div><div class="v" id="selLang">—</div>
281 <div class="k">Tokens</div><div class="v" id="selTokens">—</div>
282 <div class="k">Lines</div><div class="v" id="selLines">—</div>
283 <div class="k">Degree</div><div class="v" id="selDegree">—</div>
284 </div>
285 <div id="exports"></div>
286 <div class="divider"></div>
287 <div class="h1">Imports</div>
288 <div class="meta" id="selImports">—</div>
289 <div class="divider"></div>
290 <div class="h1">Dependents</div>
291 <div class="meta" id="selDependents">—</div>
292 <div class="divider"></div>
293 <div class="h1">Summary</div>
294 <div class="meta" id="selSummary">—</div>
295 <div id="warn" class="warn" style="display:none"></div>
296 </aside>
297 <main class="canvasWrap">
298 <canvas id="c"></canvas>
299 <div class="hint">Drag = pan · Wheel = zoom · Click = select</div>
300 </main>
301 </div>
302
303 <script id="graph-data" type="application/json">{json}</script>
304 <script>
305 const data = JSON.parse(document.getElementById('graph-data').textContent);
306 const meta = document.getElementById('meta');
307 const warn = document.getElementById('warn');
308 meta.textContent = data.projectRoot + " · nodes=" + data.nodes.length + " · edges=" + data.edges.length;
309 if (data.truncated) {{
310 warn.style.display = "block";
311 warn.textContent = "Export truncated: original nodes=" + data.originalNodeCount + ", exported nodes=" + data.nodes.length + ". Use --max-nodes to adjust.";
312 }}
313
314 const canvas = document.getElementById('c');
315 const ctx = canvas.getContext('2d');
316
317 function fitCanvas() {{
318 const dpr = window.devicePixelRatio || 1;
319 canvas.width = Math.floor(canvas.clientWidth * dpr);
320 canvas.height = Math.floor(canvas.clientHeight * dpr);
321 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
322 }}
323 window.addEventListener('resize', () => {{ fitCanvas(); draw(); }});
324 fitCanvas();
325
326 const nodes = data.nodes.map(n => ({{ ...n, x: 0, y: 0 }}));
327 const edges = data.edges;
328
329 // Simple circular layout (fast + deterministic).
330 const R = 420;
331 for (let i = 0; i < nodes.length; i++) {{
332 const a = (i / Math.max(1, nodes.length)) * Math.PI * 2;
333 nodes[i].x = Math.cos(a) * R;
334 nodes[i].y = Math.sin(a) * R;
335 }}
336
337 const adj = new Map();
338 const imports = new Map();
339 const dependents = new Map();
340 for (const e of edges) {{
341 if (!adj.has(e.source)) adj.set(e.source, new Set());
342 if (!adj.has(e.target)) adj.set(e.target, new Set());
343 adj.get(e.source).add(e.target);
344 adj.get(e.target).add(e.source);
345
346 if ((e.kind || '') === 'import') {{
347 if (!imports.has(e.source)) imports.set(e.source, new Set());
348 if (!dependents.has(e.target)) dependents.set(e.target, new Set());
349 imports.get(e.source).add(e.target);
350 dependents.get(e.target).add(e.source);
351 }}
352 }}
353
354 let view = {{ x: canvas.clientWidth / 2, y: canvas.clientHeight / 2, k: 1 }};
355 let dragging = false;
356 let last = null;
357 let selected = null;
358 let filtered = new Set(nodes.map(n => n.id));
359 let revHi = new Set();
360
361 function screenToWorld(px, py) {{
362 return {{
363 x: (px - view.x) / view.k,
364 y: (py - view.y) / view.k
365 }};
366 }}
367
368 function hitTest(px, py) {{
369 const w = screenToWorld(px, py);
370 let best = null;
371 let bestD2 = 1e18;
372 for (const n of nodes) {{
373 if (!filtered.has(n.id)) continue;
374 const dx = n.x - w.x;
375 const dy = n.y - w.y;
376 const d2 = dx*dx + dy*dy;
377 const r = 6 + Math.min(14, Math.floor(Math.sqrt(n.degree || 0)));
378 if (d2 <= r*r && d2 < bestD2) {{
379 best = n;
380 bestD2 = d2;
381 }}
382 }}
383 return best;
384 }}
385
386 function draw() {{
387 ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
388
389 ctx.save();
390 ctx.translate(view.x, view.y);
391 ctx.scale(view.k, view.k);
392
393 // Edges
394 ctx.lineWidth = 1 / view.k;
395 for (const e of edges) {{
396 if (!filtered.has(e.source) || !filtered.has(e.target)) continue;
397 const s = nodes[e.source];
398 const t = nodes[e.target];
399 if (!s || !t) continue;
400 const edgeHiSel = selected !== null && (e.source === selected || e.target === selected);
401 const edgeHiRev = revHi.size && (revHi.has(e.source) && revHi.has(e.target));
402 if (edgeHiRev) {{
403 ctx.strokeStyle = 'rgba(251, 113, 133, 0.65)';
404 }} else {{
405 ctx.strokeStyle = edgeHiSel ? getComputedStyle(document.documentElement).getPropertyValue('--edge-hi') : getComputedStyle(document.documentElement).getPropertyValue('--edge');
406 }}
407 ctx.beginPath();
408 ctx.moveTo(s.x, s.y);
409 ctx.lineTo(t.x, t.y);
410 ctx.stroke();
411 }}
412
413 // Nodes
414 for (const n of nodes) {{
415 if (!filtered.has(n.id)) continue;
416 const isSel = selected === n.id;
417 const isNbr = selected !== null && adj.get(selected)?.has(n.id);
418 const isRev = revHi.size && revHi.has(n.id);
419 const r = 6 + Math.min(14, Math.floor(Math.sqrt(n.degree || 0)));
420 ctx.beginPath();
421 ctx.arc(n.x, n.y, r, 0, Math.PI*2);
422 if (isSel) {{
423 ctx.fillStyle = '#38bdf8';
424 }} else if (isRev) {{
425 ctx.fillStyle = 'rgba(251, 113, 133, 0.80)';
426 }} else if (isNbr) {{
427 ctx.fillStyle = 'rgba(56,189,248,0.65)';
428 }} else {{
429 ctx.fillStyle = 'rgba(229,231,235,0.65)';
430 }}
431 ctx.fill();
432 }}
433
434 ctx.restore();
435 }}
436
437 function renderPathList(containerId, ids) {{
438 const el = document.getElementById(containerId);
439 el.innerHTML = '';
440 if (!ids || !ids.length) {{
441 el.textContent = '—';
442 return;
443 }}
444 for (const id of ids.slice(0, 30)) {{
445 const n = nodes[id];
446 if (!n) continue;
447 const a = document.createElement('a');
448 a.href = '#';
449 a.style.color = 'inherit';
450 a.style.textDecoration = 'none';
451 a.style.display = 'block';
452 a.style.padding = '2px 0';
453 a.textContent = n.path;
454 a.addEventListener('click', (ev) => {{
455 ev.preventDefault();
456 setSelection(n);
457 }});
458 el.appendChild(a);
459 }}
460 if (ids.length > 30) {{
461 const more = document.createElement('div');
462 more.className = 'meta';
463 more.style.marginTop = '6px';
464 more.textContent = '+' + (ids.length - 30) + ' more';
465 el.appendChild(more);
466 }}
467 }}
468
469 function computeReverseTransitive(startId) {{
470 const out = new Set();
471 const q = [startId];
472 out.add(startId);
473 while (q.length) {{
474 const cur = q.pop();
475 const preds = dependents.get(cur);
476 if (!preds) continue;
477 for (const p of preds) {{
478 if (out.has(p)) continue;
479 out.add(p);
480 q.push(p);
481 }}
482 }}
483 return out;
484 }}
485
486 function setSelection(n) {{
487 const p = document.getElementById('selPath');
488 const l = document.getElementById('selLang');
489 const t = document.getElementById('selTokens');
490 const lc = document.getElementById('selLines');
491 const d = document.getElementById('selDegree');
492 const s = document.getElementById('selSummary');
493 const ex = document.getElementById('exports');
494 const impEl = document.getElementById('selImports');
495 const depEl = document.getElementById('selDependents');
496 ex.innerHTML = '';
497 if (!n) {{
498 selected = null;
499 revHi = new Set();
500 p.textContent = '—';
501 l.textContent = '—';
502 t.textContent = '—';
503 lc.textContent = '—';
504 d.textContent = '—';
505 s.textContent = '—';
506 impEl.textContent = '—';
507 depEl.textContent = '—';
508 draw();
509 return;
510 }}
511 selected = n.id;
512 revHi = new Set();
513 p.textContent = n.path;
514 l.textContent = n.language || '—';
515 t.textContent = String(n.tokenCount ?? 0);
516 lc.textContent = String(n.lineCount ?? 0);
517 d.textContent = String(n.degree ?? 0);
518 s.textContent = n.summary || '—';
519 if (Array.isArray(n.exports) && n.exports.length) {{
520 for (const e of n.exports.slice(0, 25)) {{
521 const b = document.createElement('span');
522 b.className = 'badge';
523 b.textContent = e;
524 ex.appendChild(b);
525 }}
526 }}
527
528 const imps = Array.from(imports.get(n.id) || []).sort((a, b) => (nodes[a]?.path || '').localeCompare(nodes[b]?.path || ''));
529 const deps = Array.from(dependents.get(n.id) || []).sort((a, b) => (nodes[a]?.path || '').localeCompare(nodes[b]?.path || ''));
530 renderPathList('selImports', imps);
531 renderPathList('selDependents', deps);
532
533 draw();
534 }}
535
536 canvas.addEventListener('mousedown', (ev) => {{
537 dragging = true;
538 last = {{ x: ev.clientX, y: ev.clientY }};
539 }});
540 window.addEventListener('mouseup', () => {{ dragging = false; last = null; }});
541 window.addEventListener('mousemove', (ev) => {{
542 if (!dragging || !last) return;
543 view.x += (ev.clientX - last.x);
544 view.y += (ev.clientY - last.y);
545 last = {{ x: ev.clientX, y: ev.clientY }};
546 draw();
547 }});
548 canvas.addEventListener('wheel', (ev) => {{
549 ev.preventDefault();
550 const scale = Math.exp(-ev.deltaY * 0.001);
551 const before = screenToWorld(ev.clientX, ev.clientY);
552 view.k = Math.min(6, Math.max(0.2, view.k * scale));
553 const after = screenToWorld(ev.clientX, ev.clientY);
554 view.x += (after.x - before.x) * view.k;
555 view.y += (after.y - before.y) * view.k;
556 draw();
557 }}, {{ passive: false }});
558 canvas.addEventListener('click', (ev) => {{
559 const n = hitTest(ev.clientX, ev.clientY);
560 setSelection(n);
561 }});
562
563 canvas.addEventListener('contextmenu', (ev) => {{
564 ev.preventDefault();
565 const n = hitTest(ev.clientX, ev.clientY);
566 if (!n) return;
567 setSelection(n);
568 revHi = computeReverseTransitive(n.id);
569 draw();
570 }});
571
572 const q = document.getElementById('q');
573 q.addEventListener('input', () => {{
574 const needle = q.value.trim().toLowerCase();
575 filtered = new Set();
576 if (!needle) {{
577 for (const n of nodes) filtered.add(n.id);
578 }} else {{
579 for (const n of nodes) {{
580 if ((n.path || '').toLowerCase().includes(needle)) filtered.add(n.id);
581 }}
582 }}
583 if (selected !== null && !filtered.has(selected)) setSelection(null);
584 draw();
585 }});
586
587 document.getElementById('reset').addEventListener('click', () => {{
588 view = {{ x: canvas.clientWidth / 2, y: canvas.clientHeight / 2, k: 1 }};
589 q.value = '';
590 filtered = new Set(nodes.map(n => n.id));
591 setSelection(null);
592 draw();
593 }});
594
595 document.getElementById('clearHighlight').addEventListener('click', () => {{
596 revHi = new Set();
597 draw();
598 }});
599
600 document.getElementById('exportPng').addEventListener('click', () => {{
601 const url = canvas.toDataURL('image/png');
602 const a = document.createElement('a');
603 a.href = url;
604 a.download = 'lean-ctx-graph.png';
605 document.body.appendChild(a);
606 a.click();
607 a.remove();
608 }});
609
610 draw();
611 </script>
612</body>
613</html>
614"#
615 ))
616}
617
618pub fn export_graph_html_string_from_index(
619 index: &ProjectIndex,
620 max_nodes: usize,
621) -> Result<String> {
622 if max_nodes == 0 {
623 return Err(anyhow!("max_nodes must be >= 1"));
624 }
625 let graph = build_export_graph(index, max_nodes);
626 render_html(&graph)
627}
628
629pub fn export_graph_html_string(project_root: &str, max_nodes: usize) -> Result<String> {
630 if max_nodes == 0 {
631 return Err(anyhow!("max_nodes must be >= 1"));
632 }
633 let index = graph_index::load_or_build(project_root);
634 export_graph_html_string_from_index(&index, max_nodes)
635}
636
637pub fn export_graph_html(project_root: &str, out_path: &Path, max_nodes: usize) -> Result<()> {
638 let html = export_graph_html_string(project_root, max_nodes)?;
639 std::fs::write(out_path, html).with_context(|| format!("write {}", out_path.display()))?;
640 Ok(())
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 #[test]
648 fn escape_prevents_script_breakout() {
649 let s = r#"{"x":"</script><script>alert(1)</script><!--"}"#;
650 let out = escape_for_script_tag(s);
651 assert!(!out.contains("</script"));
652 assert!(!out.contains("<!--"));
653 }
654}