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('<', "<")
13 .replace('>', ">")
14 .replace('"', """)
15 .replace('\'', "'")
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 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}