Skip to main content

cgx_engine/
export.rs

1use std::collections::{HashMap, HashSet};
2use std::io::Write;
3use std::process::Command;
4
5use crate::graph::{Edge, GraphDb, Node};
6
7/// Serialize the full graph (nodes, edges, communities, metadata) to pretty-printed JSON.
8pub 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
44/// Export the graph as a Mermaid `graph TD` diagram.
45///
46/// Selects the `max_nodes` highest-degree nodes (clamped to 500) and the
47/// edges between them.  Edge styles vary by kind: `CALLS` →, `IMPORTS` -.->`, `CO_CHANGES` ==>.
48pub 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
116/// Export the graph in Graphviz DOT format with colour-coded node kinds and edge types.
117pub 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
202/// Export the graph as an SVG image.
203///
204/// Attempts to render via the system `dot` (Graphviz) binary; falls back to a
205/// built-in circle layout if `dot` is not installed.
206pub 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('&', "&amp;")
332                .replace('<', "&lt;")
333                .replace('>', "&gt;");
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
354/// Export the graph in GraphML (XML) format compatible with Gephi and yEd.
355pub 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('&', "&amp;")
456        .replace('<', "&lt;")
457        .replace('>', "&gt;")
458        .replace('"', "&quot;")
459        .replace('\'', "&apos;")
460}