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_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    // Prevent ending the <script> tag accidentally.
53    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    // Sort by degree desc, then token_count desc, then path asc.
80    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}