1use std::collections::{HashMap, HashSet};
2use std::io::Write;
3use std::process::Command;
4
5use crate::graph::{Edge, GraphDb, Node};
6
7pub fn export_json(db: &GraphDb) -> anyhow::Result<String> {
9 let nodes = db.get_all_nodes()?;
10 let edges = db.get_all_edges()?;
11 let communities = db.get_communities()?;
12 let breakdown = db.get_language_breakdown()?;
13 let indexed_at = chrono::Utc::now().to_rfc3339();
14
15 let community_list: Vec<serde_json::Value> = communities
16 .into_iter()
17 .map(|(id, label, node_count, top_nodes)| {
18 serde_json::json!({
19 "id": id,
20 "label": label,
21 "node_count": node_count,
22 "top_nodes": top_nodes,
23 })
24 })
25 .collect();
26
27 let output = serde_json::json!({
28 "meta": {
29 "repo_id": db.repo_id,
30 "indexed_at": indexed_at,
31 "node_count": nodes.len(),
32 "edge_count": edges.len(),
33 "language_breakdown": breakdown,
34 "community_count": community_list.len(),
35 },
36 "nodes": nodes,
37 "edges": edges,
38 "communities": community_list,
39 });
40
41 Ok(serde_json::to_string_pretty(&output)?)
42}
43
44pub fn export_mermaid(db: &GraphDb, max_nodes: usize) -> anyhow::Result<String> {
49 let max = max_nodes.clamp(1, 500);
50 let nodes = db.get_all_nodes()?;
51 let edges = db.get_all_edges()?;
52
53 if nodes.is_empty() {
54 return Ok("graph TD\n A[\"No data\"]\n".to_string());
55 }
56
57 let node_ids: HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
58
59 let mut node_degree: HashMap<String, usize> = HashMap::new();
60 for edge in &edges {
61 if node_ids.contains(edge.src.as_str()) && node_ids.contains(edge.dst.as_str()) {
62 *node_degree.entry(edge.src.clone()).or_default() += 1;
63 *node_degree.entry(edge.dst.clone()).or_default() += 1;
64 }
65 }
66
67 let mut ranked: Vec<&Node> = nodes.iter().collect();
68 ranked.sort_by_key(|n| -(node_degree.get(&n.id).copied().unwrap_or(0) as i64));
69 ranked.truncate(max);
70
71 let selected: HashSet<&str> = ranked.iter().map(|n| n.id.as_str()).collect();
72
73 let mut included_edges: Vec<&Edge> = edges
74 .iter()
75 .filter(|e| selected.contains(e.src.as_str()) && selected.contains(e.dst.as_str()))
76 .collect();
77 included_edges.truncate(max);
78
79 let mut output = String::from("graph TD\n");
80
81 let mut safe_ids: HashMap<&str, String> = HashMap::new();
82 for node in &ranked {
83 let safe = sanitize_mermaid_id(&node.id);
84 safe_ids.insert(&node.id, safe.clone());
85 let label = sanitize_mermaid_label(&node.name);
86 output.push_str(&format!(" {}[\"{}\"]\n", safe, label));
87 }
88
89 for edge in &included_edges {
90 if let (Some(src_safe), Some(dst_safe)) = (
91 safe_ids.get(edge.src.as_str()),
92 safe_ids.get(edge.dst.as_str()),
93 ) {
94 let style = match edge.kind.as_str() {
95 "CALLS" => "-->",
96 "IMPORTS" => "-.->",
97 "CO_CHANGES" => "==>",
98 _ => "-->",
99 };
100 output.push_str(&format!(" {} {} {}\n", src_safe, style, dst_safe));
101 }
102 }
103
104 if ranked.len() < nodes.len() {
105 output.push_str(&format!(
106 " %% Showing {}/{} nodes ({} edges filtered)\n",
107 ranked.len(),
108 nodes.len(),
109 edges.len() - included_edges.len()
110 ));
111 }
112
113 Ok(output)
114}
115
116pub fn export_dot(db: &GraphDb) -> anyhow::Result<String> {
118 let nodes = db.get_all_nodes()?;
119 let edges = db.get_all_edges()?;
120
121 if nodes.is_empty() {
122 return Ok("digraph G {\n label=\"No data\";\n}\n".to_string());
123 }
124
125 let mut output = String::from("digraph G {\n");
126 output.push_str(" rankdir=LR;\n");
127 output.push_str(" bgcolor=\"#0a0a0f\";\n");
128 output.push_str(" node [fontname=\"JetBrains Mono\", fontsize=10];\n");
129 output.push_str(" edge [fontname=\"JetBrains Mono\", fontsize=8];\n\n");
130
131 let kind_colors: HashMap<&str, &str> = [
132 ("Function", "#00ff88"),
133 ("Class", "#3b82f6"),
134 ("File", "#f59e0b"),
135 ("Module", "#8b5cf6"),
136 ("Author", "#ec4899"),
137 ("Variable", "#06b6d4"),
138 ("Type", "#f97316"),
139 ]
140 .into_iter()
141 .collect();
142
143 let max_churn = nodes
144 .iter()
145 .map(|n| n.churn)
146 .fold(0.0f64, f64::max)
147 .max(0.01);
148
149 for node in &nodes {
150 let safe_id = sanitize_dot_id(&node.id);
151 let label = node.name.replace('"', "\\\"");
152 let color = kind_colors
153 .get(node.kind.as_str())
154 .copied()
155 .unwrap_or("#888888");
156 let size = 0.3 + (node.churn / max_churn) * 0.7;
157
158 output.push_str(&format!(
159 " \"{}\" [label=\"{}\", color=\"{}\", fontcolor=\"{}\", width={:.2}, height={:.2}];\n",
160 safe_id,
161 label,
162 color,
163 color,
164 size,
165 size * 0.6
166 ));
167 }
168
169 output.push('\n');
170
171 let edge_colors: HashMap<&str, &str> = [
172 ("CALLS", "#ffffff33"),
173 ("IMPORTS", "#3b82f644"),
174 ("CO_CHANGES", "#ef444466"),
175 ("OWNS", "#ec489944"),
176 ("INHERITS", "#8b5cf644"),
177 ]
178 .into_iter()
179 .collect();
180
181 for edge in &edges {
182 let safe_src = sanitize_dot_id(&edge.src);
183 let safe_dst = sanitize_dot_id(&edge.dst);
184 let color = edge_colors
185 .get(edge.kind.as_str())
186 .copied()
187 .unwrap_or("#ffffff22");
188
189 output.push_str(&format!(
190 " \"{}\" -> \"{}\" [color=\"{}\", penwidth={:.1}];\n",
191 safe_src,
192 safe_dst,
193 color,
194 (edge.weight * 1.5).clamp(0.5, 5.0)
195 ));
196 }
197
198 output.push_str("}\n");
199 Ok(output)
200}
201
202pub fn export_svg(db: &GraphDb) -> anyhow::Result<String> {
207 let dot_output = export_dot(db)?;
208
209 if let Ok(svg) = dot_to_svg(&dot_output) {
210 return Ok(svg);
211 }
212
213 fallback_svg_with_data(db)
214}
215
216fn fallback_svg_with_data(db: &GraphDb) -> anyhow::Result<String> {
217 let nodes = db.get_all_nodes()?;
218 let edges = db.get_all_edges()?;
219 render_svg_circle(&nodes, &edges)
220}
221
222fn dot_to_svg(dot: &str) -> anyhow::Result<String> {
223 let mut child = Command::new("dot")
224 .arg("-Tsvg")
225 .stdin(std::process::Stdio::piped())
226 .stdout(std::process::Stdio::piped())
227 .stderr(std::process::Stdio::null())
228 .spawn()?;
229
230 if let Some(mut stdin) = child.stdin.take() {
231 stdin.write_all(dot.as_bytes())?;
232 }
233
234 let output = child.wait_with_output()?;
235 if output.status.success() {
236 Ok(String::from_utf8_lossy(&output.stdout).to_string())
237 } else {
238 anyhow::bail!("dot command failed")
239 }
240}
241
242fn render_svg_circle(nodes: &[Node], edges: &[Edge]) -> anyhow::Result<String> {
243 if nodes.is_empty() {
244 return Ok(r##"<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200" viewBox="0 0 400 200">
245 <rect width="400" height="200" fill="#0a0a0f"/>
246 <text x="200" y="100" text-anchor="middle" fill="#888" font-family="JetBrains Mono, monospace" font-size="14">No data</text>
247</svg>"##.to_string());
248 }
249
250 let display_nodes: Vec<&Node> = nodes.iter().collect();
251 let max_display = display_nodes.len().min(200);
252 let display_nodes = &display_nodes[..max_display];
253
254 let node_ids: HashSet<&str> = display_nodes.iter().map(|n| n.id.as_str()).collect();
255 let display_edges: Vec<&Edge> = edges
256 .iter()
257 .filter(|e| node_ids.contains(e.src.as_str()) && node_ids.contains(e.dst.as_str()))
258 .take(max_display * 2)
259 .collect();
260
261 let center_x = 400.0;
262 let center_y = 400.0;
263 let radius = 320.0;
264 let total = display_nodes.len() as f64;
265
266 let mut positions: HashMap<&str, (f64, f64)> = HashMap::new();
267 for (i, node) in display_nodes.iter().enumerate() {
268 let angle = 2.0 * std::f64::consts::PI * (i as f64) / total - std::f64::consts::PI / 2.0;
269 let x = center_x + radius * angle.cos();
270 let y = center_y + radius * angle.sin();
271 positions.insert(&node.id, (x, y));
272 }
273
274 let kind_colors: HashMap<&str, &str> = [
275 ("Function", "#00ff88"),
276 ("Class", "#3b82f6"),
277 ("File", "#f59e0b"),
278 ("Module", "#8b5cf6"),
279 ("Author", "#ec4899"),
280 ("Variable", "#06b6d4"),
281 ("Type", "#f97316"),
282 ]
283 .into_iter()
284 .collect();
285
286 let edge_colors: HashMap<&str, &str> = [
287 ("CALLS", "#ffffff22"),
288 ("IMPORTS", "#3b82f644"),
289 ("CO_CHANGES", "#ef444466"),
290 ("OWNS", "#ec489944"),
291 ("INHERITS", "#8b5cf644"),
292 ]
293 .into_iter()
294 .collect();
295
296 let mut svg = format!(
297 r##"<svg xmlns="http://www.w3.org/2000/svg" width="800" height="800" viewBox="0 0 800 800">
298 <rect width="800" height="800" fill="#0a0a0f"/>
299 <text x="10" y="20" fill="#555" font-family="JetBrains Mono, monospace" font-size="10">cgx graph - {} nodes, {} edges</text>
300"##,
301 nodes.len(),
302 edges.len()
303 );
304
305 for edge in &display_edges {
306 if let (Some(&(x1, y1)), Some(&(x2, y2))) = (
307 positions.get(edge.src.as_str()),
308 positions.get(edge.dst.as_str()),
309 ) {
310 let color = edge_colors
311 .get(edge.kind.as_str())
312 .copied()
313 .unwrap_or("#ffffff11");
314 svg.push_str(&format!(
315 r##" <line x1="{:.1}" y1="{:.1}" x2="{:.1}" y2="{:.1}" stroke="{}" stroke-width="{:.1}" opacity="0.6"/>
316"##,
317 x1, y1, x2, y2, color, (edge.weight * 1.0).clamp(0.3, 3.0)
318 ));
319 }
320 }
321
322 for node in display_nodes.iter() {
323 if let Some(&(x, y)) = positions.get(node.id.as_str()) {
324 let color = kind_colors
325 .get(node.kind.as_str())
326 .copied()
327 .unwrap_or("#888888");
328 let r = 4.0 + (node.churn * 8.0).min(12.0);
329 let label = node.name.chars().take(20).collect::<String>();
330 let escaped_label = label
331 .replace('&', "&")
332 .replace('<', "<")
333 .replace('>', ">");
334
335 svg.push_str(&format!(
336 r##" <circle cx="{:.1}" cy="{:.1}" r="{:.1}" fill="{}" opacity="0.8"/>
337"##,
338 x, y, r, color
339 ));
340 svg.push_str(&format!(
341 r##" <text x="{:.1}" y="{:.1}" fill="#ccc" font-family="JetBrains Mono, monospace" font-size="8" text-anchor="middle">{}</text>
342"##,
343 x,
344 y - r - 3.0,
345 escaped_label
346 ));
347 }
348 }
349
350 svg.push_str("</svg>\n");
351 Ok(svg)
352}
353
354pub fn export_graphml(db: &GraphDb) -> anyhow::Result<String> {
356 let nodes = db.get_all_nodes()?;
357 let edges = db.get_all_edges()?;
358
359 let mut xml = String::from(
360 r#"<?xml version="1.0" encoding="UTF-8"?>
361<graphml xmlns="http://graphml.graphdrawing.org/xmlns"
362 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
363 xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns
364 http://graphml.graphdrawing.org/xmlns/1.0/graphml.xsd">
365 <key id="name" for="node" attr.name="name" attr.type="string"/>
366 <key id="kind" for="node" attr.name="kind" attr.type="string"/>
367 <key id="path" for="node" attr.name="path" attr.type="string"/>
368 <key id="churn" for="node" attr.name="churn" attr.type="double"/>
369 <key id="coupling" for="node" attr.name="coupling" attr.type="double"/>
370 <key id="community" for="node" attr.name="community" attr.type="long"/>
371 <key id="language" for="node" attr.name="language" attr.type="string"/>
372 <key id="kind" for="edge" attr.name="kind" attr.type="string"/>
373 <key id="weight" for="edge" attr.name="weight" attr.type="double"/>
374 <key id="confidence" for="edge" attr.name="confidence" attr.type="double"/>
375 <graph id="G" edgedefault="directed">
376"#,
377 );
378
379 for node in &nodes {
380 let safe_id = xml_escape(&node.id);
381 let safe_name = xml_escape(&node.name);
382 let safe_path = xml_escape(&node.path);
383 let safe_kind = xml_escape(&node.kind);
384 let safe_lang = xml_escape(&node.language);
385
386 xml.push_str(&format!(" <node id=\"{}\">\n", safe_id));
387 xml.push_str(&format!(" <data key=\"name\">{}</data>\n", safe_name));
388 xml.push_str(&format!(" <data key=\"kind\">{}</data>\n", safe_kind));
389 xml.push_str(&format!(" <data key=\"path\">{}</data>\n", safe_path));
390 xml.push_str(&format!(
391 " <data key=\"churn\">{}</data>\n",
392 node.churn
393 ));
394 xml.push_str(&format!(
395 " <data key=\"coupling\">{}</data>\n",
396 node.coupling
397 ));
398 xml.push_str(&format!(
399 " <data key=\"community\">{}</data>\n",
400 node.community
401 ));
402 xml.push_str(&format!(
403 " <data key=\"language\">{}</data>\n",
404 safe_lang
405 ));
406 xml.push_str(" </node>\n");
407 }
408
409 for edge in &edges {
410 let safe_src = xml_escape(&edge.src);
411 let safe_dst = xml_escape(&edge.dst);
412 let safe_kind = xml_escape(&edge.kind);
413
414 xml.push_str(&format!(
415 " <edge source=\"{}\" target=\"{}\">\n",
416 safe_src, safe_dst
417 ));
418 xml.push_str(&format!(" <data key=\"kind\">{}</data>\n", safe_kind));
419 xml.push_str(&format!(
420 " <data key=\"weight\">{}</data>\n",
421 edge.weight
422 ));
423 xml.push_str(&format!(
424 " <data key=\"confidence\">{}</data>\n",
425 edge.confidence
426 ));
427 xml.push_str(" </edge>\n");
428 }
429
430 xml.push_str(" </graph>\n</graphml>\n");
431 Ok(xml)
432}
433
434fn sanitize_mermaid_id(id: &str) -> String {
435 id.replace(
436 [':', '.', '/', '-', '(', ')', '[', ']', ' ', '<', '>', '|'],
437 "_",
438 )
439}
440
441fn sanitize_mermaid_label(name: &str) -> String {
442 name.replace('"', "'")
443 .replace('[', "(")
444 .replace(']', ")")
445 .chars()
446 .take(40)
447 .collect()
448}
449
450fn sanitize_dot_id(id: &str) -> String {
451 id.replace('"', "\\\"").replace(['\n', '\r'], " ")
452}
453
454fn xml_escape(s: &str) -> String {
455 s.replace('&', "&")
456 .replace('<', "<")
457 .replace('>', ">")
458 .replace('"', """)
459 .replace('\'', "'")
460}