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