Skip to main content

codesynapse_core/
tree_html.rs

1use serde_json::{json, Value};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5use crate::error::Result;
6use crate::security::{check_file_size, MAX_GRAPH_FILE_BYTES};
7
8pub const DEFAULT_MAX_CHILDREN: usize = 200;
9
10fn html_escape(s: &str) -> String {
11    s.replace('&', "&")
12        .replace('<', "&lt;")
13        .replace('>', "&gt;")
14        .replace('"', "&quot;")
15        .replace('\'', "&#x27;")
16}
17
18pub fn common_root(paths: &[&str]) -> String {
19    let parts: Vec<Vec<String>> = paths
20        .iter()
21        .filter(|p| !p.is_empty())
22        .map(|p| {
23            Path::new(p)
24                .components()
25                .map(|c| c.as_os_str().to_string_lossy().into_owned())
26                .collect::<Vec<_>>()
27        })
28        .collect();
29
30    if parts.is_empty() {
31        return String::new();
32    }
33
34    let mut common = parts[0].clone();
35    for part in &parts[1..] {
36        let i = common
37            .iter()
38            .zip(part.iter())
39            .take_while(|(a, b)| a == b)
40            .count();
41        common.truncate(i);
42    }
43
44    if common.is_empty() {
45        return String::new();
46    }
47
48    let mut pb = PathBuf::new();
49    for c in &common {
50        pb.push(c);
51    }
52    pb.to_string_lossy().into_owned()
53}
54
55pub fn make_truncation_leaf(extra: usize) -> Value {
56    json!({
57        "name": format!("(+{} more)", extra),
58        "total_count": extra,
59        "children": []
60    })
61}
62
63fn finalise(node: &mut Value) -> u64 {
64    let kids_len = node
65        .get("children")
66        .and_then(|c| c.as_array())
67        .map(|a| a.len())
68        .unwrap_or(0);
69    if kids_len == 0 {
70        return node
71            .get("total_count")
72            .and_then(|v| v.as_u64())
73            .unwrap_or(1);
74    }
75
76    let mut total = 0u64;
77    if let Some(arr) = node.get_mut("children").and_then(|c| c.as_array_mut()) {
78        for child in arr.iter_mut() {
79            total += finalise(child);
80        }
81        arr.sort_by(|a, b| {
82            let a_has_kids = a
83                .get("children")
84                .and_then(|c| c.as_array())
85                .map(|arr| !arr.is_empty())
86                .unwrap_or(false);
87            let b_has_kids = b
88                .get("children")
89                .and_then(|c| c.as_array())
90                .map(|arr| !arr.is_empty())
91                .unwrap_or(false);
92            let a_name = a
93                .get("name")
94                .and_then(|v| v.as_str())
95                .unwrap_or("")
96                .to_lowercase();
97            let b_name = b
98                .get("name")
99                .and_then(|v| v.as_str())
100                .unwrap_or("")
101                .to_lowercase();
102            let a_ord = if a_has_kids { 0usize } else { 1 };
103            let b_ord = if b_has_kids { 0usize } else { 1 };
104            a_ord.cmp(&b_ord).then(a_name.cmp(&b_name))
105        });
106    }
107    let total = total.max(1);
108    if let Some(v) = node.get_mut("total_count") {
109        *v = json!(total);
110    }
111    total
112}
113
114pub fn build_tree(
115    graph: &Value,
116    root: Option<&str>,
117    max_children: usize,
118    project_label: Option<&str>,
119) -> Value {
120    let nodes = match graph.get("nodes").and_then(|n| n.as_array()) {
121        Some(n) => n,
122        None => {
123            return json!({"name": "(empty graph)", "total_count": 0, "children": []});
124        }
125    };
126
127    let file_nodes: Vec<&Value> = nodes
128        .iter()
129        .filter(|n| {
130            n.get("source_file")
131                .and_then(|v| v.as_str())
132                .map(|s| !s.is_empty())
133                .unwrap_or(false)
134        })
135        .collect();
136
137    if file_nodes.is_empty() {
138        return json!({"name": "(empty graph)", "total_count": 0, "children": []});
139    }
140
141    let source_files: Vec<&str> = file_nodes
142        .iter()
143        .filter_map(|n| n.get("source_file").and_then(|v| v.as_str()))
144        .collect();
145
146    let resolved_root = match root {
147        Some(r) => r.to_string(),
148        None => common_root(&source_files),
149    };
150    let root_path = PathBuf::from(&resolved_root);
151
152    let label_root = project_label
153        .map(|s| s.to_string())
154        .or_else(|| {
155            root_path
156                .file_name()
157                .map(|n| n.to_string_lossy().into_owned())
158        })
159        .unwrap_or_else(|| resolved_root.clone());
160    let label_root = if label_root.is_empty() {
161        "/".to_string()
162    } else {
163        label_root
164    };
165
166    let mut by_file: HashMap<&str, Vec<&Value>> = HashMap::new();
167    for n in &file_nodes {
168        let src = n.get("source_file").and_then(|v| v.as_str()).unwrap_or("");
169        by_file.entry(src).or_default().push(n);
170    }
171
172    // dir_index: path string → mutable node stored in a flat vec; we'll build a tree at the end
173    // Use an index-based approach: Vec of (path_string, parent_idx, name, children_indices)
174    struct DirEntry {
175        name: String,
176        children_dirs: Vec<usize>,
177        children_files: Vec<Value>,
178    }
179
180    let mut dir_entries: Vec<DirEntry> = Vec::new();
181    let mut path_to_idx: HashMap<String, usize> = HashMap::new();
182
183    let root_key = root_path.to_string_lossy().into_owned();
184    dir_entries.push(DirEntry {
185        name: label_root,
186        children_dirs: Vec::new(),
187        children_files: Vec::new(),
188    });
189    path_to_idx.insert(root_key.clone(), 0);
190
191    fn ensure_dir(
192        abs_path: &Path,
193        root_path: &Path,
194        dir_entries: &mut Vec<DirEntry>,
195        path_to_idx: &mut HashMap<String, usize>,
196    ) -> usize {
197        let key = abs_path.to_string_lossy().into_owned();
198        if let Some(&idx) = path_to_idx.get(&key) {
199            return idx;
200        }
201        if abs_path == abs_path.parent().unwrap_or(abs_path) || abs_path == root_path {
202            return 0;
203        }
204        let parent_path = abs_path.parent().unwrap_or(root_path);
205        let parent_idx = ensure_dir(parent_path, root_path, dir_entries, path_to_idx);
206        let name = abs_path
207            .file_name()
208            .map(|n| n.to_string_lossy().into_owned())
209            .unwrap_or_default();
210        let idx = dir_entries.len();
211        dir_entries.push(DirEntry {
212            name,
213            children_dirs: Vec::new(),
214            children_files: Vec::new(),
215        });
216        path_to_idx.insert(key, idx);
217        dir_entries[parent_idx].children_dirs.push(idx);
218        idx
219    }
220
221    let mut sorted_files: Vec<(&str, Vec<&Value>)> = by_file.into_iter().collect();
222    sorted_files.sort_by_key(|(k, _)| *k);
223
224    for (src_file, syms) in &sorted_files {
225        let src_path = Path::new(src_file);
226        let parent_path = match src_path.strip_prefix(&root_path) {
227            Ok(rel) => root_path
228                .join(rel)
229                .parent()
230                .map(|p| p.to_path_buf())
231                .unwrap_or_else(|| root_path.clone()),
232            Err(_) => root_path.clone(),
233        };
234
235        let dir_idx = ensure_dir(&parent_path, &root_path, &mut dir_entries, &mut path_to_idx);
236
237        let file_name = src_path
238            .file_name()
239            .map(|n| n.to_string_lossy().into_owned())
240            .unwrap_or_else(|| src_file.to_string());
241
242        let mut sym_children: Vec<Value> = syms
243            .iter()
244            .filter_map(|n| {
245                let label = n
246                    .get("label")
247                    .or_else(|| n.get("id"))
248                    .and_then(|v| v.as_str())
249                    .unwrap_or("?");
250                let file_type = n.get("file_type").and_then(|v| v.as_str()).unwrap_or("");
251                if label == file_name && file_type == "code" {
252                    return None;
253                }
254                Some(json!({"name": label, "total_count": 1, "children": []}))
255            })
256            .collect();
257
258        sym_children.sort_by(|a, b| {
259            let a_name = a.get("name").and_then(|v| v.as_str()).unwrap_or("");
260            let b_name = b.get("name").and_then(|v| v.as_str()).unwrap_or("");
261            let a_priv = a_name.starts_with('_');
262            let b_priv = b_name.starts_with('_');
263            a_priv
264                .cmp(&b_priv)
265                .then(a_name.to_lowercase().cmp(&b_name.to_lowercase()))
266        });
267
268        if sym_children.len() > max_children {
269            let extra = sym_children.len() - max_children;
270            sym_children.truncate(max_children);
271            sym_children.push(make_truncation_leaf(extra));
272        }
273
274        let total = if sym_children.is_empty() {
275            1
276        } else {
277            sym_children.len() as u64
278        };
279        let file_node = json!({
280            "name": file_name,
281            "total_count": total,
282            "children": sym_children
283        });
284        dir_entries[dir_idx].children_files.push(file_node);
285    }
286
287    fn to_value(idx: usize, dir_entries: &[DirEntry]) -> Value {
288        let entry = &dir_entries[idx];
289        let mut children: Vec<Value> = entry
290            .children_dirs
291            .iter()
292            .map(|&cidx| to_value(cidx, dir_entries))
293            .collect();
294        children.extend(entry.children_files.iter().cloned());
295
296        let mut node = json!({
297            "name": entry.name,
298            "total_count": 0,
299            "children": children
300        });
301        finalise(&mut node);
302        node
303    }
304
305    to_value(0, &dir_entries)
306}
307
308static HTML_TEMPLATE: &str = r##"<!DOCTYPE html>
309<html lang="en">
310<head>
311  <meta charset="UTF-8">
312  <title>__TITLE__</title>
313  <style>
314    body {
315      font-family: 'Segoe UI', sans-serif;
316      margin: 0;
317      padding: 0;
318      background: #f9f9f9;
319      color: #333;
320    }
321    h1 {
322      margin: 20px 0 0 24px;
323      font-size: 2.2rem;
324      font-weight: bold;
325      color: #1e3a56;
326    }
327    .controls {
328      margin: 20px 0 15px 24px;
329    }
330    button {
331      margin-right: 10px;
332      padding: 8px 18px;
333      background: #007bff;
334      color: #fff;
335      border: none;
336      border-radius: 5px;
337      font-size: 0.95rem;
338      cursor: pointer;
339      transition: background 0.2s ease-in-out;
340      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
341    }
342    button:hover { background: #0056b3; }
343    button:active { background: #004085; }
344    #tree-container {
345      width: calc(100vw - 48px);
346      height: 85vh;
347      overflow: auto;
348      border-radius: 8px;
349      background: #fff;
350      margin-left: 24px;
351      margin-right: 24px;
352      box-shadow: 0 4px 12px rgba(0,0,0,0.08);
353      border: 1px solid #ddd;
354    }
355    svg {
356      background: #fff;
357      border-radius: 8px;
358      display: block;
359    }
360    .node circle { stroke-width: 2.5px; }
361    .node text {
362      font: 13px 'Segoe UI', sans-serif;
363      paint-order: stroke fill;
364      stroke: #fff;
365      stroke-width: 3px;
366      stroke-linejoin: round;
367      stroke-opacity: 0.85;
368    }
369    .link {
370      fill: none;
371      stroke-opacity: 0.7;
372      stroke-width: 2px;
373    }
374  </style>
375</head>
376<body>
377  <h1>__HEADER__</h1>
378  <div class="controls">
379    <button onclick="expandAll()">Expand All</button>
380    <button onclick="collapseAll()">Collapse All</button>
381    <button onclick="resetView()">Reset View</button>
382  </div>
383  <div id="tree-container">
384    <svg id="tree-svg" width="__SVG_WIDTH__" height="__SVG_HEIGHT__"></svg>
385  </div>
386
387  <script src="https://d3js.org/d3.v7.min.js"></script>
388  <script>
389    const initialJsonData = __DATA_JSON__;
390
391    function transformData(jsonData) {
392        function processNode(node, parentL1StageName) {
393            let displayName = node.name;
394            if (node.total_count !== undefined) {
395                if (!/\(Total Count: \d+\)$/.test(displayName)) {
396                    displayName += ` (Total Count: ${node.total_count})`;
397                }
398            }
399            const newNode = { name: displayName };
400            if (parentL1StageName === "Root") {
401                 newNode.originalStageName = node.name;
402            } else {
403                newNode.originalStageName = parentL1StageName;
404            }
405            if (node.children && node.children.length > 0) {
406                const stageNameToPass = (parentL1StageName === "Root") ? node.name : parentL1StageName;
407                newNode.children = node.children.map(child => processNode(child, stageNameToPass));
408            }
409            return newNode;
410        }
411        let rootDisplayName = jsonData.name;
412        if (jsonData.total_count !== undefined && !/\(Total Count: \d+\)$/.test(rootDisplayName)) {
413            rootDisplayName += ` (Total Count: ${jsonData.total_count})`;
414        }
415        return {
416            name: rootDisplayName,
417            originalStageName: "Root",
418            children: (jsonData.children || []).map(child => processNode(child, "Root"))
419        };
420    }
421
422    const treeData = transformData(initialJsonData);
423
424    const PALETTE = [
425      ["#3498DB","#2980B9","#AED6F1"], ["#2ECC71","#27AE60","#A9DFBF"],
426      ["#E74C3C","#C0392B","#F5B7B1"], ["#9B59B6","#8E44AD","#D7BDE2"],
427      ["#F39C12","#D68910","#FAD7A0"], ["#1ABC9C","#117864","#A2D9CE"],
428      ["#34495E","#1B2631","#ABB2B9"], ["#E67E22","#BA4A00","#F5CBA7"],
429      ["#16A085","#0E6655","#A2D9CE"], ["#D35400","#A04000","#EDBB99"],
430      ["#7F8C8D","#566573","#D5DBDB"], ["#C0392B","#7B241C","#F5B7B1"],
431      ["#2E86C1","#1B4F72","#A9CCE3"], ["#28B463","#196F3D","#A9DFBF"],
432      ["#AF7AC5","#6C3483","#D2B4DE"],
433    ];
434    const phaseColors = { "Root": { fill: "#4A4A4A", stroke: "#333333", collapsedFill: "#6C757D" },
435                          "Default": { fill: "#BDC3C7", stroke: "#95A5A6", collapsedFill: "#ECF0F1" } };
436    (initialJsonData.children || []).forEach((c, i) => {
437      const pal = PALETTE[i % PALETTE.length];
438      phaseColors[c.name] = { fill: pal[0], stroke: pal[1], collapsedFill: pal[2] };
439    });
440
441    const levelSpecificPalettes = {
442      0: { fill: "#4A4A4A", stroke: "#333333", collapsedFill: "#6C757D" },
443      2: { fill: "#6ab04c", stroke: "#508a38", collapsedFill: "#a3d391" },
444      3: { fill: "#f0932b", stroke: "#d0730f", collapsedFill: "#f6c07e" },
445      4: { fill: "#be2edd", stroke: "#a01cb3", collapsedFill: "#e08bf2" },
446      5: { fill: "#00a8ff", stroke: "#007ac1", collapsedFill: "#74d2ff" },
447      6: { fill: "#e55039", stroke: "#c23620", collapsedFill: "#f09a8d" },
448      default: { fill: "#747d8c", stroke: "#57606f", collapsedFill: "#a4b0be" }
449    };
450
451    const svgElement = d3.select("#tree-svg");
452    const initialSvgWidth = +svgElement.attr("width");
453    const initialSvgHeight = +svgElement.attr("height");
454    const margin = { top: 40, right: 120, bottom: 80, left: 450 };
455    let width = initialSvgWidth - margin.left - margin.right;
456    let height = initialSvgHeight - margin.top - margin.bottom;
457    const duration = 500;
458    let nodeCounter = 0;
459    const g = svgElement.append("g").attr("transform", `translate(${margin.left},${margin.top})`);
460    const treemap = d3.tree().nodeSize([40, 0]);
461    let rootNode = d3.hierarchy(treeData, d => d.children);
462    rootNode.x0 = 0;
463    rootNode.y0 = 0;
464
465    if (rootNode.children) {
466      rootNode.children.forEach(d_child => {
467        if (d_child.children) { collapseBranch(d_child); }
468      });
469    }
470    updateTree(rootNode);
471
472    function collapseBranch(d) { if (d.children) { d._children = d.children; d._children.forEach(collapseBranch); d.children = null; } }
473    function expandBranch(d) { if (d._children) { d.children = d._children; d._children = null; } if (d.children) { d.children.forEach(expandBranch); } }
474    window.expandAll = () => { expandBranch(rootNode); updateTree(rootNode); };
475    window.collapseAll = () => { if (rootNode.children) { rootNode.children.forEach(collapseBranch); } updateTree(rootNode); };
476    window.resetView = () => { if (rootNode.children) { rootNode.children.forEach(d_child => { if (d_child.children || d_child._children) { collapseBranch(d_child); } }); } if (rootNode._children && !rootNode.children) { rootNode.children = rootNode._children; rootNode._children = null; } updateTree(rootNode); };
477
478    function updateTree(source) {
479      const treeLayoutData = treemap(rootNode);
480      let nodes = treeLayoutData.descendants();
481      let links = treeLayoutData.descendants().slice(1);
482
483      let minX = 0;
484      let maxX = 0;
485      if (nodes.length > 0) {
486        minX = d3.min(nodes, d => d.x);
487        maxX = d3.max(nodes, d => d.x);
488      }
489
490      let neededHeight = Math.max(initialSvgHeight, maxX - minX + margin.top + margin.bottom + 100);
491      svgElement.transition().duration(duration / 2).attr("height", neededHeight);
492      g.transition().duration(duration / 2).attr("transform", `translate(${margin.left},${margin.top - minX + 40})`);
493
494      nodes.forEach(d => { d.y = d.depth * 400; });
495
496      const node = g.selectAll('g.node').data(nodes, d => d.id || (d.id = ++nodeCounter));
497      const nodeEnter = node.enter().append('g')
498        .attr('class', d => "node" + (d.children || d._children ? " node--internal" : " node--leaf") + (d._children ? " _children" : ""))
499        .attr('transform', d => `translate(${source.y0},${source.x0})`)
500        .on('click', (event, d) => { if (d.children) { d._children = d.children; d.children = null; } else if (d._children) { d.children = d._children; d._children = null; } updateTree(d); })
501        .style('cursor', d => (d.children || d._children) ? 'pointer' : 'default');
502
503      nodeEnter.append('circle').attr('r', 1e-6);
504
505      nodeEnter.append('text')
506        .attr('dy', '.35em')
507        .attr('x', d => d.children || d._children ? -14 : 14)
508        .attr('text-anchor', d => d.children || d._children ? 'end' : 'start')
509        .style("fill-opacity", 1e-6)
510        .call(wrapText, 380);
511
512      const nodeUpdate = nodeEnter.merge(node);
513      nodeUpdate.transition().duration(duration)
514        .attr('transform', d => `translate(${d.y},${d.x})`)
515        .attr('class', d => "node" + (d.children ? " node--internal" : " node--leaf") + (d._children ? " node--internal _children" : ""));
516
517      nodeUpdate.select('circle').attr('r', 8.5)
518        .style('fill', d => {
519            let palette;
520            if (d.depth === 0) {
521                palette = levelSpecificPalettes[0];
522            } else if (d.depth === 1) {
523                palette = phaseColors[d.data.originalStageName] || phaseColors.Default;
524            } else {
525                palette = levelSpecificPalettes[d.depth] || levelSpecificPalettes.default;
526            }
527            if (d._children) return palette.collapsedFill;
528            if (d.children) return palette.fill;
529            return "#fff";
530        })
531        .style('stroke', d => {
532            let palette;
533            if (d.depth === 0) {
534                palette = levelSpecificPalettes[0];
535            } else if (d.depth === 1) {
536                palette = phaseColors[d.data.originalStageName] || phaseColors.Default;
537            } else {
538                palette = levelSpecificPalettes[d.depth] || levelSpecificPalettes.default;
539            }
540            return palette.stroke;
541        });
542      nodeUpdate.select('text').style("fill-opacity", 1).call(wrapText, 380);
543
544      const nodeExit = node.exit().transition().duration(duration).attr('transform', d => `translate(${source.y},${source.x})`).remove();
545      nodeExit.select('circle').attr('r', 1e-6);
546      nodeExit.select('text').style('fill-opacity', 1e-6);
547
548      const link = g.selectAll('path.link').data(links, d => d.id);
549      const linkEnter = link.enter().insert('path', "g").attr('class', 'link').attr('d', d => { const o = { x: source.x0, y: source.y0 }; return diagonal(o, o); });
550
551      linkEnter.merge(link).transition().duration(duration).attr('d', d => diagonal(d, d.parent))
552        .style('stroke', d => {
553            const sourceNode = d.parent;
554            if (!sourceNode) return phaseColors.Default.stroke;
555            const l1AncestorName = sourceNode.data.originalStageName;
556            const colorPalette = phaseColors[l1AncestorName] || phaseColors.Default;
557            return colorPalette.stroke;
558        });
559      link.exit().transition().duration(duration).attr('d', d => { const o = { x: source.x, y: source.y }; return diagonal(o, o); }).remove();
560      nodes.forEach(d => { d.x0 = d.x; d.y0 = d.y; });
561    }
562
563    function diagonal(s, d) { return `M ${s.y} ${s.x} C ${(s.y + d.y) / 2} ${s.x}, ${(s.y + d.y) / 2} ${d.x}, ${d.y} ${d.x}`; }
564
565    function wrapText(textElements, maxWidth) {
566        const textPartColors = { name: '#343a40', count: '#0056b3' };
567        const countRegex = /(\s\(Total Count: \d+\))$/;
568
569        textElements.each(function () {
570            const textD3 = d3.select(this);
571            const originalNodeText = textD3.datum().data.name;
572            const x = parseFloat(textD3.attr("x") || 0);
573            const initialDy = textD3.attr("dy");
574            const textAnchor = textD3.attr("text-anchor");
575            const lineHeight = 1.1;
576
577            textD3.text(null);
578
579            let namePart = originalNodeText;
580            let countPartText = "";
581
582            const countMatch = originalNodeText.match(countRegex);
583            if (countMatch && originalNodeText.endsWith(countMatch[0])) {
584                namePart = originalNodeText.substring(0, originalNodeText.length - countMatch[0].length).trim();
585                countPartText = countMatch[0].trim();
586            }
587
588            const tokens = [];
589            namePart.split(/\s+/).filter(Boolean).forEach(word => {
590                tokens.push({ text: word, type: 'name' });
591            });
592            if (countPartText) {
593                tokens.push({ text: countPartText, type: 'count' });
594            }
595
596            if (tokens.length === 0 && originalNodeText) {
597                tokens.push({ text: originalNodeText, type: 'name' });
598            }
599
600            let currentTspan = textD3.append("tspan").attr("x", x).attr("dy", initialDy);
601            if (textAnchor === "end") currentTspan.attr("text-anchor", "end");
602
603            let lineTokens = [];
604
605            for (let i = 0; i < tokens.length; i++) {
606                const tokenObj = tokens[i];
607                lineTokens.push(tokenObj);
608                currentTspan.text(lineTokens.map(t => t.text).join(" "));
609                if (currentTspan.node().getComputedTextLength() > maxWidth && lineTokens.length > 1) {
610                    lineTokens.pop();
611                    currentTspan.text(null);
612                    lineTokens.forEach((prevToken, idx) => {
613                        currentTspan.append("tspan")
614                            .text((idx > 0 ? " " : "") + prevToken.text)
615                            .style("fill", textPartColors[prevToken.type] || textPartColors.name)
616                            .style("font-weight", prevToken.type === 'count' ? "bold" : "normal");
617                    });
618                    lineTokens = [tokenObj];
619                    currentTspan = textD3.append("tspan").attr("x", x).attr("dy", lineHeight + "em");
620                    if (textAnchor === "end") currentTspan.attr("text-anchor", "end");
621                }
622            }
623
624            currentTspan.text(null);
625            lineTokens.forEach((token, idx) => {
626                currentTspan.append("tspan")
627                    .text((idx > 0 ? " " : "") + token.text)
628                    .style("fill", textPartColors[token.type] || textPartColors.name)
629                    .style("font-weight", token.type === 'count' ? "bold" : "normal");
630            });
631
632            if (textD3.selectAll("tspan > tspan").empty() && textD3.select("tspan").text().length === 0 && originalNodeText) {
633                let t = textD3.select("tspan");
634                let displayText = originalNodeText;
635                t.text(displayText).style("fill", textPartColors.name);
636                if (t.node() && t.node().getComputedTextLength() > maxWidth && displayText.length > 20) {
637                    let estimatedChars = Math.floor(maxWidth / (t.node().getComputedTextLength()/displayText.length) );
638                    displayText = displayText.substring(0, Math.max(0, estimatedChars - 3)) + "...";
639                    t.text(displayText);
640                }
641            }
642        });
643    }
644  </script>
645</body>
646</html>
647"##;
648
649pub fn emit_html(
650    tree: &Value,
651    title: &str,
652    header: &str,
653    svg_width: u32,
654    svg_height: u32,
655) -> String {
656    let data_json = serde_json::to_string(tree)
657        .unwrap_or_else(|_| "{}".to_string())
658        .replace("</", "<\\/");
659
660    HTML_TEMPLATE
661        .replace("__TITLE__", &html_escape(title))
662        .replace("__HEADER__", &html_escape(header))
663        .replace("__SVG_WIDTH__", &svg_width.to_string())
664        .replace("__SVG_HEIGHT__", &svg_height.to_string())
665        .replace("__DATA_JSON__", &data_json)
666}
667
668pub fn write_tree_html(
669    graph_path: &Path,
670    output_path: &Path,
671    root: Option<&str>,
672    max_children: usize,
673    project_label: Option<&str>,
674) -> Result<PathBuf> {
675    check_file_size(graph_path, MAX_GRAPH_FILE_BYTES)?;
676    let raw = std::fs::read_to_string(graph_path)?;
677    let graph: Value = serde_json::from_str(&raw)?;
678
679    let tree = build_tree(&graph, root, max_children, project_label);
680    let tree_name = tree.get("name").and_then(|v| v.as_str()).unwrap_or("graph");
681    let title = format!("{} \u{2014} codesynapse tree viewer", tree_name);
682    let header = format!("{} \u{2014} Knowledge Graph", tree_name);
683    let html = emit_html(&tree, &title, &header, 6000, 8000);
684
685    if let Some(parent) = output_path.parent() {
686        std::fs::create_dir_all(parent)?;
687    }
688    std::fs::write(output_path, html)?;
689    Ok(output_path.to_path_buf())
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use serde_json::json;
696
697    #[test]
698    fn test_common_root_empty() {
699        assert_eq!(common_root(&[]), "");
700    }
701
702    #[test]
703    fn test_common_root_single() {
704        let r = common_root(&["/home/user/project/src/main.py"]);
705        assert!(r.contains("home") || r.contains("project") || !r.is_empty());
706    }
707
708    #[test]
709    fn test_common_root_multiple_same_dir() {
710        let r = common_root(&["/home/user/proj/a.py", "/home/user/proj/b.py"]);
711        assert!(r.ends_with("proj") || r.contains("proj"), "got: {r}");
712    }
713
714    #[test]
715    fn test_common_root_no_common() {
716        let r = common_root(&["/a/b/c.py", "/x/y/z.py"]);
717        assert!(r == "/" || r.is_empty() || r == "\\");
718    }
719
720    #[test]
721    fn test_common_root_filter_empty() {
722        let r = common_root(&["", "/home/user/proj/main.py"]);
723        assert!(!r.is_empty());
724    }
725
726    #[test]
727    fn test_make_truncation_leaf() {
728        let leaf = make_truncation_leaf(42);
729        assert_eq!(leaf["name"].as_str().unwrap(), "(+42 more)");
730        assert_eq!(leaf["total_count"].as_u64().unwrap(), 42);
731        assert!(leaf["children"].as_array().unwrap().is_empty());
732    }
733
734    #[test]
735    fn test_build_tree_empty_graph() {
736        let graph = json!({"nodes": []});
737        let tree = build_tree(&graph, None, 200, None);
738        assert_eq!(tree["name"].as_str().unwrap(), "(empty graph)");
739        assert_eq!(tree["total_count"].as_u64().unwrap(), 0);
740    }
741
742    #[test]
743    fn test_build_tree_no_nodes_key() {
744        let graph = json!({});
745        let tree = build_tree(&graph, None, 200, None);
746        assert_eq!(tree["name"].as_str().unwrap(), "(empty graph)");
747    }
748
749    #[test]
750    fn test_build_tree_basic() {
751        let graph = json!({
752            "nodes": [
753                {"id": "a", "label": "MyClass", "source_file": "/proj/src/main.py", "file_type": "code"},
754                {"id": "b", "label": "helper", "source_file": "/proj/src/util.py", "file_type": "code"}
755            ]
756        });
757        let tree = build_tree(&graph, Some("/proj"), 200, None);
758        let children = tree["children"].as_array().unwrap();
759        assert!(!children.is_empty(), "tree should have children");
760        assert!(tree["total_count"].as_u64().unwrap() > 0);
761    }
762
763    #[test]
764    fn test_build_tree_project_label() {
765        let graph = json!({
766            "nodes": [
767                {"id": "x", "label": "Foo", "source_file": "/proj/a.py", "file_type": "code"}
768            ]
769        });
770        let tree = build_tree(&graph, Some("/proj"), 200, Some("MyProject"));
771        assert_eq!(tree["name"].as_str().unwrap(), "MyProject");
772    }
773
774    #[test]
775    fn test_build_tree_max_children_truncation() {
776        let syms: Vec<Value> = (0..5).map(|i| {
777            json!({"id": format!("n{i}"), "label": format!("Sym{i}"), "source_file": "/proj/big.py", "file_type": "code"})
778        }).collect();
779        let graph = json!({"nodes": syms});
780        let tree = build_tree(&graph, Some("/proj"), 3, None);
781
782        fn find_truncation(node: &Value) -> bool {
783            if let Some(name) = node["name"].as_str() {
784                if name.starts_with("(+") {
785                    return true;
786                }
787            }
788            if let Some(kids) = node["children"].as_array() {
789                for k in kids {
790                    if find_truncation(k) {
791                        return true;
792                    }
793                }
794            }
795            false
796        }
797        assert!(find_truncation(&tree), "should have truncation leaf");
798    }
799
800    #[test]
801    fn test_emit_html_contains_title() {
802        let tree = json!({"name": "myproject", "total_count": 1, "children": []});
803        let html = emit_html(&tree, "My Title", "My Header", 6000, 8000);
804        assert!(html.contains("<title>My Title</title>"));
805        assert!(html.contains("My Header"));
806        assert!(!html.contains("__DATA_JSON__"));
807    }
808
809    #[test]
810    fn test_emit_html_script_injection_escaped() {
811        let tree = json!({"name": "x</script><script>alert(1)", "total_count": 1, "children": []});
812        let html = emit_html(&tree, "t", "h", 100, 100);
813        assert!(
814            !html.contains("</script><script>alert(1)"),
815            "raw </script> must not appear unescaped"
816        );
817    }
818
819    #[test]
820    fn test_emit_html_svg_dimensions() {
821        let tree = json!({"name": "r", "total_count": 0, "children": []});
822        let html = emit_html(&tree, "t", "h", 1234, 5678);
823        assert!(html.contains("width=\"1234\""));
824        assert!(html.contains("height=\"5678\""));
825    }
826}