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
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
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('&', "&amp;")
454        .replace('<', "&lt;")
455        .replace('>', "&gt;")
456        .replace('"', "&quot;")
457        .replace('\'', "&apos;")
458}